// SPDX-License-Identifier: GPL-3.0-or-later package ui import ( "fmt" "strings" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" "krino/gui/internal/model" "krino/internal/config" "krino/internal/sexp" ) // condKinds are the tests a condition row offers, plus the three operators // that hold other conditions. A row's entry holds that form's arguments // exactly as they are written, so no test is out of reach of the form // editor and none is silently rewritten (see docs/gui-design.md §5.1 and // the deviation noted in plan 17). var condKinds = []string{"type", "name", "path", "content", "size", "age", "duplicate", "matched", "and", "or", "not"} // condHints is the example shown beside a row, by kind. var condHints = map[string]string{ "type": `pdf doc (extensions or a group)`, "name": `"^faktura" "^fv" (regexes, any may match)`, "path": `"work/" (regex against the path under the root)`, "content": `"invoice" "faktura" (keywords, any may match)`, "size": `> 10M`, "age": `> 90d`, "duplicate": `"~/docs" (or empty: the directory's own tree)`, "matched": `(no arguments)`, "and": `(type pdf) (content "invoice")`, "or": `(type doc) (type docx)`, "not": `(duplicate)`, } // actionKinds are the actions a rule can carry, in the order the form // offers them. var actionKinds = []string{"copy", "move", "rename", "delete", "delete permanent"} // formsView is the Forms half of the Rules tab: the directory's excludes // and rules on the left, the selected one as a form in the middle. type formsView struct { w *Window owner *rulesView root *gtk.Box list *gtk.ListBox add, del, up, down *gtk.Button place *gtk.Box note *gtk.Label forms []model.Form sel int editor *formEditor settingRows []*settingRow onSettings bool pending glib.SourceHandle quiet bool commentsAcknowledged map[string]bool } func newFormsView(w *Window, owner *rulesView) *formsView { f := &formsView{w: w, owner: owner, sel: -1, commentsAcknowledged: map[string]bool{}} f.root = gtk.NewBox(gtk.OrientationHorizontal, 0) f.list = gtk.NewListBox() f.list.SetSelectionMode(gtk.SelectionSingle) listScroll := gtk.NewScrolledWindow() listScroll.SetChild(f.list) listScroll.SetVExpand(true) f.add = gtk.NewButtonWithLabel("Add rule") f.del = gtk.NewButtonWithLabel("Delete") f.up = gtk.NewButtonWithLabel("Up") f.down = gtk.NewButtonWithLabel("Down") buttons := gtk.NewBox(gtk.OrientationHorizontal, 4) buttons.SetMarginStart(6) buttons.SetMarginEnd(6) buttons.SetMarginTop(4) buttons.SetMarginBottom(4) for _, b := range []*gtk.Button{f.add, f.del, f.up, f.down} { buttons.Append(b) } left := gtk.NewBox(gtk.OrientationVertical, 0) left.Append(listScroll) left.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) left.Append(buttons) left.SetSizeRequest(260, -1) f.note = gtk.NewLabel("") f.note.SetXAlign(0) f.note.SetWrap(true) f.note.SetMarginStart(8) f.note.SetMarginEnd(8) f.note.SetMarginTop(6) f.place = gtk.NewBox(gtk.OrientationVertical, 0) f.place.SetVExpand(true) placeScroll := gtk.NewScrolledWindow() placeScroll.SetChild(f.place) placeScroll.SetVExpand(true) placeScroll.SetHExpand(true) right := gtk.NewBox(gtk.OrientationVertical, 0) right.Append(f.note) right.Append(placeScroll) f.root.Append(left) f.root.Append(gtk.NewSeparator(gtk.OrientationVertical)) f.root.Append(right) f.list.ConnectRowSelected(func(row *gtk.ListBoxRow) { if row == nil || f.quiet { return } // Row 0 is the directory itself; the forms follow it. if row.Index() == 0 { f.showSettings() return } f.show(row.Index() - 1) }) f.add.ConnectClicked(f.onAdd) f.del.ConnectClicked(f.onDelete) f.up.ConnectClicked(func() { f.move(-1) }) f.down.ConnectClicked(func() { f.move(1) }) return f } // reload re-reads the text in the editor and rebuilds the list. Text that // does not parse has no forms: the caller keeps the Text tab (GUI design // §5.2). func (f *formsView) reload() error { forms, err := f.owner.formsOf() if err != nil { return err } f.forms = forms keep := f.sel f.quiet = true clearList(f.list) f.list.Append(settingsRow()) for _, form := range forms { row := gtk.NewListBoxRow() label := gtk.NewLabel(escape(formLabel(form))) label.SetXAlign(0) label.SetMarginStart(6) label.SetMarginEnd(6) label.SetMarginTop(2) label.SetMarginBottom(2) label.SetEllipsize(3) // end label.SetMaxWidthChars(30) label.SetTooltipText(escape(form.Label)) row.SetChild(label) f.list.Append(row) } f.quiet = false switch { case keep >= 0 && keep < len(forms): f.list.SelectRow(f.list.RowAtIndex(keep + 1)) f.show(keep) case f.onSettings: f.list.SelectRow(f.list.RowAtIndex(0)) f.showSettings() default: f.sel = -1 f.clearEditor("Select the directory or a rule to edit it, or Add rule.") } return nil } // settingsRow is the first line of the list: the directory's own settings. func settingsRow() *gtk.ListBoxRow { row := gtk.NewListBoxRow() label := gtk.NewLabel("the directory itself") label.SetXAlign(0) label.SetMarginStart(6) label.SetMarginEnd(6) label.SetMarginTop(2) label.SetMarginBottom(2) row.SetChild(label) return row } // showSettings builds the form for the directory's own settings: one row // per setting krino.conf(5) documents, each holding what the file writes, // and empty when the file leaves it out (GUI design §5.1). func (f *formsView) showSettings() { f.sel = -1 f.onSettings = true f.editor = nil f.note.SetText("These are the directory's own settings. An empty field is one the file does not set, and krino's default applies.") box := gtk.NewBox(gtk.OrientationVertical, 6) box.SetMarginStart(8) box.SetMarginEnd(8) box.SetMarginTop(6) box.SetMarginBottom(6) f.settingRows = nil for _, head := range model.DirSettings { args, _, err := f.owner.rules.Setting(head) if err != nil { f.owner.setCheck("settings: " + err.Error()) return } row := newSettingRow(head, args, f.armSettings) f.settingRows = append(f.settingRows, row) box.Append(row.root) } if child := f.place.FirstChild(); child != nil { f.place.Remove(child) } f.place.Append(box) } // armSettings writes the settings back after the same pause a form edit // waits. func (f *formsView) armSettings() { if f.pending != 0 { glib.SourceRemove(f.pending) } f.pending = glib.TimeoutAdd(checkDelay, func() bool { f.pending = 0 f.applySettings() return false }) } // applySettings writes every setting whose field has changed. func (f *formsView) applySettings() { changed := false for _, row := range f.settingRows { args := row.value() was, _, err := f.owner.rules.Setting(row.head) if err != nil { f.owner.setCheck("settings: " + err.Error()) return } if args == was { continue } if err := f.owner.rules.SetSetting(row.head, args); err != nil { f.owner.setCheck("settings: " + err.Error()) row.set(was) continue } changed = true } if changed { f.owner.textChangedByForm() } } // settingRow is one directory setting: its name, and what the file writes // for it. A setting with fixed choices gets them; the rest are written as // they are, so no value is out of reach. type settingRow struct { root *gtk.Box head string entry *gtk.Entry drop *gtk.DropDown items []string } // settingChoices are the settings whose values are a fixed few. var settingChoices = map[string][]string{ "recursive": {"", "yes", "no"}, "case": {"", "ignore", "strict"}, "fold": {"", "yes", "no"}, "on-conflict": {"", "suffix", "skip", "overwrite"}, } // settingHints is the example shown in an empty field. var settingHints = map[string]string{ "path": `"~/downloads"`, "max-depth": `3`, "min-age": `2m (0, 30s, 2m, 1h, 1d, 1w)`, "max-read": `50M (0 reads it all)`, "max-size": `2G`, "busy": `".part" ".aria2" ".crdownload"`, "ignore": `"*.part" ".*"`, } func newSettingRow(head, args string, changed func()) *settingRow { r := &settingRow{head: head} r.root = gtk.NewBox(gtk.OrientationHorizontal, 6) label := gtk.NewLabel(head) label.SetXAlign(0) label.SetSizeRequest(110, -1) r.root.Append(label) if items, ok := settingChoices[head]; ok { r.items = items shown := make([]string, len(items)) copy(shown, items) shown[0] = "default" r.drop = gtk.NewDropDownFromStrings(shown) r.drop.SetSelected(uint(indexOf(items, args))) if indexOf(items, args) < 0 { r.drop.SetSelected(0) } r.drop.Connect("notify::selected", func() { changed() }) r.root.Append(r.drop) return r } r.entry = gtk.NewEntry() r.entry.SetText(args) r.entry.SetHExpand(true) r.entry.SetPlaceholderText(settingHints[head]) r.entry.SetTooltipText(settingHints[head]) r.entry.ConnectChanged(func() { changed() }) r.root.Append(r.entry) return r } // value is what the row would write; "" means the setting is left out. func (r *settingRow) value() string { if r.drop != nil { return r.items[r.drop.Selected()] } return strings.TrimSpace(r.entry.Text()) } // set puts a value back, after a refused edit. func (r *settingRow) set(args string) { if r.drop != nil { if i := indexOf(r.items, args); i >= 0 { r.drop.SetSelected(uint(i)) } return } r.entry.SetText(args) } // formLabel is one line of the list: what the form is, and what it does. func formLabel(f model.Form) string { if f.Kind == model.ExcludeForm { return "exclude: " + f.Label } var what []string for _, a := range f.Rule.Actions { what = append(what, a.Kind.String()) } if len(what) == 0 { return f.Label } return f.Label + " (" + strings.Join(what, ", ") + ")" } // show builds the editor for form i. func (f *formsView) show(i int) { if i < 0 || i >= len(f.forms) { return } f.sel = i f.onSettings = false form := f.forms[i] f.note.SetText("") if has, err := f.owner.rules.FormHasComments(i); err == nil && has { f.note.SetText("This form has comments inside it. The form editor writes it back from what krino parsed, so those comments would be dropped - the Text tab keeps them.") } f.editor = newFormEditor(form, f.armApply) if child := f.place.FirstChild(); child != nil { f.place.Remove(child) } f.place.Append(f.editor.root) } // clearEditor empties the middle pane. func (f *formsView) clearEditor(text string) { f.editor = nil if child := f.place.FirstChild(); child != nil { f.place.Remove(child) } f.note.SetText(text) } // armApply writes the form back after a pause, so typing in a field does // not rewrite the file on every keystroke. func (f *formsView) armApply() { if f.pending != 0 { glib.SourceRemove(f.pending) } f.pending = glib.TimeoutAdd(checkDelay, func() bool { f.pending = 0 f.apply() return false }) } // apply replaces the selected form's text with what the editor holds. func (f *formsView) apply() { if f.editor == nil || f.sel < 0 { return } text, err := f.editor.text() if err != nil { f.owner.setCheck("form: " + err.Error()) return } if !f.confirmComments() { return } if err := f.owner.rules.ReplaceForm(f.sel, text); err != nil { f.owner.setCheck("form: " + err.Error()) return } f.owner.textChangedByForm() f.refreshLabels() } // confirmComments asks once per form before an edit drops the comments // inside it (GUI design §5.1). func (f *formsView) confirmComments() bool { has, err := f.owner.rules.FormHasComments(f.sel) if err != nil || !has { return true } key := fmt.Sprintf("%d:%s", f.sel, f.forms[f.sel].Label) if f.commentsAcknowledged[key] { return true } d := gtk.NewMessageDialog(&f.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent, gtk.MessageWarning, gtk.ButtonsNone) d.SetObjectProperty("text", "This form has comments inside it") d.SetObjectProperty("secondary-text", "Editing it here writes it back from what krino parsed, which drops those comments. The Text tab keeps them.") d.AddButton("Go to the Text tab", int(gtk.ResponseCancel)) d.AddButton("Edit here anyway", int(gtk.ResponseAccept)) d.ConnectResponse(func(response int) { d.Destroy() if response == int(gtk.ResponseAccept) { f.commentsAcknowledged[key] = true f.apply() return } f.owner.showText() }) d.Show() return false } // refreshLabels re-reads the forms so the list shows what the edit did, // keeping the selection. func (f *formsView) refreshLabels() { forms, err := f.owner.formsOf() if err != nil || len(forms) != len(f.forms) { return } f.forms = forms for i, form := range forms { if row := f.list.RowAtIndex(i); row != nil { if label, ok := row.Child().(*gtk.Label); ok { label.SetText(escape(formLabel(form))) } } } } // onAdd puts a new rule after the selected one and opens it. func (f *formsView) onAdd() { name := "new rule" for i := 2; f.nameTaken(name); i++ { name = fmt.Sprintf("new rule %d", i) } i, err := f.owner.rules.AddRuleAfter(f.sel, name) if err != nil { f.owner.setCheck("add: " + err.Error()) return } f.owner.textChangedByForm() f.sel = i if err := f.reload(); err != nil { f.owner.setCheck("add: " + err.Error()) } } // nameTaken reports whether a rule of that name is already in the file. func (f *formsView) nameTaken(name string) bool { for _, form := range f.forms { if form.Kind == model.RuleForm && form.Label == name { return true } } return false } // onDelete removes the selected form, after asking. func (f *formsView) onDelete() { if f.sel < 0 || f.sel >= len(f.forms) { return } form := f.forms[f.sel] d := gtk.NewMessageDialog(&f.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent, gtk.MessageQuestion, gtk.ButtonsNone) d.SetObjectProperty("text", "Delete "+escape(formLabel(form))+"?") d.SetObjectProperty("secondary-text", "It goes from the text in the editor, with the comment lines directly above it. Nothing is written until you press Save.") d.AddButton("Cancel", int(gtk.ResponseCancel)) del := d.AddButton("Delete", int(gtk.ResponseAccept)) if b, ok := del.(*gtk.Button); ok { b.AddCSSClass("destructive-action") } d.ConnectResponse(func(response int) { d.Destroy() if response != int(gtk.ResponseAccept) { return } if err := f.owner.rules.DeleteForm(f.sel); err != nil { f.owner.setCheck("delete: " + err.Error()) return } f.owner.textChangedByForm() if f.sel >= len(f.forms)-1 { f.sel = len(f.forms) - 2 } if err := f.reload(); err != nil { f.owner.setCheck("delete: " + err.Error()) } }) d.Show() } // move shifts the selected form one place up or down, with its comments. func (f *formsView) move(delta int) { if f.sel < 0 { return } to := f.sel + delta if to < 0 || to >= len(f.forms) { return } if err := f.owner.rules.MoveForm(f.sel, delta); err != nil { f.owner.setCheck("move: " + err.Error()) return } f.owner.textChangedByForm() f.sel = to if err := f.reload(); err != nil { f.owner.setCheck("move: " + err.Error()) } } // formEditor is the widgets of one form, and can write it back. type formEditor struct { kind model.FormKind root *gtk.Box name *gtk.Entry conds *gtk.Box rows []*condRow acts *gtk.Box arows []*actionRow stop *gtk.CheckButton cse *gtk.DropDown fold *gtk.DropDown onConf *gtk.DropDown changed func() } func newFormEditor(form model.Form, changed func()) *formEditor { fe := &formEditor{kind: form.Kind, changed: changed} fe.root = gtk.NewBox(gtk.OrientationVertical, 6) fe.root.SetMarginStart(8) fe.root.SetMarginEnd(8) fe.root.SetMarginTop(6) fe.root.SetMarginBottom(6) if form.Kind == model.RuleForm { fe.name = gtk.NewEntry() fe.name.SetText(form.Rule.Name) fe.name.SetHExpand(true) fe.name.ConnectChanged(func() { changed() }) fe.root.Append(field("Name", fe.name)) } fe.conds = gtk.NewBox(gtk.OrientationVertical, 4) fe.root.Append(heading("Conditions - all of these must hold")) fe.root.Append(fe.conds) addCond := gtk.NewButtonWithLabel("+ test") addCond.ConnectClicked(func() { fe.addCond(nil) changed() }) fe.root.Append(leftAligned(addCond)) when := form.Rule.When if form.Kind == model.ExcludeForm { when = form.Exclude.When } else if !form.Rule.HasWhen { when = nil } for _, c := range when { fe.addCond(c) } if form.Kind == model.RuleForm { fe.acts = gtk.NewBox(gtk.OrientationVertical, 4) fe.root.Append(heading("Actions - in order")) fe.root.Append(fe.acts) addAct := gtk.NewButtonWithLabel("+ action") addAct.ConnectClicked(func() { fe.addAction(config.Action{Kind: config.Move}) changed() }) fe.root.Append(leftAligned(addAct)) for _, a := range form.Rule.Actions { fe.addAction(a) } fe.stop = gtk.NewCheckButtonWithLabel("(stop) - a file this rule matches takes no later rule") fe.stop.SetActive(form.Rule.Stop) fe.stop.ConnectToggled(func() { changed() }) fe.root.Append(fe.stop) fe.root.Append(heading("Settings for this rule")) fe.cse = dropDown([]string{"default", "ignore", "strict"}, caseIndex(form.Rule.Settings), changed) fe.fold = dropDown([]string{"default", "yes", "no"}, foldIndex(form.Rule.Settings), changed) fe.onConf = dropDown([]string{"default", "suffix", "skip", "overwrite"}, conflictIndex(form.Rule.Settings), changed) fe.root.Append(field("case", fe.cse)) fe.root.Append(field("fold", fe.fold)) fe.root.Append(field("on-conflict", fe.onConf)) } return fe } // text is the form as the printer writes it, from what the widgets hold. func (fe *formEditor) text() (string, error) { var when []*sexp.Node for _, row := range fe.rows { if row.gone { continue } text := row.text() nodes, err := sexp.Parse("form", []byte(text)) if err != nil { return "", fmt.Errorf("%s: %v", text, err) } if len(nodes) != 1 { return "", fmt.Errorf("%s is not one condition", text) } when = append(when, nodes[0]) } if fe.kind == model.ExcludeForm { if len(when) == 0 { return "", fmt.Errorf("an exclude needs a condition") } return config.PrintExclude(&config.Exclude{When: when}), nil } rule := &config.Rule{ Name: fe.name.Text(), HasWhen: len(when) > 0, When: when, Stop: fe.stop.Active(), } for _, row := range fe.arows { if row.gone { continue } rule.Actions = append(rule.Actions, row.action()) } rule.Settings = settingsFrom(fe.cse, fe.fold, fe.onConf) return config.PrintRule(rule), nil } // addCond adds a condition row, filled in from n when there is one. func (fe *formEditor) addCond(n *sexp.Node) { row := newCondRow(n, fe.changed) fe.rows = append(fe.rows, row) fe.conds.Append(row.root) } // addAction adds an action row. func (fe *formEditor) addAction(a config.Action) { row := newActionRow(a, fe.changed) fe.arows = append(fe.arows, row) fe.acts.Append(row.root) } // condRow is one condition: its kind, and its arguments as written. type condRow struct { root *gtk.Box kind *gtk.DropDown args *gtk.Entry gone bool } func newCondRow(n *sexp.Node, changed func()) *condRow { c := &condRow{} c.root = gtk.NewBox(gtk.OrientationHorizontal, 6) c.kind = gtk.NewDropDownFromStrings(condKinds) c.args = gtk.NewEntry() c.args.SetHExpand(true) if n != nil { head, args := splitNode(n) if i := indexOf(condKinds, head); i >= 0 { c.kind.SetSelected(uint(i)) } c.args.SetText(args) } c.showHint() remove := gtk.NewButtonWithLabel("-") remove.ConnectClicked(func() { c.gone = true c.root.SetVisible(false) changed() }) c.kind.Connect("notify::selected", func() { c.showHint() changed() }) c.args.ConnectChanged(func() { changed() }) c.root.Append(c.kind) c.root.Append(c.args) c.root.Append(remove) return c } // text is the condition as it will be written. func (c *condRow) text() string { kind := condKinds[c.kind.Selected()] args := strings.TrimSpace(c.args.Text()) if args == "" { return "(" + kind + ")" } return "(" + kind + " " + args + ")" } // showHint puts the example for the chosen kind in the entry, where it // shows while the row is empty and as its tooltip once it is not. func (c *condRow) showHint() { hint := condHints[condKinds[c.kind.Selected()]] c.args.SetPlaceholderText(hint) c.args.SetTooltipText(hint) } // actionRow is one action: what it does, and its argument. type actionRow struct { root *gtk.Box kind *gtk.DropDown arg *gtk.Entry gone bool } func newActionRow(a config.Action, changed func()) *actionRow { r := &actionRow{} r.root = gtk.NewBox(gtk.OrientationHorizontal, 6) r.kind = gtk.NewDropDownFromStrings(actionKinds) r.kind.SetSelected(uint(actionIndex(a.Kind))) r.arg = gtk.NewEntry() r.arg.SetText(a.Arg) r.arg.SetHExpand(true) r.arg.SetPlaceholderText("Invoices/{mtime:%Y} - {name} {ext} {1} {mtime:FMT} {now:FMT}") r.setArgSensitive() remove := gtk.NewButtonWithLabel("-") remove.ConnectClicked(func() { r.gone = true r.root.SetVisible(false) changed() }) r.kind.Connect("notify::selected", func() { r.setArgSensitive() changed() }) r.arg.ConnectChanged(func() { changed() }) r.root.Append(r.kind) r.root.Append(r.arg) r.root.Append(remove) return r } // setArgSensitive turns the argument off for the two deletes, which take // none. func (r *actionRow) setArgSensitive() { kind := actionKinds[r.kind.Selected()] r.arg.SetSensitive(kind != "delete" && kind != "delete permanent") } // action is the action the row holds. func (r *actionRow) action() config.Action { switch actionKinds[r.kind.Selected()] { case "copy": return config.Action{Kind: config.Copy, Arg: r.arg.Text()} case "rename": return config.Action{Kind: config.Rename, Arg: r.arg.Text()} case "delete": return config.Action{Kind: config.Delete} case "delete permanent": return config.Action{Kind: config.DeletePermanent} } return config.Action{Kind: config.Move, Arg: r.arg.Text()} } // settingsFrom reads the three rule settings; "default" leaves one out. func settingsFrom(cse, fold, onConf *gtk.DropDown) config.Settings { var s config.Settings switch cse.Selected() { case 1: m := config.CaseIgnore s.Case = &m case 2: m := config.CaseStrict s.Case = &m } switch fold.Selected() { case 1: yes := true s.Fold = &yes case 2: no := false s.Fold = &no } switch onConf.Selected() { case 1: c := config.ConflictSuffix s.OnConflict = &c case 2: c := config.ConflictSkip s.OnConflict = &c case 3: c := config.ConflictOverwrite s.OnConflict = &c } return s } func caseIndex(s config.Settings) int { if s.Case == nil { return 0 } if *s.Case == config.CaseStrict { return 2 } return 1 } func foldIndex(s config.Settings) int { if s.Fold == nil { return 0 } if *s.Fold { return 1 } return 2 } func conflictIndex(s config.Settings) int { if s.OnConflict == nil { return 0 } switch *s.OnConflict { case config.ConflictSkip: return 2 case config.ConflictOverwrite: return 3 } return 1 } func actionIndex(k config.ActionKind) int { switch k { case config.Copy: return 0 case config.Rename: return 2 case config.Delete: return 3 case config.DeletePermanent: return 4 } return 1 } // splitNode is a condition's head and the rest of it as written, which is // what a row's entry holds. func splitNode(n *sexp.Node) (head, args string) { if n.Kind != sexp.List || len(n.Children) == 0 { return "", n.String() } parts := make([]string, 0, len(n.Children)-1) for _, c := range n.Children[1:] { parts = append(parts, c.String()) } return n.Head(), strings.Join(parts, " ") } func indexOf(ss []string, s string) int { for i, v := range ss { if v == s { return i } } return -1 } // field is a labelled row of the form. func field(label string, child gtk.Widgetter) *gtk.Box { box := gtk.NewBox(gtk.OrientationHorizontal, 6) l := gtk.NewLabel(label) l.SetXAlign(0) l.SetSizeRequest(90, -1) box.Append(l) box.Append(child) return box } // heading is a section title inside the form. func heading(text string) *gtk.Label { l := gtk.NewLabel(text) l.SetXAlign(0) l.SetMarginTop(6) l.AddCSSClass("heading") return l } // leftAligned keeps a button from stretching across the form. func leftAligned(w gtk.Widgetter) *gtk.Box { box := gtk.NewBox(gtk.OrientationHorizontal, 0) box.Append(w) return box } // dropDown is a combo with its starting choice and change handler. func dropDown(items []string, selected int, changed func()) *gtk.DropDown { d := gtk.NewDropDownFromStrings(items) d.SetSelected(uint(selected)) d.Connect("notify::selected", func() { changed() }) return d }