From cc14151a145ddc430f45a7c9bbb2966d00f7c1a5 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Fri, 21 Apr 2023 16:47:15 -0400 Subject: [PATCH] Primitive combo box --- .../theme/assets/wintergreen-icons-small.png | Bin 2658 -> 2680 bytes elements/combobox.go | 245 ++++++++++++++++++ examples/input/main.go | 20 +- theme.go | 4 +- 4 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 elements/combobox.go diff --git a/default/theme/assets/wintergreen-icons-small.png b/default/theme/assets/wintergreen-icons-small.png index 6ee264dff0d421b2ee28f9c81ff904d5a4ac78d3..7407e3a1b70e9907fb3e56db608ef6f8e25e7215 100644 GIT binary patch delta 2598 zcmV+>3fcAI6!;X7HUTY>H#>h?dLCuHnpVw1mYp0o2+1UuuDFV8SMOZc6^&?=)Obqb z*{7?b%C@SUWL3Vq+@0Ruv*LA4tVD}hVeILUh$7v`-}meAdwN!&$v1hpiCe(q97SLR zO5o6)5}*MET3|dbT_`}0>i}?+QVEn|MIa^P(XJFf=7{3Qc1Q*glku~!fOm=zG=PbKQ-h{n?f|&*d&N`K6v)|b7Bz=?Tq3CV}d?6*8!k7F+&}S6;l^Kh@X8PoG8NcjS+}|-5LO&bN~bb zgJNPxGCp~1bTHbb4i$f=&cB)%sWSUsclZmhbpU9=1a)pltoZT32XVClB3GQD#xQi* zeXo1Ug`ai+Tpo!~7f>CH{$doh0vOU9Q2SW)xxYAe5cnMcSD#2wz>tiDfa)NE5=?@| zc%;dnacb1*8yx_dOa%47STRB+2cr>T$CXnkL0qn#ateP`oDP40nM0R@8UhmG@pv@i zgp}PvS2z--PCMx{Z@t=4f+C$q86q@F&~;}>*}WWZ#=(BYk>&7YN3CG9RjM_Z`Nr6sUATPYYBB0EC+y&Tx}A0uX=z1Rwwb2tWV=5P$&xzkp@_;HiH9l!w(AjlusE zC~vnR*{l|bJ@0)n0bLCt)TNkKH)Yn6%^&JAPZdu%QJ9azvHdjfy`Zu&d z&Gw$ys=JGQEAqV)v4B36E98t+Kk$Ve(}x$gSD8k%dDGhZ5BExDUQLAW(j9 z)0`$m<>wCUMz}w=zb>N}| z^B1>bAaQ@N(zBi*(VytIjPaN9^sQW0pt20#GYFGkb9M*?jNZ98Q2*hwL2jI zvOZ@Xz^e#8_~IR1$|rFQG5j0&31j z3GgaHo}M4SDlcyLk!;4ySjtH`Hje?BwQB{*NayM1QM}~>6->{~e7h4m=|Vuw;uG*H z0)!|*2_?Y5N%JIVfQHRsKw_!}E|3=VUh{a5uS1yCGuKYQS|J@!Py3w5;Z=k@ZAgD# z70ldv0^k70X7OoZIzO403|b31A*auIsf4GvjJ78GF1kzA}l@QaaRkb1^R)%mS71i z*)#_5)VBqUg~ocu4of~TTvio<>~Vhxa9Nkd3CcY-tCyGhgu4m!x?aB*5JEzaa2P+Y zGAdM944hgkLLoO*ex{-7D8X70hRv8>(98E0VGTH_Q9{t?q{3SqF2V?I7`>d!lzR|R zHm$Wvd=q8Jn7Ml^(GJ;t_DiV2(jJ#9e8%HSROFgm(+6?`>j^MW8K?-W1FL^R`(}i2 zGMtCF8Dmi@O6jRw3MbIEw1DN*a?0K3?rYzXG&HNPLDZ4JNw6R6*9Y_gGv+9bL#>Wf zdaM-i20WpCSH{a{E<{~fz*R$OTeK}z#s}3PUWP+;Fk*t{Z=3uB22=U&V7mLffR2B^ z6A`hiNOga>O;INHPSikh z?L_nY1otmI-QNl|*?NP^a;g4_21p7Ns92Tn6WHv-`$0{HO;%%5{g+-9_6XDu6Fhup z+8SUpRe56XhrcY%HTfSVc=$G(0261ZqrA0Pizstr8+}i+QQFyr~i{c{#q9{&o62O0p3!5myWOy=cNSmm{ zsVPisq8V{V!h0Sz)s^v*%BBVrgqs*lrS$=S}9ZTL->v#YAVI{z_&;6-)WeZWg@@?ZW-F!7PnvDX0t2t{84xWK7A+d=??&k zNgrUqv=%r7G*+RjXJ%a8)f;~KXfxY9&oN5bLC~d1FU9DLb~0IVb=_@s<#d-LBcQgzx4Lp=?xTu?0D%MS0|1W(erqtnT9ZdbaMr$` zBrO^ETM-mAv>*iF@c_A`5r6;$AOHafKmY;|fB+f<_|buX0)~Xu&uwj}CjbBd07*qo IM6N<$g7hEML;wH) delta 2576 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 diff --git a/elements/combobox.go b/elements/combobox.go new file mode 100644 index 0000000..2301220 --- /dev/null +++ b/elements/combobox.go @@ -0,0 +1,245 @@ +package elements + +import "image" +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/input" +import "git.tebibyte.media/sashakoshka/tomo/canvas" +import "git.tebibyte.media/sashakoshka/tomo/default/theme" +import "git.tebibyte.media/sashakoshka/tomo/default/config" +import "git.tebibyte.media/sashakoshka/tomo/textdraw" + +type Option string + +func (option Option) Title () string { + if option == "" { + return "(None)" + } else { + return string(option) + } +} + +// Button is a clickable button. +type ComboBox struct { + entity tomo.FocusableEntity + drawer textdraw.Drawer + + options []Option + selected Option + + enabled bool + pressed bool + + config config.Wrapped + theme theme.Wrapped + + onChange func () +} + +// NewButton creates a new button with the specified label text. +func NewComboBox (options ...Option) (element *ComboBox) { + if len(options) == 0 { options = []Option { "" } } + element = &ComboBox { enabled: true, options: options } + element.entity = tomo.NewEntity(element).(tomo.FocusableEntity) + element.theme.Case = tomo.C("tomo", "comboBox") + element.drawer.SetFace (element.theme.FontFace ( + tomo.FontStyleRegular, + tomo.FontSizeNormal)) + element.Select(options[0]) + return +} + +// Entity returns this element's entity. +func (element *ComboBox) Entity () tomo.Entity { + return element.entity +} + +// Draw causes the element to draw to the specified destination canvas. +func (element *ComboBox) Draw (destination canvas.Canvas) { + state := element.state() + bounds := element.entity.Bounds() + pattern := element.theme.Pattern(tomo.PatternButton, state) + + pattern.Draw(destination, bounds) + + foreground := element.theme.Color(tomo.ColorForeground, state) + sink := element.theme.Sink(tomo.PatternButton) + margin := element.theme.Margin(tomo.PatternButton) + padding := element.theme.Padding(tomo.PatternButton) + + offset := image.Pt(0, bounds.Dy() / 2).Add(bounds.Min) + + textBounds := element.drawer.LayoutBounds() + offset.Y -= textBounds.Dy() / 2 + offset.Y -= textBounds.Min.Y + offset.X -= textBounds.Min.X + + icon := element.theme.Icon(tomo.IconExpand, tomo.IconSizeSmall) + if icon != nil { + iconBounds := icon.Bounds() + addedWidth := iconBounds.Dx() + margin.X + iconOffset := bounds.Min + + iconOffset.X += padding[3] + iconOffset.Y = + bounds.Min.Y + + (bounds.Dy() - + iconBounds.Dy()) / 2 + if element.pressed { + iconOffset = iconOffset.Add(sink) + } + offset.X += addedWidth + padding[3] + + icon.Draw(destination, foreground, iconOffset) + } + + if element.pressed { + offset = offset.Add(sink) + } + element.drawer.Draw(destination, foreground, offset) +} + +// OnClick sets the function to be called when the button is clicked. +func (element *ComboBox) OnChange (callback func ()) { + element.onChange = callback +} + +func (element *ComboBox) Value () Option { + return element.selected +} + +func (element *ComboBox) Select (option Option) { + element.selected = option + element.drawer.SetText([]rune(option.Title())) + element.updateMinimumSize() + element.entity.Invalidate() + if element.onChange != nil { + element.onChange() + } +} + +func (element *ComboBox) Filled () bool { + return element.selected != "" +} + +// Focus gives this element input focus. +func (element *ComboBox) Focus () { + if !element.entity.Focused() { element.entity.Focus() } +} + +// Enabled returns whether this button is enabled or not. +func (element *ComboBox) Enabled () bool { + return element.enabled +} + +// SetEnabled sets whether this button can be clicked or not. +func (element *ComboBox) SetEnabled (enabled bool) { + if element.enabled == enabled { return } + element.enabled = enabled + element.entity.Invalidate() +} + +// SetTheme sets the element's theme. +func (element *ComboBox) SetTheme (new tomo.Theme) { + if new == element.theme.Theme { return } + element.theme.Theme = new + element.drawer.SetFace (element.theme.FontFace ( + tomo.FontStyleRegular, + tomo.FontSizeNormal)) + element.updateMinimumSize() + element.entity.Invalidate() +} + +// SetConfig sets the element's configuration. +func (element *ComboBox) SetConfig (new tomo.Config) { + if new == element.config.Config { return } + element.config.Config = new + element.updateMinimumSize() + element.entity.Invalidate() +} + +func (element *ComboBox) HandleFocusChange () { + element.entity.Invalidate() +} + +func (element *ComboBox) HandleMouseDown ( + position image.Point, + button input.Button, + modifiers input.Modifiers, +) { + if !element.Enabled() { return } + element.Focus() + if button != input.ButtonLeft { return } + element.dropDown() +} + +func (element *ComboBox) HandleMouseUp ( + position image.Point, + button input.Button, + modifiers input.Modifiers, +) { } + +func (element *ComboBox) HandleKeyDown (key input.Key, modifiers input.Modifiers) { + if !element.Enabled() { return } + if key == input.KeyEnter { + element.pressed = true + element.entity.Invalidate() + } +} + +func (element *ComboBox) HandleKeyUp(key input.Key, modifiers input.Modifiers) { + if key == input.KeyEnter && element.pressed { + element.pressed = false + element.entity.Invalidate() + if !element.Enabled() { return } + element.dropDown() + } +} + +func (element *ComboBox) dropDown () { + window := element.entity.Window() + menu, err := window.NewMenu(element.entity.Bounds()) + if err != nil { return } + + list := NewList() + for _, option := range element.options { + option := option + cell := NewCell(NewLabel(option.Title())) + cell.OnSelectionChange(func () { + if cell.Selected() { + element.Select(option) + menu.Close() + } + }) + list.Adopt(cell) + } + + menu.Adopt(list) + list.Focus() + menu.Show() +} + +func (element *ComboBox) updateMinimumSize () { + padding := element.theme.Padding(tomo.PatternButton) + margin := element.theme.Margin(tomo.PatternButton) + + textBounds := element.drawer.LayoutBounds() + minimumSize := textBounds.Sub(textBounds.Min) + + icon := element.theme.Icon(tomo.IconExpand, tomo.IconSizeSmall) + if icon != nil { + bounds := icon.Bounds() + minimumSize.Max.X += bounds.Dx() + minimumSize.Max.X += margin.X + } + + minimumSize = padding.Inverse().Apply(minimumSize) + element.entity.SetMinimumSize(minimumSize.Dx(), minimumSize.Dy()) +} + +func (element *ComboBox) state () tomo.State { + return tomo.State { + Disabled: !element.Enabled(), + Focused: element.entity.Focused(), + Pressed: element.pressed, + } +} diff --git a/examples/input/main.go b/examples/input/main.go index e8035c0..088e08f 100644 --- a/examples/input/main.go +++ b/examples/input/main.go @@ -19,6 +19,11 @@ func run () { firstName := elements.NewTextBox("First name", "") lastName := elements.NewTextBox("Last name", "") fingerLength := elements.NewTextBox("Length of fingers", "") + purpose := elements.NewComboBox ( + "", + "Gaslight", + "Gatekeep", + "Girlboss") button := elements.NewButton("Ok") button.SetEnabled(false) @@ -36,17 +41,24 @@ func run () { // enable the Ok button if all three inputs have text in them check := func () { button.SetEnabled ( - firstName.Filled() && - lastName.Filled() && - fingerLength.Filled()) + firstName.Filled() && + lastName.Filled() && + fingerLength.Filled() && + purpose.Filled()) } firstName.OnChange(check) lastName.OnChange(check) fingerLength.OnChange(check) + purpose.OnChange(check) // add elements to container container.AdoptExpand(elements.NewLabel("Choose your words carefully.")) - container.Adopt(firstName, lastName, fingerLength, elements.NewLine(), button) + container.Adopt ( + firstName, lastName, + fingerLength, + elements.NewLabel("Purpose:"), + purpose, + elements.NewLine(), button) window.OnClose(tomo.Stop) window.Show() } diff --git a/theme.go b/theme.go index f459707..6faab9b 100644 --- a/theme.go +++ b/theme.go @@ -232,7 +232,9 @@ const ( IconUnite IconDiffer IconInvert - IconIntersect) + IconIntersect + + IconExpand) const ( // Status icons