Migrated over some elements

This commit is contained in:
2023-04-14 22:03:22 -04:00
parent 4c6f1f80e7
commit 68128c94d8
16 changed files with 526 additions and 410 deletions

3
elements/notdone/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package elements provides standard elements that are commonly used in GUI
// applications.
package elements

177
elements/notdone/label.go Normal file
View File

@@ -0,0 +1,177 @@
package elements
import "golang.org/x/image/math/fixed"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/textdraw"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// Label is a simple text box.
type Label struct {
entity tomo.FlexibleEntity
align textdraw.Align
wrap bool
text string
drawer textdraw.Drawer
forcedColumns int
forcedRows int
minHeight int
config config.Wrapped
theme theme.Wrapped
}
// NewLabel creates a new label. If wrap is set to true, the text inside will be
// wrapped.
func NewLabel (text string, wrap bool) (element *Label) {
element = &Label { }
element.theme.Case = tomo.C("tomo", "label")
element.drawer.SetFace (element.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal))
element.SetWrap(wrap)
element.SetText(text)
return
}
// Bind binds this element to an entity.
func (element *Label) Bind (entity tomo.Entity) {
element.entity = entity.(tomo.FlexibleEntity)
if element.entity == nil { return }
element.updateMinimumSize()
}
// EmCollapse forces a minimum width and height upon the label. The width is
// measured in emspaces, and the height is measured in lines. If a zero value is
// given for a dimension, its minimum will be determined by the label's content.
// If the label's content is greater than these dimensions, it will be truncated
// to fit.
func (element *Label) EmCollapse (columns int, rows int) {
element.forcedColumns = columns
element.forcedRows = rows
if element.entity == nil { return }
element.updateMinimumSize()
}
// FlexibleHeightFor returns the reccomended height for this element based on
// the given width in order to allow the text to wrap properly.
func (element *Label) FlexibleHeightFor (width int) (height int) {
if element.wrap {
return element.drawer.ReccomendedHeightFor(width)
} else {
return element.minHeight
}
}
// SetText sets the label's text.
func (element *Label) SetText (text string) {
if element.text == text { return }
element.text = text
element.drawer.SetText([]rune(text))
if element.entity == nil { return }
element.updateMinimumSize()
element.entity.Invalidate()
}
// SetWrap sets wether or not the label's text wraps. If the text is set to
// wrap, the element will have a minimum size of a single character and
// automatically wrap its text. If the text is set to not wrap, the element will
// have a minimum size that fits its text.
func (element *Label) SetWrap (wrap bool) {
if wrap == element.wrap { return }
if !wrap {
element.drawer.SetMaxWidth(0)
element.drawer.SetMaxHeight(0)
}
element.wrap = wrap
if element.entity == nil { return }
element.updateMinimumSize()
element.entity.Invalidate()
}
// SetAlign sets the alignment method of the label.
func (element *Label) SetAlign (align textdraw.Align) {
if align == element.align { return }
element.align = align
element.drawer.SetAlign(align)
if element.entity == nil { return }
element.updateMinimumSize()
element.entity.Invalidate()
}
// SetTheme sets the element's theme.
func (element *Label) SetTheme (new tomo.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.drawer.SetFace (element.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal))
if element.entity == nil { return }
element.updateMinimumSize()
element.entity.Invalidate()
}
// SetConfig sets the element's configuration.
func (element *Label) SetConfig (new tomo.Config) {
if new == element.config.Config { return }
element.config.Config = new
if element.entity == nil { return }
element.updateMinimumSize()
element.entity.Invalidate()
}
// Draw causes the element to draw to the specified destination canvas.
func (element *Label) Draw (destination canvas.Canvas) {
if element.entity == nil { return }
bounds := element.entity. Bounds()
if element.wrap {
element.drawer.SetMaxWidth(bounds.Dx())
element.drawer.SetMaxHeight(bounds.Dy())
}
element.entity.DrawBackground(destination, bounds)
textBounds := element.drawer.LayoutBounds()
foreground := element.theme.Color (
tomo.ColorForeground,
tomo.State { })
element.drawer.Draw(destination, foreground, bounds.Min.Sub(textBounds.Min))
}
func (element *Label) updateMinimumSize () {
var width, height int
if element.wrap {
em := element.drawer.Em().Round()
if em < 1 {
em = element.theme.Padding(tomo.PatternBackground)[0]
}
width, height = em, element.drawer.LineHeight().Round()
// FIXME we shoudl not have to pass in the element here
element.entity.NotifyFlexibleHeightChange(element)
} else {
bounds := element.drawer.LayoutBounds()
width, height = bounds.Dx(), bounds.Dy()
}
if element.forcedColumns > 0 {
width =
element.drawer.Em().
Mul(fixed.I(element.forcedColumns)).Floor()
}
if element.forcedRows > 0 {
height =
element.drawer.LineHeight().
Mul(fixed.I(element.forcedRows)).Floor()
}
element.minHeight = height
element.entity.SetMinimumSize(width, height)
}

View File

