Compare commits

...

33 Commits

Author SHA1 Message Date
a3bb4098fb Pegboard defaults to FlowVertical layout 2024-09-12 18:11:08 -04:00
c30ac90577 NewRoot now correctly returns a Root struct 2024-09-12 17:24:37 -04:00
7628903e59 Add PageWrapper sub-component to Notebook 2024-09-12 15:44:11 -04:00
ce08487eff Make a distinction between notebook tabs and pages 2024-09-12 15:13:38 -04:00
6130b84a27 Change naming of Notebook methods 2024-09-12 15:10:29 -04:00
8b80520f8c Rename TabbedContainer to Notebook 2024-09-12 15:08:03 -04:00
38434db75c TextView tests its own compliance to tomo.Object 2024-09-12 15:06:20 -04:00
ffb6e9fb95 Document torn tag 2024-09-12 15:04:38 -04:00
12b855ba24 Document special root container in Menu 2024-09-12 15:01:14 -04:00
5b8e401e60 Fix spelling mistake in HSVAColorPicker documentation 2024-09-12 14:59:57 -04:00
7144900d31 Remove TODO in segment.go
It would have conflicted with nasin.NewApplicationWindow
2024-09-12 14:55:44 -04:00
51ce2a84f2 Update object code to use new containers 2024-09-12 14:54:26 -04:00
f1f71208f2 Fix role of Root 2024-09-12 14:54:14 -04:00
cdf23c9b13 Overhaul collection of containers 2024-09-12 14:07:54 -04:00
b8b80f8862 Update goutil 2024-09-12 03:20:09 -04:00
2224d2e73e Fix history overflowing 2024-09-12 02:56:56 -04:00
c2245ec304 Fix object code 2024-09-12 02:34:28 -04:00
dca3880a87 Update Tomo API 2024-09-12 02:25:01 -04:00
f0573bf551 Fix preview image alignment 2024-09-10 18:54:42 -04:00
ba2eeeba74 Fix grammar in README 2024-09-10 18:51:55 -04:00
b37d5398d8 Forgot to embed the image 2024-09-10 18:51:04 -04:00
f761da8cdc Add preview image 2024-09-10 18:50:32 -04:00
039e0da646 Combine internal packages into one internal package 2024-09-10 18:29:04 -04:00
6f2a31cd60 Replace HSV color functionality with that of goutil 2024-09-10 18:24:50 -04:00
177167510b Shift+Ctrl+Z now works for redo 2024-09-06 00:16:27 -04:00
3077249a13 TextInput updates state better when typing 2024-09-06 00:14:10 -04:00
38d950f44a Remove debug line 2024-09-06 00:13:15 -04:00
8b1b2e4199 Text input history is looking good 2024-09-06 00:12:24 -04:00
63ad06e214 Improvements to internal/history 2024-09-06 00:12:05 -04:00
ac1a952b40 Move color functionality into subpackage of internal 2024-09-05 22:46:58 -04:00
45a6634e73 Add (buggy) history support to TextInput 2024-09-05 20:11:57 -04:00
ead7d493d7 Add history mechanism 2024-09-05 20:11:40 -04:00
c1cf6edd8e Add SetOverflow to Label 2024-08-29 17:06:38 -04:00
24 changed files with 663 additions and 521 deletions

View File

