From 5ac256761056a45c662007c0d1f785a9f6d73213 Mon Sep 17 00:00:00 2001 From: "diamondburned (Forefront)" Date: Sun, 28 Jun 2020 18:38:09 -0700 Subject: [PATCH] Added Configurator --- internal/gts/gts.go | 18 ++ internal/ui/config/preferences/preferences.go | 2 +- internal/ui/config/widgets.go | 60 +++---- internal/ui/primitives/primitives.go | 24 +++ internal/ui/service/config/config.go | 64 +++++++ internal/ui/service/config/widgets.go | 163 ++++++++++++++++++ internal/ui/service/header.go | 13 +- internal/ui/service/menu/menu.go | 4 + internal/ui/service/service.go | 8 +- 9 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 internal/ui/service/config/config.go create mode 100644 internal/ui/service/config/widgets.go diff --git a/internal/gts/gts.go b/internal/gts/gts.go index 2f3a8dc..be69a42 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -9,6 +9,7 @@ import ( "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" ) const AppID = "com.github.diamondburned.cchat-gtk" @@ -30,6 +31,23 @@ func NewModalDialog() (*gtk.Dialog, error) { } d.SetModal(true) d.SetTransientFor(App.Window) + + return d, nil +} + +func NewEmptyModalDialog() (*gtk.Dialog, error) { + d, err := NewModalDialog() + if err != nil { + return nil, err + } + + b, err := d.GetContentArea() + if err != nil { + return nil, errors.Wrap(err, "Failed to get content area") + } + + d.Remove(b) + return d, nil } diff --git a/internal/ui/config/preferences/preferences.go b/internal/ui/config/preferences/preferences.go index e4b0687..a0bcbda 100644 --- a/internal/ui/config/preferences/preferences.go +++ b/internal/ui/config/preferences/preferences.go @@ -62,7 +62,7 @@ func Section(entries []config.Entry) *gtk.Grid { } grid.SetRowSpacing(4) - grid.SetColumnSpacing(4) + grid.SetColumnSpacing(8) grid.Show() return grid } diff --git a/internal/ui/config/widgets.go b/internal/ui/config/widgets.go index 4b66457..b719c3b 100644 --- a/internal/ui/config/widgets.go +++ b/internal/ui/config/widgets.go @@ -23,21 +23,20 @@ func Combo(selected *int, options []string, change func(int)) EntryValue { return &_combo{selected, options, change} } +func (c *_combo) set(v int) { + *c.selected = v + if c.change != nil { + c.change(v) + } +} + func (c *_combo) Construct() gtk.IWidget { var combo, _ = gtk.ComboBoxTextNew() for _, opt := range c.options { combo.Append(opt, opt) } - combo.Connect("changed", func() { - active := combo.GetActive() - *c.selected = active - - if c.change != nil { - c.change(active) - } - }) - + combo.Connect("changed", func() { c.set(combo.GetActive()) }) combo.SetActive(*c.selected) combo.SetHAlign(gtk.ALIGN_END) combo.Show() @@ -67,19 +66,17 @@ func Switch(value *bool, change func(bool)) EntryValue { return &_switch{value, change} } +func (s *_switch) set(v bool) { + *s.value = v + if s.change != nil { + s.change(v) + } +} + func (s *_switch) Construct() gtk.IWidget { sw, _ := gtk.SwitchNew() sw.SetActive(*s.value) - - sw.Connect("notify::active", func() { - v := sw.GetActive() - *s.value = v - - if s.change != nil { - s.change(v) - } - }) - + sw.Connect("notify::active", func() { s.set(sw.GetActive()) }) sw.SetHAlign(gtk.ALIGN_END) sw.Show() @@ -95,7 +92,7 @@ func (s *_switch) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(b, &value); err != nil { return err } - *s.value = value + s.set(value) return nil } @@ -108,6 +105,14 @@ func InputEntry(value *string, change func(string) error) EntryValue { return &_inputentry{value, change} } +func (e *_inputentry) set(v string) error { + *e.value = v + if e.change != nil { + return e.change(v) + } + return nil +} + func (e *_inputentry) Construct() gtk.IWidget { entry, _ := gtk.EntryNew() entry.SetHExpand(true) @@ -119,14 +124,11 @@ func (e *_inputentry) Construct() gtk.IWidget { return } - *e.value = v - if e.change != nil { - if err := e.change(v); err != nil { - entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "dialog-error") - entry.SetIconTooltipText(gtk.ENTRY_ICON_SECONDARY, err.Error()) - } else { - entry.RemoveIcon(gtk.ENTRY_ICON_SECONDARY) - } + if err := e.set(v); err != nil { + entry.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "dialog-error") + entry.SetIconTooltipText(gtk.ENTRY_ICON_SECONDARY, err.Error()) + } else { + entry.RemoveIcon(gtk.ENTRY_ICON_SECONDARY) } }) @@ -144,6 +146,6 @@ func (e *_inputentry) UnmarshalJSON(b []byte) error { if err := json.Unmarshal(b, &value); err != nil { return err } - *e.value = value + e.set(value) return nil } diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index 9b25b80..0be9db0 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -1,10 +1,15 @@ package primitives import ( + "path/filepath" + "runtime" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/log" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" ) type Namer interface { @@ -225,3 +230,22 @@ func ActionPopover(p *gtk.Popover, actions [][2]string) { box.Show() p.Add(box) } + +func PrepareCSS(css string) *gtk.CssProvider { + p, _ := gtk.CssProviderNew() + if err := p.LoadFromData(css); err != nil { + _, fn, caller, _ := runtime.Caller(1) + fn = filepath.Base(fn) + log.Error(errors.Wrapf(err, "CSS fail at %s:%d", fn, caller)) + } + return p +} + +type StyleContextGetter interface { + GetStyleContext() (*gtk.StyleContext, error) +} + +func AttachCSS(ctx StyleContextGetter, prov *gtk.CssProvider) { + s, _ := ctx.GetStyleContext() + s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) +} diff --git a/internal/ui/service/config/config.go b/internal/ui/service/config/config.go new file mode 100644 index 0000000..1437c7d --- /dev/null +++ b/internal/ui/service/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/ui/service/menu" + "github.com/gotk3/gotk3/gtk" +) + +type Configurator interface { + cchat.Service + cchat.Configurator +} + +func MenuItem(conf Configurator) menu.Item { + return menu.SimpleItem("Configure", func() { + SpawnConfigurator(conf) + }) +} + +func SpawnConfigurator(conf Configurator) error { + c, err := conf.Configuration() + if err != nil { + return err + } + + Spawn(conf.Name().Content, c, func() error { + return conf.SetConfiguration(c) + }) + + return nil +} + +func Spawn(name string, conf map[string]string, apply func() error) { + container := newContainer(conf, apply) + container.Grid.SetVAlign(gtk.ALIGN_START) + + sw, _ := gtk.ScrolledWindowNew(nil, nil) + sw.Add(container.Grid) + sw.Show() + + b, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, vmargin) + b.SetMarginTop(vmargin) + b.SetMarginBottom(vmargin) + b.SetMarginStart(hmargin) + b.SetMarginEnd(hmargin) + b.PackStart(sw, true, true, 0) + b.PackStart(container.ErrHeader, false, false, 0) + b.Show() + + var title = "Configure " + name + + h, _ := gtk.HeaderBarNew() + h.SetTitle(title) + h.SetShowCloseButton(true) + h.Show() + + d, _ := gts.NewEmptyModalDialog() + d.SetDefaultSize(400, 300) + d.Add(b) + d.SetTitle(title) + d.SetTitlebar(h) + d.Show() +} diff --git a/internal/ui/service/config/widgets.go b/internal/ui/service/config/widgets.go new file mode 100644 index 0000000..f4dbfa4 --- /dev/null +++ b/internal/ui/service/config/widgets.go @@ -0,0 +1,163 @@ +package config + +import ( + "errors" + "html" + "sort" + "strings" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/gotk3/gotk3/gtk" +) + +const ( + vmargin = 8 + hmargin = 16 +) + +var monospaceCSS = primitives.PrepareCSS(` + * { + font-family: monospace; + } +`) + +type container struct { + Grid *gtk.Grid + ErrHeader *errorHeader + Entries map[string]*entry + + fielderr *cchat.ErrInvalidConfigAtField + apply func() error +} + +func newContainer(conf map[string]string, apply func() error) *container { + var keys = make([]string, 0, len(conf)) + for k := range conf { + keys = append(keys, k) + } + + sort.Strings(keys) + + errh := newErrorHeader() + errh.Show() + + var grid, _ = gtk.GridNew() + var entries = make(map[string]*entry, len(keys)) + + var cc = &container{ + Grid: grid, + ErrHeader: errh, + Entries: entries, + + fielderr: &cchat.ErrInvalidConfigAtField{}, + apply: apply, + } + + for i, k := range keys { + l, _ := gtk.LabelNew(k) + l.SetHExpand(true) + l.SetXAlign(0) + l.Show() + primitives.AttachCSS(l, monospaceCSS) + + e := newEntry(k, conf, cc.onEntryChange) + e.Show() + entries[k] = e + + grid.Attach(l, 0, i, 1, 1) + grid.Attach(e, 1, i, 1, 1) + } + + grid.SetRowHomogeneous(true) + grid.SetRowSpacing(4) + grid.SetColumnHomogeneous(true) + grid.SetColumnSpacing(8) + grid.Show() + + return cc +} + +func (c *container) onEntryChange() { + err := c.apply() + c.ErrHeader.SetError(err) + + // Reset the field error before unmarshaling into it again. If As() fails, + // then all field errors will be cleared, as no keys will match. + c.fielderr.Key = "" + + // fieldErred is true if the error is a field-specific error. + var fieldErred = errors.As(err, &c.fielderr) + + // Loop over entries even if there is no field error. This clears up all + // errors even if that is not the case. + for k, entry := range c.Entries { + if fieldErred && k == c.fielderr.Key { + entry.SetError(c.fielderr.Err) + } else { + entry.SetError(nil) + } + } +} + +type entry struct { + *gtk.Entry +} + +func newEntry(k string, conf map[string]string, change func()) *entry { + e, _ := gtk.EntryNew() + e.SetText(conf[k]) + e.SetHExpand(true) + e.Connect("changed", func() { + conf[k], _ = e.GetText() + change() + }) + primitives.AttachCSS(e, monospaceCSS) + return &entry{e} +} + +func (e *entry) SetError(err error) { + if err != nil { + e.SetIconFromIconName(gtk.ENTRY_ICON_SECONDARY, "dialog-error") + e.SetIconTooltipText(gtk.ENTRY_ICON_SECONDARY, err.Error()) + } else { + e.RemoveIcon(gtk.ENTRY_ICON_SECONDARY) + } +} + +type errorHeader struct { + *gtk.Revealer + l *gtk.Label +} + +func newErrorHeader() *errorHeader { + l, _ := gtk.LabelNew("") + l.SetXAlign(0) + l.Show() + + r, _ := gtk.RevealerNew() + r.SetTransitionDuration(50) + r.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_UP) + r.SetRevealChild(false) + r.SetMarginTop(vmargin) + r.SetMarginBottom(vmargin) + r.Add(l) + + return &errorHeader{r, l} +} + +func (eh *errorHeader) SetError(err error) { + if err != nil { + // Cleanup the error message. + parts := strings.Split(err.Error(), ": ") + ermsg := parts[len(parts)-1] + + eh.SetRevealChild(true) + eh.l.SetMarkup(`Error: ` + html.EscapeString(ermsg)) + eh.l.SetTooltipText(err.Error()) + } else { + eh.SetRevealChild(false) + eh.l.SetText("") + eh.l.SetTooltipText("") + } +} diff --git a/internal/ui/service/header.go b/internal/ui/service/header.go index 8aab916..e9fc399 100644 --- a/internal/ui/service/header.go +++ b/internal/ui/service/header.go @@ -2,8 +2,9 @@ package service import ( "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat-gtk/internal/ui/service/config" + "github.com/diamondburned/cchat-gtk/internal/ui/service/menu" "github.com/diamondburned/imgutil" "github.com/gotk3/gotk3/gtk" ) @@ -18,7 +19,7 @@ type header struct { icon *rich.Icon Add *gtk.Button - Menu *gtk.Menu + Menu *menu.LazyMenu } func newHeader(svc cchat.Service) *header { @@ -58,9 +59,11 @@ func newHeader(svc cchat.Service) *header { reveal.SetMode(true) reveal.Show() - // Spawn the menu on right click. - menu, _ := gtk.MenuNew() - primitives.BindMenu(reveal, menu) + // Construct a menu and its items. + var menu = menu.NewLazyMenu(reveal) + if configurator, ok := svc.(config.Configurator); ok { + menu.AddItems(config.MenuItem(configurator)) + } return &header{reveal, box, l, i, add, menu} } diff --git a/internal/ui/service/menu/menu.go b/internal/ui/service/menu/menu.go index 500c8ed..90eee9b 100644 --- a/internal/ui/service/menu/menu.go +++ b/internal/ui/service/menu/menu.go @@ -28,6 +28,10 @@ func (m *LazyMenu) AddItems(items ...Item) { m.items = append(m.items, items...) } +func (m *LazyMenu) AddSimpleItem(name string, fn func()) { + m.AddItems(SimpleItem(name, fn)) +} + func (m *LazyMenu) Reset() { m.items = nil } diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 43af443..09412f5 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -117,12 +117,8 @@ func NewContainer(svc cchat.Service, ctrl Controller) *Container { ctrl.AuthenticateSession(container, svc) }) - // Make menu items. - primitives.AppendMenuItems(header.Menu, []gtk.IMenuItem{ - primitives.MenuItem("Save Sessions", func() { - container.SaveAllSessions() - }), - }) + // Add more menu item(s). + header.Menu.AddSimpleItem("Save Sessions", container.SaveAllSessions) return container }