@@ -0,0 +1,51 @@
package elements
// Numeric is a type constraint representing a number.
type Numeric interface {
~float32 | ~float64 |
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
// LerpSlider is a slider that has a minimum and maximum value, and who's value
// can be any numeric type.
type LerpSlider[T Numeric] struct {
*Slider
min T
max T
}
// NewLerpSlider creates a new LerpSlider with a minimum and maximum value. If
// vertical is set to true, the slider will be vertical instead of horizontal.
func NewLerpSlider[T Numeric] (min, max T, value T, vertical bool) (element *LerpSlider[T]) {
if min > max {
temp := max
max = min
min = temp
}
element = &LerpSlider[T] {
Slider: NewSlider(0, vertical),
min: min,
max: max,
}
element.SetValue(value)
return
}
// SetValue sets the slider's value.
func (element *LerpSlider[T]) SetValue (value T) {
value -= element.min
element.Slider.SetValue(float64(value) / float64(element.Range()))
}
// Value returns the slider's value.
func (element *LerpSlider[T]) Value () (value T) {
return T (
float64(element.Slider.Value()) * float64(element.Range())) +
element.min
}
// Range returns the difference between the slider's maximum and minimum values.
func (element *LerpSlider[T]) Range () T {
return element.max - element.min
}

455
elements/notdone/list.go Normal file
View File

@@ -0,0 +1,455 @@
package elements
import "fmt"
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/artist"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
type listEntity interface {
tomo.FlexibleEntity
tomo.ContainerEntity
tomo.ScrollableEntity
}
// List is an element that contains several objects that a user can select.
type List struct {
entity listEntity
pressed bool
contentHeight int
forcedMinimumWidth int
forcedMinimumHeight int
selectedEntry int
scroll int
config config.Wrapped
theme theme.Wrapped
onNoEntrySelected func ()
onScrollBoundsChange func ()
}
// NewList creates a new list element with the specified entries.
func NewList (entries ...ListEntry) (element *List) {
element = &List { selectedEntry: -1 }
element.theme.Case = tomo.C("tomo", "list")
element.entries = make([]ListEntry, len(entries))
for index, entry := range entries {
element.entries[index] = entry
}
return
}
// SetTheme sets the element's theme.
func (element *List) SetTheme (new tomo.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
for index, entry := range element.entries {
entry.SetTheme(element.theme.Theme)
element.entries[index] = entry
}
element.updateMinimumSize()
element.entity.Invalidate()
}
// SetConfig sets the element's configuration.
func (element *List) SetConfig (new tomo.Config) {
if new == element.config.Config { return }
element.config.Config = new
for index, entry := range element.entries {
entry.SetConfig(element.config)
element.entries[index] = entry
}
element.updateMinimumSize()
element.redo()
}
// Collapse forces a minimum width and height upon the list. If a zero value is
// given for a dimension, its minimum will be determined by the list's content.
// If the list's height goes beyond the forced size, it will need to be accessed
// via scrolling. If an entry's width goes beyond the forced size, its text will
// be truncated so that it fits.
func (element *List) Collapse (width, height int) {
if
element.forcedMinimumWidth == width &&
element.forcedMinimumHeight == height {
return
}
element.forcedMinimumWidth = width
element.forcedMinimumHeight = height
element.updateMinimumSize()
for index, entry := range element.entries {
element.entries[index] = element.resizeEntryToFit(entry)
}
element.redo()
}
func (element *List) HandleMouseDown (x, y int, button input.Button) {
if !element.Enabled() { return }
if !element.Focused() { element.Focus() }
if button != input.ButtonLeft { return }
element.pressed = true
if element.selectUnderMouse(x, y) && element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
func (element *List) HandleMouseUp (x, y int, button input.Button) {
if button != input.ButtonLeft { return }
element.pressed = false
}
func (element *List) HandleMotion (x, y int) {
if element.pressed {
if element.selectUnderMouse(x, y) && element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
}
func (element *List) HandleKeyDown (key input.Key, modifiers input.Modifiers) {
if !element.Enabled() { return }
altered := false
switch key {
case input.KeyLeft, input.KeyUp:
altered = element.changeSelectionBy(-1)
case input.KeyRight, input.KeyDown:
altered = element.changeSelectionBy(1)
case input.KeyEscape:
altered = element.selectEntry(-1)
}
if altered && element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *List) HandleKeyUp(key input.Key, modifiers input.Modifiers) { }
// ScrollContentBounds returns the full content size of the element.
func (element *List) ScrollContentBounds () (bounds image.Rectangle) {
return image.Rect (
0, 0,
1, element.contentHeight)
}
// ScrollViewportBounds returns the size and position of the element's viewport
// relative to ScrollBounds.
func (element *List) ScrollViewportBounds () (bounds image.Rectangle) {
return image.Rect (
0, element.scroll,
0, element.scroll + element.scrollViewportHeight())
}
// ScrollTo scrolls the viewport to the specified point relative to
// ScrollBounds.
func (element *List) ScrollTo (position image.Point) {
element.scroll = position.Y
if element.scroll < 0 {
element.scroll = 0
} else if element.scroll > element.maxScrollHeight() {
element.scroll = element.maxScrollHeight()
}
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
element.scrollBoundsChange()
}
// ScrollAxes returns the supported axes for scrolling.
func (element *List) ScrollAxes () (horizontal, vertical bool) {
return false, true
}
// OnNoEntrySelected sets a function to be called when the user chooses to
// deselect the current selected entry by clicking on empty space within the
// list or by pressing the escape key.
func (element *List) OnNoEntrySelected (callback func ()) {
element.onNoEntrySelected = callback
}
// OnScrollBoundsChange sets a function to be called when the element's viewport
// bounds, content bounds, or scroll axes change.
func (element *List) OnScrollBoundsChange (callback func ()) {
element.onScrollBoundsChange = callback
}
// CountEntries returns the amount of entries in the list.
func (element *List) CountEntries () (count int) {
return len(element.entries)
}
// Append adds one or more entries to the end of the list.
func (element *List) Append (entries ...ListEntry) {
// append
for index, entry := range entries {
entry = element.resizeEntryToFit(entry)
entry.SetTheme(element.theme.Theme)
entry.SetConfig(element.config)
entries[index] = entry
}
element.entries = append(element.entries, entries...)
// recalculate, redraw, notify
element.updateMinimumSize()
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.scrollBoundsChange()
}
// EntryAt returns the entry at the specified index. If the index is out of
// bounds, it panics.
func (element *List) EntryAt (index int) (entry ListEntry) {
if index < 0 || index >= len(element.entries) {
panic(fmt.Sprint("basic.List.EntryAt index out of range: ", index))
}
return element.entries[index]
}
// Insert inserts an entry into the list at the speified index. If the index is
// out of bounds, it is constrained either to zero or len(entries).
func (element *List) Insert (index int, entry ListEntry) {
if index < 0 { index = 0 }
if index > len(element.entries) { index = len(element.entries) }
// insert
element.entries = append (
element.entries[:index + 1],
element.entries[index:]...)
entry = element.resizeEntryToFit(entry)
element.entries[index] = entry
// recalculate, redraw, notify
element.updateMinimumSize()
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.scrollBoundsChange()
}
// Remove removes the entry at the specified index. If the index is out of
// bounds, it panics.
func (element *List) Remove (index int) {
if index < 0 || index >= len(element.entries) {
panic(fmt.Sprint("basic.List.Remove index out of range: ", index))
}
// delete
element.entries = append (
element.entries[:index],
element.entries[index + 1:]...)
// recalculate, redraw, notify
element.updateMinimumSize()
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.scrollBoundsChange()
}
// Clear removes all entries from the list.
func (element *List) Clear () {
element.entries = nil
// recalculate, redraw, notify
element.updateMinimumSize()
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.scrollBoundsChange()
}
// Replace replaces the entry at the specified index with another. If the index
// is out of bounds, it panics.
func (element *List) Replace (index int, entry ListEntry) {
if index < 0 || index >= len(element.entries) {
panic(fmt.Sprint("basic.List.Replace index out of range: ", index))
}
// replace
entry = element.resizeEntryToFit(entry)
element.entries[index] = entry
// redraw
element.updateMinimumSize()
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.scrollBoundsChange()
}
// Select selects a specific item in the list. If the index is out of bounds,
// no items will be selecected.
func (element *List) Select (index int) {
if element.selectEntry(index) {
element.redo()
}
}
func (element *List) selectUnderMouse (x, y int) (updated bool) {
padding := element.theme.Padding(tomo.PatternSunken)
bounds := padding.Apply(element.Bounds())
mousePoint := image.Pt(x, y)
dot := image.Pt (
bounds.Min.X,
bounds.Min.Y - element.scroll)
newlySelectedEntryIndex := -1
for index, entry := range element.entries {
entryPosition := dot
dot.Y += entry.Bounds().Dy()
if entryPosition.Y > bounds.Max.Y { break }
if mousePoint.In(entry.Bounds().Add(entryPosition)) {
newlySelectedEntryIndex = index
break
}
}
return element.selectEntry(newlySelectedEntryIndex)
}
func (element *List) selectEntry (index int) (updated bool) {
if element.selectedEntry == index { return false }
element.selectedEntry = index
if element.selectedEntry < 0 {
if element.onNoEntrySelected != nil {
element.onNoEntrySelected()
}
} else {
element.entries[element.selectedEntry].RunSelect()
}
return true
}
func (element *List) changeSelectionBy (delta int) (updated bool) {
newIndex := element.selectedEntry + delta
if newIndex < 0 { newIndex = len(element.entries) - 1 }
if newIndex >= len(element.entries) { newIndex = 0 }
return element.selectEntry(newIndex)
}
func (element *List) resizeEntryToFit (entry ListEntry) (resized ListEntry) {
bounds := element.Bounds()
padding := element.theme.Padding(tomo.PatternSunken)
entry.Resize(padding.Apply(bounds).Dx())
return entry
}
func (element *List) updateMinimumSize () {
element.contentHeight = 0
for _, entry := range element.entries {
element.contentHeight += entry.Bounds().Dy()
}
minimumWidth := element.forcedMinimumWidth
minimumHeight := element.forcedMinimumHeight
if minimumWidth == 0 {
for _, entry := range element.entries {
entryWidth := entry.MinimumWidth()
if entryWidth > minimumWidth {
minimumWidth = entryWidth
}
}
}
if minimumHeight == 0 {
minimumHeight = element.contentHeight
}
padding := element.theme.Padding(tomo.PatternSunken)
minimumHeight += padding[0] + padding[2]
element.core.SetMinimumSize(minimumWidth, minimumHeight)
}
func (element *List) scrollBoundsChange () {
if parent, ok := element.core.Parent().(tomo.ScrollableParent); ok {
parent.NotifyScrollBoundsChange(element)
}
if element.onScrollBoundsChange != nil {
element.onScrollBoundsChange()
}
}
func (element *List) scrollViewportHeight () (height int) {
padding := element.theme.Padding(tomo.PatternSunken)
return element.Bounds().Dy() - padding[0] - padding[2]
}
func (element *List) maxScrollHeight () (height int) {
height =
element.contentHeight -
element.scrollViewportHeight()
if height < 0 { height = 0 }
return
}
func (element *List) Layout () {
for index, entry := range element.entries {
element.entries[index] = element.resizeEntryToFit(entry)
}
if element.scroll > element.maxScrollHeight() {
element.scroll = element.maxScrollHeight()
}
element.draw()
element.scrollBoundsChange()
}
func (element *List) Draw (destination canvas.Canvas) {
bounds := element.Bounds()
padding := element.theme.Padding(tomo.PatternSunken)
innerBounds := padding.Apply(bounds)
state := tomo.State {
Disabled: !element.Enabled(),
Focused: element.Focused(),
}
dot := image.Point {
innerBounds.Min.X,
innerBounds.Min.Y - element.scroll,
}
innerCanvas := canvas.Cut(element.core, innerBounds)
for index, entry := range element.entries {
entryPosition := dot
dot.Y += entry.Bounds().Dy()
if dot.Y < innerBounds.Min.Y { continue }
if entryPosition.Y > innerBounds.Max.Y { break }
entry.Draw (
innerCanvas, entryPosition,
element.Focused(), element.selectedEntry == index)
}
covered := image.Rect (
0, 0,
innerBounds.Dx(), element.contentHeight,
).Add(innerBounds.Min).Intersect(innerBounds)
pattern := element.theme.Pattern(tomo.PatternSunken, state)
artist.DrawShatter (
element.core, pattern, bounds, covered)
}

View File

@@ -0,0 +1,104 @@
package elements
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/textdraw"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// ListEntry is an item that can be added to a list.
type ListEntry struct {
drawer textdraw.Drawer
bounds image.Rectangle
text string
width int
minimumWidth int
config config.Wrapped
theme theme.Wrapped
onSelect func ()
}
func NewListEntry (text string, onSelect func ()) (entry ListEntry) {
entry = ListEntry {
text: text,
onSelect: onSelect,
}
entry.theme.Case = tomo.C("tomo", "listEntry")
entry.drawer.SetFace (entry.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal))
entry.drawer.SetText([]rune(text))
entry.updateBounds()
return
}
func (entry *ListEntry) SetTheme (new tomo.Theme) {
if new == entry.theme.Theme { return }
entry.theme.Theme = new
entry.drawer.SetFace (entry.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal))
entry.updateBounds()
}
func (entry *ListEntry) SetConfig (new tomo.Config) {
if new == entry.config.Config { return }
entry.config.Config = new
}
func (entry *ListEntry) updateBounds () {
padding := entry.theme.Padding(tomo.PatternRaised)
entry.bounds = padding.Inverse().Apply(entry.drawer.LayoutBounds())
entry.bounds = entry.bounds.Sub(entry.bounds.Min)
entry.minimumWidth = entry.bounds.Dx()
entry.bounds.Max.X = entry.width
}
func (entry *ListEntry) Draw (
destination canvas.Canvas,
offset image.Point,
focused bool,
on bool,
) (
updatedRegion image.Rectangle,
) {
state := tomo.State {
Focused: focused,
On: on,
}
pattern := entry.theme.Pattern(tomo.PatternRaised, state)
padding := entry.theme.Padding(tomo.PatternRaised)
bounds := entry.Bounds().Add(offset)
pattern.Draw(destination, bounds)
foreground := entry.theme.Color (tomo.ColorForeground, state)
return entry.drawer.Draw (
destination,
foreground,
offset.Add(image.Pt(padding[artist.SideLeft], padding[artist.SideTop])).
Sub(entry.drawer.LayoutBounds().Min))
}
func (entry *ListEntry) RunSelect () {
if entry.onSelect != nil {
entry.onSelect()
}
}
func (entry *ListEntry) Bounds () (bounds image.Rectangle) {
return entry.bounds
}
func (entry *ListEntry) Resize (width int) {
entry.width = width
entry.updateBounds()
}
func (entry *ListEntry) MinimumWidth () (width int) {
return entry.minimumWidth
}

View File

@@ -0,0 +1,80 @@
package elements
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// ProgressBar displays a visual indication of how far along a task is.
type ProgressBar struct {
progress float64
config config.Wrapped
theme theme.Wrapped
}
// NewProgressBar creates a new progress bar displaying the given progress
// level.
func NewProgressBar (progress float64) (element *ProgressBar) {
element = &ProgressBar { progress: progress }
element.theme.Case = tomo.C("tomo", "progressBar")
element.Core, element.core = core.NewCore(element, element.draw)
element.updateMinimumSize()
return
}
// SetProgress sets the progress level of the bar.
func (element *ProgressBar) SetProgress (progress float64) {
if progress == element.progress { return }
element.progress = progress
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
// SetTheme sets the element's theme.
func (element *ProgressBar) SetTheme (new tomo.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *ProgressBar) SetConfig (new tomo.Config) {
if new == nil || new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *ProgressBar) updateMinimumSize() {
padding := element.theme.Padding(tomo.PatternSunken)
innerPadding := element.theme.Padding(tomo.PatternMercury)
element.core.SetMinimumSize (
padding.Horizontal() + innerPadding.Horizontal(),
padding.Vertical() + innerPadding.Vertical())
}
func (element *ProgressBar) redo () {
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
func (element *ProgressBar) draw () {
bounds := element.Bounds()
pattern := element.theme.Pattern(tomo.PatternSunken, tomo.State { })
padding := element.theme.Padding(tomo.PatternSunken)
pattern.Draw(element.core, bounds)
bounds = padding.Apply(bounds)
meterBounds := image.Rect (
bounds.Min.X, bounds.Min.Y,
bounds.Min.X + int(float64(bounds.Dx()) * element.progress),
bounds.Max.Y)
mercury := element.theme.Pattern(tomo.PatternMercury, tomo.State { })
mercury.Draw(element.core, meterBounds)
}

View File

@@ -0,0 +1,321 @@
package elements
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// ScrollBar is an element similar to Slider, but it has special behavior that
// makes it well suited for controlling the viewport position on one axis of a
// scrollable element. Instead of having a value from zero to one, it stores
// viewport and content boundaries. When the user drags the scroll bar handle,
// the scroll bar calls the OnScroll callback assigned to it with the position
// the user is trying to move the handle to. A program can check to see if this
// value is valid, move the viewport, and give the scroll bar the new viewport
// bounds (which will then cause it to move the handle).
//
// Typically, you wont't want to use a ScrollBar by itself. A ScrollContainer is
// better for most cases.
type ScrollBar struct {
vertical bool
enabled bool
dragging bool
dragOffset image.Point
track image.Rectangle
bar image.Rectangle
contentBounds image.Rectangle
viewportBounds image.Rectangle
config config.Wrapped
theme theme.Wrapped
onScroll func (viewport image.Point)
}
// NewScrollBar creates a new scroll bar. If vertical is set to true, the scroll
// bar will be vertical instead of horizontal.
func NewScrollBar (vertical bool) (element *ScrollBar) {
element = &ScrollBar {
vertical: vertical,
enabled: true,
}
if vertical {
element.theme.Case = tomo.C("tomo", "scrollBarHorizontal")
} else {
element.theme.Case = tomo.C("tomo", "scrollBarVertical")
}
element.Core, element.core = core.NewCore(element, element.handleResize)
element.updateMinimumSize()
return
}
func (element *ScrollBar) handleResize () {
if element.core.HasImage() {
element.recalculate()
element.draw()
}
}
func (element *ScrollBar) HandleMouseDown (x, y int, button input.Button) {
velocity := element.config.ScrollVelocity()
point := image.Pt(x, y)
if point.In(element.bar) {
// the mouse is pressed down within the bar's handle
element.dragging = true
element.drawAndPush()
element.dragOffset =
point.Sub(element.bar.Min).
Add(element.Bounds().Min)
element.dragTo(point)
} else {
// the mouse is pressed down within the bar's gutter
switch button {
case input.ButtonLeft:
// start scrolling at this point, but set the offset to
// the middle of the handle
element.dragging = true
element.dragOffset = element.fallbackDragOffset()
element.dragTo(point)
case input.ButtonMiddle:
// page up/down on middle click
viewport := 0
if element.vertical {
viewport = element.viewportBounds.Dy()
} else {
viewport = element.viewportBounds.Dx()
}
if element.isAfterHandle(point) {
element.scrollBy(viewport)
} else {
element.scrollBy(-viewport)
}
case input.ButtonRight:
// inch up/down on right click
if element.isAfterHandle(point) {
element.scrollBy(velocity)
} else {
element.scrollBy(-velocity)
}
}
}
}
func (element *ScrollBar) HandleMouseUp (x, y int, button input.Button) {
if element.dragging {
element.dragging = false
element.drawAndPush()
}
}
func (element *ScrollBar) HandleMotion (x, y int) {
if element.dragging {
element.dragTo(image.Pt(x, y))
}
}
func (element *ScrollBar) HandleScroll (x, y int, deltaX, deltaY float64) {
if element.vertical {
element.scrollBy(int(deltaY))
} else {
element.scrollBy(int(deltaX))
}
}
// SetEnabled sets whether or not the scroll bar can be interacted with.
func (element *ScrollBar) SetEnabled (enabled bool) {
if element.enabled == enabled { return }
element.enabled = enabled
element.drawAndPush()
}
// Enabled returns whether or not the element is enabled.
func (element *ScrollBar) Enabled () (enabled bool) {
return element.enabled
}
// SetBounds sets the content and viewport bounds of the scroll bar.
func (element *ScrollBar) SetBounds (content, viewport image.Rectangle) {
element.contentBounds = content
element.viewportBounds = viewport
element.recalculate()
element.drawAndPush()
}
// OnScroll sets a function to be called when the user tries to move the scroll
// bar's handle. The callback is passed a point representing the new viewport
// position. For the scroll bar's position to visually update, the callback must
// check if the position is valid and call ScrollBar.SetBounds with the new
// viewport bounds.
func (element *ScrollBar) OnScroll (callback func (viewport image.Point)) {
element.onScroll = callback
}
// SetTheme sets the element's theme.
func (element *ScrollBar) SetTheme (new tomo.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.drawAndPush()
}
// SetConfig sets the element's configuration.
func (element *ScrollBar) SetConfig (new tomo.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.drawAndPush()
}
func (element *ScrollBar) isAfterHandle (point image.Point) bool {
if element.vertical {
return point.Y > element.bar.Min.Y
} else {
return point.X > element.bar.Min.X
}
}
func (element *ScrollBar) fallbackDragOffset () image.Point {
if element.vertical {
return element.Bounds().Min.
Add(image.Pt(0, element.bar.Dy() / 2))
} else {
return element.Bounds().Min.
Add(image.Pt(element.bar.Dx() / 2, 0))
}
}
func (element *ScrollBar) scrollBy (delta int) {
deltaPoint := image.Point { }
if element.vertical {
deltaPoint.Y = delta
} else {
deltaPoint.X = delta
}
if element.onScroll != nil {
element.onScroll(element.viewportBounds.Min.Add(deltaPoint))
}
}
func (element *ScrollBar) dragTo (point image.Point) {
point = point.Sub(element.dragOffset)
var scrollX, scrollY float64
if element.vertical {
ratio :=
float64(element.contentBounds.Dy()) /
float64(element.track.Dy())
scrollX = float64(element.viewportBounds.Min.X)
scrollY = float64(point.Y) * ratio
} else {
ratio :=
float64(element.contentBounds.Dx()) /
float64(element.track.Dx())
scrollX = float64(point.X) * ratio
scrollY = float64(element.viewportBounds.Min.Y)
}
if element.onScroll != nil {
element.onScroll(image.Pt(int(scrollX), int(scrollY)))
}
}
func (element *ScrollBar) recalculate () {
if element.vertical {
element.recalculateVertical()
} else {
element.recalculateHorizontal()
}
}
func (element *ScrollBar) recalculateVertical () {
bounds := element.Bounds()
padding := element.theme.Padding(tomo.PatternGutter)
element.track = padding.Apply(bounds)
contentBounds := element.contentBounds
viewportBounds := element.viewportBounds
if element.Enabled() {
element.bar.Min.X = element.track.Min.X
element.bar.Max.X = element.track.Max.X
ratio :=
float64(element.track.Dy()) /
float64(contentBounds.Dy())
element.bar.Min.Y = int(float64(viewportBounds.Min.Y) * ratio)
element.bar.Max.Y = int(float64(viewportBounds.Max.Y) * ratio)
element.bar.Min.Y += element.track.Min.Y
element.bar.Max.Y += element.track.Min.Y
}
// if the handle is out of bounds, don't display it
if element.bar.Dy() >= element.track.Dy() {
element.bar = image.Rectangle { }
}
}
func (element *ScrollBar) recalculateHorizontal () {
bounds := element.Bounds()
padding := element.theme.Padding(tomo.PatternGutter)
element.track = padding.Apply(bounds)
contentBounds := element.contentBounds
viewportBounds := element.viewportBounds
if element.Enabled() {
element.bar.Min.Y = element.track.Min.Y
element.bar.Max.Y = element.track.Max.Y
ratio :=
float64(element.track.Dx()) /
float64(contentBounds.Dx())
element.bar.Min.X = int(float64(viewportBounds.Min.X) * ratio)
element.bar.Max.X = int(float64(viewportBounds.Max.X) * ratio)
element.bar.Min.X += element.track.Min.X
element.bar.Max.X += element.track.Min.X
}
// if the handle is out of bounds, don't display it
if element.bar.Dx() >= element.track.Dx() {
element.bar = image.Rectangle { }
}
}
func (element *ScrollBar) updateMinimumSize () {
gutterPadding := element.theme.Padding(tomo.PatternGutter)
handlePadding := element.theme.Padding(tomo.PatternHandle)
if element.vertical {
element.core.SetMinimumSize (
gutterPadding.Horizontal() + handlePadding.Horizontal(),
gutterPadding.Vertical() + handlePadding.Vertical() * 2)
} else {
element.core.SetMinimumSize (
gutterPadding.Horizontal() + handlePadding.Horizontal() * 2,
gutterPadding.Vertical() + handlePadding.Vertical())
}
}
func (element *ScrollBar) drawAndPush () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *ScrollBar) draw () {
bounds := element.Bounds()
state := tomo.State {
Disabled: !element.Enabled(),
Pressed: element.dragging,
}
element.theme.Pattern(tomo.PatternGutter, state).Draw (
element.core,
bounds)
element.theme.Pattern(tomo.PatternHandle, state).Draw (
element.core,
element.bar)
}

234
elements/notdone/slider.go Normal file
View File

@@ -0,0 +1,234 @@
package elements
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// Slider is a slider control with a floating point value between zero and one.
type Slider struct {
value float64
vertical bool
dragging bool
dragOffset int
track image.Rectangle
bar image.Rectangle
config config.Wrapped
theme theme.Wrapped
onSlide func ()
onRelease func ()
}
// NewSlider creates a new slider with the specified value. If vertical is set
// to true,
func NewSlider (value float64, vertical bool) (element *Slider) {
element = &Slider {
value: value,
vertical: vertical,
}
if vertical {
element.theme.Case = tomo.C("tomo", "sliderVertical")
} else {
element.theme.Case = tomo.C("tomo", "sliderHorizontal")
}
element.Core, element.core = core.NewCore(element, element.draw)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore(element.core, element.redo)
element.updateMinimumSize()
return
}
func (element *Slider) HandleMouseDown (x, y int, button input.Button) {
if !element.Enabled() { return }
element.Focus()
if button == input.ButtonLeft {
element.dragging = true
element.value = element.valueFor(x, y)
if element.onSlide != nil {
element.onSlide()
}
element.redo()
}
}
func (element *Slider) HandleMouseUp (x, y int, button input.Button) {
if button != input.ButtonLeft || !element.dragging { return }
element.dragging = false
if element.onRelease != nil {
element.onRelease()
}
element.redo()
}
func (element *Slider) HandleMotion (x, y int) {
if element.dragging {
element.dragging = true
element.value = element.valueFor(x, y)
if element.onSlide != nil {
element.onSlide()
}
element.redo()
}
}
func (element *Slider) HandleScroll (x, y int, deltaX, deltaY float64) { }
func (element *Slider) HandleKeyDown (key input.Key, modifiers input.Modifiers) {
switch key {
case input.KeyUp:
element.changeValue(0.1)
case input.KeyDown:
element.changeValue(-0.1)
case input.KeyRight:
if element.vertical {
element.changeValue(-0.1)
} else {
element.changeValue(0.1)
}
case input.KeyLeft:
if element.vertical {
element.changeValue(0.1)
} else {
element.changeValue(-0.1)
}
}
}
func (element *Slider) HandleKeyUp (key input.Key, modifiers input.Modifiers) { }
// Value returns the slider's value.
func (element *Slider) Value () (value float64) {
return element.value
}
// SetEnabled sets whether or not the slider can be interacted with.
func (element *Slider) SetEnabled (enabled bool) {
element.focusableControl.SetEnabled(enabled)
}
// SetValue sets the slider's value.
func (element *Slider) SetValue (value float64) {
if value < 0 { value = 0 }
if value > 1 { value = 1 }
if element.value == value { return }
element.value = value
if element.onRelease != nil {
element.onRelease()
}
element.redo()
}
// OnSlide sets a function to be called every time the slider handle changes
// position while being dragged.
func (element *Slider) OnSlide (callback func ()) {
element.onSlide = callback
}
// OnRelease sets a function to be called when the handle stops being dragged.
func (element *Slider) OnRelease (callback func ()) {
element.onRelease = callback
}
// SetTheme sets the element's theme.
func (element *Slider) SetTheme (new tomo.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Slider) SetConfig (new tomo.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *Slider) changeValue (delta float64) {
element.value += delta
if element.value < 0 {
element.value = 0
}
if element.value > 1 {
element.value = 1
}
if element.onRelease != nil {
element.onRelease()
}
element.redo()
}
func (element *Slider) valueFor (x, y int) (value float64) {
if element.vertical {
value =
float64(y - element.track.Min.Y - element.bar.Dy() / 2) /
float64(element.track.Dy() - element.bar.Dy())
value = 1 - value
} else {
value =
float64(x - element.track.Min.X - element.bar.Dx() / 2) /
float64(element.track.Dx() - element.bar.Dx())
}
if value < 0 { value = 0 }
if value > 1 { value = 1 }
return
}
func (element *Slider) updateMinimumSize () {
gutterPadding := element.theme.Padding(tomo.PatternGutter)
handlePadding := element.theme.Padding(tomo.PatternHandle)
if element.vertical {
element.core.SetMinimumSize (
gutterPadding.Horizontal() + handlePadding.Horizontal(),
gutterPadding.Vertical() + handlePadding.Vertical() * 2)
} else {
element.core.SetMinimumSize (
gutterPadding.Horizontal() + handlePadding.Horizontal() * 2,
gutterPadding.Vertical() + handlePadding.Vertical())
}
}
func (element *Slider) redo () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *Slider) draw () {
bounds := element.Bounds()
element.track = element.theme.Padding(tomo.PatternGutter).Apply(bounds)
if element.vertical {
barSize := element.track.Dx()
element.bar = image.Rect(0, 0, barSize, barSize).Add(bounds.Min)
barOffset :=
float64(element.track.Dy() - barSize) *
(1 - element.value)
element.bar = element.bar.Add(image.Pt(0, int(barOffset)))
} else {
barSize := element.track.Dy()
element.bar = image.Rect(0, 0, barSize, barSize).Add(bounds.Min)
barOffset :=
float64(element.track.Dx() - barSize) *
element.value
element.bar = element.bar.Add(image.Pt(int(barOffset), 0))
}
state := tomo.State {
Focused: element.Focused(),
Disabled: !element.Enabled(),
Pressed: element.dragging,
}
element.theme.Pattern(tomo.PatternGutter, state).Draw (
element.core,
bounds)
element.theme.Pattern(tomo.PatternHandle, state).Draw (
element.core,
element.bar)
}

View File

@@ -0,0 +1,83 @@
package elements
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// Spacer can be used to put space between two elements..
type Spacer struct {
line bool
config config.Wrapped
theme theme.Wrapped
}
// NewSpacer creates a new spacer. If line is set to true, the spacer will be
// filled with a line color, and if compressed to its minimum width or height,
// will appear as a line.
func NewSpacer (line bool) (element *Spacer) {
element = &Spacer { line: line }
element.theme.Case = tomo.C("tomo", "spacer")
element.Core, element.core = core.NewCore(element, element.draw)
element.updateMinimumSize()
return
}
/// SetLine sets whether or not the spacer will appear as a colored line.
func (element *Spacer) SetLine (line bool) {
if element.line == line { return }
element.line = line
element.updateMinimumSize()
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
// SetTheme sets the element's theme.
func (element *Spacer) SetTheme (new tomo.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Spacer) SetConfig (new tomo.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.redo()
}
func (element *Spacer) updateMinimumSize () {
if element.line {
padding := element.theme.Padding(tomo.PatternLine)
element.core.SetMinimumSize (
padding.Horizontal(),
padding.Vertical())
} else {
element.core.SetMinimumSize(1, 1)
}
}
func (element *Spacer) redo () {
if !element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
func (element *Spacer) draw () {
bounds := element.Bounds()
if element.line {
pattern := element.theme.Pattern (
tomo.PatternLine,
tomo.State { })
pattern.Draw(element.core, bounds)
} else {
pattern := element.theme.Pattern (
tomo.PatternBackground,
tomo.State { })
pattern.Draw(element.core, bounds)
}
}

200
elements/notdone/switch.go Normal file
View File

@@ -0,0 +1,200 @@
package elements
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/textdraw"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// Switch is a toggle-able on/off switch with an optional label. It is
// functionally identical to Checkbox, but plays a different semantic role.
type Switch struct {
drawer textdraw.Drawer
pressed bool
checked bool
text string
config config.Wrapped
theme theme.Wrapped
onToggle func ()
}
// NewSwitch creates a new switch with the specified label text.
func NewSwitch (text string, on bool) (element *Switch) {
element = &Switch {
checked: on,
text: text,
}
element.theme.Case = tomo.C("tomo", "switch")
element.Core, element.core = core.NewCore(element, element.draw)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore(element.core, element.redo)
element.drawer.SetFace (element.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal))
element.drawer.SetText([]rune(text))
element.updateMinimumSize()
return
}
func (element *Switch) HandleMouseDown (x, y int, button input.Button) {
if !element.Enabled() { return }
element.Focus()
element.pressed = true
element.redo()
}
func (element *Switch) HandleMouseUp (x, y int, button input.Button) {
if button != input.ButtonLeft || !element.pressed { return }
element.pressed = false
within := image.Point { x, y }.
In(element.Bounds())
if within {
element.checked = !element.checked
}
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
if within && element.onToggle != nil {
element.onToggle()
}
}
func (element *Switch) HandleKeyDown (key input.Key, modifiers input.Modifiers) {
if key == input.KeyEnter {
element.pressed = true
element.redo()
}
}
func (element *Switch) HandleKeyUp (key input.Key, modifiers input.Modifiers) {
if key == input.KeyEnter && element.pressed {
element.pressed = false
element.checked = !element.checked
element.redo()
if element.onToggle != nil {
element.onToggle()
}
}
}
// OnToggle sets the function to be called when the switch is flipped.
func (element *Switch) OnToggle (callback func ()) {
element.onToggle = callback
}
// Value reports whether or not the switch is currently on.
func (element *Switch) Value () (on bool) {
return element.checked
}
// SetEnabled sets whether this switch can be flipped or not.
func (element *Switch) SetEnabled (enabled bool) {
element.focusableControl.SetEnabled(enabled)
}
// SetText sets the checkbox's label text.
func (element *Switch) SetText (text string) {
if element.text == text { return }
element.text = text
element.drawer.SetText([]rune(text))
element.updateMinimumSize()
element.redo()
}
// SetTheme sets the element's theme.
func (element *Switch) 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.redo()
}
// SetConfig sets the element's configuration.
func (element *Switch) SetConfig (new tomo.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *Switch) redo () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *Switch) updateMinimumSize () {
textBounds := element.drawer.LayoutBounds()
lineHeight := element.drawer.LineHeight().Round()
if element.text == "" {
element.core.SetMinimumSize(lineHeight * 2, lineHeight)
} else {
element.core.SetMinimumSize (
lineHeight * 2 +
element.theme.Margin(tomo.PatternBackground).X +
textBounds.Dx(),
lineHeight)
}
}
func (element *Switch) draw () {
bounds := element.Bounds()
handleBounds := image.Rect(0, 0, bounds.Dy(), bounds.Dy()).Add(bounds.Min)
gutterBounds := image.Rect(0, 0, bounds.Dy() * 2, bounds.Dy()).Add(bounds.Min)
state := tomo.State {
Disabled: !element.Enabled(),
Focused: element.Focused(),
Pressed: element.pressed,
}
element.core.DrawBackground (
element.theme.Pattern(tomo.PatternBackground, state))
if element.checked {
handleBounds.Min.X += bounds.Dy()
handleBounds.Max.X += bounds.Dy()
if element.pressed {
handleBounds.Min.X -= 2
handleBounds.Max.X -= 2
}
} else {
if element.pressed {
handleBounds.Min.X += 2
handleBounds.Max.X += 2
}
}
gutterPattern := element.theme.Pattern (
tomo.PatternGutter, state)
gutterPattern.Draw(element.core, gutterBounds)
handlePattern := element.theme.Pattern (
tomo.PatternHandle, state)
handlePattern.Draw(element.core, handleBounds)
textBounds := element.drawer.LayoutBounds()
offset := bounds.Min.Add(image.Point {
X: bounds.Dy() * 2 +
element.theme.Margin(tomo.PatternBackground).X,
})
offset.Y -= textBounds.Min.Y
offset.X -= textBounds.Min.X
foreground := element.theme.Color(tomo.ColorForeground, state)
element.drawer.Draw(element.core, foreground, offset)
}

