Compare commits
33 Commits
Author | SHA1 | Date | |
---|---|---|---|
a3bb4098fb | |||
c30ac90577 | |||
7628903e59 | |||
ce08487eff | |||
6130b84a27 | |||
8b80520f8c | |||
38434db75c | |||
ffb6e9fb95 | |||
12b855ba24 | |||
5b8e401e60 | |||
7144900d31 | |||
51ce2a84f2 | |||
f1f71208f2 | |||
cdf23c9b13 | |||
b8b80f8862 | |||
2224d2e73e | |||
c2245ec304 | |||
dca3880a87 | |||
f0573bf551 | |||
ba2eeeba74 | |||
b37d5398d8 | |||
f761da8cdc | |||
039e0da646 | |||
6f2a31cd60 | |||
177167510b | |||
3077249a13 | |||
38d950f44a | |||
8b1b2e4199 | |||
63ad06e214 | |||
ac1a952b40 | |||
45a6634e73 | |||
ead7d493d7 | |||
c1cf6edd8e |
@ -1,9 +1,11 @@
|
||||
# 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)
|
||||
|
||||
Objects contains a standard collection of re-usable objects. It should be viewed
|
||||
also as a reference for how to create custom objects in Tomo.
|
||||
Objects contains a standard collection of re-usable objects. It should also be
|
||||
viewed as a reference for how to create custom objects in Tomo.
|
||||
|
||||
## Styling
|
||||
All objects in this module have roles of the form:
|
||||
|
93
abstractcontainer.go
Normal file
93
abstractcontainer.go
Normal 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
BIN
assets/preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
@ -60,7 +60,7 @@ func NewCalendar (tm time.Time) *Calendar {
|
||||
calendar.grid.SetRole(tomo.R("objects", "CalendarGrid"))
|
||||
calendar.grid.SetAttr(tomo.ALayout(layouts.NewGrid (
|
||||
true, true, true, true, true, true, true)()))
|
||||
calendar.box.Add(NewInnerContainer (
|
||||
calendar.box.Add(NewContainer (
|
||||
layouts.Row { false, true, false },
|
||||
prevButton, calendar.monthLabel, nextButton))
|
||||
calendar.box.Add(calendar.grid)
|
||||
|
@ -6,7 +6,7 @@ import "git.tebibyte.media/tomo/tomo/input"
|
||||
import "git.tebibyte.media/tomo/tomo/event"
|
||||
import "git.tebibyte.media/tomo/tomo/canvas"
|
||||
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)
|
||||
|
||||
@ -14,11 +14,11 @@ var _ tomo.Object = new(HSVAColorPicker)
|
||||
// parameters.
|
||||
//
|
||||
// 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.
|
||||
type HSVAColorPicker struct {
|
||||
box tomo.ContainerBox
|
||||
value internal.HSVA
|
||||
value ucolor.HSVA
|
||||
|
||||
pickerMap *hsvaColorPickerMap
|
||||
hueSlider *Slider
|
||||
@ -80,7 +80,7 @@ func (this *HSVAColorPicker) Value () color.Color {
|
||||
// SetValue sets the color of the picker.
|
||||
func (this *HSVAColorPicker) SetValue (value color.Color) {
|
||||
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.alphaSlider.SetValue(float64(this.value.A) / 0xFFFF)
|
||||
this.pickerMap.Invalidate()
|
||||
@ -152,7 +152,7 @@ func (this *hsvaColorPickerMap) Draw (can canvas.Canvas) {
|
||||
xx := x - bounds.Min.X
|
||||
yy := y - bounds.Min.Y
|
||||
|
||||
pixel := internal.HSVA {
|
||||
pixel := ucolor.HSVA {
|
||||
H: this.parent.value.H,
|
||||
S: float64(xx) / float64(bounds.Dx()),
|
||||
V: 1 - float64(yy) / float64(bounds.Dy()),
|
||||
|
128
container.go
128
container.go
@ -1,133 +1,21 @@
|
||||
package objects
|
||||
|
||||
import "image"
|
||||
import "git.tebibyte.media/tomo/tomo"
|
||||
import "git.tebibyte.media/tomo/tomo/event"
|
||||
|
||||
var _ tomo.ContentObject = new(Container)
|
||||
|
||||
// Container is an object that can contain other objects. It can be used as a
|
||||
// primitive for building more complex layouts. It has two main variants: an
|
||||
// outer container, and an inner container. The outer container has padding
|
||||
// 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.
|
||||
// Container is an object that can contain other objects. It is plain looking,
|
||||
// and is intended to be used within other containers as a primitive for
|
||||
// building more complex layouts.
|
||||
type Container struct {
|
||||
box tomo.ContainerBox
|
||||
abstractContainer
|
||||
}
|
||||
|
||||
func newContainer (layout tomo.Layout, children ...tomo.Object) *Container {
|
||||
this := &Container {
|
||||
box: tomo.NewContainerBox(),
|
||||
}
|
||||
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...)
|
||||
// NewContainer creates a new container.
|
||||
func NewContainer (layout tomo.Layout, children ...tomo.Object) *Container {
|
||||
this := &Container { }
|
||||
this.init(layout, children...)
|
||||
this.box.SetRole(tomo.R("objects", "Container"))
|
||||
this.box.SetTag("outer", true)
|
||||
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)
|
||||
}
|
||||
|
19
dialog.go
19
dialog.go
@ -16,7 +16,6 @@ type DialogKind int; const (
|
||||
// Dialog is a modal dialog window.
|
||||
type Dialog struct {
|
||||
tomo.Window
|
||||
controlRow *Container
|
||||
}
|
||||
|
||||
type clickable interface {
|
||||
@ -36,11 +35,11 @@ func NewDialog (kind DialogKind, parent tomo.Window, title, message string, opti
|
||||
|
||||
dialog := &Dialog { }
|
||||
if parent == nil {
|
||||
window, err := tomo.NewWindow(image.Rectangle { })
|
||||
window, err := tomo.NewWindow(tomo.WindowKindNormal, image.Rectangle { })
|
||||
if err != nil { return nil, err }
|
||||
dialog.Window = window
|
||||
} else {
|
||||
window, err := parent.NewModal(image.Rectangle { })
|
||||
window, err := parent.NewChild(tomo.WindowKindModal, image.Rectangle { })
|
||||
if err != nil { return nil, err }
|
||||
dialog.Window = window
|
||||
}
|
||||
@ -60,16 +59,16 @@ func NewDialog (kind DialogKind, parent tomo.Window, title, message string, opti
|
||||
|
||||
for _, option := range options {
|
||||
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(NewOuterContainer (
|
||||
|
||||
dialog.SetRoot(NewRoot (
|
||||
layouts.Column { true, false },
|
||||
NewInnerContainer(layouts.ContractHorizontal, icon, messageText),
|
||||
dialog.controlRow))
|
||||
NewContentSegment(layouts.ContractHorizontal, icon, messageText),
|
||||
NewOptionSegment(nil, options...)))
|
||||
return dialog, nil
|
||||
}
|
||||
|
||||
|
7
go.mod
7
go.mod
@ -1,5 +1,8 @@
|
||||
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
6
go.sum
@ -1,2 +1,4 @@
|
||||
git.tebibyte.media/tomo/tomo v0.46.1 h1:/8fT6I9l4TK529zokrThbNDHGRvUsNgif1Zs++0PBSQ=
|
||||
git.tebibyte.media/tomo/tomo v0.46.1/go.mod h1:WrtilgKB1y8O2Yu7X4mYcRiqOlPR8NuUnoA/ynkQWrs=
|
||||
git.tebibyte.media/sashakoshka/goutil v0.3.1 h1:zvAMKS+aea96q6oTttCWfNLXqOHisI3IKAwX6BWKfY0=
|
||||
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=
|
||||
|
@ -3,178 +3,6 @@ package internal
|
||||
import "fmt"
|
||||
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.
|
||||
func FormatNRGBA (nrgba color.NRGBA) string {
|
||||
return fmt.Sprintf("%02X%02X%02X%02X", nrgba.R, nrgba.G, nrgba.B, nrgba.A)
|
||||
|
92
internal/history.go
Normal file
92
internal/history.go
Normal 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
|
||||
}
|
5
label.go
5
label.go
@ -58,3 +58,8 @@ func (this *Label) OnDotChange (callback func ()) event.Cookie {
|
||||
func (this *Label) SetAlign (x, y tomo.Align) {
|
||||
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))
|
||||
}
|
||||
|
9
menu.go
9
menu.go
@ -9,6 +9,9 @@ import "git.tebibyte.media/tomo/objects/layouts"
|
||||
// Menu is a menu window.
|
||||
//
|
||||
// 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,
|
||||
// causes the menu to be "torn off" into a movable window.
|
||||
type Menu struct {
|
||||
@ -40,7 +43,7 @@ func newMenu (parent tomo.Window, bounds image.Rectangle, items ...tomo.Object)
|
||||
menu := &Menu { }
|
||||
menu.bounds = bounds
|
||||
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 }
|
||||
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.Window.SetRoot(menu.rootContainer)
|
||||
@ -75,7 +78,7 @@ func (this *Menu) TearOff () {
|
||||
if this.parent == nil { return }
|
||||
this.torn = true
|
||||
|
||||
window, err := this.parent.NewChild(this.bounds)
|
||||
window, err := this.parent.NewChild(tomo.WindowKindToolbar, this.bounds)
|
||||
window.SetIcon(tomo.IconListChoose)
|
||||
if err != nil { return }
|
||||
|
||||
|
170
notebook.go
Normal file
170
notebook.go
Normal 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
22
pegboard.go
Normal 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
20
root.go
Normal 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
|
||||
}
|
@ -172,14 +172,14 @@ func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
|
||||
switch key {
|
||||
case input.KeyUp, input.KeyLeft:
|
||||
if modifiers.Alt {
|
||||
if modifiers.Alt() {
|
||||
this.SetValue(0)
|
||||
} else {
|
||||
this.scrollBy(this.StepSize())
|
||||
}
|
||||
return true
|
||||
case input.KeyDown, input.KeyRight:
|
||||
if modifiers.Alt {
|
||||
if modifiers.Alt() {
|
||||
this.SetValue(1)
|
||||
} else {
|
||||
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 {
|
||||
cookie.Close()
|
||||
}
|
||||
this.owner.layout.linked = nil
|
||||
this.owner.box.SetAttr(tomo.ALayout(this.owner.layout))
|
||||
return nil
|
||||
}
|
||||
|
||||
type scrollbarLayout struct {
|
||||
|
@ -202,7 +202,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
vector := image.Point { }
|
||||
switch key {
|
||||
case input.KeyPageUp:
|
||||
if modifiers.Shift {
|
||||
if modifiers.Shift() {
|
||||
vector.X -= this.PageSize().X
|
||||
} else {
|
||||
vector.Y -= this.PageSize().Y
|
||||
@ -210,7 +210,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
this.scrollBy(vector)
|
||||
return true
|
||||
case input.KeyPageDown:
|
||||
if modifiers.Shift {
|
||||
if modifiers.Shift() {
|
||||
vector.X += this.PageSize().X
|
||||
} else {
|
||||
vector.Y += this.PageSize().Y
|
||||
@ -218,7 +218,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
this.scrollBy(vector)
|
||||
return true
|
||||
case input.KeyUp:
|
||||
if modifiers.Shift {
|
||||
if modifiers.Shift() {
|
||||
vector.X -= this.StepSize().X
|
||||
} else {
|
||||
vector.Y -= this.StepSize().Y
|
||||
@ -226,7 +226,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
this.scrollBy(vector)
|
||||
return true
|
||||
case input.KeyDown:
|
||||
if modifiers.Shift {
|
||||
if modifiers.Shift() {
|
||||
vector.X += this.StepSize().X
|
||||
} else {
|
||||
vector.Y += this.StepSize().Y
|
||||
@ -241,6 +241,8 @@ func (this *ScrollContainer) handleKeyUp (key input.Key, numpad bool) bool {
|
||||
switch key {
|
||||
case input.KeyPageUp: return true
|
||||
case input.KeyPageDown: return true
|
||||
case input.KeyUp: return true
|
||||
case input.KeyDown: return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
89
segment.go
Normal file
89
segment.go
Normal 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
|
||||
}
|
@ -127,7 +127,7 @@ func (this *Slider) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
|
||||
switch key {
|
||||
case input.KeyUp, input.KeyLeft:
|
||||
if this.box.Window().Modifiers().Alt {
|
||||
if this.box.Window().Modifiers().Alt() {
|
||||
this.SetValue(0)
|
||||
} else {
|
||||
this.SetValue(this.Value() - increment)
|
||||
@ -135,7 +135,7 @@ func (this *Slider) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
this.on.valueChange.Broadcast()
|
||||
return true
|
||||
case input.KeyDown, input.KeyRight:
|
||||
if this.box.Window().Modifiers().Alt {
|
||||
if this.box.Window().Modifiers().Alt() {
|
||||
this.SetValue(1)
|
||||
} else {
|
||||
this.SetValue(this.Value() + increment)
|
||||
|
24
swatch.go
24
swatch.go
@ -90,9 +90,9 @@ func (this *Swatch) Choose () {
|
||||
var err error
|
||||
var window tomo.Window
|
||||
if parent := this.box.Window(); parent != nil {
|
||||
window, err = parent.NewChild(image.Rectangle { })
|
||||
window, err = parent.NewChild(tomo.WindowKindNormal, image.Rectangle { })
|
||||
} else {
|
||||
window, err = tomo.NewWindow(image.Rectangle { })
|
||||
window, err = tomo.NewWindow(tomo.WindowKindNormal, image.Rectangle { })
|
||||
}
|
||||
if err != nil {
|
||||
log.Println("objects: could not create swatch modal:", err)
|
||||
@ -139,18 +139,16 @@ func (this *Swatch) Choose () {
|
||||
})
|
||||
okButton.OnClick(commit)
|
||||
|
||||
controlRow := NewInnerContainer (
|
||||
layouts.ContractHorizontal,
|
||||
cancelButton,
|
||||
okButton)
|
||||
controlRow.SetAlign(tomo.AlignEnd, tomo.AlignMiddle)
|
||||
window.SetRoot(NewOuterContainer (
|
||||
window.SetRoot(NewRoot (
|
||||
layouts.Column { true, false },
|
||||
colorPicker,
|
||||
NewInnerContainer(layouts.Row { false, true },
|
||||
NewLabel("Hex"),
|
||||
hexInput),
|
||||
controlRow))
|
||||
NewContentSegment (
|
||||
layouts.Column { true, false },
|
||||
colorPicker,
|
||||
NewContainer (
|
||||
layouts.Row { false, true },
|
||||
NewLabel("Hex"),
|
||||
hexInput)),
|
||||
NewOptionSegment(nil, cancelButton, okButton)))
|
||||
window.OnClose(func () {
|
||||
if committed {
|
||||
this.on.confirm.Broadcast()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
125
textinput.go
125
textinput.go
@ -1,19 +1,30 @@
|
||||
package objects
|
||||
|
||||
import "time"
|
||||
import "image"
|
||||
import "git.tebibyte.media/tomo/tomo"
|
||||
import "git.tebibyte.media/tomo/tomo/text"
|
||||
import "git.tebibyte.media/tomo/tomo/input"
|
||||
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)
|
||||
|
||||
type textHistoryItem struct {
|
||||
text string
|
||||
dot text.Dot
|
||||
}
|
||||
|
||||
// TextInput is a single-line editable text box.
|
||||
type TextInput struct {
|
||||
box tomo.TextBox
|
||||
text []rune
|
||||
multiline bool
|
||||
history *internal.History[textHistoryItem]
|
||||
on struct {
|
||||
dotChange event.FuncBroadcaster
|
||||
valueChange event.FuncBroadcaster
|
||||
confirm event.FuncBroadcaster
|
||||
}
|
||||
@ -22,7 +33,11 @@ type TextInput struct {
|
||||
func newTextInput (text string, multiline bool) *TextInput {
|
||||
textInput := &TextInput {
|
||||
box: tomo.NewTextBox(),
|
||||
text: []rune(text),
|
||||
multiline: multiline,
|
||||
history: internal.NewHistory[textHistoryItem] (
|
||||
textHistoryItem { text: text },
|
||||
textInputHistoryMaximum),
|
||||
}
|
||||
textInput.box.SetRole(tomo.R("objects", "TextInput"))
|
||||
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.AAlign(tomo.AlignStart, tomo.AlignMiddle))
|
||||
}
|
||||
textInput.SetValue(text)
|
||||
textInput.box.SetText(text)
|
||||
textInput.box.SetFocusable(true)
|
||||
textInput.box.SetSelectable(true)
|
||||
textInput.box.OnKeyDown(textInput.handleKeyDown)
|
||||
textInput.box.OnKeyUp(textInput.handleKeyUp)
|
||||
textInput.box.OnScroll(textInput.handleScroll)
|
||||
textInput.box.OnDotChange(textInput.handleDotChange)
|
||||
return textInput
|
||||
}
|
||||
|
||||
@ -69,6 +85,7 @@ func (this *TextInput) SetFocused (focused bool) {
|
||||
// Select sets the text cursor or selection.
|
||||
func (this *TextInput) Select (dot text.Dot) {
|
||||
this.box.Select(dot)
|
||||
this.historySwapDot()
|
||||
}
|
||||
|
||||
// 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
|
||||
// selection changes.
|
||||
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.
|
||||
@ -109,6 +126,7 @@ func (this *TextInput) OnContentBoundsChange (callback func ()) event.Cookie {
|
||||
func (this *TextInput) SetValue (text string) {
|
||||
this.text = []rune(text)
|
||||
this.box.SetText(text)
|
||||
this.logLargeAction()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (this *TextInput) Type (char rune) {
|
||||
dot := this.Dot()
|
||||
this.historySwapDot()
|
||||
this.text, dot = text.Type(this.text, dot, rune(char))
|
||||
this.Select(dot)
|
||||
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 {
|
||||
dot := this.Dot()
|
||||
modifiers := this.box.Window().Modifiers()
|
||||
word := modifiers.Control
|
||||
changed := false
|
||||
dot := this.Dot()
|
||||
txt := this.text
|
||||
modifiers := this.box.Window().Modifiers()
|
||||
word := modifiers.Control()
|
||||
changed := false
|
||||
|
||||
defer func () {
|
||||
this.Select(dot)
|
||||
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.dotChange.Broadcast()
|
||||
this.logKeystroke()
|
||||
}
|
||||
} ()
|
||||
|
||||
typ := func () {
|
||||
this.text, dot = text.Type(this.text, dot, rune(key))
|
||||
typeRune := func () {
|
||||
txt, dot = text.Type(txt, dot, rune(key))
|
||||
changed = true
|
||||
}
|
||||
|
||||
if this.multiline && !modifiers.Control {
|
||||
if this.multiline && !modifiers.Control() {
|
||||
switch {
|
||||
case key == '\n', key == '\t':
|
||||
typ()
|
||||
typeRune()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -170,15 +238,25 @@ func (this *TextInput) handleKeyDown (key input.Key, numpad bool) bool {
|
||||
this.on.confirm.Broadcast()
|
||||
return true
|
||||
case key == input.KeyBackspace:
|
||||
this.text, dot = text.Backspace(this.text, dot, word)
|
||||
txt, dot = text.Backspace(txt, dot, word)
|
||||
changed = true
|
||||
return true
|
||||
case key == input.KeyDelete:
|
||||
this.text, dot = text.Delete(this.text, dot, word)
|
||||
txt, dot = text.Delete(txt, dot, word)
|
||||
changed = true
|
||||
return true
|
||||
case key.Printable() && !modifiers.Control:
|
||||
typ()
|
||||
case key.Printable() && !modifiers.Control():
|
||||
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
|
||||
default:
|
||||
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 {
|
||||
modifiers := this.box.Window().Modifiers()
|
||||
|
||||
if this.multiline && !modifiers.Control {
|
||||
if this.multiline && !modifiers.Control() {
|
||||
switch {
|
||||
case key == '\n', key == '\t':
|
||||
return true
|
||||
@ -202,7 +280,11 @@ func (this *TextInput) handleKeyUp (key input.Key, numpad bool) bool {
|
||||
return true
|
||||
case key == input.KeyDelete:
|
||||
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
|
||||
default:
|
||||
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))))
|
||||
return true
|
||||
}
|
||||
|
||||
func (this *TextInput) handleDotChange () {
|
||||
this.historySwapDot()
|
||||
this.on.dotChange.Broadcast()
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import "git.tebibyte.media/tomo/tomo"
|
||||
import "git.tebibyte.media/tomo/tomo/text"
|
||||
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.
|
||||
type TextView struct {
|
||||
|
Loading…
Reference in New Issue
Block a user