From 8716c479e90bcf6ff9454c5e995547d2cdf72d71 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 8 Apr 2023 21:18:24 -0700 Subject: [PATCH 1/2] ListBox, ScrollBar, Magic Form and forms demo --- README.md | 19 +- eg/README.md | 16 +- eg/forms/README.md | 9 + eg/forms/main.go | 353 ++++++++++++++++++++-------- eg/forms/screenshot.png | Bin 0 -> 30767 bytes functions.go | 5 +- go.mod | 6 +- go.sum | 35 +++ listbox.go | 309 ++++++++++++++++++++++++ magicform/magicform.go | 503 ++++++++++++++++++++++++++++++++++++++++ scrollbar.go | 254 ++++++++++++++++++++ style/button.go | 24 ++ supervisor.go | 50 ++-- theme/theme.go | 2 + 14 files changed, 1456 insertions(+), 129 deletions(-) create mode 100644 eg/forms/README.md create mode 100644 eg/forms/screenshot.png create mode 100644 listbox.go create mode 100644 magicform/magicform.go create mode 100644 scrollbar.go diff --git a/README.md b/README.md index ae2d0e2..6baa1a6 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,15 @@ most complex. **Fully implemented widgets:** +In order of simplicity: + * [x] **BaseWidget**: the base class of all Widgets. * The `Widget` interface describes the functions common to all Widgets, such as SetBackground, Configure, MoveTo, Resize, and so on. * BaseWidget provides sane default implementations for all the methods required by the Widget interface. Most Widgets inherit from - the BaseWidget. -* [x] **Frame**: a layout wrapper for other widgets. + the BaseWidget and override what they need. +* [x] **Frame**: a layout wrapper for child widgets. * Pack() lets you add child widgets to the Frame, aligned against one side or another, and ability to expand widgets to take up remaining space in their part of the Frame. @@ -153,6 +155,10 @@ most complex. a modal pop-up by the MenuButton and MenuBar. [Example](eg/menus) * [x] **SelectBox**: a kind of MenuButton that lets the user choose a value from a list of possible values. +* [x] **Scrollbar**: a Frame including a trough, scroll buttons and a + draggable slider. +* [x] **ListBox**: a multi-line select box with a ScrollBar that can hold arbitrary + child widgets (usually Labels which have a shortcut function for). Some useful helper widgets: @@ -161,16 +167,11 @@ Some useful helper widgets: custom hexadecimal value by hand (needs assistance from your program). [Example](eg/colorpicker) -**Work in progress widgets:** - -* [ ] **Scrollbar**: a Frame including a trough, scroll buttons and a - draggable slider. - -**Wish list for the longer-term future:** +**Planned widgets:** * [ ] **TextBox:** an editable text field that the user can focus and type a value into. - * Would depend on the WindowManager to manage focus for the widgets. +* [ ] **TextArea:** an editable multi-line text field with a scrollbar. ## Supervisor for Interaction diff --git a/eg/README.md b/eg/README.md index d10c3fc..41c6235 100644 --- a/eg/README.md +++ b/eg/README.md @@ -3,17 +3,13 @@ Here are some example programs using go/ui, each accompanied by a screenshot and description: -* [Hello, World!](hello-world/): a basic UI demo with a Label and a - Button. -* [Frame Place()](frame-place/): demonstrates using the Place() layout - management option for Frame widgets. -* [Window Manager](windows/): demonstrates the Window widget and window - management features of the Supervisor. -* [Tooltip](tooltip/): demonstrates the Tooltip widget on a variety of buttons - scattered around the window. +* [Hello, World!](hello-world/): a basic UI demo with a Label and a Button. +* [Frame Placement](frame-place/): demonstrates using the Place() layout management option for Frame widgets. +* [Window Manager](windows/): demonstrates the Window widget and window management features of the Supervisor. +* [Forms](forms/): demonstrates some form controls and the `magicform` helper module for building forms quickly. +* [Tooltip](tooltip/): demonstrates the Tooltip widget on a variety of buttons scattered around the window. * [Menus](menus/): demonstrates various Menu Buttons and a Menu Bar. -* [Themes](themes/): a UI demo that shows off the Default, Flat, and Dark UI - themes as part of experimental theming support. +* [Themes](themes/): a UI demo that shows off the Default, Flat, and Dark UI themes as part of experimental theming support. * [TabFrame](tabframe/): demo for the TabFrame widget showing multiple Windows with tabbed interfaces. * [ColorPicker](colorpicker/): demo for the ColorPicker widget. diff --git a/eg/forms/README.md b/eg/forms/README.md new file mode 100644 index 0000000..d64386c --- /dev/null +++ b/eg/forms/README.md @@ -0,0 +1,9 @@ +# Forms + +A demonstration of form controls in `go/ui` and example how to use the `magicform` helper module. + +```bash +go run main.go +``` + +![Screenshot](screenshot.png) diff --git a/eg/forms/main.go b/eg/forms/main.go index 161f342..63a9591 100644 --- a/eg/forms/main.go +++ b/eg/forms/main.go @@ -6,109 +6,280 @@ import ( "git.kirsle.net/go/render" "git.kirsle.net/go/render/sdl" "git.kirsle.net/go/ui" + "git.kirsle.net/go/ui/magicform" + "git.kirsle.net/go/ui/style" ) func init() { sdl.DefaultFontFilename = "../DejaVuSans.ttf" } +var ( + MenuFont = render.Text{ + Size: 12, + PadX: 4, + PadY: 2, + } + TabFont = render.Text{ + Size: 12, + PadX: 4, + PadY: 2, + } +) + +var ButtonStylePrimary = &style.Button{ + Background: render.RGBA(0, 60, 153, 255), + Foreground: render.White, + HoverBackground: render.RGBA(0, 153, 255, 255), + HoverForeground: render.White, + OutlineColor: render.DarkGrey, + OutlineSize: 1, + BorderStyle: style.BorderRaised, + BorderSize: 2, +} + func main() { - mw, err := ui.NewMainWindow("Forms Test") + mw, err := ui.NewMainWindow("Forms Test", 500, 375) if err != nil { panic(err) } - mw.SetBackground(render.White) + // Tabbed UI. + tabFrame := ui.NewTabFrame("Tabs") + makeAppFrame(mw, tabFrame) + makeAboutFrame(mw, tabFrame) - // Buttons row. - { - frame := ui.NewFrame("Frame 1") - mw.Pack(frame, ui.Pack{ - Side: ui.N, - FillX: true, - Padding: 4, - }) - label := ui.NewLabel(ui.Label{ - Text: "Buttons:", - }) - frame.Pack(label, ui.Pack{ - Side: ui.W, - }) - - // Buttons. - btn := ui.NewButton("Button 1", ui.NewLabel(ui.Label{ - Text: "Click me!", - })) - btn.Handle(ui.Click, func(ed ui.EventData) error { - fmt.Println("Clicked!") - return nil - }) - frame.Pack(btn, ui.Pack{ - Side: ui.W, - PadX: 4, - }) - - mw.Supervisor().Add(btn) - } - - // Selectbox row. - { - frame := ui.NewFrame("Frame 2") - mw.Pack(frame, ui.Pack{ - Side: ui.N, - FillX: true, - Padding: 4, - }) - label := ui.NewLabel(ui.Label{ - Text: "Set window color:", - }) - frame.Pack(label, ui.Pack{ - Side: ui.W, - }) - - var colors = []struct{ - Label string - Value render.Color - }{ - {"White", render.White}, - {"Yellow", render.Yellow}, - {"Cyan", render.Cyan}, - {"Green", render.Green}, - {"Blue", render.RGBA(0, 153, 255, 255)}, - {"Pink", render.Pink}, - } - - // Create the SelectBox and populate its options. - sel := ui.NewSelectBox("Select 1", ui.Label{}) - for _, option := range colors { - sel.AddItem(option.Label, option.Value, func() { - fmt.Printf("Picked option: %s\n", option.Value) - }) - } - - // On change: set the window BG color. - sel.Handle(ui.Change, func(ed ui.EventData) error { - if val, ok := sel.GetValue(); ok { - if color, ok := val.Value.(render.Color); ok { - fmt.Printf("Set background to: %s\n", val.Label) - mw.SetBackground(color) - } else { - fmt.Println("Not a valid color!") - } - } else { - fmt.Println("Not a valid SelectBox value!") - } - return nil - }) - - frame.Pack(sel, ui.Pack{ - Side: ui.W, - PadX: 4, - }) - sel.Supervise(mw.Supervisor()) - mw.Supervisor().Add(sel) // TODO: ideally Supervise() is all that's needed, - // but w/o this extra Add() the Button doesn't react. - } + tabFrame.Supervise(mw.Supervisor()) + mw.Pack(tabFrame, ui.Pack{ + Side: ui.N, + Expand: true, + Padding: 10, + }) + mw.SetBackground(render.Grey) mw.MainLoop() } + +func makeAppFrame(mw *ui.MainWindow, tf *ui.TabFrame) *ui.Frame { + frame := tf.AddTab("Index", ui.NewLabel(ui.Label{ + Text: "Form Controls", + Font: TabFont, + })) + + // Form variables + var ( + bgcolor = render.Grey + letter string + checkBool1 bool + checkBool2 = true + pagerLabel = "Page 1 of 20" + ) + + // Magic Form is a handy module for easily laying out forms of widgets. + form := magicform.Form{ + Supervisor: mw.Supervisor(), + Engine: mw.Engine, + Vertical: true, + LabelWidth: 120, + PadY: 2, + PadX: 8, + } + + // You add to it a list of fields which support all sorts of different + // form control types. + fields := []magicform.Field{ + // Simple text sections - you can write paragraphs or use a bold font + // to make section labels that span the full width of your frame. + { + Label: "Checkbox controls bound to bool values:", + Font: MenuFont, + }, + + // Checkbox widgets: just bind a BoolVariable and this row will draw + // with a checkbox next to a label. + { + Label: "Check this box to toggle a boolean", + Font: MenuFont, + BoolVariable: &checkBool1, + OnClick: func() { + fmt.Printf("The checkbox was clicked! Value is now: %+v\n", checkBool1) + }, + }, + { + Label: "Uncheck this one", + Font: MenuFont, + BoolVariable: &checkBool2, + OnClick: func() { + fmt.Printf("The checkbox was clicked! Value is now: %+v\n", checkBool1) + }, + }, + + // SelectBox widgets: just bind a SelectValue and provide Options and + // it will draw with a label (LabelWidth wide) next to a SelectBox button. + { + Label: "Window color:", + Font: MenuFont, + SelectValue: &bgcolor, + Options: []magicform.Option{ + { + Label: "Grey", + Value: render.Grey, + }, + { + Label: "White", + Value: render.White, + }, + { + Label: "Yellow", + Value: render.Yellow, + }, + { + Label: "Cyan", + Value: render.Cyan, + }, + { + Label: "Green", + Value: render.Green, + }, + { + Label: "Blue", + Value: render.RGBA(0, 153, 255, 255), + }, + { + Label: "Pink", + Value: render.Pink, + }, + }, + OnSelect: func(v interface{}) { + value, _ := v.(render.Color) + mw.SetBackground(value) + }, + }, + + // ListBox widgets + { + Type: magicform.Listbox, + Label: "Favorite letter:", + Font: MenuFont, + SelectValue: &letter, + Options: []magicform.Option{ + {Label: "A is for apple", Value: "A"}, + {Label: "B is for boy", Value: "B"}, + {Label: "C is for cat", Value: "C"}, + {Label: "D is for dog", Value: "D"}, + {Label: "E is for elephant", Value: "E"}, + {Label: "F is for far", Value: "F"}, + {Label: "G is for ghost", Value: "G"}, + {Label: "H is for high", Value: "H"}, + {Label: "I is for inside", Value: "I"}, + {Label: "J is for joker", Value: "J"}, + {Label: "K is for kangaroo", Value: "K"}, + {Label: "L is for lion", Value: "L"}, + {Label: "M is for mouse", Value: "M"}, + {Label: "N is for night", Value: "N"}, + {Label: "O is for over", Value: "O"}, + {Label: "P is for parry", Value: "P"}, + {Label: "Q is for quarry", Value: "Q"}, + {Label: "R is for reality", Value: "R"}, + {Label: "S is for sunshine", Value: "S"}, + {Label: "T is for tree", Value: "T"}, + {Label: "U is for under", Value: "U"}, + {Label: "V is for vehicle", Value: "V"}, + {Label: "W is for watermelon", Value: "W"}, + {Label: "X is for xylophone", Value: "X"}, + {Label: "Y is for yellow", Value: "Y"}, + {Label: "Z is for zebra", Value: "Z"}, + }, + OnSelect: func(v interface{}) { + value, _ := v.(string) + fmt.Printf("You clicked on: %s\n", value) + }, + }, + + // Pager rows to show an easy paginated UI. + // TODO: this is currently broken and Supervisor doesn't pick it up + { + Label: "A paginator when you need one. You can limit MaxPageButtons\n" + + "and the right arrow can keep selecting past the last page.", + }, + { + LabelVariable: &pagerLabel, + Label: "Page:", + Pager: ui.NewPager(ui.Pager{ + Page: 1, + Pages: 20, + PerPage: 10, + MaxPageButtons: 8, + Font: MenuFont, + OnChange: func(page, perPage int) { + fmt.Printf("Pager clicked: page=%d perPage=%d\n", page, perPage) + pagerLabel = fmt.Sprintf("Page %d of %d", page, 20) + }, + }), + }, + + // Simple variable bindings. + { + Type: magicform.Value, + Label: "The first bool var:", + TextVariable: &pagerLabel, + }, + + // Buttons for the bottom of your form. + { + Buttons: []magicform.Field{ + { + Label: "Save", + ButtonStyle: ButtonStylePrimary, + Font: MenuFont, + OnClick: func() { + fmt.Println("Primary button clicked") + }, + }, + { + Label: "Cancel", + Font: MenuFont, + OnClick: func() { + fmt.Println("Secondary button clicked") + }, + }, + }, + }, + } + + form.Create(frame, fields) + return frame +} + +func makeAboutFrame(mw *ui.MainWindow, tf *ui.TabFrame) *ui.Frame { + frame := tf.AddTab("About", ui.NewLabel(ui.Label{ + Text: "About", + Font: TabFont, + })) + + form := magicform.Form{ + Supervisor: mw.Supervisor(), + Engine: mw.Engine, + Vertical: true, + LabelWidth: 120, + PadY: 2, + PadX: 8, + } + + fields := []magicform.Field{ + { + Label: "About", + Font: MenuFont, + }, + + { + Label: "This example shows off the UI toolkit's use for form controls,\n" + + "and how the magicform helper module can make simple forms\n" + + "easy to compose quickly.", + Font: MenuFont, + }, + } + + form.Create(frame, fields) + return frame +} diff --git a/eg/forms/screenshot.png b/eg/forms/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..a30268e682ab010dbbd6a4a44f1bf71597abeb28 GIT binary patch literal 30767 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𝔫Db2pDicv@t6;NOkxG#K_#n+RpJEBY)Ug? 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 literal 0 HcmV?d00001 diff --git a/functions.go b/functions.go index 91f340e..77f7248 100644 --- a/functions.go +++ b/functions.go @@ -12,6 +12,7 @@ func AbsolutePosition(w Widget) render.Point { var ( node = w ok bool + pt render.Point ) for { @@ -20,7 +21,9 @@ func AbsolutePosition(w Widget) render.Point { return abs } - abs.Add(node.Point()) + pt = node.Point() + pt.Add(render.NewPoint(node.BorderSize(), node.BorderSize())) + abs.Add(pt) } } diff --git a/go.mod b/go.mod index 0960981..7f08d07 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.kirsle.net/go/ui go 1.16 require ( - git.kirsle.net/go/render v0.0.0-20211231003948-9e640ab5c3da - github.com/veandco/go-sdl2 v0.4.8 // indirect - golang.org/x/image v0.0.0-20211028202545-6944b10bf410 + git.kirsle.net/go/render v0.0.0-20220505053906-129a24300dfa + github.com/veandco/go-sdl2 v0.4.33 // indirect + golang.org/x/image v0.6.0 ) diff --git a/go.sum b/go.sum index f23c9c7..555fd9c 100644 --- a/go.sum +++ b/go.sum @@ -4,11 +4,18 @@ git.kirsle.net/go/render v0.0.0-20210614025954-d77f5056b782 h1:Ko+NvZxmJbW+M1dA2 git.kirsle.net/go/render v0.0.0-20210614025954-d77f5056b782/go.mod h1:ss7pvZbGWrMaDuZwyUTjV9+T0AJwAkxZZHwMFsvHrkk= git.kirsle.net/go/render v0.0.0-20211231003948-9e640ab5c3da h1:wbeh/hHiwmXqf/3VPrbE/PADTcT1niQWhxxK81Ize3o= git.kirsle.net/go/render v0.0.0-20211231003948-9e640ab5c3da/go.mod h1:ss7pvZbGWrMaDuZwyUTjV9+T0AJwAkxZZHwMFsvHrkk= +git.kirsle.net/go/render v0.0.0-20220505053906-129a24300dfa h1:Oa99SXkmFGnUNy+toPMQyW/eYotN1nZ9BWAThQ/huiM= +git.kirsle.net/go/render v0.0.0-20220505053906-129a24300dfa/go.mod h1:ss7pvZbGWrMaDuZwyUTjV9+T0AJwAkxZZHwMFsvHrkk= github.com/veandco/go-sdl2 v0.4.1/go.mod h1:FB+kTpX9YTE+urhYiClnRzpOXbiWgaU3+5F2AB78DPg= github.com/veandco/go-sdl2 v0.4.7 h1:VfpCM+LfEGDbHdByglCo2bcBsevjFvzl8W0f6VLNitg= github.com/veandco/go-sdl2 v0.4.7/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= github.com/veandco/go-sdl2 v0.4.8 h1:A26KeX6R1CGt/BQGEov6oxYmVGMMEWDVqTvK1tXvahE= github.com/veandco/go-sdl2 v0.4.8/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= +github.com/veandco/go-sdl2 v0.4.33 h1:cxQ0OdUBEByHxvCyrGxy9F8WpL38Ya6hzV4n27QL84M= +github.com/veandco/go-sdl2 v0.4.33/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20210504121937-7319ad40d33e h1:PzJMNfFQx+QO9hrC1GwZ4BoPGeNGhfeQEgcQFArEjPk= golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -16,6 +23,34 @@ golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jp golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= +golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/listbox.go b/listbox.go new file mode 100644 index 0000000..472cd4a --- /dev/null +++ b/listbox.go @@ -0,0 +1,309 @@ +package ui + +import ( + "fmt" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui/style" +) + +// ListBox is a selectable list of values like a multi-line SelectBox. +type ListBox struct { + *Frame + name string + children []*ListValue + style *style.ListBox + supervisor *Supervisor + + list *Frame + scrollbar *ScrollBar + scrollFraction float64 + maxHeight int + + // Variable bindings: give these pointers to your values. + Variable interface{} // pointer to e.g. a string or int + // TextVariable *string // string value + // IntVariable *int // integer value +} + +// ListValue is an item in the ListBox. It has an arbitrary widget as a +// "label" (usually a Label) and a value (string or int) when it's "selected" +type ListValue struct { + Frame *Frame + Label Widget + Value interface{} +} + +// NewListBox creates a new ListBox. +func NewListBox(name string, config ListBox) *ListBox { + w := &ListBox{ + Frame: NewFrame(name + " Frame"), + list: NewFrame(name + " List"), + name: name, + children: []*ListValue{}, + Variable: config.Variable, + // TextVariable: config.TextVariable, + // IntVariable: config.IntVariable, + style: &style.DefaultListBox, + } + + // if config.Width > 0 && config.Height > 0 { + // w.Frame.Resize(render.NewRect(config.Width, config.Height)) + // } + + w.IDFunc(func() string { + return fmt.Sprintf("ListBox<%s>", name) + }) + + w.SetStyle(Theme.ListBox) + + w.setup() + return w +} + +// SetStyle sets the listbox style. +func (w *ListBox) SetStyle(v *style.ListBox) { + if v == nil { + v = &style.DefaultListBox + } + + w.style = v + fmt.Printf("set style: %+v\n", v) + w.Frame.Configure(Config{ + BorderSize: w.style.BorderSize, + BorderStyle: BorderStyle(w.style.BorderStyle), + Background: w.style.Background, + }) + + // If the child is a Label, apply the foreground color. + // if label, ok := w.child.(*Label); ok { + // label.Font.Color = w.style.Foreground + // } +} + +// GetStyle gets the listbox style. +func (w *ListBox) GetStyle() *style.ListBox { + return w.style +} + +// Supervise the ListBox. This is necessary for granting mouse-over events +// to the items in the list. +func (w *ListBox) Supervise(s *Supervisor) { + w.supervisor = s + w.scrollbar.Supervise(s) + + // Add all the list items to be supervised. + for _, c := range w.children { + w.supervisor.Add(c.Frame) + } +} + +// AddLabel adds a simple text-based label to the Listbox. +// The label is the text value to display. +// The value is the underlying value (string or int) for the TextVariable or IntVariable. +// The function callback runs when the option is picked. +func (w *ListBox) AddLabel(label string, value interface{}, f func()) { + row := NewFrame(label + " Frame") + + child := NewLabel(Label{ + Text: label, + Font: render.Text{ + Color: w.style.Foreground, + Size: 11, + Padding: 2, + }, + }) + row.Pack(child, Pack{ + Side: W, + FillX: true, + }) + + // Add this label and its value mapping to the ListBox. + w.children = append(w.children, &ListValue{ + Frame: row, + Label: child, + Value: value, + }) + + // Event handlers for the item row. + // row.Handle(MouseOver, func(ed EventData) error { + // if ed.Point.Inside(AbsoluteRect(w.scrollbar)) { + // return nil // ignore if over scrollbar + // } + + // row.SetBackground(w.style.HoverBackground) + // child.Font.Color = w.style.HoverForeground + // return nil + // }) + row.Handle(MouseMove, func(ed EventData) error { + if ed.Point.Inside(AbsoluteRect(w.scrollbar)) { + // we wandered onto the scrollbar, cancel mouseover + return row.Event(MouseOut, ed) + } + row.SetBackground(w.style.HoverBackground) + child.Font.Color = w.style.HoverForeground + return nil + }) + row.Handle(MouseOut, func(ed EventData) error { + if cur, ok := w.GetValue(); ok && cur == value { + row.SetBackground(w.style.SelectedBackground) + child.Font.Color = w.style.SelectedForeground + } else { + fmt.Printf("couldn't get value? %+v %+v\n", cur, ok) + row.SetBackground(w.style.Background) + child.Font.Color = w.style.Foreground + } + return nil + }) + row.Handle(MouseUp, func(ed EventData) error { + if cur, ok := w.GetValue(); ok && cur == value { + row.SetBackground(w.style.SelectedBackground) + child.Font.Color = w.style.SelectedForeground + } else { + row.SetBackground(w.style.Background) + child.Font.Color = w.style.Foreground + } + return nil + }) + + row.Handle(Click, func(ed EventData) error { + // Trigger if we are not hovering over the (overlapping) scrollbar. + if !ed.Point.Inside(AbsoluteRect(w.scrollbar)) { + w.Event(Change, EventData{ + Supervisor: w.supervisor, + Value: value, + }) + } + return nil + }) + + // Append the item into the ListBox frame. + w.Frame.Pack(row, Pack{ + Side: N, + PadY: 1, + Fill: true, + }) + + // If the current text label isn't in the options, pick + // the first option. + if _, ok := w.GetValue(); !ok { + w.Variable = w.children[0].Value + row.SetBackground(w.style.SelectedBackground) + } +} + +// TODO: RemoveItem() + +// GetValue returns the currently selected item in the ListBox. +// +// Returns the SelectValue and true on success, and the Label or underlying Value +// can be read from the SelectValue struct. If no valid option is selected, the +// bool value returns false. +func (w *ListBox) GetValue() (*ListValue, bool) { + for _, row := range w.children { + if w.Variable != nil && w.Variable == row.Value { + return row, true + } + } + return nil, false +} + +// SetValueByLabel sets the currently selected option to the given label. +func (w *ListBox) SetValueByLabel(label string) bool { + for _, option := range w.children { + if child, ok := option.Label.(*Label); ok && child.Text == label { + w.Variable = option.Value + return true + } + } + return false +} + +// SetValue sets the currently selected option to the given value. +func (w *ListBox) SetValue(value interface{}) bool { + w.Variable = value + for _, option := range w.children { + if option.Value == value { + w.Variable = option.Value + return true + } + } + return false +} + +// Compute to re-evaluate the button state (in the case of radio buttons where +// a different button will affect the state of this one when clicked). +func (w *ListBox) Compute(e render.Engine) { + w.computeVisible() + w.Frame.Compute(e) +} + +// setup the UI components and event handlers. +func (w *ListBox) setup() { + // w.Configure(Config{ + // BorderSize: 1, + // BorderStyle: BorderSunken, + // Background: theme.InputBackgroundColor, + // }) + w.scrollbar = NewScrollBar(ScrollBar{}) + w.scrollbar.Handle(Scroll, func(ed EventData) error { + fmt.Printf("Scroll event: %f%% unit %d\n", ed.ScrollFraction*100, ed.ScrollUnits) + w.scrollFraction = ed.ScrollFraction + return nil + }) + w.Frame.Pack(w.scrollbar, Pack{ + Side: E, + FillY: true, + Padding: 0, + }) + // w.Frame.Pack(w.list, Pack{ + // Side: E, + // FillY: true, + // Expand: true, + // }) +} + +// Compute which items of the list should be visible based on scroll position. +func (w *ListBox) computeVisible() { + if len(w.children) == 0 { + return + } + + // Sample the first element's height. + var ( + myHeight = w.height + maxTop = w.maxHeight - myHeight + w.children[len(w.children)-1].Frame.height + top = int(w.scrollFraction * float64(maxTop)) + // itemHeight = w.children[0].Label.Size().H + ) + + var ( + scan int + scrollFreed int + totalHeight int + ) + for _, c := range w.children { + childHeight := c.Frame.Size().H + 2 + if top > 0 && scan+childHeight < top { + scrollFreed += childHeight + c.Frame.Hide() + } else if scan+childHeight > myHeight+scrollFreed { + c.Frame.Hide() + } else { + c.Frame.Show() + } + scan += childHeight // for padding + totalHeight += childHeight + } + + w.maxHeight = totalHeight +} + +func (w *ListBox) Present(e render.Engine, p render.Point) { + w.Frame.Present(e, p) + + // HACK to get the scrollbar to appear on top of the list frame :( + pos := AbsolutePosition(w.scrollbar) + // pos.X += w.BoxThickness(w.style.BorderSize / 2) // HACK + w.scrollbar.Present(e, pos) +} diff --git a/magicform/magicform.go b/magicform/magicform.go new file mode 100644 index 0000000..536540b --- /dev/null +++ b/magicform/magicform.go @@ -0,0 +1,503 @@ +// Package magicform helps create simple form layouts with go/ui. +package magicform + +import ( + "fmt" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui" + "git.kirsle.net/go/ui/style" +) + +type Type int + +const ( + Auto Type = iota + Text // free, wide Label row + Frame // custom frame from the caller + Button // Single button with a label + Value // a Label & Value row (value not editable) + Textbox + Checkbox + Radiobox + Selectbox + Listbox + Color + Pager +) + +// Form configuration. +type Form struct { + Supervisor *ui.Supervisor // Required for most useful forms + Engine render.Engine + + // For vertical forms. + Vertical bool + LabelWidth int // size of left frame for labels. + PadY int // spacer between (vertical) forms + PadX int +} + +/* +Field for your form (or form-aligned label sections, etc.) + +The type of Form control to render is inferred based on bound +variables and other configuration. +*/ +type Field struct { + // Type may be inferred by presence of other params. + Type Type + + // Set a text string and font for simple labels or paragraphs. + Label string + LabelVariable *string // a TextVariable to drive the Label + Font render.Text + + // Easy button row: make Buttons an array of Button fields + Buttons []Field + ButtonStyle *style.Button + + // Easy Paginator. DO NOT SUPERVISE, let the Create do so! + Pager *ui.Pager + + // If you send a *ui.Frame to insert, the Type is inferred + // to be Frame. + Frame *ui.Frame + + // Variable bindings, the type may infer to be: + BoolVariable *bool // Checkbox + TextVariable *string // Textbox + IntVariable *int // Textbox + Options []Option // Selectbox + SelectValue interface{} // Selectbox default choice + Color *render.Color // Color + Readonly bool // draw the value as a flat label + + // For text-type fields, opt-in to let magicform prompt the + // user using the game's developer shell. + PromptUser func(answer string) + + // Tooltip to add to a form control. + // Checkbox only for now. + Tooltip ui.Tooltip // config for the tooltip only + + // Handlers you can configure + OnSelect func(value interface{}) // Selectbox + OnClick func() // Button +} + +// Option used in Selectbox or Radiobox fields. +type Option struct { + Value interface{} + Label string + Separator bool +} + +/* +Create the form field and populate it into the given Frame. + +Renders the form vertically. +*/ +func (form Form) Create(into *ui.Frame, fields []Field) { + for n, row := range fields { + row := row + + if row.Frame != nil { + into.Pack(row.Frame, ui.Pack{ + Side: ui.N, + FillX: true, + }) + continue + } + + frame := ui.NewFrame(fmt.Sprintf("Line %d", n)) + into.Pack(frame, ui.Pack{ + Side: ui.N, + FillX: true, + PadY: form.PadY, + }) + + // Buttons row? + if row.Buttons != nil && len(row.Buttons) > 0 { + for _, row := range row.Buttons { + row := row + + btn := ui.NewButton(row.Label, ui.NewLabel(ui.Label{ + Text: row.Label, + Font: row.Font, + })) + if row.ButtonStyle != nil { + btn.SetStyle(row.ButtonStyle) + } + + btn.Handle(ui.Click, func(ed ui.EventData) error { + if row.OnClick != nil { + row.OnClick() + } else { + return fmt.Errorf("no OnClick handler for button %s", row.Label) + } + return nil + }) + + btn.Compute(form.Engine) + form.Supervisor.Add(btn) + + // Tooltip? TODO - make nicer. + if row.Tooltip.Text != "" || row.Tooltip.TextVariable != nil { + tt := ui.NewTooltip(btn, row.Tooltip) + tt.Supervise(form.Supervisor) + } + + frame.Pack(btn, ui.Pack{ + Side: ui.W, + PadX: 2, + PadY: 2, + }) + } + + continue + } + + // Infer the type of the form field. + if row.Type == Auto { + row.Type = row.Infer() + if row.Type == Auto { + continue + } + } + + // Is there a label frame to the left? + // - Checkbox gets a full row. + fmt.Printf("Label=%+v Var=%+v\n", row.Label, row.LabelVariable) + if (row.Label != "" || row.LabelVariable != nil) && row.Type != Checkbox { + labFrame := ui.NewFrame("Label Frame") + labFrame.Configure(ui.Config{ + Width: form.LabelWidth, + }) + frame.Pack(labFrame, ui.Pack{ + Side: ui.W, + }) + + // Draw the label text into it. + label := ui.NewLabel(ui.Label{ + Text: row.Label, + TextVariable: row.LabelVariable, + Font: row.Font, + }) + labFrame.Pack(label, ui.Pack{ + Side: ui.W, + }) + } + + // Pager row? + if row.Pager != nil { + row.Pager.Supervise(form.Supervisor) + frame.Pack(row.Pager, ui.Pack{ + Side: ui.W, + }) + } + + // Simple "Value" row with a Label to its left. + if row.Type == Value { + lbl := ui.NewLabel(ui.Label{ + Text: row.Label, + Font: row.Font, + TextVariable: row.TextVariable, + IntVariable: row.IntVariable, + }) + + frame.Pack(lbl, ui.Pack{ + Side: ui.W, + FillX: true, + Expand: true, + }) + + // Tooltip? TODO - make nicer. + if row.Tooltip.Text != "" || row.Tooltip.TextVariable != nil { + tt := ui.NewTooltip(lbl, row.Tooltip) + tt.Supervise(form.Supervisor) + } + } + + // Color picker button. + if row.Type == Color && row.Color != nil { + btn := ui.NewButton("ColorPicker", ui.NewLabel(ui.Label{ + Text: " ", + Font: row.Font, + })) + style := style.DefaultButton + style.Background = *row.Color + style.HoverBackground = style.Background.Lighten(20) + btn.SetStyle(&style) + + form.Supervisor.Add(btn) + frame.Pack(btn, ui.Pack{ + Side: ui.W, + FillX: true, + Expand: true, + }) + + btn.Handle(ui.Click, func(ed ui.EventData) error { + // Open a ColorPicker widget. + picker, err := ui.NewColorPicker(ui.ColorPicker{ + Title: "Select a color", + Supervisor: form.Supervisor, + Engine: form.Engine, + Color: *row.Color, + OnManualInput: func(callback func(render.Color)) { + // TODO: prompt for color + }, + }) + if err != nil { + return err + } + + picker.Then(func(color render.Color) { + *row.Color = color + style.Background = color + style.HoverBackground = style.Background.Lighten(20) + + // call onClick to save change to disk now + if row.OnClick != nil { + row.OnClick() + } + }) + + picker.Center(form.Engine.WindowSize()) + picker.Show() + return nil + }) + } + + // Buttons and Text fields (for now). + if row.Type == Button || row.Type == Textbox { + btn := ui.NewButton("Button", ui.NewLabel(ui.Label{ + Text: row.Label, + Font: row.Font, + TextVariable: row.TextVariable, + IntVariable: row.IntVariable, + })) + + frame.Pack(btn, ui.Pack{ + Side: ui.W, + FillX: true, + Expand: true, + }) + + // Not clickable if Readonly. + if !row.Readonly { + form.Supervisor.Add(btn) + } + + // Tooltip? TODO - make nicer. + if row.Tooltip.Text != "" || row.Tooltip.TextVariable != nil { + tt := ui.NewTooltip(btn, row.Tooltip) + tt.Supervise(form.Supervisor) + } + + // Handlers + btn.Handle(ui.Click, func(ed ui.EventData) error { + // Text boxes, we want to prompt the user to enter new value? + if row.PromptUser != nil { + var value string + if row.TextVariable != nil { + value = *row.TextVariable + } else if row.IntVariable != nil { + value = fmt.Sprintf("%d", *row.IntVariable) + } + + // TODO: prompt user for new value + _ = value + // shmem.PromptPre("Enter new value: ", value, func(answer string) { + // if answer != "" { + // row.PromptUser(answer) + // } + // }) + } + + if row.OnClick != nil { + row.OnClick() + } + return nil + }) + } + + // Checkbox? + if row.Type == Checkbox { + cb := ui.NewCheckbox("Checkbox", row.BoolVariable, ui.NewLabel(ui.Label{ + Text: row.Label, + Font: row.Font, + })) + cb.Supervise(form.Supervisor) + frame.Pack(cb, ui.Pack{ + Side: ui.W, + FillX: true, + }) + + // Tooltip? TODO - make nicer. + if row.Tooltip.Text != "" || row.Tooltip.TextVariable != nil { + tt := ui.NewTooltip(cb, row.Tooltip) + tt.Supervise(form.Supervisor) + } + + // Handlers + cb.Handle(ui.Click, func(ed ui.EventData) error { + if row.OnClick != nil { + row.OnClick() + } + return nil + }) + } + + // Selectbox? also Radiobox for now. + if row.Type == Selectbox || row.Type == Radiobox { + btn := ui.NewSelectBox("Select", ui.Label{ + Font: row.Font, + }) + frame.Pack(btn, ui.Pack{ + Side: ui.W, + FillX: true, + Expand: true, + }) + + if row.Options != nil { + for _, option := range row.Options { + if option.Separator { + btn.AddSeparator() + continue + } + btn.AddItem(option.Label, option.Value, func() {}) + } + } + + if row.SelectValue != nil { + btn.SetValue(row.SelectValue) + } + + btn.Handle(ui.Change, func(ed ui.EventData) error { + if selection, ok := btn.GetValue(); ok { + if row.OnSelect != nil { + row.OnSelect(selection.Value) + } + + // Update bound variables. + if v, ok := selection.Value.(int); ok && row.IntVariable != nil { + *row.IntVariable = v + } + if v, ok := selection.Value.(string); ok && row.TextVariable != nil { + *row.TextVariable = v + } + } + return nil + }) + + // Tooltip? TODO - make nicer. + if row.Tooltip.Text != "" || row.Tooltip.TextVariable != nil { + tt := ui.NewTooltip(btn, row.Tooltip) + tt.Supervise(form.Supervisor) + } + + btn.Supervise(form.Supervisor) + form.Supervisor.Add(btn) + } + + // ListBox? + if row.Type == Listbox { + btn := ui.NewListBox("List", ui.ListBox{ + Variable: row.SelectValue, + }) + btn.Configure(ui.Config{ + Height: 120, + }) + frame.Pack(btn, ui.Pack{ + Side: ui.W, + FillX: true, + Expand: true, + }) + + if row.Options != nil { + for _, option := range row.Options { + if option.Separator { + // btn.AddSeparator() + continue + } + fmt.Printf("LISTBOX: Insert label '%s' with value %+v\n", option.Label, option.Value) + btn.AddLabel(option.Label, option.Value, func() {}) + } + } + + if row.SelectValue != nil { + fmt.Printf("LISTBOX: Set value to %s\n", row.SelectValue) + btn.SetValue(row.SelectValue) + } + + btn.Handle(ui.Change, func(ed ui.EventData) error { + if selection, ok := btn.GetValue(); ok { + if row.OnSelect != nil { + row.OnSelect(selection.Value) + } + + // Update bound variables. + if v, ok := selection.Value.(int); ok && row.IntVariable != nil { + *row.IntVariable = v + } + if v, ok := selection.Value.(string); ok && row.TextVariable != nil { + *row.TextVariable = v + } + } + return nil + }) + + // Tooltip? TODO - make nicer. + if row.Tooltip.Text != "" || row.Tooltip.TextVariable != nil { + tt := ui.NewTooltip(btn, row.Tooltip) + tt.Supervise(form.Supervisor) + } + + btn.Supervise(form.Supervisor) + // form.Supervisor.Add(btn) // for btn.Handle(Change) to work?? + } + } +} + +/* +Infer the type if the field was of type Auto. + +Returns the first Type inferred from the field by checking in +this order: + +- Frame if the field has a *Frame +- Checkbox if there is a *BoolVariable +- Selectbox if there are Options +- Textbox if there is a *TextVariable +- Text if there is a Label + +May return Auto if none of the above and be ignored. +*/ +func (field Field) Infer() Type { + if field.Frame != nil { + return Frame + } + + if field.BoolVariable != nil { + return Checkbox + } + + if field.Options != nil && len(field.Options) > 0 { + return Selectbox + } + + if field.TextVariable != nil || field.IntVariable != nil { + return Textbox + } + + if field.Label != "" { + return Text + } + + if field.Pager != nil { + return Pager + } + + return Auto +} diff --git a/scrollbar.go b/scrollbar.go new file mode 100644 index 0000000..8df985c --- /dev/null +++ b/scrollbar.go @@ -0,0 +1,254 @@ +package ui + +import ( + "fmt" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui/style" +) + +// Scrollbar dimensions, TODO: make configurable. +var ( + scrollWidth = 20 + scrollbarHeight = 40 +) + +// ScrollBar is a classic scrolling widget. +type ScrollBar struct { + *Frame + style *style.Button + supervisor *Supervisor + + trough *Frame + slider *Frame + + // Configurable scroll ranges. + Min int + Max int + Step int + value int + + // Variable bindings: give these pointers to your values. + Variable interface{} // pointer to e.g. a string or int + // TextVariable *string // string value + // IntVariable *int // integer value + + // Drag/drop state. + dragging bool // mouse down on slider + scrollPx int // px from the top where the slider is placed + dragStart render.Point // where the mouse was on click + wasScrollPx int + + everyTick func() +} + +// NewScrollBar creates a new ScrollBar. +func NewScrollBar(config ScrollBar) *ScrollBar { + w := &ScrollBar{ + Frame: NewFrame("Scrollbar Frame"), + Variable: config.Variable, + style: &style.DefaultButton, + Min: config.Min, + Max: config.Max, + Step: config.Step, + } + + if w.Max == 0 { + w.Max = 100 + } + if w.Step == 0 { + w.Step = 1 + } + + w.IDFunc(func() string { + return "ScrollBar" + }) + + w.SetStyle(Theme.Button) + + w.setup() + return w +} + +// SetStyle sets the ScrollBar style. +func (w *ScrollBar) SetStyle(v *style.Button) { + if v == nil { + v = &style.DefaultButton + } + + w.style = v + fmt.Printf("set style: %+v\n", v) + w.Frame.Configure(Config{ + BorderSize: w.style.BorderSize, + BorderStyle: BorderSunken, + Background: w.style.Background.Darken(40), + }) +} + +// GetStyle gets the ScrollBar style. +func (w *ScrollBar) GetStyle() *style.Button { + return w.style +} + +// Supervise the ScrollBar. This is necessary for granting mouse-over events +// to the items in the list. +func (w *ScrollBar) Supervise(s *Supervisor) { + w.supervisor = s + + // Add all the list items to be supervised. + w.supervisor.Add(w.slider) + for _, c := range w.Frame.Children() { + w.supervisor.Add(c) + } +} + +// Compute to re-evaluate the button state (in the case of radio buttons where +// a different button will affect the state of this one when clicked). +func (w *ScrollBar) Compute(e render.Engine) { + w.Frame.Compute(e) + if w.everyTick != nil { + w.everyTick() + } +} + +// setup the UI components and event handlers. +func (w *ScrollBar) setup() { + w.Configure(Config{ + Width: scrollWidth, + }) + + // The trough that holds the slider. + w.trough = NewFrame("Trough") + + // Up button + upBtn := NewButton("Up", NewLabel(Label{ + Text: "^", + })) + upBtn.Handle(MouseDown, func(ed EventData) error { + w.everyTick = func() { + w.scrollPx -= w.Step + if w.scrollPx < 0 { + w.scrollPx = 0 + } + w.trough.Place(w.slider, Place{ + Top: w.scrollPx, + }) + w.sendScrollEvent() + } + return nil + }) + upBtn.Handle(MouseUp, func(ed EventData) error { + w.everyTick = nil + return nil + }) + + // The slider + w.slider = NewFrame("Slider") + w.slider.Configure(Config{ + BorderSize: w.style.BorderSize, + BorderStyle: BorderStyle(w.style.BorderStyle), + Background: w.style.Background, + Width: scrollWidth - w.BoxThickness(w.style.BorderSize), + Height: scrollbarHeight, + }) + + // Slider events + w.slider.Handle(MouseOver, func(ed EventData) error { + w.slider.SetBackground(w.style.HoverBackground) + return nil + }) + w.slider.Handle(MouseOut, func(ed EventData) error { + w.slider.SetBackground(w.style.Background) + return nil + }) + w.slider.Handle(MouseDown, func(ed EventData) error { + w.dragging = true + w.dragStart = ed.Point + w.wasScrollPx = w.scrollPx + fmt.Printf("begin drag from %s\n", ed.Point) + return nil + }) + w.slider.Handle(MouseUp, func(ed EventData) error { + fmt.Println("mouse released") + w.dragging = false + return nil + }) + w.slider.Handle(MouseMove, func(ed EventData) error { + if w.dragging { + var ( + delta = w.dragStart.Compare(ed.Point) + moveTo = w.wasScrollPx + delta.Y + ) + + if moveTo < 0 { + moveTo = 0 + } else if moveTo > w.trough.height-w.slider.height { + moveTo = w.trough.height - w.slider.height + } + + fmt.Printf("delta drag: %s\n", delta) + w.scrollPx = moveTo + w.trough.Place(w.slider, Place{ + Top: w.scrollPx, + }) + w.sendScrollEvent() + } + return nil + }) + + downBtn := NewButton("Down", NewLabel(Label{ + Text: "v", + })) + downBtn.Handle(MouseDown, func(ed EventData) error { + w.everyTick = func() { + w.scrollPx += w.Step + if w.scrollPx > w.trough.height-w.slider.height { + w.scrollPx = w.trough.height - w.slider.height + } + w.trough.Place(w.slider, Place{ + Top: w.scrollPx, + }) + w.sendScrollEvent() + } + return nil + }) + downBtn.Handle(MouseUp, func(ed EventData) error { + w.everyTick = nil + return nil + }) + + w.Frame.Pack(upBtn, Pack{ + Side: N, + FillX: true, + }) + w.Frame.Pack(w.trough, Pack{ + Side: N, + Fill: true, + Expand: true, + }) + w.trough.Place(w.slider, Place{ + Top: w.scrollPx, + Left: 0, + }) + w.Frame.Pack(downBtn, Pack{ + Side: N, + FillX: true, + }) +} + +// Present the scrollbar. +func (w *ScrollBar) Present(e render.Engine, p render.Point) { + w.Frame.Present(e, p) +} + +func (w *ScrollBar) sendScrollEvent() { + var fraction float64 + if w.scrollPx > 0 { + fraction = float64(w.scrollPx) / (float64(w.trough.height) - float64(w.slider.height)) + } + w.Event(Scroll, EventData{ + ScrollFraction: fraction, + ScrollUnits: int(fraction * float64(w.Max)), + ScrollPages: 0, + }) +} diff --git a/style/button.go b/style/button.go index e29fe72..f567b5d 100644 --- a/style/button.go +++ b/style/button.go @@ -30,6 +30,18 @@ var ( BorderSize: 2, } + DefaultListBox = ListBox{ + Background: render.White, + Foreground: render.Black, + HoverBackground: render.Cyan, + HoverForeground: render.Orange, + SelectedBackground: render.Blue, + SelectedForeground: render.White, + BorderStyle: BorderSunken, + // BorderColor: render.RGBA(200, 200, 200, 255), + BorderSize: 2, + } + DefaultTooltip = Tooltip{ Background: render.RGBA(0, 0, 0, 230), Foreground: render.White, @@ -69,3 +81,15 @@ type Tooltip struct { Background render.Color Foreground render.Color } + +// ListBox style configuration. +type ListBox struct { + Background render.Color + Foreground render.Color // Labels only + SelectedBackground render.Color + SelectedForeground render.Color + HoverBackground render.Color + HoverForeground render.Color + BorderStyle BorderStyle + BorderSize int +} diff --git a/supervisor.go b/supervisor.go index 51cca1d..cd3fa55 100644 --- a/supervisor.go +++ b/supervisor.go @@ -23,6 +23,7 @@ const ( KeyDown KeyUp KeyPress + Scroll // Drag/drop event handlers. DragStop // if a widget is being dragged and the drag is done @@ -60,6 +61,23 @@ type EventData struct { // Clicked is true if the primary mouse button is down during // a MouseMove Clicked bool + + // A Value given e.g. from a ListBox click. + Value interface{} + + // Scroll event values. + ScrollFraction float64 // between 0 and 1 for the scrollbar percentage + + // Number of units that have scrolled. It is up to the caller to decide + // what units mean (e.g. characters, lines of text, pixels, etc.) + // The scrollbar fraction times your Step value provides the units. + ScrollUnits int + + // Number of pages that have scrolled. It is up to the caller to decide + // what a page is. It would typically be a number of your Units slightly + // less than what fits in the list so the user sees some overlap as + // they scroll quickly by pages. + ScrollPages int // TODO: not implemented } // RelativePoint returns the ed.Point adjusted to be relative to the widget on screen. @@ -256,20 +274,22 @@ func (s *Supervisor) Hovering(cursor render.Point) (hovering, outside []WidgetSl // cursor, transmit mouse events to the widgets. // // This function has two use cases: -// - In runWindowEvents where we run events for the top-most focused window of -// the window manager. -// - In Supervisor.Loop() for the widgets that are NOT owned by a managed -// window, so that these widgets always get events. +// - In runWindowEvents where we run events for the top-most focused window of +// the window manager. +// - In Supervisor.Loop() for the widgets that are NOT owned by a managed +// window, so that these widgets always get events. // // Parameters: -// XY (Point): mouse cursor position as calculated in Loop() -// ev, hovering, outside: values from Loop(), self explanatory. -// behavior: indicates how this method is being used. +// +// XY (Point): mouse cursor position as calculated in Loop() +// ev, hovering, outside: values from Loop(), self explanatory. +// behavior: indicates how this method is being used. // // behavior options: -// 0: widgets NOT part of a managed window. On this pass, if a widget IS -// a part of a window, it gets no events triggered. -// 1: widgets are part of the active focused window. +// +// 0: widgets NOT part of a managed window. On this pass, if a widget IS +// a part of a window, it gets no events triggered. +// 1: widgets are part of the active focused window. func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, outside []WidgetSlot, toFocusedWindow bool) (bool, error) { // Do we run any events? @@ -587,11 +607,11 @@ UI which you had called Present() on prior. The current draw order of the Supervisor is as follows: -1. Managed windows are drawn in the order of most recently focused on top. -2. Pop-up modals such as Menus are drawn. Modals have an "event grab" and all - mouse events go to them, or clicking outside of them dismisses the modals. -3. DrawOnTop widgets such as Tooltips that should always be drawn "last" so as - not to be overwritten by neighboring widgets. + 1. Managed windows are drawn in the order of most recently focused on top. + 2. Pop-up modals such as Menus are drawn. Modals have an "event grab" and all + mouse events go to them, or clicking outside of them dismisses the modals. + 3. DrawOnTop widgets such as Tooltips that should always be drawn "last" so as + not to be overwritten by neighboring widgets. */ func (s *Supervisor) DrawOnTop(w Widget) { s.onTop = append(s.onTop, w) diff --git a/theme/theme.go b/theme/theme.go index 71afe0c..0181d5c 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -21,6 +21,7 @@ type Theme struct { Window *style.Window Label *style.Label Button *style.Button + ListBox *style.ListBox Tooltip *style.Tooltip TabFrame *style.Button } @@ -30,6 +31,7 @@ var Default = Theme{ Name: "Default", Label: &style.DefaultLabel, Button: &style.DefaultButton, + ListBox: &style.DefaultListBox, Tooltip: &style.DefaultTooltip, TabFrame: &style.DefaultButton, } From 98dfa2cce5d246b1ef431ad6c5f262bb709950b6 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 8 Dec 2023 19:50:24 -0800 Subject: [PATCH 2/2] Image: add ReplaceFromImage and Destroy to cleanup textures --- image.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/image.go b/image.go index 6aff98b..2931c5b 100644 --- a/image.go +++ b/image.go @@ -81,6 +81,19 @@ func ImageFromFile(filename string) (*Image, error) { }, nil } +// ReplaceFromImage replaces the image with a new image. +func (w *Image) ReplaceFromImage(im image.Image) error { + // Free the old texture. + if w.texture != nil { + if err := w.texture.Free(); err != nil { + return err + } + w.texture = nil + } + w.Image = im + return nil +} + // OpenImage initializes an Image with a given file name. // // The file extension is important and should be a supported ImageType. @@ -188,3 +201,11 @@ func (w *Image) Present(e render.Engine, p render.Point) { // Call the BaseWidget Present in case we have subscribers. w.BaseWidget.Present(e, p) } + +// Destroy cleans up the image and releases textures. +func (w *Image) Destroy() { + if w.texture != nil { + w.texture.Free() + w.texture = nil + } +}