diff --git a/artist/text.go b/artist/text.go index 1cbd460..3cad8b3 100644 --- a/artist/text.go +++ b/artist/text.go @@ -19,6 +19,7 @@ type wordLayout struct { spaceAfter int breaksAfter int text []characterLayout + whitespace []characterLayout } // Align specifies a text alignment method. @@ -184,6 +185,32 @@ func (drawer *TextDrawer) ReccomendedHeightFor (width int) (height int) { return dot.Y.Round() } +// PositionOf returns the position of the character at the specified index +// relative to the baseline. +func (drawer *TextDrawer) PositionOf (index int) (position image.Point) { + if !drawer.layoutClean { drawer.recalculate() } + index ++ + for _, word := range drawer.layout { + position = word.position + for _, character := range word.text { + index -- + position.X = word.position.X + character.x + if index < 1 { return } + } + for _, character := range word.whitespace { + index -- + position.X = word.position.X + character.x + if index < 1 { return } + } + } + return +} + +// Length returns the amount of runes in the drawer's text. +func (drawer *TextDrawer) Length () (length int) { + return len(drawer.runes) +} + func (drawer *TextDrawer) recalculate () { drawer.layoutClean = true drawer.layout = nil @@ -194,7 +221,8 @@ func (drawer *TextDrawer) recalculate () { metrics := drawer.face.Metrics() dot := fixed.Point26_6 { 0, 0 } index := 0 - horizontalExtent := 0 + horizontalExtent := 0 + currentCharacterX := fixed.Int26_6(0) previousCharacter := rune(-1) for index < len(drawer.runes) { @@ -203,7 +231,7 @@ func (drawer *TextDrawer) recalculate () { word.position.Y = dot.Y.Round() // process a word - currentCharacterX := fixed.Int26_6(0) + currentCharacterX = 0 wordWidth := fixed.Int26_6(0) for index < len(drawer.runes) && !unicode.IsSpace(drawer.runes[index]) { character := drawer.runes[index] @@ -243,31 +271,37 @@ func (drawer *TextDrawer) recalculate () { dot.X = wordWidth } - // skip over whitespace, going onto a new line if there is a + // process whitespace, going onto a new line if there is a // newline character + spaceWidth := fixed.Int26_6(0) for index < len(drawer.runes) && unicode.IsSpace(drawer.runes[index]) { character := drawer.runes[index] + _, advance, ok := drawer.face.GlyphBounds(character) + index ++ + if !ok { continue } + word.whitespace = append(word.whitespace, characterLayout { + x: currentCharacterX.Round(), + character: character, + }) + spaceWidth += advance + currentCharacterX += advance + if character == '\n' { dot.Y += metrics.Height dot.X = 0 word.breaksAfter ++ - previousCharacter = character - index ++ + break } else { - _, advance, ok := drawer.face.GlyphBounds(character) - word.spaceAfter = advance.Round() - index ++ - if !ok { continue } - dot.X += advance if previousCharacter >= 0 { dot.X += drawer.face.Kern ( previousCharacter, character) } - previousCharacter = character } + previousCharacter = character } + word.spaceAfter = spaceWidth.Round() // add the word to the layout drawer.layout = append(drawer.layout, word) @@ -293,6 +327,16 @@ func (drawer *TextDrawer) recalculate () { } } + // add a little null to the last character + if len(drawer.layout) > 0 { + lastWord := &drawer.layout[len(drawer.layout) - 1] + lastWord.whitespace = append ( + lastWord.whitespace, + characterLayout { + x: currentCharacterX.Round(), + }) + } + if drawer.wrap { drawer.layoutBounds.Max.X = drawer.width } else { diff --git a/elements/basic/textbox.go b/elements/basic/textbox.go new file mode 100644 index 0000000..e70adc4 --- /dev/null +++ b/elements/basic/textbox.go @@ -0,0 +1,202 @@ +package basic + +import "image" +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/theme" +import "git.tebibyte.media/sashakoshka/tomo/artist" +import "git.tebibyte.media/sashakoshka/tomo/elements/core" + +type TextBox struct { + *core.Core + core core.CoreControl + + enabled bool + selected bool + + cursor int + placeholder string + text string + placeholderDrawer artist.TextDrawer + valueDrawer artist.TextDrawer +} + +func NewTextBox (placeholder, text string) (element *TextBox) { + element = &TextBox { enabled: true } + element.Core, element.core = core.NewCore(element) + element.placeholderDrawer.SetFace(theme.FontFaceRegular()) + element.valueDrawer.SetFace(theme.FontFaceRegular()) + element.placeholder = placeholder + element.placeholderDrawer.SetText(placeholder) + element.updateMinimumSize() + element.SetText(text) + return +} + +func (element *TextBox) Resize (width, height int) { + element.core.AllocateCanvas(width, height) + element.draw() +} + +func (element *TextBox) HandleMouseDown (x, y int, button tomo.Button) { + element.Select() +} + +func (element *TextBox) HandleMouseUp (x, y int, button tomo.Button) { } +func (element *TextBox) HandleMouseMove (x, y int) { } +func (element *TextBox) HandleScroll (x, y int, deltaX, deltaY float64) { } + +func (element *TextBox) HandleKeyDown ( + key tomo.Key, + modifiers tomo.Modifiers, + repeated bool, +) { + switch { + case key == tomo.KeyBackspace: + if len(element.text) < 1 { break } + element.cursor -- + element.SetText(element.text[:len(element.text) - 1]) + case key.Printable(): + element.cursor ++ + element.SetText(element.text + string(rune(key))) + } +} + +func (element *TextBox) HandleKeyUp(key tomo.Key, modifiers tomo.Modifiers) { } + +func (element *TextBox) Selected () (selected bool) { + return element.selected +} + +func (element *TextBox) Select () { + element.core.RequestSelection() +} + +func (element *TextBox) HandleSelection ( + direction tomo.SelectionDirection, +) ( + accepted bool, +) { + direction = direction.Canon() + if !element.enabled { return false } + if element.selected && direction != tomo.SelectionDirectionNeutral { + return false + } + + element.selected = true + if element.core.HasImage() { + element.draw() + element.core.PushAll() + } + return true +} + +func (element *TextBox) HandleDeselection () { + element.selected = false + if element.core.HasImage() { + element.draw() + element.core.PushAll() + } +} + +func (element *TextBox) SetEnabled (enabled bool) { + if element.enabled == enabled { return } + element.enabled = enabled + if element.core.HasImage () { + element.draw() + element.core.PushAll() + } +} + +func (element *TextBox) SetPlaceholder (placeholder string) { + if element.placeholder == placeholder { return } + + element.placeholder = placeholder + element.placeholderDrawer.SetText(placeholder) + + element.updateMinimumSize() + if element.core.HasImage () { + element.draw() + element.core.PushAll() + } +} + +func (element *TextBox) updateMinimumSize () { + textBounds := element.placeholderDrawer.LayoutBounds() + element.core.SetMinimumSize ( + textBounds.Dx() + + theme.Padding() * 2, + element.placeholderDrawer.LineHeight().Round() + + theme.Padding() * 2) +} + +func (element *TextBox) SetText (text string) { + if element.text == text { return } + + element.text = text + element.valueDrawer.SetText(text) + if element.cursor > element.valueDrawer.Length() { + element.cursor = element.valueDrawer.Length() + } + + if element.core.HasImage () { + element.draw() + element.core.PushAll() + } +} + +func (element *TextBox) draw () { + bounds := element.core.Bounds() + + artist.FillRectangle ( + element.core, + theme.InputPattern ( + element.enabled, + element.Selected()), + bounds) + + innerBounds := bounds + innerBounds.Min.X += theme.Padding() + innerBounds.Min.Y += theme.Padding() + innerBounds.Max.X -= theme.Padding() + innerBounds.Max.Y -= theme.Padding() + + if element.text == "" && !element.selected { + // draw placeholder + textBounds := element.placeholderDrawer.LayoutBounds() + offset := image.Point { + X: theme.Padding(), + Y: theme.Padding(), + } + foreground := theme.ForegroundPattern(false) + element.placeholderDrawer.Draw ( + element.core, + foreground, + offset.Sub(textBounds.Min)) + } else { + // draw input value + textBounds := element.valueDrawer.LayoutBounds() + offset := image.Point { + X: theme.Padding(), + Y: theme.Padding(), + } + foreground := theme.ForegroundPattern(element.enabled) + element.valueDrawer.Draw ( + element.core, + foreground, + offset.Sub(textBounds.Min)) + + if element.selected { + // cursor + cursorPosition := element.valueDrawer.PositionOf ( + element.cursor) + artist.Line ( + element.core, + theme.ForegroundPattern(true), 1, + cursorPosition.Add(offset), + image.Pt ( + cursorPosition.X, + cursorPosition.Y + element.valueDrawer. + LineHeight().Round()).Add(offset)) + } + } +} diff --git a/examples/input/main.go b/examples/input/main.go new file mode 100644 index 0000000..fd24239 --- /dev/null +++ b/examples/input/main.go @@ -0,0 +1,34 @@ +package main + +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/layouts" +import "git.tebibyte.media/sashakoshka/tomo/elements/basic" +import _ "git.tebibyte.media/sashakoshka/tomo/backends/x" + +func main () { + tomo.Run(run) +} + +func run () { + window, _ := tomo.NewWindow(2, 2) + window.SetTitle("Approaching") + container := basic.NewContainer(layouts.Vertical { true, true }) + window.Adopt(container) + + firstName := basic.NewTextBox("First name", "") + lastName := basic.NewTextBox("Last name", "") + fingerLength := basic.NewTextBox("Length of fingers", "") + button := basic.NewButton("Ok") + + container.Adopt(basic.NewLabel("Choose your words carefully.", false), true) + container.Adopt(firstName, false) + container.Adopt(lastName, false) + container.Adopt(fingerLength, false) + container.Adopt(basic.NewSpacer(true), false) + container.Adopt(button, false) + + firstName.Select() + + window.OnClose(tomo.Stop) + window.Show() +} diff --git a/theme/input.go b/theme/input.go new file mode 100644 index 0000000..962fec6 --- /dev/null +++ b/theme/input.go @@ -0,0 +1,33 @@ +package theme + +import "git.tebibyte.media/sashakoshka/tomo/artist" + +var inputPattern = artist.NewMultiBorder ( + artist.Border { Weight: 1, Stroke: strokePattern }, + artist.Border { + Weight: 1, + Stroke: artist.Chiseled { + Highlight: artist.NewUniform(hex(0x89925AFF)), + Shadow: artist.NewUniform(hex(0xD2CB9AFF)), + }, + }, + artist.Border { Stroke: artist.NewUniform(hex(0xD2CB9AFF)) }) +var selectedInputPattern = artist.NewMultiBorder ( + artist.Border { Weight: 1, Stroke: strokePattern }, + artist.Border { Weight: 1, Stroke: accentPattern }, + artist.Border { Stroke: artist.NewUniform(hex(0xD2CB9AFF)) }) +var disabledInputPattern = artist.NewMultiBorder ( + artist.Border { Weight: 1, Stroke: weakForegroundPattern }, + artist.Border { Stroke: backgroundPattern }) + +func InputPattern (enabled, selected bool) (artist.Pattern) { + if enabled { + if selected { + return selectedInputPattern + } else { + return inputPattern + } + } else { + return disabledInputPattern + } +}