diff --git a/backends/x/entity.go b/backends/x/entity.go index 19e37a3..6fde145 100644 --- a/backends/x/entity.go +++ b/backends/x/entity.go @@ -102,10 +102,10 @@ func (entity *entity) scrollTargetChildAt (point image.Point) *entity { return nil } -func (entity *entity) forMouseTargetContainers (callback func (tomo.MouseTargetContainer)) { +func (entity *entity) forMouseTargetContainers (callback func (tomo.MouseTargetContainer, tomo.Element)) { if entity.parent == nil { return } if parent, ok := entity.parent.element.(tomo.MouseTargetContainer); ok { - callback(parent) + callback(parent, entity.element) } entity.parent.forMouseTargetContainers(callback) } @@ -211,7 +211,8 @@ func (entity *entity) PlaceChild (index int, bounds image.Rectangle) { func (entity *entity) SelectChild (index int, selected bool) { child := entity.children[index] - if element, ok := entity.element.(tomo.Selectable); ok { + if element, ok := child.element.(tomo.Selectable); ok { + if child.selected == selected { return } child.selected = selected element.HandleSelectionChange() } diff --git a/backends/x/event.go b/backends/x/event.go index 5f6feba..c099268 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -212,11 +212,11 @@ func (window *window) handleButtonPress ( point.X, point.Y, input.Button(buttonEvent.Detail)) } - callback := func (container tomo.MouseTargetContainer) { + callback := func (container tomo.MouseTargetContainer, child tomo.Element) { container.HandleChildMouseDown ( point.X, point.Y, input.Button(buttonEvent.Detail), - underneath.element) + child) } underneath.forMouseTargetContainers(callback) } @@ -238,12 +238,12 @@ func (window *window) handleButtonRelease ( int(buttonEvent.EventY), input.Button(buttonEvent.Detail)) } - callback := func (container tomo.MouseTargetContainer) { + callback := func (container tomo.MouseTargetContainer, child tomo.Element) { container.HandleChildMouseUp ( int(buttonEvent.EventX), int(buttonEvent.EventY), input.Button(buttonEvent.Detail), - dragging.element) + child) } dragging.forMouseTargetContainers(callback) } diff --git a/default/theme/default.go b/default/theme/default.go index 74ef9de..068e233 100644 --- a/default/theme/default.go +++ b/default/theme/default.go @@ -212,14 +212,9 @@ func (Default) Pattern (id tomo.Pattern, state tomo.State, c tomo.Case) artist.P switch id { case tomo.PatternBackground: return patterns.Uhex(0xaaaaaaFF) case tomo.PatternDead: return defaultTextures[0][offset] - case tomo.PatternRaised: - if c.Match("tomo", "listEntry", "") { - return defaultTextures[10][offset] - } else { - return defaultTextures[1][offset] - } - case tomo.PatternSunken: return defaultTextures[2][offset] - case tomo.PatternPinboard: return defaultTextures[3][offset] + case tomo.PatternRaised: return defaultTextures[1][offset] + case tomo.PatternSunken: return defaultTextures[2][offset] + case tomo.PatternPinboard: return defaultTextures[3][offset] case tomo.PatternButton: switch { case c.Match("tomo", "checkbox", ""): @@ -272,16 +267,8 @@ func (Default) Color (id tomo.Color, state tomo.State, c tomo.Case) color.RGBA { // Padding returns the default padding value for the given pattern. func (Default) Padding (id tomo.Pattern, c tomo.Case) artist.Inset { switch id { - case tomo.PatternRaised: - if c.Match("tomo", "listEntry", "") { - return artist.I(4, 8) - } else { - return artist.I(8) - } case tomo.PatternSunken: - if c.Match("tomo", "list", "") { - return artist.I(4, 0, 3) - } else if c.Match("tomo", "progressBar", "") { + if c.Match("tomo", "progressBar", "") { return artist.I(2, 1, 1, 2) } else { return artist.I(8) diff --git a/elements/cell.go b/elements/cell.go index 61334c3..c030a6e 100644 --- a/elements/cell.go +++ b/elements/cell.go @@ -13,18 +13,19 @@ type cellEntity interface { // Cell is a single-element container that satisfies tomo.Selectable. It // provides styling based on whether or not it is selected. type Cell struct { - entity cellEntity - child tomo.Element - enabled bool - padding bool - theme theme.Wrapped + entity cellEntity + child tomo.Element + enabled bool + theme theme.Wrapped + + onSelectionChange func () } // NewCell creates a new cell element. If padding is true, the cell will have // padding on all sides. Child can be nil and added later with the Adopt() // method. -func NewCell (child tomo.Element, padding bool) (element *Cell) { - element = &Cell { padding: padding } +func NewCell (child tomo.Element) (element *Cell) { + element = &Cell { enabled: true } element.theme.Case = tomo.C("tomo", "cell") element.entity = tomo.NewEntity(element).(cellEntity) element.Adopt(child) @@ -42,7 +43,7 @@ func (element *Cell) Draw (destination canvas.Canvas) { pattern := element.theme.Pattern(tomo.PatternTableCell, element.state()) if element.child == nil { pattern.Draw(destination, bounds) - } else if element.padding { + } else { artist.DrawShatter ( destination, pattern, bounds, element.child.Entity().Bounds()) @@ -54,9 +55,7 @@ func (element *Cell) Layout () { if element.child == nil { return } bounds := element.entity.Bounds() - if element.padding { - bounds = element.theme.Padding(tomo.PatternTableCell).Apply(bounds) - } + bounds = element.theme.Padding(tomo.PatternTableCell).Apply(bounds) element.entity.PlaceChild(0, bounds) } @@ -80,6 +79,7 @@ func (element *Cell) Adopt (child tomo.Element) { element.updateMinimumSize() element.entity.Invalidate() + element.invalidateChild() element.entity.InvalidateLayout() } @@ -93,9 +93,7 @@ func (element *Cell) SetEnabled (enabled bool) { if element.enabled == enabled { return } element.enabled = enabled element.entity.Invalidate() - if child, ok := element.child.(tomo.Enableable); ok { - child.SetEnabled(enabled) - } + element.invalidateChild() } // SetTheme sets this element's theme. @@ -104,11 +102,26 @@ func (element *Cell) SetTheme (theme tomo.Theme) { element.theme.Theme = theme element.updateMinimumSize() element.entity.Invalidate() + element.invalidateChild() element.entity.InvalidateLayout() } +// OnSelectionChange sets a function to be called when this element is selected +// or unselected. +func (element *Cell) OnSelectionChange (callback func ()) { + element.onSelectionChange = callback +} + +func (element *Cell) Selected () bool { + return element.entity.Selected() +} + func (element *Cell) HandleSelectionChange () { element.entity.Invalidate() + element.invalidateChild() + if element.onSelectionChange != nil { + element.onSelectionChange() + } } func (element *Cell) HandleChildMinimumSizeChange (tomo.Element) { @@ -132,11 +145,15 @@ func (element *Cell) updateMinimumSize () { width += childWidth height += childHeight } - if element.padding { - padding := element.theme.Padding(tomo.PatternTableCell) - width += padding.Horizontal() - height += padding.Vertical() - } + padding := element.theme.Padding(tomo.PatternTableCell) + width += padding.Horizontal() + height += padding.Vertical() element.entity.SetMinimumSize(width, height) } + +func (element *Cell) invalidateChild () { + if element.child != nil { + element.child.Entity().Invalidate() + } +} diff --git a/elements/list.go b/elements/list.go new file mode 100644 index 0000000..33eacb5 --- /dev/null +++ b/elements/list.go @@ -0,0 +1,315 @@ +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/artist" +import "git.tebibyte.media/sashakoshka/tomo/default/theme" + +type listEntity interface { + tomo.ContainerEntity + tomo.ScrollableEntity +} + +type List struct { + entity listEntity + + scratch map[tomo.Element] scratchEntry + scroll image.Point + contentBounds image.Rectangle + columnSizes []int + selected int + + forcedMinimumWidth int + forcedMinimumHeight int + + theme theme.Wrapped + + onScrollBoundsChange func () +} + +func NewList (columns int, children ...tomo.Selectable) (element *List) { + if columns < 1 { columns = 1 } + element = &List { selected: -1 } + element.scratch = make(map[tomo.Element] scratchEntry) + element.columnSizes = make([]int, columns) + element.theme.Case = tomo.C("tomo", "list") + element.entity = tomo.NewEntity(element).(listEntity) + + for _, child := range children { + element.Adopt(child) + } + return +} + +func (element *List) Entity () tomo.Entity { + return element.entity +} + +func (element *List) Draw (destination canvas.Canvas) { + rocks := make([]image.Rectangle, element.entity.CountChildren()) + for index := 0; index < element.entity.CountChildren(); index ++ { + rocks[index] = element.entity.Child(index).Entity().Bounds() + } + + pattern := element.theme.Pattern(tomo.PatternSunken, tomo.State { }) + artist.DrawShatter(destination, pattern, element.entity.Bounds(), rocks...) +} + +func (element *List) Layout () { + margin := element.theme.Margin(tomo.PatternSunken) + padding := element.theme.Padding(tomo.PatternSunken) + bounds := padding.Apply(element.entity.Bounds()) + element.contentBounds = image.Rectangle { } + + dot := bounds.Min.Sub(element.scroll) + xStart := dot.X + rowHeight := 0 + columnIndex := 0 + nextLine := func () { + dot.X = xStart + dot.Y += margin.Y + dot.Y += rowHeight + rowHeight = 0 + columnIndex = 0 + } + + for index := 0; index < element.entity.CountChildren(); index ++ { + child := element.entity.Child(index) + entry := element.scratch[child] + + if columnIndex >= len(element.columnSizes) { + nextLine() + } + width := element.columnSizes[columnIndex] + height := int(entry.minSize) + + if len(element.columnSizes) == 1 && width < bounds.Dx() { + width = bounds.Dx() + } + + if rowHeight < height { + rowHeight = height + } + + childBounds := tomo.Bounds ( + dot.X, dot.Y, + width, height) + element.entity.PlaceChild(index, childBounds) + element.contentBounds = element.contentBounds.Union(childBounds) + + dot.X += width + margin.X + + columnIndex ++ + } + + element.contentBounds = + element.contentBounds.Sub(element.contentBounds.Min) + + element.entity.NotifyScrollBoundsChange() + if element.onScrollBoundsChange != nil { + element.onScrollBoundsChange() + } +} + +func (element *List) Adopt (child tomo.Element) { + element.entity.Adopt(child) + element.scratch[child] = scratchEntry { } + element.updateMinimumSize() + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +func (element *List) Disown (child tomo.Element) { + index := element.entity.IndexOf(child) + if index < 0 { return } + if index == element.selected { + element.selected = -1 + element.entity.SelectChild(index, false) + } + element.entity.Disown(index) + delete(element.scratch, child) + element.updateMinimumSize() + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +func (element *List) DisownAll () { + func () { + for index := 0; index < element.entity.CountChildren(); index ++ { + index := index + defer element.entity.Disown(index) + } + } () + element.scratch = make(map[tomo.Element] scratchEntry) + element.updateMinimumSize() + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +func (element *List) HandleChildMouseDown (x, y int, button input.Button, child tomo.Element) { + if child, ok := child.(tomo.Selectable); ok { + index := element.entity.IndexOf(child) + if element.selected == index { return } + if element.selected >= 0 { + element.entity.SelectChild(element.selected, false) + } + element.selected = index + element.entity.SelectChild(index, true) + } +} + +func (element *List) HandleChildMouseUp (int, int, input.Button, tomo.Element) { } + +func (element *List) HandleChildMinimumSizeChange (child tomo.Element) { + element.updateMinimumSize() + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +func (element *List) HandleChildFlexibleHeightChange (child tomo.Flexible) { + element.updateMinimumSize() + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +func (element *List) DrawBackground (destination canvas.Canvas) { + element.entity.DrawBackground(destination) +} + +// SetTheme sets the element's theme. +func (element *List) SetTheme (theme tomo.Theme) { + if theme == element.theme.Theme { return } + element.theme.Theme = theme + element.updateMinimumSize() + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +// 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() + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +// ScrollContentBounds returns the full content size of the element. +func (element *List) ScrollContentBounds () image.Rectangle { + return element.contentBounds +} + +// ScrollViewportBounds returns the size and position of the element's +// viewport relative to ScrollBounds. +func (element *List) ScrollViewportBounds () image.Rectangle { + padding := element.theme.Padding(tomo.PatternBackground) + bounds := padding.Apply(element.entity.Bounds()) + bounds = bounds.Sub(bounds.Min).Add(element.scroll) + return bounds +} + +// ScrollTo scrolls the viewport to the specified point relative to +// ScrollBounds. +func (element *List) ScrollTo (position image.Point) { + if position.Y < 0 { + position.Y = 0 + } + maxScrollHeight := element.maxScrollHeight() + if position.Y > maxScrollHeight { + position.Y = maxScrollHeight + } + element.scroll = position + element.entity.Invalidate() + element.entity.InvalidateLayout() +} + +// 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 +} + +// ScrollAxes returns the supported axes for scrolling. +func (element *List) ScrollAxes () (horizontal, vertical bool) { + return false, true +} + +func (element *List) maxScrollHeight () (height int) { + padding := element.theme.Padding(tomo.PatternSunken) + viewportHeight := element.entity.Bounds().Dy() - padding.Vertical() + height = element.contentBounds.Dy() - viewportHeight + if height < 0 { height = 0 } + return +} + +func (element *List) updateMinimumSize () { + margin := element.theme.Margin(tomo.PatternSunken) + padding := element.theme.Padding(tomo.PatternSunken) + + for index := range element.columnSizes { + element.columnSizes[index] = 0 + } + + height := 0 + rowHeight := 0 + columnIndex := 0 + nextLine := func () { + height += rowHeight + rowHeight = 0 + columnIndex = 0 + } + for index := 0; index < element.entity.CountChildren(); index ++ { + if columnIndex >= len(element.columnSizes) { + if index > 0 { height += margin.Y } + nextLine() + } + + child := element.entity.Child(index) + entry := element.scratch[child] + + entryWidth, entryHeight := element.entity.ChildMinimumSize(index) + entry.minBreadth = float64(entryWidth) + entry.minSize = float64(entryHeight) + element.scratch[child] = entry + + if rowHeight < entryHeight { + rowHeight = entryHeight + } + if element.columnSizes[columnIndex] < entryWidth { + element.columnSizes[columnIndex] = entryWidth + } + + columnIndex ++ + } + nextLine() + + width := 0; for index, size := range element.columnSizes { + width += size + if index > 0 { width += margin.X } + } + width += padding.Horizontal() + height += padding.Vertical() + + if element.forcedMinimumHeight > 0 { + height = element.forcedMinimumHeight + } + if element.forcedMinimumWidth > 0 { + width = element.forcedMinimumWidth + } + + element.entity.SetMinimumSize(width, height) +} diff --git a/examples/list/main.go b/examples/list/main.go index 0aeeec8..9f49fee 100644 --- a/examples/list/main.go +++ b/examples/list/main.go @@ -2,10 +2,8 @@ package main import "git.tebibyte.media/sashakoshka/tomo" import "git.tebibyte.media/sashakoshka/tomo/popups" -import "git.tebibyte.media/sashakoshka/tomo/layouts" import "git.tebibyte.media/sashakoshka/tomo/elements" import "git.tebibyte.media/sashakoshka/tomo/elements/testing" -import "git.tebibyte.media/sashakoshka/tomo/elements/containers" import _ "git.tebibyte.media/sashakoshka/tomo/backends/all" func main () { @@ -16,18 +14,16 @@ func run () { window, _ := tomo.NewWindow(tomo.Bounds(0, 0, 300, 0)) window.SetTitle("List Sidebar") - container := containers.NewContainer(layouts.Horizontal { true, true }) + container := elements.NewHBox(true, true) window.Adopt(container) var currentPage tomo.Element turnPage := func (newPage tomo.Element) { - container.Warp (func () { - if currentPage != nil { - container.Disown(currentPage) - } - container.Adopt(newPage, true) - currentPage = newPage - }) + if currentPage != nil { + container.Disown(currentPage) + } + container.Adopt(newPage, true) + currentPage = newPage } intro := elements.NewLabel ( @@ -39,7 +35,7 @@ func run () { }) mouse := testing.NewMouse() input := elements.NewTextBox("Write some text", "") - form := containers.NewContainer(layouts.Vertical { true, false}) + form := elements.NewVBox(false, true) form.Adopt(elements.NewLabel("I have:", false), false) form.Adopt(elements.NewSpacer(true), false) form.Adopt(elements.NewCheckbox("Skin", true), false) @@ -47,13 +43,21 @@ func run () { form.Adopt(elements.NewCheckbox("Bone", false), false) art := testing.NewArtist() + makePage := func (name string, callback func ()) tomo.Selectable { + cell := elements.NewCell(elements.NewLabel(name, false)) + cell.OnSelectionChange (func () { + if cell.Selected() { callback() } + }) + return cell + } + list := elements.NewList ( - elements.NewListEntry("button", func () { turnPage(button) }), - elements.NewListEntry("mouse", func () { turnPage(mouse) }), - elements.NewListEntry("input", func () { turnPage(input) }), - elements.NewListEntry("form", func () { turnPage(form) }), - elements.NewListEntry("art", func () { turnPage(art) })) - list.OnNoEntrySelected(func () { turnPage (intro) }) + 1, + makePage("button", func () { turnPage(button) }), + makePage("mouse", func () { turnPage(mouse) }), + makePage("input", func () { turnPage(input) }), + makePage("form", func () { turnPage(form) }), + makePage("art", func () { turnPage(art) })) list.Collapse(96, 0) container.Adopt(list, false) diff --git a/examples/scroll/main.go b/examples/scroll/main.go index af07def..60fd6fe 100644 --- a/examples/scroll/main.go +++ b/examples/scroll/main.go @@ -1,6 +1,6 @@ package main -// import "image" +import "image" import "git.tebibyte.media/sashakoshka/tomo" import "git.tebibyte.media/sashakoshka/tomo/elements" import _ "git.tebibyte.media/sashakoshka/tomo/backends/all" @@ -18,48 +18,49 @@ func run () { textBox := elements.NewTextBox("", copypasta) disconnectedContainer := elements.NewHBox(false, true) - // list := elements.NewList ( - // elements.NewListEntry("This is list item 0", nil), - // elements.NewListEntry("This is list item 1", nil), - // elements.NewListEntry("This is list item 2", nil), - // elements.NewListEntry("This is list item 3", nil), - // elements.NewListEntry("This is list item 4", nil), - // elements.NewListEntry("This is list item 5", nil), - // elements.NewListEntry("This is list item 6", nil), - // elements.NewListEntry("This is list item 7", nil), - // elements.NewListEntry("This is list item 8", nil), - // elements.NewListEntry("This is list item 9", nil), - // elements.NewListEntry("This is list item 10", nil), - // elements.NewListEntry("This is list item 11", nil), - // elements.NewListEntry("This is list item 12", nil), - // elements.NewListEntry("This is list item 13", nil), - // elements.NewListEntry("This is list item 14", nil), - // elements.NewListEntry("This is list item 15", nil), - // elements.NewListEntry("This is list item 16", nil), - // elements.NewListEntry("This is list item 17", nil), - // elements.NewListEntry("This is list item 18", nil), - // elements.NewListEntry("This is list item 19", nil), - // elements.NewListEntry("This is list item 20", nil)) - // list.Collapse(0, 32) - // scrollBar := elements.NewScrollBar(true) - // list.OnScrollBoundsChange (func () { - // scrollBar.SetBounds ( - // list.ScrollContentBounds(), - // list.ScrollViewportBounds()) - // }) - // scrollBar.OnScroll (func (viewport image.Point) { - // list.ScrollTo(viewport) - // }) + list := elements.NewList ( + 1, + elements.NewCell(elements.NewLabel("This is list item 0", false)), + elements.NewCell(elements.NewLabel("This is list item 1", false)), + elements.NewCell(elements.NewLabel("This is list item 2", false)), + elements.NewCell(elements.NewLabel("This is list item 3", false)), + elements.NewCell(elements.NewLabel("This is list item 4", false)), + elements.NewCell(elements.NewLabel("This is list item 5", false)), + elements.NewCell(elements.NewLabel("This is list item 6", false)), + elements.NewCell(elements.NewLabel("This is list item 7", false)), + elements.NewCell(elements.NewLabel("This is list item 8", false)), + elements.NewCell(elements.NewLabel("This is list item 9", false)), + elements.NewCell(elements.NewLabel("This is list item 10", false)), + elements.NewCell(elements.NewLabel("This is list item 11", false)), + elements.NewCell(elements.NewLabel("This is list item 12", false)), + elements.NewCell(elements.NewLabel("This is list item 13", false)), + elements.NewCell(elements.NewLabel("This is list item 14", false)), + elements.NewCell(elements.NewLabel("This is list item 15", false)), + elements.NewCell(elements.NewLabel("This is list item 16", false)), + elements.NewCell(elements.NewLabel("This is list item 17", false)), + elements.NewCell(elements.NewLabel("This is list item 18", false)), + elements.NewCell(elements.NewLabel("This is list item 19", false)), + elements.NewCell(elements.NewLabel("This is list item 20", false))) + list.Collapse(0, 32) + scrollBar := elements.NewScrollBar(true) + list.OnScrollBoundsChange (func () { + scrollBar.SetBounds ( + list.ScrollContentBounds(), + list.ScrollViewportBounds()) + }) + scrollBar.OnScroll (func (viewport image.Point) { + list.ScrollTo(viewport) + }) container.Adopt(elements.NewLabel("A ScrollContainer:", false), false) container.Adopt(elements.NewScroll(textBox, true, false), false) - // disconnectedContainer.Adopt(list, false) + disconnectedContainer.Adopt(list, false) disconnectedContainer.Adopt (elements.NewLabel ( "Notice how the scroll bar to the right can be used to " + "control the list, despite not even touching it. It is " + "indeed a thing you can do. It is also terrible UI design so " + "don't do it.", true), true) - // disconnectedContainer.Adopt(scrollBar, false) + disconnectedContainer.Adopt(scrollBar, false) container.Adopt(disconnectedContainer, true) window.OnClose(tomo.Stop)