@ -1,9 +1,11 @@
# objects # objects
![Some of the objects in this package](assets/preview.png)
[![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/objects.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/objects) [![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/objects.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/objects)
Objects contains a standard collection of re-usable objects. It should be viewed Objects contains a standard collection of re-usable objects. It should also be
also as a reference for how to create custom objects in Tomo. viewed as a reference for how to create custom objects in Tomo.
## Styling ## Styling
All objects in this module have roles of the form: All objects in this module have roles of the form:

93
abstractcontainer.go Normal file
View File

@ -0,0 +1,93 @@
package objects
import "image"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/event"
type abstractContainer struct {
box tomo.ContainerBox
}
func (this *abstractContainer) init (layout tomo.Layout, children ...tomo.Object) {
this.box = tomo.NewContainerBox()
this.SetLayout(layout)
for _, child := range children {
this.Add(child)
}
}
// GetBox returns the underlying box.
func (this *abstractContainer) GetBox () tomo.Box {
return this.box
}
// ContentBounds returns the bounds of the inner content of the container
// relative to the container's InnerBounds.
func (this *abstractContainer) ContentBounds () image.Rectangle {
return this.box.ContentBounds()
}
// ScrollTo shifts the origin of the container's content to the origin of the
// container's InnerBounds, offset by the given point.
func (this *abstractContainer) ScrollTo (position image.Point) {
this.box.ScrollTo(position)
}
// OnContentBoundsChange specifies a function to be called when the container's
// ContentBounds or InnerBounds changes.
func (this *abstractContainer) OnContentBoundsChange (callback func ()) event.Cookie {
return this.box.OnContentBoundsChange(callback)
}
// SetLayout sets the layout of the container.
func (this *abstractContainer) SetLayout (layout tomo.Layout) {
if layout == nil {
this.box.UnsetAttr(tomo.AttrKindLayout)
} else {
this.box.SetAttr(tomo.ALayout(layout))
}
}
// SetAlign sets the X and Y alignment of the container.
func (this *abstractContainer) SetAlign (x, y tomo.Align) {
this.box.SetAttr(tomo.AAlign(x, y))
}
// SetOverflow sets the X and Y overflow of the container.
func (this *abstractContainer) SetOverflow (x, y bool) {
this.box.SetAttr(tomo.AOverflow(x, y))
}
// Add appends a child object. If the object is already a child of another
// object, it will be removed from that object first.
func (this *abstractContainer) Add (object tomo.Object) {
this.box.Add(object)
}
// Remove removes a child object, if it is a child of this container.
func (this *abstractContainer) Remove (object tomo.Object) {
this.box.Remove(object)
}
// Insert inserts a child object before a specified object. If the before object
// is nil or is not contained within this container, the inserted object is
// appended. If the inserted object is already a child of another object, it
// will be removed from that object first.
func (this *abstractContainer) Insert (child tomo.Object, before tomo.Object) {
this.box.Insert(child, before)
}
// Clear removes all child objects.
func (this *abstractContainer) Clear () {
this.box.Clear()
}
// Len returns hte amount of child objects.
func (this *abstractContainer) Len () int {
return this.box.Len()
}
// At returns the child object at the specified index.
func (this *abstractContainer) At (index int) tomo.Object {
return this.box.At(index)
}

BIN
assets/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -60,7 +60,7 @@ func NewCalendar (tm time.Time) *Calendar {
calendar.grid.SetRole(tomo.R("objects", "CalendarGrid")) calendar.grid.SetRole(tomo.R("objects", "CalendarGrid"))
calendar.grid.SetAttr(tomo.ALayout(layouts.NewGrid ( calendar.grid.SetAttr(tomo.ALayout(layouts.NewGrid (
true, true, true, true, true, true, true)())) true, true, true, true, true, true, true)()))
calendar.box.Add(NewInnerContainer ( calendar.box.Add(NewContainer (
layouts.Row { false, true, false }, layouts.Row { false, true, false },
prevButton, calendar.monthLabel, nextButton)) prevButton, calendar.monthLabel, nextButton))
calendar.box.Add(calendar.grid) calendar.box.Add(calendar.grid)

View File

@ -6,7 +6,7 @@ import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/tomo/event" import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/tomo/canvas" import "git.tebibyte.media/tomo/tomo/canvas"
import "git.tebibyte.media/tomo/objects/layouts" import "git.tebibyte.media/tomo/objects/layouts"
import "git.tebibyte.media/tomo/objects/internal" import "git.tebibyte.media/sashakoshka/goutil/image/color"
var _ tomo.Object = new(HSVAColorPicker) var _ tomo.Object = new(HSVAColorPicker)
@ -14,11 +14,11 @@ var _ tomo.Object = new(HSVAColorPicker)
// parameters. // parameters.
// //
// Sub-components: // Sub-components:
// - ColorPickerMap is a recangular control where the X axis controls // - ColorPickerMap is a rectangular control where the X axis controls
// saturation and the Y axis controls value. // saturation and the Y axis controls value.
type HSVAColorPicker struct { type HSVAColorPicker struct {
box tomo.ContainerBox box tomo.ContainerBox
value internal.HSVA value ucolor.HSVA
pickerMap *hsvaColorPickerMap pickerMap *hsvaColorPickerMap
hueSlider *Slider hueSlider *Slider
@ -80,7 +80,7 @@ func (this *HSVAColorPicker) Value () color.Color {
// SetValue sets the color of the picker. // SetValue sets the color of the picker.
func (this *HSVAColorPicker) SetValue (value color.Color) { func (this *HSVAColorPicker) SetValue (value color.Color) {
if value == nil { value = color.Transparent } if value == nil { value = color.Transparent }
this.value = internal.HSVAModel.Convert(value).(internal.HSVA) this.value = ucolor.HSVAModel.Convert(value).(ucolor.HSVA)
this.hueSlider.SetValue(this.value.H) this.hueSlider.SetValue(this.value.H)
this.alphaSlider.SetValue(float64(this.value.A) / 0xFFFF) this.alphaSlider.SetValue(float64(this.value.A) / 0xFFFF)
this.pickerMap.Invalidate() this.pickerMap.Invalidate()
@ -152,7 +152,7 @@ func (this *hsvaColorPickerMap) Draw (can canvas.Canvas) {
xx := x - bounds.Min.X xx := x - bounds.Min.X
yy := y - bounds.Min.Y yy := y - bounds.Min.Y
pixel := internal.HSVA { pixel := ucolor.HSVA {
H: this.parent.value.H, H: this.parent.value.H,
S: float64(xx) / float64(bounds.Dx()), S: float64(xx) / float64(bounds.Dx()),
V: 1 - float64(yy) / float64(bounds.Dy()), V: 1 - float64(yy) / float64(bounds.Dy()),

View File

@ -1,133 +1,21 @@
package objects package objects
import "image"
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/event"
var _ tomo.ContentObject = new(Container) var _ tomo.ContentObject = new(Container)
// Container is an object that can contain other objects. It can be used as a // Container is an object that can contain other objects. It is plain looking,
// primitive for building more complex layouts. It has two main variants: an // and is intended to be used within other containers as a primitive for
// outer container, and an inner container. The outer container has padding // building more complex layouts.
// around its edges, whereas the inner container does not. It also has a
// "sunken" variation designed to hold a scrolled list of items.
//
// Tags:
// - [outer] The container is the root of a window.
// - [inner] The container is within another container, and is part of a
// larger layout.
// - [sunken] The container holds a visually grouped, usually scrolled, list
// of items.
type Container struct { type Container struct {
box tomo.ContainerBox abstractContainer
} }
func newContainer (layout tomo.Layout, children ...tomo.Object) *Container { // NewContainer creates a new container.
this := &Container { func NewContainer (layout tomo.Layout, children ...tomo.Object) *Container {
box: tomo.NewContainerBox(), this := &Container { }
} this.init(layout, children...)
this.box.SetAttr(tomo.ALayout(layout))
for _, child := range children {
this.Add(child)
}
return this
}
// NewOuterContainer creates a new container that has padding around it, as well
// as a solid background color. It is meant to be used as a root container for a
// window, tab pane, etc.
func NewOuterContainer (layout tomo.Layout, children ...tomo.Object) *Container {
this := newContainer(layout, children...)
this.box.SetRole(tomo.R("objects", "Container")) this.box.SetRole(tomo.R("objects", "Container"))
this.box.SetTag("outer", true) this.box.SetTag("outer", true)
return this return this
} }
// NewSunkenContainer creates a new container with a sunken style and padding
// around it. It is meant to be used as a root container for a ScrollContainer.
func NewSunkenContainer (layout tomo.Layout, children ...tomo.Object) *Container {
this := newContainer(layout, children...)
this.box.SetRole(tomo.R("objects", "Container"))
this.box.SetTag("sunken", true)
return this
}
// NewInnerContainer creates a new container that has no padding around it.
func NewInnerContainer (layout tomo.Layout, children ...tomo.Object) *Container {
this := newContainer(layout, children...)
this.box.SetRole(tomo.R("objects", "Container"))
this.box.SetTag("inner", true)
return this
}
// GetBox returns the underlying box.
func (this *Container) GetBox () tomo.Box {
return this.box
}
// ContentBounds returns the bounds of the inner content of the container
// relative to the container's InnerBounds.
func (this *Container) ContentBounds () image.Rectangle {
return this.box.ContentBounds()
}
// ScrollTo shifts the origin of the container's content to the origin of the
// container's InnerBounds, offset by the given point.
func (this *Container) ScrollTo (position image.Point) {
this.box.ScrollTo(position)
}
// OnContentBoundsChange specifies a function to be called when the container's
// ContentBounds or InnerBounds changes.
func (this *Container) OnContentBoundsChange (callback func ()) event.Cookie {
return this.box.OnContentBoundsChange(callback)
}
// SetLayout sets the layout of the container.
func (this *Container) SetLayout (layout tomo.Layout) {
this.box.SetAttr(tomo.ALayout(layout))
}
// SetAlign sets the X and Y alignment of the container.
func (this *Container) SetAlign (x, y tomo.Align) {
this.box.SetAttr(tomo.AAlign(x, y))
}
// SetOverflow sets the X and Y overflow of the container.
func (this *Container) SetOverflow (x, y bool) {
this.box.SetAttr(tomo.AOverflow(x, y))
}
// Add appends a child object. If the object is already a child of another
// object, it will be removed from that object first.
func (this *Container) Add (object tomo.Object) {
this.box.Add(object)
}
// Remove removes a child object, if it is a child of this container.
func (this *Container) Remove (object tomo.Object) {
this.box.Remove(object)
}
// Insert inserts a child object before a specified object. If the before object
// is nil or is not contained within this container, the inserted object is
// appended. If the inserted object is already a child of another object, it
// will be removed from that object first.
func (this *Container) Insert (child tomo.Object, before tomo.Object) {
this.box.Insert(child, before)
}
// Clear removes all child objects.
func (this *Container) Clear () {
this.box.Clear()
}
// Len returns hte amount of child objects.
func (this *Container) Len () int {
return this.box.Len()
}
// At returns the child object at the specified index.
func (this *Container) At (index int) tomo.Object {
return this.box.At(index)
}

View File

@ -16,7 +16,6 @@ type DialogKind int; const (
// Dialog is a modal dialog window. // Dialog is a modal dialog window.
type Dialog struct { type Dialog struct {
tomo.Window tomo.Window
controlRow *Container
} }
type clickable interface { type clickable interface {
@ -36,11 +35,11 @@ func NewDialog (kind DialogKind, parent tomo.Window, title, message string, opti
dialog := &Dialog { } dialog := &Dialog { }
if parent == nil { if parent == nil {
window, err := tomo.NewWindow(image.Rectangle { }) window, err := tomo.NewWindow(tomo.WindowKindNormal, image.Rectangle { })
if err != nil { return nil, err } if err != nil { return nil, err }
dialog.Window = window dialog.Window = window
} else { } else {
window, err := parent.NewModal(image.Rectangle { }) window, err := parent.NewChild(tomo.WindowKindModal, image.Rectangle { })
if err != nil { return nil, err } if err != nil { return nil, err }
dialog.Window = window dialog.Window = window
} }
@ -60,16 +59,16 @@ func NewDialog (kind DialogKind, parent tomo.Window, title, message string, opti
for _, option := range options { for _, option := range options {
if option, ok := option.(clickable); ok { if option, ok := option.(clickable); ok {
option.OnClick(dialog.Close) option.OnClick(func () {
dialog.Close()
})
} }
} }
dialog.controlRow = NewInnerContainer(layouts.ContractHorizontal, options...)
dialog.controlRow.SetAlign(tomo.AlignEnd, tomo.AlignEnd) dialog.SetRoot(NewRoot (
dialog.SetRoot(NewOuterContainer (
layouts.Column { true, false }, layouts.Column { true, false },
NewInnerContainer(layouts.ContractHorizontal, icon, messageText), NewContentSegment(layouts.ContractHorizontal, icon, messageText),
dialog.controlRow)) NewOptionSegment(nil, options...)))
return dialog, nil return dialog, nil
} }

7
go.mod
View File

@ -1,5 +1,8 @@
module git.tebibyte.media/tomo/objects module git.tebibyte.media/tomo/objects
go 1.20 go 1.21.0
require git.tebibyte.media/tomo/tomo v0.46.1 require (
git.tebibyte.media/sashakoshka/goutil v0.3.1
git.tebibyte.media/tomo/tomo v0.48.0
)

6
go.sum
View File

@ -1,2 +1,4 @@
git.tebibyte.media/tomo/tomo v0.46.1 h1:/8fT6I9l4TK529zokrThbNDHGRvUsNgif1Zs++0PBSQ= git.tebibyte.media/sashakoshka/goutil v0.3.1 h1:zvAMKS+aea96q6oTttCWfNLXqOHisI3IKAwX6BWKfY0=
git.tebibyte.media/tomo/tomo v0.46.1/go.mod h1:WrtilgKB1y8O2Yu7X4mYcRiqOlPR8NuUnoA/ynkQWrs= git.tebibyte.media/sashakoshka/goutil v0.3.1/go.mod h1:Yo/M2sbi9IbzZCFsEj8/Fg7sNwHkDaJ6saTHOha+Dow=
git.tebibyte.media/tomo/tomo v0.48.0 h1:AE21ElHwUSPsX82ZWCnoNxJFi9Oswyd3dPDPMbxTueQ=
git.tebibyte.media/tomo/tomo v0.48.0/go.mod h1:WrtilgKB1y8O2Yu7X4mYcRiqOlPR8NuUnoA/ynkQWrs=

View File

@ -3,178 +3,6 @@ package internal
import "fmt" import "fmt"
import "image/color" import "image/color"
// HSV represents a color with hue, saturation, and value components. Each
// component C is in range 0 <= C <= 1.
type HSV struct {
H float64
S float64
V float64
}
// HSVA is an HSV color with an added 8-bit alpha component. The alpha component
// ranges from 0x0000 (fully transparent) to 0xFFFF (opaque), and has no bearing
// on the other components.
type HSVA struct {
H float64
S float64
V float64
A uint16
}
var (
HSVModel color.Model = color.ModelFunc(hsvModel)
HSVAModel color.Model = color.ModelFunc(hsvaModel)
)
func (hsv HSV) RGBA () (r, g, b, a uint32) {
// Adapted from:
// https://www.cs.rit.edu/~ncs/color/t_convert.html
component := func (x float64) uint32 {
return uint32(float64(0xFFFF) * x)
}
s := clamp01(hsv.S)
v := clamp01(hsv.V)
if s == 0 {
light := component(v)
return light, light, light, 0xFFFF
}
h := clamp01(hsv.H) * 360
sector := int(h / 60)
// otherwise when given 1.0 for H, sector would overflow to 6
if sector > 5 { sector = 5 }
offset := (h / 60) - float64(sector)
p := component(v * (1 - s))
q := component(v * (1 - s * offset))
t := component(v * (1 - s * (1 - offset)))
va := component(v)
switch sector {
case 0: return va, t, p, 0xFFFF
case 1: return q, va, p, 0xFFFF
case 2: return p, va, t, 0xFFFF
case 3: return p, q, va, 0xFFFF
case 4: return t, p, va, 0xFFFF
default: return va, p, q, 0xFFFF
}
}
func (hsva HSVA) RGBA () (r, g, b, a uint32) {
r, g, b, a = HSV {
H: hsva.H,
S: hsva.S,
V: hsva.V,
}.RGBA()
a = uint32(hsva.A)
// alpha premultiplication
r = (r * a) / 0xFFFF
g = (g * a) / 0xFFFF
b = (b * a) / 0xFFFF
return
}
// Canon returns the color but with the H, S, and V fields are constrained to
// the range 0.0-1.0
func (hsv HSV) Canon () HSV {
hsv.H = clamp01(hsv.H)
hsv.S = clamp01(hsv.S)
hsv.V = clamp01(hsv.V)
return hsv
}
// Canon returns the color but with the H, S, and V fields are constrained to
// the range 0.0-1.0
func (hsva HSVA) Canon () HSVA {
hsva.H = clamp01(hsva.H)
hsva.S = clamp01(hsva.S)
hsva.V = clamp01(hsva.V)
return hsva
}
func clamp01 (x float64) float64 {
if x > 1.0 { return 1.0 }
if x < 0.0 { return 0.0 }
return x
}
func hsvModel (c color.Color) color.Color {
switch c := c.(type) {
case HSV: return c
case HSVA: return HSV { H: c.H, S: c.S, V: c.V }
default:
r, g, b, a := c.RGBA()
// alpha unpremultiplication
r = (r / a) * 0xFFFF
g = (g / a) * 0xFFFF
b = (b / a) * 0xFFFF
return rgbToHSV(r, g, b)
}
}
func hsvaModel (c color.Color) color.Color {
switch c := c.(type) {
case HSV: return HSVA { H: c.H, S: c.S, V: c.V, A: 0xFFFF }
case HSVA: return c
default:
r, g, b, a := c.RGBA()
hsv := rgbToHSV(r, g, b)
return HSVA {
H: hsv.H,
S: hsv.S,
V: hsv.V,
A: uint16(a),
}
}
}
func rgbToHSV (r, g, b uint32) HSV {
// Adapted from:
// https://www.cs.rit.edu/~ncs/color/t_convert.html
component := func (x uint32) float64 {
return clamp01(float64(x) / 0xFFFF)
}
cr := component(r)
cg := component(g)
cb := component(b)
var maxComponent float64
if cr > maxComponent { maxComponent = cr }
if cg > maxComponent { maxComponent = cg }
if cb > maxComponent { maxComponent = cb }
var minComponent = 1.0
if cr < minComponent { minComponent = cr }
if cg < minComponent { minComponent = cg }
if cb < minComponent { minComponent = cb }
hsv := HSV {
V: maxComponent,
}
delta := maxComponent - minComponent
if delta == 0 {
// hsva.S is undefined, so hue doesn't matter
return hsv
}
hsv.S = delta / maxComponent
switch {
case cr == maxComponent: hsv.H = (cg - cb) / delta
case cg == maxComponent: hsv.H = 2 + (cb - cr) / delta
case cb == maxComponent: hsv.H = 4 + (cr - cg) / delta
}
hsv.H *= 60
if hsv.H < 0 { hsv.H += 360 }
hsv.H /= 360
return hsv
}
// FormatNRGBA formats an NRGBA value into a hex string. // FormatNRGBA formats an NRGBA value into a hex string.
func FormatNRGBA (nrgba color.NRGBA) string { func FormatNRGBA (nrgba color.NRGBA) string {
return fmt.Sprintf("%02X%02X%02X%02X", nrgba.R, nrgba.G, nrgba.B, nrgba.A) return fmt.Sprintf("%02X%02X%02X%02X", nrgba.R, nrgba.G, nrgba.B, nrgba.A)

92
internal/history.go Normal file
View File

@ -0,0 +1,92 @@
package internal
import "time"
// History stores a stack of items, always keeping the bottom-most one. It must
// be created using the NewHistory constructor, otherwise it will be invalid.
type History[T comparable] struct {
max int
stack []T
topIndex int
topTime time.Time
}
// NewHistory creates a new History. The initial item will be on the bottom, and
// it will remain there until the History overflows and chooses the item after
// it to be the initial item.
func NewHistory[T comparable] (initial T, max int) *History[T] {
return &History[T] {
max: max,
stack: []T { initial },
}
}
// Top returns the most recent item.
func (this *History[T]) Top () T {
return this.stack[this.topIndex]
}
// Swap replaces the most recent item with another.
func (this *History[T]) Swap (item T) {
this.topTime = time.Now()
this.SwapSilently(item)
}
// SwapSilently replaces the most recent item with another without updating the
// time.
func (this *History[T]) SwapSilently (item T) {
this.stack[this.topIndex] = item
}
// Push pushes a new item onto the stack. If the stack overflows (becomes bigger
// than the specified max value), the initial item is removed and the one on top
// of it takes its place.
func (this *History[T]) Push (item T) {
this.topTime = time.Now()
if this.Top() != item {
this.topIndex ++
this.stack = append(this.stack[:this.topIndex], item)
}
if len(this.stack) > this.max {
this.topIndex --
this.stack = this.stack[1:]
}
}
// PushWeak replaces the most recent item if it was added recently (sooner than
// specified by minAge), and will otherwise push the item normally. If the
// history was popped or cleared beforehand, the item will always be pushed
// normally. This is intended to be used for things such as keystrokes.
func (this *History[T]) PushWeak (item T, minAge time.Duration) {
if time.Since(this.topTime) > minAge {
this.Push(item)
} else {
this.Swap(item)
}
}
// Redo undoes an Undo operation and returns the resulting top of the stack.
func (this *History[T]) Redo () T {
if this.topIndex < len(this.stack) - 1 {
this.topIndex ++
}
return this.Top()
}
// Undo removes the most recent item and returns what was under it. If there is
// only one item (the initial item), it will kept and returned.
func (this *History[T]) Undo () T {
this.topTime = time.Time { }
if this.topIndex > 0 {
this.topIndex --
}
return this.Top()
}
// Clear removes all items except for the initial one.
func (this *History[T]) Clear () {
this.topTime = time.Time { }
this.stack = this.stack[:1]
this.topIndex = 0
}

View File

@ -58,3 +58,8 @@ func (this *Label) OnDotChange (callback func ()) event.Cookie {
func (this *Label) SetAlign (x, y tomo.Align) { func (this *Label) SetAlign (x, y tomo.Align) {
this.box.SetAttr(tomo.AAlign(x, y)) this.box.SetAttr(tomo.AAlign(x, y))
} }
// SetOverflow sets the X and Y overflow of the label.
func (this *Label) SetOverflow (x, y bool) {
this.box.SetAttr(tomo.AOverflow(x, y))
}

View File

@ -9,6 +9,9 @@ import "git.tebibyte.media/tomo/objects/layouts"
// Menu is a menu window. // Menu is a menu window.
// //
// Sub-components: // Sub-components:
// - Root is the root of the window. It is differentiated from a normal Root
// object in that it has the [menu] tag. If the menu has been torn off, it
// will have the [torn] tag.
// - TearLine is a horizontal line at the top of the menu that, when clicked, // - TearLine is a horizontal line at the top of the menu that, when clicked,
// causes the menu to be "torn off" into a movable window. // causes the menu to be "torn off" into a movable window.
type Menu struct { type Menu struct {
@ -40,7 +43,7 @@ func newMenu (parent tomo.Window, bounds image.Rectangle, items ...tomo.Object)
menu := &Menu { } menu := &Menu { }
menu.bounds = bounds menu.bounds = bounds
menu.parent = parent menu.parent = parent
window, err := menu.parent.NewMenu(menu.bounds) window, err := menu.parent.NewChild(tomo.WindowKindMenu, menu.bounds)
if err != nil { return nil, err } if err != nil { return nil, err }
menu.Window = window menu.Window = window
@ -62,7 +65,7 @@ func newMenu (parent tomo.Window, bounds image.Rectangle, items ...tomo.Object)
}) })
} }
} }
menu.rootContainer.SetRole(tomo.R("objects", "Container")) menu.rootContainer.SetRole(tomo.R("objects", "Root"))
menu.rootContainer.SetTag("menu", true) menu.rootContainer.SetTag("menu", true)
menu.Window.SetRoot(menu.rootContainer) menu.Window.SetRoot(menu.rootContainer)
@ -75,7 +78,7 @@ func (this *Menu) TearOff () {
if this.parent == nil { return } if this.parent == nil { return }
this.torn = true this.torn = true
window, err := this.parent.NewChild(this.bounds) window, err := this.parent.NewChild(tomo.WindowKindToolbar, this.bounds)
window.SetIcon(tomo.IconListChoose) window.SetIcon(tomo.IconListChoose)
if err != nil { return } if err != nil { return }

170
notebook.go Normal file
View File

@ -0,0 +1,170 @@
package objects
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(Notebook)
// Notebook holds multiple objects, each in their own page. The user can click
// the tab bar at the top to choose which one is activated.
//
// Sub-components:
// - TabRow sits at the top of the container and contains a row of tabs.
// - TabSpacer sits at either end of the tab row, bookending the list of tabs.
// - Tab appears in the tab row for each tab in the notebook. The user can
// click on the tab to switch to it.
// - PageWrapper sits underneath the TabRow and contains the active page.
//
// TabSpacer tags:
// - [left] The spacer is on the left.
// - [right] The spacer is on the right.
//
// Tab tags:
// - [active] The tab is currently active and its page is visible.
type Notebook struct {
box tomo.ContainerBox
leftSpacer tomo.Box
rightSpacer tomo.Box
tabsRow tomo.ContainerBox
pageWrapper tomo.ContainerBox
active string
pages []*page
}
// NewNotebook creates a new tabbed notebook.
func NewNotebook () *Notebook {
notebook := &Notebook {
box: tomo.NewContainerBox(),
}
notebook.box.SetRole(tomo.R("objects", "Notebook"))
notebook.box.SetAttr(tomo.ALayout(layouts.Column { false, true }))
notebook.leftSpacer = tomo.NewBox()
notebook.leftSpacer.SetRole(tomo.R("objects", "TabSpacer"))
notebook.leftSpacer.SetTag("left", true)
notebook.rightSpacer = tomo.NewBox()
notebook.rightSpacer.SetRole(tomo.R("objects", "TabSpacer"))
notebook.rightSpacer.SetTag("right", true)
notebook.tabsRow = tomo.NewContainerBox()
notebook.tabsRow.SetRole(tomo.R("objects", "TabRow"))
notebook.box.Add(notebook.tabsRow)
notebook.pageWrapper = tomo.NewContainerBox()
notebook.pageWrapper.SetRole(tomo.R("objects", "PageWrapper"))
notebook.pageWrapper.SetAttr(tomo.ALayout(layouts.Column { true }))
notebook.box.Add(notebook.pageWrapper)
notebook.Clear()
notebook.setTabRowLayout()
return notebook
}
// GetBox returns the underlying box.
func (this *Notebook) GetBox () tomo.Box {
return this.box
}
// Activate switches to a named page.
func (this *Notebook) Activate (name string) {
if _, tab := this.findTab(this.active); tab != nil {
tab.setActive(false)
this.pageWrapper.Remove(tab.root)
}
if _, tab := this.findTab(name); tab != nil {
tab.setActive(true)
this.pageWrapper.Add(tab.root)
} else {
name = ""
}
this.active = name
}
// Add adds an object as a page with the specified name.
func (this *Notebook) Add (name string, root tomo.Object) {
pag := &page {
TextBox: tomo.NewTextBox(),
name: name,
root: root,
}
pag.SetRole(tomo.R("objects", "Tab"))
pag.SetText(name)
pag.OnButtonDown(func (button input.Button) bool {
if button != input.ButtonLeft { return false }
this.Activate(name)
return true
})
pag.OnButtonUp(func (button input.Button) bool {
if button != input.ButtonLeft { return false }
return true
})
this.pages = append(this.pages, pag)
this.tabsRow.Insert(pag, this.rightSpacer)
this.setTabRowLayout()
// if the row was empty before, activate this tab
if len(this.pages) == 1 {
this.Activate(name)
}
}
// Remove removes the named page.
func (this *Notebook) Remove (name string) {
index, tab := this.findTab(name)
if index < 0 { return }
nextIndex := index - 1
this.tabsRow.Remove(tab)
this.pages = append(this.pages[:index], this.pages[index - 1:]...)
this.setTabRowLayout()
if nextIndex < 0 { nextIndex = 0 }
if nextIndex >= len(this.pages) { nextIndex = len(this.pages) - 1 }
if nextIndex < 0 {
this.Activate("")
} else {
this.Activate(this.pages[nextIndex].name)
}
}
// Clear removes all tabs.
func (this *Notebook) Clear () {
this.pages = nil
this.tabsRow.Clear()
this.tabsRow.Add(this.leftSpacer)
this.tabsRow.Add(this.rightSpacer)
this.pageWrapper.Clear()
}
func (this *Notebook) setTabRowLayout () {
row := make(layouts.Row, 1 + len(this.pages) + 1)
row[len(row) - 1] = true
this.tabsRow.SetAttr(tomo.ALayout(row))
}
func (this *Notebook) findTab (name string) (int, *page) {
for index, pag := range this.pages {
if pag.name == name { return index, pag }
}
return -1, nil
}
type page struct {
tomo.TextBox
name string
root tomo.Object
}
func (this *page) setActive (active bool) {
if active {
this.SetRole(tomo.R("objects", "Tab"))
this.SetTag("active", true)
} else {
this.SetRole(tomo.R("objects", "Tab"))
this.SetTag("active", false)
}
}

22
pegboard.go Normal file
View File

@ -0,0 +1,22 @@
package objects
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.ContentObject = new(Pegboard)
// Pegboard is an object that can contain other objects. It is intended to
// contain a flowed list of objects which represent some data, such as files.
type Pegboard struct {
abstractContainer
}
// NewPegboard creates a new pegboard. If the provided layout is nil, it will
// use a FlowVertical layout.
func NewPegboard (layout tomo.Layout, children ...tomo.Object) *Pegboard {
if layout == nil { layout = layouts.FlowVertical }
pegboard := &Pegboard { }
pegboard.init(layout, children...)
pegboard.box.SetRole(tomo.R("objects", "Pegboard"))
return pegboard
}

20
root.go Normal file
View File

@ -0,0 +1,20 @@
package objects
import "git.tebibyte.media/tomo/tomo"
var _ tomo.ContentObject = new(Root)
// Root is an object that can contain other objects. It is intended to be used
// as the root of a window in order to contain its segments.
type Root struct {
abstractContainer
}
// NewRoot creates a new container.
func NewRoot (layout tomo.Layout, children ...tomo.Object) *Root {
this := &Root { }
this.init(layout, children...)
this.box.SetRole(tomo.R("objects", "Root"))
this.box.SetTag("outer", true)
return this
}

View File

@ -172,14 +172,14 @@ func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool {
switch key { switch key {
case input.KeyUp, input.KeyLeft: case input.KeyUp, input.KeyLeft:
if modifiers.Alt { if modifiers.Alt() {
this.SetValue(0) this.SetValue(0)
} else { } else {
this.scrollBy(this.StepSize()) this.scrollBy(this.StepSize())
} }
return true return true
case input.KeyDown, input.KeyRight: case input.KeyDown, input.KeyRight:
if modifiers.Alt { if modifiers.Alt() {
this.SetValue(1) this.SetValue(1)
} else { } else {
this.scrollBy(-this.StepSize()) this.scrollBy(-this.StepSize())
@ -320,12 +320,13 @@ func (this *Scrollbar) newLinkCookie (subCookies ...event.Cookie) *scrollbarCook
} }
} }
func (this *scrollbarCookie) Close () { func (this *scrollbarCookie) Close () error {
for _, cookie := range this.subCookies { for _, cookie := range this.subCookies {
cookie.Close() cookie.Close()
} }
this.owner.layout.linked = nil this.owner.layout.linked = nil
this.owner.box.SetAttr(tomo.ALayout(this.owner.layout)) this.owner.box.SetAttr(tomo.ALayout(this.owner.layout))
return nil
} }
type scrollbarLayout struct { type scrollbarLayout struct {

View File

@ -202,7 +202,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
vector := image.Point { } vector := image.Point { }
switch key { switch key {
case input.KeyPageUp: case input.KeyPageUp:
if modifiers.Shift { if modifiers.Shift() {
vector.X -= this.PageSize().X vector.X -= this.PageSize().X
} else { } else {
vector.Y -= this.PageSize().Y vector.Y -= this.PageSize().Y
@ -210,7 +210,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
this.scrollBy(vector) this.scrollBy(vector)
return true return true
case input.KeyPageDown: case input.KeyPageDown:
if modifiers.Shift { if modifiers.Shift() {
vector.X += this.PageSize().X vector.X += this.PageSize().X
} else { } else {
vector.Y += this.PageSize().Y vector.Y += this.PageSize().Y
@ -218,7 +218,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
this.scrollBy(vector) this.scrollBy(vector)
return true return true
case input.KeyUp: case input.KeyUp:
if modifiers.Shift { if modifiers.Shift() {
vector.X -= this.StepSize().X vector.X -= this.StepSize().X
} else { } else {
vector.Y -= this.StepSize().Y vector.Y -= this.StepSize().Y
@ -226,7 +226,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
this.scrollBy(vector) this.scrollBy(vector)
return true return true
case input.KeyDown: case input.KeyDown:
if modifiers.Shift { if modifiers.Shift() {
vector.X += this.StepSize().X vector.X += this.StepSize().X
} else { } else {
vector.Y += this.StepSize().Y vector.Y += this.StepSize().Y
@ -241,6 +241,8 @@ func (this *ScrollContainer) handleKeyUp (key input.Key, numpad bool) bool {
switch key { switch key {
case input.KeyPageUp: return true case input.KeyPageUp: return true
case input.KeyPageDown: return true case input.KeyPageDown: return true
case input.KeyUp: return true
case input.KeyDown: return true
} }
return false return false
} }

89
segment.go Normal file
View File

@ -0,0 +1,89 @@
package objects
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.ContentObject = new(Segment)
// Segment is an object that can contain other objects, and provides a way to
// categorize the functionality of a window. Segments are typically laid out
// vertically in a window. There are several variants, the main one being
// the content segment. They can all be created using specialized constructors.
//
// The following is a brief visual discription of how they are typically used,
// and in what order:
//
// ┌──────────────────────────┐
// │ ┌──┐ ┌──┐ ┌────────────┐ ├─ command
// │ │◄─│ │─►│ │/foo/bar/baz│ │
// │ └──┘ └──┘ └────────────┘ │
// ├──────────────────────────┤
// │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ├─ content
// │ │ │ │ │ │ │ │ │ │
// │ └───┘ └───┘ └───┘ └───┘ │
// │ ┌───┐ │
// │ │ │ │
// │ └───┘ │
// ├──────────────────────────┤
// │ 5 items, 4KiB ├─ status
// └──────────────────────────┘
// ┌─────────────────┐
// │ ┌───┐ │
// │ │ ! │ Continue? │
// │ └───┘ │
// ├─────────────────┤
// │ ┌──┐ ┌───┐ ├────────── option
// │ │No│ │Yes│ │
// │ └──┘ └───┘ │
// └─────────────────┘
//
// Tags:
// - [command] The segment is a command segment.
// - [content] The segment is a content segment.
// - [status] The segment is a status segment.
// - [option] The segment is an option segment.
type Segment struct {
abstractContainer
}
func newSegment (kind string, layout tomo.Layout, children ...tomo.Object) *Segment {
segment := &Segment { }
segment.init(layout, children...)
segment.box.SetRole(tomo.R("objects", "Segment"))
segment.box.SetTag(kind, true)
return segment
}
// NewCommandSegment creates a new segment intended to hold the window's
// navigational controls and command functionality. If the provided layout is
// nil, it will use a ContractHorizontal layout.
func NewCommandSegment (layout tomo.Layout, children ...tomo.Object) *Segment {
if layout == nil { layout = layouts.ContractHorizontal }
return newSegment("command", layout, children...)
}
// NewContentSegment creates a new segment intended to hold the window's main
// content. If the provided layout is nil, it will use a ContractVertical
// layout.
func NewContentSegment (layout tomo.Layout, children ...tomo.Object) *Segment {
if layout == nil { layout = layouts.ContractVertical }
return newSegment("content", layout, children...)
}
// NewStatusSegment creates a new segment intended to display the window's
// status. If the provided layout is nil, it will use a ContractHorizontal
// layout.
func NewStatusSegment (layout tomo.Layout, children ...tomo.Object) *Segment {
if layout == nil { layout = layouts.ContractHorizontal }
return newSegment("status", layout, children...)
}
// NewOptionSegment creates a new segment intended to hold the window's options.
// This is typically used for dialog boxes. If the provided layout is nil, it
// will use a ContractHorizontal layout. By default, it is end-aligned.
func NewOptionSegment (layout tomo.Layout, children ...tomo.Object) *Segment {
if layout == nil { layout = layouts.ContractHorizontal }
segment := newSegment("option", layout, children...)
segment.GetBox().SetAttr(tomo.AAlign(tomo.AlignEnd, tomo.AlignMiddle))
return segment
}

View File

@ -127,7 +127,7 @@ func (this *Slider) handleKeyDown (key input.Key, numpad bool) bool {
switch key { switch key {
case input.KeyUp, input.KeyLeft: case input.KeyUp, input.KeyLeft:
if this.box.Window().Modifiers().Alt { if this.box.Window().Modifiers().Alt() {
this.SetValue(0) this.SetValue(0)
} else { } else {
this.SetValue(this.Value() - increment) this.SetValue(this.Value() - increment)
@ -135,7 +135,7 @@ func (this *Slider) handleKeyDown (key input.Key, numpad bool) bool {
this.on.valueChange.Broadcast() this.on.valueChange.Broadcast()
return true return true
case input.KeyDown, input.KeyRight: case input.KeyDown, input.KeyRight:
if this.box.Window().Modifiers().Alt { if this.box.Window().Modifiers().Alt() {
this.SetValue(1) this.SetValue(1)
} else { } else {
this.SetValue(this.Value() + increment) this.SetValue(this.Value() + increment)

View File

@ -90,9 +90,9 @@ func (this *Swatch) Choose () {
var err error var err error
var window tomo.Window var window tomo.Window
if parent := this.box.Window(); parent != nil { if parent := this.box.Window(); parent != nil {
window, err = parent.NewChild(image.Rectangle { }) window, err = parent.NewChild(tomo.WindowKindNormal, image.Rectangle { })
} else { } else {
window, err = tomo.NewWindow(image.Rectangle { }) window, err = tomo.NewWindow(tomo.WindowKindNormal, image.Rectangle { })
} }
if err != nil { if err != nil {
log.Println("objects: could not create swatch modal:", err) log.Println("objects: could not create swatch modal:", err)
@ -139,18 +139,16 @@ func (this *Swatch) Choose () {
}) })
okButton.OnClick(commit) okButton.OnClick(commit)
controlRow := NewInnerContainer ( window.SetRoot(NewRoot (
layouts.ContractHorizontal,
cancelButton,
okButton)
controlRow.SetAlign(tomo.AlignEnd, tomo.AlignMiddle)
window.SetRoot(NewOuterContainer (
layouts.Column { true, false }, layouts.Column { true, false },
colorPicker, NewContentSegment (
NewInnerContainer(layouts.Row { false, true }, layouts.Column { true, false },
NewLabel("Hex"), colorPicker,
hexInput), NewContainer (
controlRow)) layouts.Row { false, true },
NewLabel("Hex"),
hexInput)),
NewOptionSegment(nil, cancelButton, okButton)))
window.OnClose(func () { window.OnClose(func () {
if committed { if committed {
this.on.confirm.Broadcast() this.on.confirm.Broadcast()

View File

@ -1,162 +0,0 @@
package objects
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(TabbedContainer)
// TabbedContainer holds multiple objects, each in their own tab. The user can
// click the tab bar at the top to choose which one is activated.
//
// Sub-components:
// - TabRow sits at the top of the container and contains a row of tabs.
// - TabSpacer sits at either end of the tab row, bookending the list of tabs.
// - Tab appears in the tab row for each tab in the container. The user can
// click on the tab to switch to it.
//
// TabSpacer tags:
// - [left] The spacer is on the left.
// - [right] The spacer is on the right.
//
// Tab tags:
// - [active] The tab is currently active and its contents are visible.
type TabbedContainer struct {
box tomo.ContainerBox
leftSpacer tomo.Box
rightSpacer tomo.Box
tabsRow tomo.ContainerBox
active string
tabs []*tab
}
// NewTabbedContainer creates a new tabbed container.
func NewTabbedContainer () *TabbedContainer {
tabbedContainer := &TabbedContainer {
box: tomo.NewContainerBox(),
}
tabbedContainer.box.SetRole(tomo.R("objects", "TabbedContainer"))
tabbedContainer.box.SetAttr(tomo.ALayout(layouts.Column { false, true }))
tabbedContainer.tabsRow = tomo.NewContainerBox()
tabbedContainer.tabsRow.SetRole(tomo.R("objects", "TabRow"))
tabbedContainer.box.Add(tabbedContainer.tabsRow)
tabbedContainer.leftSpacer = tomo.NewBox()
tabbedContainer.leftSpacer.SetRole(tomo.R("objects", "TabSpacer"))
tabbedContainer.leftSpacer.SetTag("left", true)
tabbedContainer.rightSpacer = tomo.NewBox()
tabbedContainer.rightSpacer.SetRole(tomo.R("objects", "TabSpacer"))
tabbedContainer.rightSpacer.SetTag("right", true)
tabbedContainer.ClearTabs()
tabbedContainer.setTabRowLayout()
return tabbedContainer
}
// GetBox returns the underlying box.
func (this *TabbedContainer) GetBox () tomo.Box {
return this.box
}
// Activate switches to a named tab.
func (this *TabbedContainer) Activate (name string) {
if _, tab := this.findTab(this.active); tab != nil {
tab.setActive(false)
this.box.Remove(tab.root)
}
if _, tab := this.findTab(name); tab != nil {
tab.setActive(true)
this.box.Add(tab.root)
} else {
name = ""
}
this.active = name
}
// AddTab adds an object as a tab with the specified name.
func (this *TabbedContainer) AddTab (name string, root tomo.Object) {
tab := &tab {
TextBox: tomo.NewTextBox(),
name: name,
root: root,
}
tab.SetRole(tomo.R("objects", "Tab"))
tab.SetText(name)
tab.OnButtonDown(func (button input.Button) bool {
if button != input.ButtonLeft { return false }
this.Activate(name)
return true
})
tab.OnButtonUp(func (button input.Button) bool {
if button != input.ButtonLeft { return false }
return true
})
this.tabs = append(this.tabs, tab)
this.tabsRow.Insert(tab, this.rightSpacer)
this.setTabRowLayout()
// if the row was empty before, activate this tab
if len(this.tabs) == 1 {
this.Activate(name)
}
}
// RemoveTab removes the named tab.
func (this *TabbedContainer) RemoveTab (name string) {
index, tab := this.findTab(name)
if index < 0 { return }
nextIndex := index - 1
this.tabsRow.Remove(tab)
this.tabs = append(this.tabs[:index], this.tabs[index - 1:]...)
this.setTabRowLayout()
if nextIndex < 0 { nextIndex = 0 }
if nextIndex >= len(this.tabs) { nextIndex = len(this.tabs) - 1 }
if nextIndex < 0 {
this.Activate("")
} else {
this.Activate(this.tabs[nextIndex].name)
}
}
// ClearTabs removes all tabs.
func (this *TabbedContainer) ClearTabs () {
this.tabs = nil
this.tabsRow.Clear()
this.tabsRow.Add(this.leftSpacer)
this.tabsRow.Add(this.rightSpacer)
}
func (this *TabbedContainer) setTabRowLayout () {
row := make(layouts.Row, 1 + len(this.tabs) + 1)
row[len(row) - 1] = true
this.tabsRow.SetAttr(tomo.ALayout(row))
}
func (this *TabbedContainer) findTab (name string) (int, *tab) {
for index, tab := range this.tabs {
if tab.name == name { return index, tab }
}
return -1, nil
}
type tab struct {
tomo.TextBox
name string
root tomo.Object
}
func (this *tab) setActive (active bool) {
if active {
this.SetRole(tomo.R("objects", "Tab"))
this.SetTag("active", true)
} else {
this.SetRole(tomo.R("objects", "Tab"))
this.SetTag("active", false)
}
}

View File

@ -1,19 +1,30 @@
package objects package objects
import "time"
import "image" import "image"
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/text" import "git.tebibyte.media/tomo/tomo/text"
import "git.tebibyte.media/tomo/tomo/input" import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/tomo/event" import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/objects/internal"
const textInputHistoryMaximum = 64
const textInputHistoryMaxAge = time.Second / 4
var _ tomo.ContentObject = new(TextInput) var _ tomo.ContentObject = new(TextInput)
type textHistoryItem struct {
text string
dot text.Dot
}
// TextInput is a single-line editable text box. // TextInput is a single-line editable text box.
type TextInput struct { type TextInput struct {
box tomo.TextBox box tomo.TextBox
text []rune text []rune
multiline bool multiline bool
history *internal.History[textHistoryItem]
on struct { on struct {
dotChange event.FuncBroadcaster
valueChange event.FuncBroadcaster valueChange event.FuncBroadcaster
confirm event.FuncBroadcaster confirm event.FuncBroadcaster
} }
@ -22,7 +33,11 @@ type TextInput struct {
func newTextInput (text string, multiline bool) *TextInput { func newTextInput (text string, multiline bool) *TextInput {
textInput := &TextInput { textInput := &TextInput {
box: tomo.NewTextBox(), box: tomo.NewTextBox(),
text: []rune(text),
multiline: multiline, multiline: multiline,
history: internal.NewHistory[textHistoryItem] (
textHistoryItem { text: text },
textInputHistoryMaximum),
} }
textInput.box.SetRole(tomo.R("objects", "TextInput")) textInput.box.SetRole(tomo.R("objects", "TextInput"))
textInput.box.SetTag("multiline", multiline) textInput.box.SetTag("multiline", multiline)
@ -34,12 +49,13 @@ func newTextInput (text string, multiline bool) *TextInput {
textInput.box.SetAttr(tomo.AOverflow(true, false)) textInput.box.SetAttr(tomo.AOverflow(true, false))
textInput.box.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle)) textInput.box.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
} }
textInput.SetValue(text) textInput.box.SetText(text)
textInput.box.SetFocusable(true) textInput.box.SetFocusable(true)
textInput.box.SetSelectable(true) textInput.box.SetSelectable(true)
textInput.box.OnKeyDown(textInput.handleKeyDown) textInput.box.OnKeyDown(textInput.handleKeyDown)
textInput.box.OnKeyUp(textInput.handleKeyUp) textInput.box.OnKeyUp(textInput.handleKeyUp)
textInput.box.OnScroll(textInput.handleScroll) textInput.box.OnScroll(textInput.handleScroll)
textInput.box.OnDotChange(textInput.handleDotChange)
return textInput return textInput
} }
@ -69,6 +85,7 @@ func (this *TextInput) SetFocused (focused bool) {
// Select sets the text cursor or selection. // Select sets the text cursor or selection.
func (this *TextInput) Select (dot text.Dot) { func (this *TextInput) Select (dot text.Dot) {
this.box.Select(dot) this.box.Select(dot)
this.historySwapDot()
} }
// Dot returns the text cursor or selection. // Dot returns the text cursor or selection.
@ -79,7 +96,7 @@ func (this *TextInput) Dot () text.Dot {
// OnDotChange specifies a function to be called when the text cursor or // OnDotChange specifies a function to be called when the text cursor or
// selection changes. // selection changes.
func (this *TextInput) OnDotChange (callback func ()) event.Cookie { func (this *TextInput) OnDotChange (callback func ()) event.Cookie {
return this.box.OnDotChange(callback) return this.on.dotChange.Connect(callback)
} }
// SetAlign sets the X and Y alignment of the text input. // SetAlign sets the X and Y alignment of the text input.
@ -109,6 +126,7 @@ func (this *TextInput) OnContentBoundsChange (callback func ()) event.Cookie {
func (this *TextInput) SetValue (text string) { func (this *TextInput) SetValue (text string) {
this.text = []rune(text) this.text = []rune(text)
this.box.SetText(text) this.box.SetText(text)
this.logLargeAction()
} }
// Value returns the text content of the input. // Value returns the text content of the input.
@ -128,39 +146,89 @@ func (this *TextInput) OnValueChange (callback func ()) event.Cookie {
return this.on.valueChange.Connect(callback) return this.on.valueChange.Connect(callback)
} }
// Undo undoes the last action.
func (this *TextInput) Undo () {
this.recoverHistoryItem(this.history.Undo())
}
// Redo redoes the last previously undone action.
func (this *TextInput) Redo () {
this.recoverHistoryItem(this.history.Redo())
}
// Type types a character at the current dot position. // Type types a character at the current dot position.
func (this *TextInput) Type (char rune) { func (this *TextInput) Type (char rune) {
dot := this.Dot() dot := this.Dot()
this.historySwapDot()
this.text, dot = text.Type(this.text, dot, rune(char)) this.text, dot = text.Type(this.text, dot, rune(char))
this.Select(dot) this.Select(dot)
this.box.SetText(string(this.text)) this.box.SetText(string(this.text))
this.logKeystroke()
} }
// TODO: add up/down controls if this is a multiline input func (this *TextInput) logKeystroke () {
if this.Dot().Empty() {
this.history.PushWeak (
this.currentHistoryState(),
textInputHistoryMaxAge)
} else {
this.logLargeAction()
}
}
func (this *TextInput) logLargeAction () {
this.history.Push(this.currentHistoryState())
}
func (this *TextInput) historySwapDot () {
top := this.history.Top()
top.dot = this.Dot()
this.history.SwapSilently(top)
}
func (this *TextInput) currentHistoryState () textHistoryItem {
return textHistoryItem {
text: string(this.text),
dot: this.Dot(),
}
}
func (this *TextInput) recoverHistoryItem (item textHistoryItem) {
this.box.SetText(item.text)
this.text = []rune(item.text)
this.box.Select(item.dot)
}
// TODO: add things like alt+up/down to move lines
func (this *TextInput) handleKeyDown (key input.Key, numpad bool) bool { func (this *TextInput) handleKeyDown (key input.Key, numpad bool) bool {
dot := this.Dot() dot := this.Dot()
modifiers := this.box.Window().Modifiers() txt := this.text
word := modifiers.Control modifiers := this.box.Window().Modifiers()
changed := false word := modifiers.Control()
changed := false
defer func () { defer func () {
this.Select(dot)
if changed { if changed {
this.box.SetText(string(this.text)) this.historySwapDot()
this.text = txt
this.box.SetText(string(txt))
this.box.Select(dot)
this.on.valueChange.Broadcast() this.on.valueChange.Broadcast()
this.on.dotChange.Broadcast()
this.logKeystroke()
} }
} () } ()
typ := func () { typeRune := func () {
this.text, dot = text.Type(this.text, dot, rune(key)) txt, dot = text.Type(txt, dot, rune(key))
changed = true changed = true
} }
if this.multiline && !modifiers.Control { if this.multiline && !modifiers.Control() {
switch { switch {
case key == '\n', key == '\t': case key == '\n', key == '\t':
typ() typeRune()
return true return true
} }
} }
@ -170,15 +238,25 @@ func (this *TextInput) handleKeyDown (key input.Key, numpad bool) bool {
this.on.confirm.Broadcast() this.on.confirm.Broadcast()
return true return true
case key == input.KeyBackspace: case key == input.KeyBackspace:
this.text, dot = text.Backspace(this.text, dot, word) txt, dot = text.Backspace(txt, dot, word)
changed = true changed = true
return true return true
case key == input.KeyDelete: case key == input.KeyDelete:
this.text, dot = text.Delete(this.text, dot, word) txt, dot = text.Delete(txt, dot, word)
changed = true changed = true
return true return true
case key.Printable() && !modifiers.Control: case key.Printable() && !modifiers.Control():
typ() typeRune()
return true
case key == 'z' || key == 'Z' && modifiers.Control():
if modifiers.Shift() {
this.Redo()
} else {
this.Undo()
}
return true
case key == 'y' && modifiers.Control():
this.Redo()
return true return true
default: default:
return false return false
@ -188,7 +266,7 @@ func (this *TextInput) handleKeyDown (key input.Key, numpad bool) bool {
func (this *TextInput) handleKeyUp (key input.Key, numpad bool) bool { func (this *TextInput) handleKeyUp (key input.Key, numpad bool) bool {
modifiers := this.box.Window().Modifiers() modifiers := this.box.Window().Modifiers()
if this.multiline && !modifiers.Control { if this.multiline && !modifiers.Control() {
switch { switch {
case key == '\n', key == '\t': case key == '\n', key == '\t':
return true return true
@ -202,7 +280,11 @@ func (this *TextInput) handleKeyUp (key input.Key, numpad bool) bool {
return true return true
case key == input.KeyDelete: case key == input.KeyDelete:
return true return true
case key.Printable() && !modifiers.Control: case key.Printable() && !modifiers.Control():
return true
case key == 'z' && modifiers.Control():
return true
case key == 'y' && modifiers.Control():
return true return true
default: default:
return false return false
@ -214,3 +296,8 @@ func (this *TextInput) handleScroll (x, y float64) bool {
this.ScrollTo(this.ContentBounds().Min.Sub(image.Pt(int(x), int(y)))) this.ScrollTo(this.ContentBounds().Min.Sub(image.Pt(int(x), int(y))))
return true return true
} }
func (this *TextInput) handleDotChange () {
this.historySwapDot()
this.on.dotChange.Broadcast()
}

View File

@ -5,7 +5,7 @@ import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/text" import "git.tebibyte.media/tomo/tomo/text"
import "git.tebibyte.media/tomo/tomo/event" import "git.tebibyte.media/tomo/tomo/event"
var _ tomo.Object = new(TabbedContainer) var _ tomo.Object = new(TextView)
// TextView is an area for displaying a large amount of multi-line text. // TextView is an area for displaying a large amount of multi-line text.
type TextView struct { type TextView struct {