510
elements/notdone/textbox.go Normal file
View File

@@ -0,0 +1,510 @@
package elements
import "io"
import "time"
import "image"
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/data"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/textdraw"
import "git.tebibyte.media/sashakoshka/tomo/textmanip"
import "git.tebibyte.media/sashakoshka/tomo/fixedutil"
import "git.tebibyte.media/sashakoshka/tomo/artist/shapes"
import "git.tebibyte.media/sashakoshka/tomo/default/theme"
import "git.tebibyte.media/sashakoshka/tomo/default/config"
// TextBox is a single-line text input.
type TextBox struct {
lastClick time.Time
dragging int
dot textmanip.Dot
scroll int
placeholder string
text []rune
placeholderDrawer textdraw.Drawer
valueDrawer textdraw.Drawer
config config.Wrapped
theme theme.Wrapped
onKeyDown func (key input.Key, modifiers input.Modifiers) (handled bool)
onChange func ()
onEnter func ()
onScrollBoundsChange func ()
}
// NewTextBox creates a new text box with the specified placeholder text, and
// a value. When the value is empty, the placeholder will be displayed in gray
// text.
func NewTextBox (placeholder, value string) (element *TextBox) {
element = &TextBox { }
element.theme.Case = tomo.C("tomo", "textBox")
element.Core, element.core = core.NewCore(element, element.handleResize)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore (element.core, func () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
})
element.placeholder = placeholder
element.placeholderDrawer.SetFace (element.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal))
element.valueDrawer.SetFace (element.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal))
element.placeholderDrawer.SetText([]rune(placeholder))
element.updateMinimumSize()
element.SetValue(value)
return
}
func (element *TextBox) handleResize () {
element.scrollToCursor()
element.draw()
if parent, ok := element.core.Parent().(tomo.ScrollableParent); ok {
parent.NotifyScrollBoundsChange(element)
}
}
func (element *TextBox) HandleMouseDown (x, y int, button input.Button) {
if !element.Enabled() { return }
if !element.Focused() { element.Focus() }
if button == input.ButtonLeft {
runeIndex := element.atPosition(image.Pt(x, y))
if runeIndex == -1 { return }
if time.Since(element.lastClick) < element.config.DoubleClickDelay() {
element.dragging = 2
element.dot = textmanip.WordAround(element.text, runeIndex)
} else {
element.dragging = 1
element.dot = textmanip.EmptyDot(runeIndex)
element.lastClick = time.Now()
}
element.redo()
}
}
func (element *TextBox) HandleMotion (x, y int) {
if !element.Enabled() { return }
switch element.dragging {
case 1:
runeIndex := element.atPosition(image.Pt(x, y))
if runeIndex > -1 {
element.dot.End = runeIndex
element.redo()
}
case 2:
runeIndex := element.atPosition(image.Pt(x, y))
if runeIndex > -1 {
if runeIndex < element.dot.Start {
element.dot.End =
runeIndex -
textmanip.WordToLeft (
element.text,
runeIndex)
} else {
element.dot.End =
runeIndex +
textmanip.WordToRight (
element.text,
runeIndex)
}
element.redo()
}
}
}
func (element *TextBox) textOffset () image.Point {
padding := element.theme.Padding(tomo.PatternInput)
bounds := element.Bounds()
innerBounds := padding.Apply(bounds)
textHeight := element.valueDrawer.LineHeight().Round()
return bounds.Min.Add (image.Pt (
padding[artist.SideLeft] - element.scroll,
padding[artist.SideTop] + (innerBounds.Dy() - textHeight) / 2))
}
func (element *TextBox) atPosition (position image.Point) int {
offset := element.textOffset()
textBoundsMin := element.valueDrawer.LayoutBounds().Min
return element.valueDrawer.AtPosition (
fixedutil.Pt(position.Sub(offset).Add(textBoundsMin)))
}
func (element *TextBox) HandleMouseUp (x, y int, button input.Button) {
if button == input.ButtonLeft {
element.dragging = 0
}
}
func (element *TextBox) HandleKeyDown(key input.Key, modifiers input.Modifiers) {
if element.onKeyDown != nil && element.onKeyDown(key, modifiers) {
return
}
scrollMemory := element.scroll
altered := true
textChanged := false
switch {
case key == input.KeyEnter:
if element.onEnter != nil {
element.onEnter()
}
case key == input.KeyBackspace:
if len(element.text) < 1 { break }
element.text, element.dot = textmanip.Backspace (
element.text,
element.dot,
modifiers.Control)
textChanged = true
case key == input.KeyDelete:
if len(element.text) < 1 { break }
element.text, element.dot = textmanip.Delete (
element.text,
element.dot,
modifiers.Control)
textChanged = true
case key == input.KeyLeft:
if modifiers.Shift {
element.dot = textmanip.SelectLeft (
element.text,
element.dot,
modifiers.Control)
} else {
element.dot = textmanip.MoveLeft (
element.text,
element.dot,
modifiers.Control)
}
case key == input.KeyRight:
if modifiers.Shift {
element.dot = textmanip.SelectRight (
element.text,
element.dot,
modifiers.Control)
} else {
element.dot = textmanip.MoveRight (
element.text,
element.dot,
modifiers.Control)
}
case key == 'a' && modifiers.Control:
element.dot.Start = 0
element.dot.End = len(element.text)
case key == 'x' && modifiers.Control:
var lifted []rune
element.text, element.dot, lifted = textmanip.Lift (
element.text,
element.dot)
if lifted != nil {
element.clipboardPut(lifted)
textChanged = true
}
case key == 'c' && modifiers.Control:
element.clipboardPut(element.dot.Slice(element.text))
case key == 'v' && modifiers.Control:
window := element.core.Window()
if window == nil { break }
window.Paste (func (d data.Data, err error) {
if err != nil { return }
reader, ok := d[data.MimePlain]
if !ok { return }
bytes, _ := io.ReadAll(reader)
element.text, element.dot = textmanip.Type (
element.text,
element.dot,
[]rune(string(bytes))...)
element.notifyAsyncTextChange()
})
case key.Printable():
element.text, element.dot = textmanip.Type (
element.text,
element.dot,
rune(key))
textChanged = true
default:
altered = false
}
if textChanged {
element.runOnChange()
element.valueDrawer.SetText(element.text)
}
if altered {
element.scrollToCursor()
}
if (textChanged || scrollMemory != element.scroll) {
if parent, ok := element.core.Parent().(tomo.ScrollableParent); ok {
parent.NotifyScrollBoundsChange(element)
}
}
if altered {
element.redo()
}
}
func (element *TextBox) clipboardPut (text []rune) {
window := element.core.Window()
if window != nil {
window.Copy(data.Bytes(data.MimePlain, []byte(string(text))))
}
}
func (element *TextBox) HandleKeyUp(key input.Key, modifiers input.Modifiers) { }
func (element *TextBox) SetPlaceholder (placeholder string) {
if element.placeholder == placeholder { return }
element.placeholder = placeholder
element.placeholderDrawer.SetText([]rune(placeholder))
element.updateMinimumSize()
element.redo()
}
func (element *TextBox) SetValue (text string) {
// if element.text == text { return }
element.text = []rune(text)
element.runOnChange()
element.valueDrawer.SetText(element.text)
if element.dot.End > element.valueDrawer.Length() {
element.dot = textmanip.EmptyDot(element.valueDrawer.Length())
}
element.scrollToCursor()
element.redo()
}
func (element *TextBox) Value () (value string) {
return string(element.text)
}
func (element *TextBox) Filled () (filled bool) {
return len(element.text) > 0
}
func (element *TextBox) OnKeyDown (
callback func (key input.Key, modifiers input.Modifiers) (handled bool),
) {
element.onKeyDown = callback
}
func (element *TextBox) OnEnter (callback func ()) {
element.onEnter = callback
}
func (element *TextBox) OnChange (callback func ()) {
element.onChange = callback
}
// OnScrollBoundsChange sets a function to be called when the element's viewport
// bounds, content bounds, or scroll axes change.
func (element *TextBox) OnScrollBoundsChange (callback func ()) {
element.onScrollBoundsChange = callback
}
// ScrollContentBounds returns the full content size of the element.
func (element *TextBox) ScrollContentBounds () (bounds image.Rectangle) {
bounds = element.valueDrawer.LayoutBounds()
return bounds.Sub(bounds.Min)
}
// ScrollViewportBounds returns the size and position of the element's viewport
// relative to ScrollBounds.
func (element *TextBox) ScrollViewportBounds () (bounds image.Rectangle) {
return image.Rect (
element.scroll,
0,
element.scroll + element.scrollViewportWidth(),
0)
}
func (element *TextBox) scrollViewportWidth () (width int) {
padding := element.theme.Padding(tomo.PatternInput)
return padding.Apply(element.Bounds()).Dx()
}
// ScrollTo scrolls the viewport to the specified point relative to
// ScrollBounds.
func (element *TextBox) ScrollTo (position image.Point) {
// constrain to minimum
element.scroll = position.X
if element.scroll < 0 { element.scroll = 0 }
// constrain to maximum
contentBounds := element.ScrollContentBounds()
maxPosition := contentBounds.Max.X - element.scrollViewportWidth()
if element.scroll > maxPosition { element.scroll = maxPosition }
element.redo()
if parent, ok := element.core.Parent().(tomo.ScrollableParent); ok {
parent.NotifyScrollBoundsChange(element)
}
}
// ScrollAxes returns the supported axes for scrolling.
func (element *TextBox) ScrollAxes () (horizontal, vertical bool) {
return true, false
}
func (element *TextBox) runOnChange () {
if element.onChange != nil {
element.onChange()
}
}
func (element *TextBox) scrollToCursor () {
if !element.core.HasImage() { return }
padding := element.theme.Padding(tomo.PatternInput)
bounds := padding.Apply(element.Bounds())
bounds = bounds.Sub(bounds.Min)
bounds.Max.X -= element.valueDrawer.Em().Round()
cursorPosition := fixedutil.RoundPt (
element.valueDrawer.PositionAt(element.dot.End))
cursorPosition.X -= element.scroll
maxX := bounds.Max.X
minX := maxX
if cursorPosition.X > maxX {
element.scroll += cursorPosition.X - maxX
} else if cursorPosition.X < minX {
element.scroll -= minX - cursorPosition.X
if element.scroll < 0 { element.scroll = 0 }
}
}
// SetTheme sets the element's theme.
func (element *TextBox) SetTheme (new tomo.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
face := element.theme.FontFace (
tomo.FontStyleRegular,
tomo.FontSizeNormal)
element.placeholderDrawer.SetFace(face)
element.valueDrawer.SetFace(face)
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *TextBox) SetConfig (new tomo.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *TextBox) updateMinimumSize () {
textBounds := element.placeholderDrawer.LayoutBounds()
padding := element.theme.Padding(tomo.PatternInput)
element.core.SetMinimumSize (
padding.Horizontal() + textBounds.Dx(),
padding.Vertical() +
element.placeholderDrawer.LineHeight().Round())
}
func (element *TextBox) notifyAsyncTextChange () {
element.runOnChange()
element.valueDrawer.SetText(element.text)
element.scrollToCursor()
if parent, ok := element.core.Parent().(tomo.ScrollableParent); ok {
parent.NotifyScrollBoundsChange(element)
}
element.redo()
}
func (element *TextBox) redo () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *TextBox) draw () {
bounds := element.Bounds()
state := tomo.State {
Disabled: !element.Enabled(),
Focused: element.Focused(),
}
pattern := element.theme.Pattern(tomo.PatternInput, state)
padding := element.theme.Padding(tomo.PatternInput)
innerCanvas := canvas.Cut(element.core, padding.Apply(bounds))
pattern.Draw(element.core, bounds)
offset := element.textOffset()
if element.Focused() && !element.dot.Empty() {
// draw selection bounds
accent := element.theme.Color(tomo.ColorAccent, state)
canon := element.dot.Canon()
foff := fixedutil.Pt(offset)
start := element.valueDrawer.PositionAt(canon.Start).Add(foff)
end := element.valueDrawer.PositionAt(canon.End).Add(foff)
end.Y += element.valueDrawer.LineHeight()
shapes.FillColorRectangle (
innerCanvas,
accent,
image.Rectangle {
fixedutil.RoundPt(start),
fixedutil.RoundPt(end),
})
}
if len(element.text) == 0 {
// draw placeholder
textBounds := element.placeholderDrawer.LayoutBounds()
foreground := element.theme.Color (
tomo.ColorForeground,
tomo.State { Disabled: true })
element.placeholderDrawer.Draw (
innerCanvas,
foreground,
offset.Sub(textBounds.Min))
} else {
// draw input value
textBounds := element.valueDrawer.LayoutBounds()
foreground := element.theme.Color(tomo.ColorForeground, state)
element.valueDrawer.Draw (
innerCanvas,
foreground,
offset.Sub(textBounds.Min))
}
if element.Focused() && element.dot.Empty() {
// draw cursor
foreground := element.theme.Color(tomo.ColorForeground, state)
cursorPosition := fixedutil.RoundPt (
element.valueDrawer.PositionAt(element.dot.End))
shapes.ColorLine (
innerCanvas,
foreground, 1,
cursorPosition.Add(offset),
image.Pt (
cursorPosition.X,
cursorPosition.Y + element.valueDrawer.
LineHeight().Round()).Add(offset))
}
}