Compare commits

...

84 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
2727972c30 File. Why not. 2024-08-27 13:38:35 -04:00
e48933385e You get an OnDotChange! Everypony gets an OnDotChange! 2024-08-25 18:55:43 -04:00
b9c4e3c003 Forgot to wrap the multiline input text haha 2024-08-25 02:49:08 -04:00
92e4eb970d Add multi-line text inputs 2024-08-25 02:47:23 -04:00
c7887c5ea4 Fix tag on right TabSpacer 2024-08-25 02:37:39 -04:00
30d4e208b1 Document all tags and named sub-components
Closes #9
2024-08-25 02:36:05 -04:00
a688e2dc24 README.md tweaks 2024-08-25 01:59:30 -04:00
e1cef9bb37 Improve README.md 2024-08-25 01:58:03 -04:00
6089dd3ff1 TextView no longer embeds tomo.TextBox 2024-08-25 01:40:40 -04:00
d4e8847908 Fix doc comments on Label, TextInput 2024-08-25 01:38:42 -04:00
82cf822602 Update NumberInput to use new TextInput methods 2024-08-25 01:32:44 -04:00
2b354979aa TextInput no longer embeds tomo.TextBox 2024-08-25 01:31:55 -04:00
1a2449d2b7 TabbedContainer no longer embeds tomo.ContainerBox 2024-08-24 22:26:00 -04:00
889c691c40 TabbedContainer no longer embeds tomo.ContainerBox 2024-08-24 22:15:21 -04:00
32eae0bcca Swatch no longer embeds tomo.CanvasBox 2024-08-24 22:12:43 -04:00
d9d758b5fc Slider no longer embeds tomo.ContainerBox 2024-08-24 22:09:31 -04:00
a11bab452b Ensure Separator fulfils tomo.Object 2024-08-24 22:03:35 -04:00
033e9debf6 Separator no longer embeds tomo.Box 2024-08-24 21:52:37 -04:00
8b79fec1bd ScrollContainer no longer embeds ContainerBox 2024-08-24 21:41:16 -04:00
bc175bb5ae Scrollbar no longer embeds tomo.ContainerBox 2024-08-24 21:35:43 -04:00
02fed8ce48 NumberInput no longer embeds tomo.ContainerBox 2024-08-24 20:19:01 -04:00
b784596b4d Update doc comment for Container 2024-08-24 20:13:38 -04:00
694f9127c0 MimeIcon no longer embeds tomo.Box 2024-08-24 20:10:49 -04:00
ae74c3dbf4 MenuItem no longer embeds tomo.ContainerBox 2024-08-24 20:10:37 -04:00
6f8d5cc426 LabelSwatch, LabelCheckbox changing their labels 2024-08-24 20:03:48 -04:00
f0c334c278 LabelSwatch no longer embeds tomo.ContainerBox 2024-08-24 20:02:14 -04:00
6ee2c5669e Ensure Label satisfies tomo.Object 2024-08-24 19:57:48 -04:00
3aa4b12ffe Update other objects to use new methods of Label 2024-08-24 19:56:58 -04:00
c7caa5bcb6 Label no longer embeds tomo.TextBox 2024-08-24 19:52:47 -04:00
0960fe013d LabelCheckbox no longer embeds tomo.ContainerBox 2024-08-24 19:50:29 -04:00
697229d183 Icon no longer embeds tomo.Box 2024-08-24 19:50:20 -04:00
c043f9bf8d Remove Heading.SetSelectable 2024-08-24 19:49:20 -04:00
14f6e175f0 Heading no longer embeds tomo.TextBox 2024-08-24 19:42:25 -04:00
df2e8f1b07 Dropdown no longer embeds tomo.ContainerBox 2024-08-24 19:33:16 -04:00
0c4e098680 HSVAColorPicker no longer embeds tomo.ContainerBox 2024-08-24 19:28:48 -04:00
fc51e7ab9f Checkbox no longer embeds tomo.ContainerBox 2024-08-24 15:41:47 -04:00
4e8823ef9f Calendar no longer embeds tomo.ContainerBox 2024-08-24 15:20:09 -04:00
8de08a9bdc Button no longer embeds tomo.ContainerBox 2024-08-24 15:11:57 -04:00
04f44cea86 Ensure Container satisfies tomo.ContentObject 2024-08-24 15:04:44 -04:00
c889838c9c Container no longer embeds tomo.ContainerBox
Progress on #7
2024-08-24 15:02:17 -04:00
7bcb4cf823 Add SetLayout to Container 2024-08-24 14:45:31 -04:00
02516bdcce Same as last commit but for TearLine 2024-08-24 14:42:08 -04:00
8432cc70da MenuItem focuses on hover
Styles should remove MenuItem[hover] styling
2024-08-24 14:37:44 -04:00
8469962c90 Use key/button functions for menu 2024-08-24 14:32:19 -04:00
0ccdb609ef Tear off menu windows now have an icon 2024-08-24 01:00:34 -04:00
d1f0786043 Dialog boxes have icons now 2024-08-23 21:39:39 -04:00
73731c6201 Scrollbar has Scrollbar role 2024-08-16 18:36:49 -04:00
7c42b7ad37 Scrollbar has ScrollbarHandle instead of SliderHandle 2024-08-16 18:36:20 -04:00
0fe4979483 Un-export SliderHandle
Closes #8
2024-08-16 18:35:19 -04:00
155752ba78 LabelSwatch uses the new button functions 2024-08-16 18:32:07 -04:00
f4a3cb3c00 LabelSwatch's label is not selectable, to match LabelCheckbox 2024-08-16 18:26:13 -04:00
36 changed files with 1628 additions and 725 deletions

View File

@ -1,6 +1,29 @@
# 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. All objects in this Objects contains a standard collection of re-usable objects. It should also be
module visually conform to whatever the theme is set to. viewed as a reference for how to create custom objects in Tomo.
## Styling
All objects in this module have roles of the form:
```
objects.TypeName
```
Where `TypeName` is the exact Go type name of the object in question. Objects
may also have different tags to indicate variations, states, etc. If applicable,
they are listed and described in the doc comment for the object's type. More
complex objects may have sub-components that are not accessible from the API.
These are listed alongside the tags.
## Setting Attributes
It is generally not recommended to set attributes on these objects. However, if
you must, they can be set by obtaining the object's underlying box through the
`GetBox` method. Be aware that the exact type of box that is returned here is
not part of the API, and may change unexpectedly even after v1.0. This caveat
also applies to boxes/sub-components making up the internal composition of the
objects.

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

@ -5,13 +5,18 @@ 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/layouts" import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(Button)
var buttonLayout = layouts.Row { true } var buttonLayout = layouts.Row { true }
var iconButtonLayout = layouts.Row { true } var iconButtonLayout = layouts.Row { true }
var bothButtonLayout = layouts.Row { false, true } var bothButtonLayout = layouts.Row { false, true }
// Button is a clickable button. // Button is a clickable button.
//
// Tags:
// - [icon] The button has an icon.
type Button struct { type Button struct {
tomo.ContainerBox box tomo.ContainerBox
label *Label label *Label
icon *Icon icon *Icon
@ -24,33 +29,45 @@ type Button struct {
// NewButton creates a new button with the specified text. // NewButton creates a new button with the specified text.
func NewButton (text string) *Button { func NewButton (text string) *Button {
box := &Button { button := &Button {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
label: NewLabel(text), label: NewLabel(text),
} }
box.SetRole(tomo.R("objects", "Button")) button.box.SetRole(tomo.R("objects", "Button"))
box.label.SetAttr(tomo.AAlign(tomo.AlignMiddle, tomo.AlignMiddle)) button.label.SetAlign(tomo.AlignMiddle, tomo.AlignMiddle)
box.SetAttr(tomo.ALayout(buttonLayout)) button.box.SetAttr(tomo.ALayout(buttonLayout))
box.SetText(text) button.SetText(text)
box.SetInputMask(true) button.box.SetInputMask(true)
box.OnButtonDown(box.handleButtonDown) button.box.OnButtonDown(button.handleButtonDown)
box.OnButtonUp(box.handleButtonUp) button.box.OnButtonUp(button.handleButtonUp)
box.OnKeyDown(box.handleKeyDown) button.box.OnKeyDown(button.handleKeyDown)
box.OnKeyUp(box.handleKeyUp) button.box.OnKeyUp(button.handleKeyUp)
box.SetFocusable(true) button.box.SetFocusable(true)
return box return button
}
// GetBox returns the underlying box.
func (this *Button) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this button has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *Button) SetFocused (focused bool) {
this.box.SetFocused(focused)
} }
// SetText sets the text of the button's label. // SetText sets the text of the button's label.
func (this *Button) SetText (text string) { func (this *Button) SetText (text string) {
this.label.SetText(text) this.label.SetText(text)
if this.labelActive && text == "" { if this.labelActive && text == "" {
this.Remove(this.label) this.box.Remove(this.label)
this.labelActive = false this.labelActive = false
} }
if !this.labelActive && text != "" { if !this.labelActive && text != "" {
this.Add(this.label) this.box.Add(this.label)
this.labelActive = true this.labelActive = true
} }
this.applyLayout() this.applyLayout()
@ -59,7 +76,7 @@ func (this *Button) SetText (text string) {
// SetIcon sets an icon for this button. Setting the icon to IconUnknown will // SetIcon sets an icon for this button. Setting the icon to IconUnknown will
// remove it. // remove it.
func (this *Button) SetIcon (id tomo.Icon) { func (this *Button) SetIcon (id tomo.Icon) {
if this.icon != nil { this.Remove(this.icon) } if this.icon != nil { this.box.Remove(this.icon) }
var icon *Icon; if id != tomo.IconUnknown { var icon *Icon; if id != tomo.IconUnknown {
icon = NewIcon(id, tomo.IconSizeSmall) icon = NewIcon(id, tomo.IconSizeSmall)
@ -67,9 +84,9 @@ func (this *Button) SetIcon (id tomo.Icon) {
this.icon = icon this.icon = icon
if this.icon != nil { if this.icon != nil {
this.Insert(this.icon, this.label) this.box.Insert(this.icon, this.label)
} }
this.SetTag("icon", this.icon != nil) this.box.SetTag("icon", this.icon != nil)
this.applyLayout() this.applyLayout()
} }
@ -80,11 +97,11 @@ func (this *Button) OnClick (callback func ()) event.Cookie {
func (this *Button) applyLayout () { func (this *Button) applyLayout () {
if this.labelActive && this.icon == nil { if this.labelActive && this.icon == nil {
this.SetAttr(tomo.ALayout(buttonLayout)) this.box.SetAttr(tomo.ALayout(buttonLayout))
} else if !this.labelActive && this.icon != nil { } else if !this.labelActive && this.icon != nil {
this.SetAttr(tomo.ALayout(iconButtonLayout)) this.box.SetAttr(tomo.ALayout(iconButtonLayout))
} else { } else {
this.SetAttr(tomo.ALayout(bothButtonLayout)) this.box.SetAttr(tomo.ALayout(bothButtonLayout))
} }
} }
@ -106,7 +123,7 @@ func (this *Button) handleButtonDown (button input.Button) bool {
func (this *Button) handleButtonUp (button input.Button) bool { func (this *Button) handleButtonUp (button input.Button) bool {
if !isClickingButton(button) { return false } if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) { if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.on.click.Broadcast() this.on.click.Broadcast()
} }
return true return true

View File

@ -6,10 +6,22 @@ import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/event" import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/objects/layouts" import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(Calendar)
// Calendar is an object that can display a date and allow the user to change // Calendar is an object that can display a date and allow the user to change
// it. It can display one month at a time. // it. It can display one month at a time.
//
// Sub-components:
// - CalendarGrid organizes the days into a grid.
// - CalendarWeekdayHeader appears at the top of each grid column, and shows
// the day of the week that column represents.
// - CalendarDay appears within the grid for each day of the current month.
//
// CalendarDay tags:
// - [weekend] The day is a weekend.
// - [weekday] The day is a weekday.
type Calendar struct { type Calendar struct {
tomo.ContainerBox box tomo.ContainerBox
grid tomo.ContainerBox grid tomo.ContainerBox
time time.Time time time.Time
@ -23,11 +35,11 @@ type Calendar struct {
// NewCalendar creates a new calendar with the specified date. // NewCalendar creates a new calendar with the specified date.
func NewCalendar (tm time.Time) *Calendar { func NewCalendar (tm time.Time) *Calendar {
calendar := &Calendar { calendar := &Calendar {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
time: tm, time: tm,
} }
calendar.SetRole(tomo.R("objects", "Calendar")) calendar.box.SetRole(tomo.R("objects", "Calendar"))
calendar.SetAttr(tomo.ALayout(layouts.ContractVertical)) calendar.box.SetAttr(tomo.ALayout(layouts.ContractVertical))
prevButton := NewButton("") prevButton := NewButton("")
prevButton.SetIcon(tomo.IconGoPrevious) prevButton.SetIcon(tomo.IconGoPrevious)
@ -42,23 +54,28 @@ func NewCalendar (tm time.Time) *Calendar {
calendar.on.valueChange.Broadcast() calendar.on.valueChange.Broadcast()
}) })
calendar.monthLabel = NewLabel("") calendar.monthLabel = NewLabel("")
calendar.monthLabel.SetAttr(tomo.AAlign(tomo.AlignMiddle, tomo.AlignMiddle)) calendar.monthLabel.SetAlign(tomo.AlignMiddle, tomo.AlignMiddle)
calendar.grid = tomo.NewContainerBox() calendar.grid = tomo.NewContainerBox()
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.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.Add(calendar.grid) calendar.box.Add(calendar.grid)
calendar.OnScroll(calendar.handleScroll) calendar.box.OnScroll(calendar.handleScroll)
calendar.refresh() calendar.refresh()
return calendar return calendar
} }
// GetBox returns the underlying box.
func (this *Calendar) GetBox () tomo.Box {
return this.box
}
// Value returns the time this calendar is displaying. // Value returns the time this calendar is displaying.
func (this *Calendar) Value () time.Time { func (this *Calendar) Value () time.Time {
return this.time return this.time

View File

@ -4,9 +4,15 @@ import "git.tebibyte.media/tomo/tomo"
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"
var _ tomo.Object = new(Checkbox)
// Checkbox is a control that can be toggled. // Checkbox is a control that can be toggled.
//
// Tags:
// - [checked] The checkbox's value is true.
// - [unchecked] The checkbox's value is false.
type Checkbox struct { type Checkbox struct {
tomo.Box box tomo.Box
value bool value bool
on struct { on struct {
valueChange event.FuncBroadcaster valueChange event.FuncBroadcaster
@ -15,18 +21,30 @@ type Checkbox struct {
// NewCheckbox creates a new checkbox with the specified value. // NewCheckbox creates a new checkbox with the specified value.
func NewCheckbox (value bool) *Checkbox { func NewCheckbox (value bool) *Checkbox {
box := &Checkbox { checkbox := &Checkbox {
Box: tomo.NewBox(), box: tomo.NewBox(),
} }
box.SetRole(tomo.R("objects", "Checkbox")) checkbox.box.SetRole(tomo.R("objects", "Checkbox"))
box.SetValue(value) checkbox.SetValue(value)
box.OnButtonDown(box.handleButtonDown) checkbox.box.OnButtonDown(checkbox.handleButtonDown)
box.OnButtonUp(box.handleButtonUp) checkbox.box.OnButtonUp(checkbox.handleButtonUp)
box.OnKeyDown(box.handleKeyDown) checkbox.box.OnKeyDown(checkbox.handleKeyDown)
box.OnKeyUp(box.handleKeyUp) checkbox.box.OnKeyUp(checkbox.handleKeyUp)
box.SetFocusable(true) checkbox.box.SetFocusable(true)
return box return checkbox
}
// GetBox returns the underlying box.
func (this *Checkbox) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this checkbox has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *Checkbox) SetFocused (focused bool) {
this.box.SetFocused(focused)
} }
// Value returns the value of the checkbox. // Value returns the value of the checkbox.
@ -38,8 +56,8 @@ func (this *Checkbox) Value () bool {
func (this *Checkbox) SetValue (value bool) { func (this *Checkbox) SetValue (value bool) {
this.value = value this.value = value
// the theme shall decide what checked and unchecked states look like // the theme shall decide what checked and unchecked states look like
this.SetTag("checked", value) this.box.SetTag("checked", value)
this.SetTag("unchecked", !value) this.box.SetTag("unchecked", !value)
} }
// Toggle toggles the value of the checkbox between true and false. // Toggle toggles the value of the checkbox between true and false.
@ -71,7 +89,7 @@ func (this *Checkbox) handleButtonDown (button input.Button) bool {
func (this *Checkbox) handleButtonUp (button input.Button) bool { func (this *Checkbox) handleButtonUp (button input.Button) bool {
if !isClickingButton(button) { return false } if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) { if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.Toggle() this.Toggle()
} }
return true return true

View File

@ -6,13 +6,19 @@ 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)
// HSVAColorPicker allows the user to pick a color by controlling its HSVA // HSVAColorPicker allows the user to pick a color by controlling its HSVA
// parameters. // parameters.
//
// Sub-components:
// - ColorPickerMap is a rectangular control where the X axis controls
// saturation and the Y axis controls value.
type HSVAColorPicker struct { type HSVAColorPicker struct {
tomo.ContainerBox box tomo.ContainerBox
value internal.HSVA value ucolor.HSVA
pickerMap *hsvaColorPickerMap pickerMap *hsvaColorPickerMap
hueSlider *Slider hueSlider *Slider
@ -26,15 +32,15 @@ type HSVAColorPicker struct {
// NewHSVAColorPicker creates a new color picker with the specified color. // NewHSVAColorPicker creates a new color picker with the specified color.
func NewHSVAColorPicker (value color.Color) *HSVAColorPicker { func NewHSVAColorPicker (value color.Color) *HSVAColorPicker {
picker := &HSVAColorPicker { picker := &HSVAColorPicker {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
} }
picker.SetRole(tomo.R("objects", "ColorPicker")) picker.box.SetRole(tomo.R("objects", "ColorPicker"))
picker.SetAttr(tomo.ALayout(layouts.Row { true, false, false })) picker.box.SetAttr(tomo.ALayout(layouts.Row { true, false, false }))
picker.pickerMap = newHsvaColorPickerMap(picker) picker.pickerMap = newHsvaColorPickerMap(picker)
picker.Add(picker.pickerMap) picker.box.Add(picker.pickerMap)
picker.hueSlider = NewVerticalSlider(0.0) picker.hueSlider = NewVerticalSlider(0.0)
picker.Add(picker.hueSlider) picker.box.Add(picker.hueSlider)
picker.hueSlider.OnValueChange(func () { picker.hueSlider.OnValueChange(func () {
picker.value.H = picker.hueSlider.Value() picker.value.H = picker.hueSlider.Value()
picker.on.valueChange.Broadcast() picker.on.valueChange.Broadcast()
@ -42,7 +48,7 @@ func NewHSVAColorPicker (value color.Color) *HSVAColorPicker {
}) })
picker.alphaSlider = NewVerticalSlider(0.0) picker.alphaSlider = NewVerticalSlider(0.0)
picker.Add(picker.alphaSlider) picker.box.Add(picker.alphaSlider)
picker.alphaSlider.OnValueChange(func () { picker.alphaSlider.OnValueChange(func () {
picker.value.A = uint16(picker.alphaSlider.Value() * 0xFFFF) picker.value.A = uint16(picker.alphaSlider.Value() * 0xFFFF)
picker.on.valueChange.Broadcast() picker.on.valueChange.Broadcast()
@ -54,6 +60,18 @@ func NewHSVAColorPicker (value color.Color) *HSVAColorPicker {
return picker return picker
} }
// GetBox returns the underlying box.
func (this *HSVAColorPicker) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this color picker has keyboard focus. If set
// to true, this method will steal focus away from whichever object currently
// has focus.
func (this *HSVAColorPicker) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// Value returns the color of the picker. // Value returns the color of the picker.
func (this *HSVAColorPicker) Value () color.Color { func (this *HSVAColorPicker) Value () color.Color {
return this.value return this.value
@ -62,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()
@ -134,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

@ -2,50 +2,20 @@ package objects
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
// Container is an object that can contain other objects. It can be used as a var _ tomo.ContentObject = new(Container)
// primitive for building more complex layouts. It has two variants: an outer
// container, and an inner container. The outer container has padding around // Container is an object that can contain other objects. It is plain looking,
// its edges, whereas the inner container does not. The container will have a // and is intended to be used within other containers as a primitive for
// corresponding object role variation of either "outer" or "inner". // building more complex layouts.
type Container struct { type Container struct {
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 {
ContainerBox: tomo.NewContainerBox(), this := &Container { }
} this.init(layout, children...)
this.SetAttr(tomo.ALayout(layout)) this.box.SetRole(tomo.R("objects", "Container"))
for _, child := range children { this.box.SetTag("outer", true)
this.Add(child)
}
return this 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.SetRole(tomo.R("objects", "Container"))
this.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.SetRole(tomo.R("objects", "Container"))
this.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.SetRole(tomo.R("objects", "Container"))
this.SetTag("inner", true)
return this
}

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 tomo.ContainerBox
} }
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
} }
@ -53,22 +52,23 @@ func NewDialog (kind DialogKind, parent tomo.Window, title, message string, opti
case DialogError: iconId = tomo.IconDialogError case DialogError: iconId = tomo.IconDialogError
} }
dialog.SetTitle(title) dialog.SetTitle(title)
dialog.SetIcon(iconId)
icon := NewIcon(iconId, tomo.IconSizeLarge) icon := NewIcon(iconId, tomo.IconSizeLarge)
messageText := NewLabel(message) messageText := NewLabel(message)
messageText.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle)) messageText.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
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.SetAttr(tomo.AAlign(tomo.AlignEnd, tomo.AlignEnd))
dialog.SetRoot(NewOuterContainer ( dialog.SetRoot(NewRoot (
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
} }

View File

@ -6,10 +6,12 @@ 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/layouts" import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(Dropdown)
// Dropdown is a non-editable text input that allows the user to pick a value // Dropdown is a non-editable text input that allows the user to pick a value
// from a list. // from a list.
type Dropdown struct { type Dropdown struct {
tomo.ContainerBox box tomo.ContainerBox
label *Label label *Label
value string value string
@ -24,28 +26,40 @@ type Dropdown struct {
// NewDropdown creates a new dropdown input with the specified items // NewDropdown creates a new dropdown input with the specified items
func NewDropdown (items ...string) *Dropdown { func NewDropdown (items ...string) *Dropdown {
dropdown := &Dropdown { dropdown := &Dropdown {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
label: NewLabel(""), label: NewLabel(""),
} }
dropdown.SetRole(tomo.R("objects", "Dropdown")) dropdown.box.SetRole(tomo.R("objects", "Dropdown"))
dropdown.SetAttr(tomo.ALayout(layouts.Row { true, false })) dropdown.box.SetAttr(tomo.ALayout(layouts.Row { true, false }))
dropdown.Add(dropdown.label) dropdown.box.Add(dropdown.label)
dropdown.Add(NewIcon(tomo.IconListChoose, tomo.IconSizeSmall)) dropdown.box.Add(NewIcon(tomo.IconListChoose, tomo.IconSizeSmall))
dropdown.SetItems(items...) dropdown.SetItems(items...)
if len(items) > 0 { if len(items) > 0 {
dropdown.SetValue(items[0]) dropdown.SetValue(items[0])
} }
dropdown.SetInputMask(true) dropdown.box.SetInputMask(true)
dropdown.OnButtonDown(dropdown.handleButtonDown) dropdown.box.OnButtonDown(dropdown.handleButtonDown)
dropdown.OnButtonUp(dropdown.handleButtonUp) dropdown.box.OnButtonUp(dropdown.handleButtonUp)
dropdown.OnKeyDown(dropdown.handleKeyDown) dropdown.box.OnKeyDown(dropdown.handleKeyDown)
dropdown.OnKeyUp(dropdown.handleKeyUp) dropdown.box.OnKeyUp(dropdown.handleKeyUp)
dropdown.SetFocusable(true) dropdown.box.SetFocusable(true)
return dropdown return dropdown
} }
// GetBox returns the underlying box.
func (this *Dropdown) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this dropdown has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *Dropdown) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// Value returns the value of the dropdown. This does not necissarily have to be // Value returns the value of the dropdown. This does not necissarily have to be
// in the list of items. // in the list of items.
func (this *Dropdown) Value () string { func (this *Dropdown) Value () string {
@ -107,7 +121,7 @@ func (this *Dropdown) handleButtonDown (button input.Button) bool {
func (this *Dropdown) handleButtonUp (button input.Button) bool { func (this *Dropdown) handleButtonUp (button input.Button) bool {
if !isClickingButton(button) { return false } if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) { if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.Choose() this.Choose()
} }
return true return true

129
file.go Normal file
View File

@ -0,0 +1,129 @@
package objects
import "time"
import "unicode"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/data"
import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(File)
// File is a representation of a file or directory.
type File struct {
box tomo.ContainerBox
label *Label
icon *MimeIcon
mime data.Mime
lastClick time.Time
on struct {
doubleClick event.FuncBroadcaster
}
}
// NewFile creates a new file icon with the given name and MIME type.
func NewFile (name string, mime data.Mime) *File {
file := &File {
box: tomo.NewContainerBox(),
label: NewLabel(""),
icon: NewMimeIcon(mime, tomo.IconSizeLarge),
}
file.box.SetRole(tomo.R("objects", "File"))
file.box.SetAttr(tomo.ALayout(layouts.ContractVertical))
file.box.Add(file.icon)
file.box.Add(file.label)
file.box.SetAttr(tomo.AAlign(tomo.AlignMiddle, tomo.AlignStart))
file.label.SetAlign(tomo.AlignMiddle, tomo.AlignStart)
// file.label.SetOverflow(false, true)
// file.label.SetWrap(true)
file.SetType(mime)
file.SetName(name)
file.box.SetInputMask(true)
file.box.SetFocusable(true)
file.box.OnButtonDown(file.handleButtonDown)
file.box.OnButtonUp(file.handleButtonUp)
file.box.OnKeyDown(file.handleKeyDown)
file.box.OnKeyUp(file.handleKeyUp)
return file
}
// GetBox returns the underlying box.
func (this *File) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this file has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *File) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// SetName sets the text of the file's label.
func (this *File) SetName (text string) {
this.label.SetText(truncateText(text, 16))
}
// SetType sets the MIME type of the file.
func (this *File) SetType (mime data.Mime) {
this.mime = mime
this.icon.SetIcon(mime, tomo.IconSizeLarge)
}
// OnDoubleClick specifies a function to be called when the file is
// double-clicked.
func (this *File) OnDoubleClick (callback func ()) event.Cookie {
return this.on.doubleClick.Connect(callback)
}
func (this *File) handleKeyDown (key input.Key, numberPad bool) bool {
if key != input.KeyEnter && key != input.Key(' ') { return false }
return true
}
func (this *File) handleKeyUp (key input.Key, numberPad bool) bool {
if key != input.KeyEnter && key != input.Key(' ') { return false }
this.on.doubleClick.Broadcast()
return true
}
func (this *File) handleButtonDown (button input.Button) bool {
if button != input.ButtonLeft { return false }
return true
}
func (this *File) handleButtonUp (button input.Button) bool {
if button != input.ButtonLeft { return false }
if this.box.Window().MousePosition().In(this.box.Bounds()) {
// TODO double click delay should be configurable
if time.Since(this.lastClick) < time.Second {
this.on.doubleClick.Broadcast()
}
this.lastClick = time.Now()
}
return true
}
func truncateText (text string, max int) string {
lastChar := -1
len := 0
for index, char := range text {
if !unicode.IsSpace(char) {
lastChar = index
}
len ++
if len >= max {
if lastChar != -1 {
return text[:lastChar] + "..."
}
break
}
}
return text
}

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

@ -2,30 +2,72 @@ package objects
import "fmt" import "fmt"
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/event"
var _ tomo.Object = new(Heading)
// Heading is a label that denotes the start of some section of content. It can // Heading is a label that denotes the start of some section of content. It can
// have a level from 0 to 2, with 0 being the most prominent and 2 being the // have a level from 0 to 2, with 0 being the most prominent and 2 being the
// most subtle. The level is described in the role variation. // most subtle.
//
// Tags:
// - [0] The heading has a level of 0 (most prominent).
// - [1] The heading has a level of 1.
// - [2] The heading has a level of 2 (least prominent).
type Heading struct { type Heading struct {
tomo.TextBox box tomo.TextBox
} }
// NewHeading creates a new section heading. The level can be from 0 to 2. // NewHeading creates a new section heading. The level can be from 0 to 2.
func NewHeading (level int, text string) *Heading { func NewHeading (level int, text string) *Heading {
if level < 0 { level = 0 } if level < 0 { level = 0 }
if level > 2 { level = 2 } if level > 2 { level = 2 }
this := &Heading { TextBox: tomo.NewTextBox() } this := &Heading { box: tomo.NewTextBox() }
this.SetRole(tomo.R("objects", "Heading")) this.box.SetRole(tomo.R("objects", "Heading"))
this.SetTag(fmt.Sprint(level), true) this.box.SetTag(fmt.Sprint(level), true)
this.SetText(text) this.SetText(text)
this.SetSelectable(true) this.box.SetSelectable(true)
this.SetFocusable(true) this.box.SetFocusable(true)
return this return this
} }
// GetBox returns the underlying box.
func (this *Heading) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this heading has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *Heading) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// SetText sets the text content of the heading.
func (this *Heading) SetText (text string) {
this.box.SetText(text)
}
// Select sets the text cursor or selection.
func (this *Heading) Select (dot text.Dot) {
this.box.Select(dot)
}
// Dot returns the text cursor or selection.
func (this *Heading) Dot () text.Dot {
return this.box.Dot()
}
// OnDotChange specifies a function to be called when the text cursor or
// selection changes.
func (this *Heading) OnDotChange (callback func ()) event.Cookie {
return this.box.OnDotChange(callback)
}
// NewMenuHeading creatss a new heading for use in menus. // NewMenuHeading creatss a new heading for use in menus.
func NewMenuHeading (text string) *Heading { func NewMenuHeading (text string) *Heading {
heading := NewHeading(0, text) heading := NewHeading(0, text)
heading.SetTag("menu", true) heading.box.SetTag("menu", true)
return heading return heading
} }

28
icon.go
View File

@ -3,9 +3,16 @@ package objects
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/canvas" import "git.tebibyte.media/tomo/tomo/canvas"
var _ tomo.Object = new(Icon)
// Icon displays a single icon. // Icon displays a single icon.
//
// Tags:
// - [large] The icon is large sized.
// - [medium] The icon is medium sized.
// - [small] The icon is small sized.
type Icon struct { type Icon struct {
tomo.Box box tomo.Box
icon tomo.Icon icon tomo.Icon
size tomo.IconSize size tomo.IconSize
} }
@ -21,14 +28,19 @@ func iconSizeString (size tomo.IconSize) string {
// NewIcon creates a new icon from an icon ID. // NewIcon creates a new icon from an icon ID.
func NewIcon (icon tomo.Icon, size tomo.IconSize) *Icon { func NewIcon (icon tomo.Icon, size tomo.IconSize) *Icon {
this := &Icon { this := &Icon {
Box: tomo.NewBox(), box: tomo.NewBox(),
} }
this.SetRole(tomo.R("objects", "Icon")) this.box.SetRole(tomo.R("objects", "Icon"))
this.SetIcon(icon, size) this.SetIcon(icon, size)
this.OnIconSetChange(this.handleIconSetChange) this.box.OnIconSetChange(this.handleIconSetChange)
return this return this
} }
// GetBox returns the underlying box.
func (this *Icon) GetBox () tomo.Box {
return this.box
}
// SetIcon sets the icon. // SetIcon sets the icon.
func (this *Icon) SetIcon (icon tomo.Icon, size tomo.IconSize) { func (this *Icon) SetIcon (icon tomo.Icon, size tomo.IconSize) {
if this.icon == icon { return } if this.icon == icon { return }
@ -42,12 +54,12 @@ func (this *Icon) handleIconSetChange () {
} }
func (this *Icon) setTexture (texture canvas.Texture) { func (this *Icon) setTexture (texture canvas.Texture) {
this.SetAttr(tomo.ATexture(texture)) this.box.SetAttr(tomo.ATexture(texture))
this.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter)) this.box.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter))
if texture == nil { if texture == nil {
this.SetAttr(tomo.AMinimumSize(0, 0)) this.box.SetAttr(tomo.AMinimumSize(0, 0))
} else { } else {
bounds := texture.Bounds() bounds := texture.Bounds()
this.SetAttr(tomo.AttrMinimumSize(bounds.Max.Sub(bounds.Min))) this.box.SetAttr(tomo.AttrMinimumSize(bounds.Max.Sub(bounds.Min)))
} }
} }

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

@ -1,19 +1,65 @@
package objects package objects
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/event"
var _ tomo.Object = new(Label)
// Label is a simple text label. // Label is a simple text label.
type Label struct { type Label struct {
tomo.TextBox box tomo.TextBox
} }
// NewLabel creates a new text label. // NewLabel creates a new text label.
func NewLabel (text string) *Label { func NewLabel (text string) *Label {
this := &Label { TextBox: tomo.NewTextBox() } this := &Label { box: tomo.NewTextBox() }
this.SetRole(tomo.R("objects", "Label")) this.box.SetRole(tomo.R("objects", "Label"))
this.SetText(text) this.SetText(text)
this.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle)) this.box.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
this.SetSelectable(true) this.box.SetSelectable(true)
this.SetFocusable(true) this.box.SetFocusable(true)
return this return this
} }
// GetBox returns the underlying box.
func (this *Label) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this label has keyboard focus. If set to true,
// this method will steal focus away from whichever object currently has focus.
func (this *Label) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// SetText sets the text content of the label.
func (this *Label) SetText (text string) {
this.box.SetText(text)
}
// Select sets the text cursor or selection.
func (this *Label) Select (dot text.Dot) {
this.box.Select(dot)
}
// Dot returns the text cursor or selection.
func (this *Label) Dot () text.Dot {
return this.box.Dot()
}
// OnDotChange specifies a function to be called when the text cursor or
// selection changes.
func (this *Label) OnDotChange (callback func ()) event.Cookie {
return this.box.OnDotChange(callback)
}
// SetAlign sets the X and Y alignment of the label.
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))
}

View File

@ -5,9 +5,11 @@ 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/layouts" import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(LabelCheckbox)
// LabelCheckbox is a checkbox with a label. // LabelCheckbox is a checkbox with a label.
type LabelCheckbox struct { type LabelCheckbox struct {
tomo.ContainerBox box tomo.ContainerBox
checkbox *Checkbox checkbox *Checkbox
label *Label label *Label
} }
@ -15,22 +17,39 @@ type LabelCheckbox struct {
// NewLabelCheckbox creates a new labeled checkbox with the specified value and // NewLabelCheckbox creates a new labeled checkbox with the specified value and
// label text. // label text.
func NewLabelCheckbox (value bool, text string) *LabelCheckbox { func NewLabelCheckbox (value bool, text string) *LabelCheckbox {
box := &LabelCheckbox { labelCheckbox := &LabelCheckbox {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
checkbox: NewCheckbox(value), checkbox: NewCheckbox(value),
label: NewLabel(text), label: NewLabel(text),
} }
box.SetRole(tomo.R("objects", "LabelCheckbox")) labelCheckbox.box.SetRole(tomo.R("objects", "LabelCheckbox"))
box.label.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle)) labelCheckbox.label.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
box.label.SetSelectable(false) labelCheckbox.label.GetBox().(tomo.TextBox).SetSelectable(false)
box.label.SetFocusable(false) labelCheckbox.label.GetBox().(tomo.TextBox).SetFocusable(false)
box.Add(box.checkbox) labelCheckbox.box.Add(labelCheckbox.checkbox)
box.Add(box.label) labelCheckbox.box.Add(labelCheckbox.label)
box.SetAttr(tomo.ALayout(layouts.Row { false, true })) labelCheckbox.box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
box.OnButtonDown(box.handleButtonDown) labelCheckbox.box.OnButtonDown(labelCheckbox.handleButtonDown)
box.OnButtonUp(box.handleButtonUp) labelCheckbox.box.OnButtonUp(labelCheckbox.handleButtonUp)
return box return labelCheckbox
}
// GetBox returns the underlying box.
func (this *LabelCheckbox) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this checkbox has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *LabelCheckbox) SetFocused (focused bool) {
this.checkbox.SetFocused(focused)
}
// SetText sets the text label of the checkbox.
func (this *LabelCheckbox) SetText (text string) {
this.label.SetText(text)
} }
// Value returns the value of the checkbox. // Value returns the value of the checkbox.
@ -61,7 +80,7 @@ func (this *LabelCheckbox) handleButtonDown (button input.Button) bool {
func (this *LabelCheckbox) handleButtonUp (button input.Button) bool { func (this *LabelCheckbox) handleButtonUp (button input.Button) bool {
if !isClickingButton(button) { return false } if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) { if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.checkbox.SetFocused(true) this.checkbox.SetFocused(true)
this.checkbox.Toggle() this.checkbox.Toggle()
} }

View File

@ -6,9 +6,11 @@ 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/layouts" import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(LabelSwatch)
// LabelSwatch is a swatch with a label. // LabelSwatch is a swatch with a label.
type LabelSwatch struct { type LabelSwatch struct {
tomo.ContainerBox box tomo.ContainerBox
swatch *Swatch swatch *Swatch
label *Label label *Label
} }
@ -16,21 +18,40 @@ type LabelSwatch struct {
// NewLabelSwatch creates a new labeled swatch with the specified color and // NewLabelSwatch creates a new labeled swatch with the specified color and
// label text. // label text.
func NewLabelSwatch (value color.Color, text string) *LabelSwatch { func NewLabelSwatch (value color.Color, text string) *LabelSwatch {
box := &LabelSwatch { labelSwatch := &LabelSwatch {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
swatch: NewSwatch(value), swatch: NewSwatch(value),
label: NewLabel(text), label: NewLabel(text),
} }
box.SetRole(tomo.R("objects", "LabelSwatch")) labelSwatch.box.SetRole(tomo.R("objects", "LabelSwatch"))
box.swatch.label = text labelSwatch.swatch.label = text
box.label.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle)) labelSwatch.label.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
box.Add(box.swatch) labelSwatch.label.GetBox().(tomo.TextBox).SetSelectable(false)
box.Add(box.label) labelSwatch.label.GetBox().(tomo.TextBox).SetFocusable(false)
box.SetAttr(tomo.ALayout(layouts.Row { false, true })) labelSwatch.box.Add(labelSwatch.swatch)
labelSwatch.box.Add(labelSwatch.label)
labelSwatch.box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
box.OnButtonDown(box.handleButtonDown) labelSwatch.box.OnButtonDown(labelSwatch.handleButtonDown)
box.OnButtonUp(box.handleButtonUp) labelSwatch.box.OnButtonUp(labelSwatch.handleButtonUp)
return box return labelSwatch
}
// GetBox returns the underlying box.
func (this *LabelSwatch) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this swatch has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *LabelSwatch) SetFocused (focused bool) {
this.swatch.SetFocused(focused)
}
// SetText sets the text label of the swatch.
func (this *LabelSwatch) SetText (text string) {
this.label.SetText(text)
} }
// Value returns the color of the swatch. // Value returns the color of the swatch.
@ -61,13 +82,13 @@ func (this *LabelSwatch) OnConfirm (callback func ()) event.Cookie {
} }
func (this *LabelSwatch) handleButtonDown (button input.Button) bool { func (this *LabelSwatch) handleButtonDown (button input.Button) bool {
if button != input.ButtonLeft { return true } if !isClickingButton(button) { return true }
return true return true
} }
func (this *LabelSwatch) handleButtonUp (button input.Button) bool { func (this *LabelSwatch) handleButtonUp (button input.Button) bool {
if button != input.ButtonLeft { return true } if !isClickingButton(button) { return true }
if this.Window().MousePosition().In(this.Bounds()) { if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.swatch.SetFocused(true) this.swatch.SetFocused(true)
this.swatch.Choose() this.swatch.Choose()
} }

28
menu.go
View File

@ -7,6 +7,13 @@ import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/objects/layouts" import "git.tebibyte.media/tomo/objects/layouts"
// Menu is a menu window. // 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 { type Menu struct {
tomo.Window tomo.Window
@ -36,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
@ -58,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)
@ -71,7 +78,8 @@ 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)
if err != nil { return } if err != nil { return }
visible := this.Window.Visible() visible := this.Window.Visible()
@ -90,21 +98,27 @@ func (this *Menu) newTearLine () tomo.Object {
tearLine := tomo.NewBox() tearLine := tomo.NewBox()
tearLine.SetRole(tomo.R("objects", "TearLine")) tearLine.SetRole(tomo.R("objects", "TearLine"))
tearLine.SetFocusable(true) tearLine.SetFocusable(true)
tearLine.OnMouseEnter(func () {
tearLine.SetFocused(true)
})
tearLine.OnMouseLeave(func () {
tearLine.SetFocused(false)
})
tearLine.OnKeyDown(func (key input.Key, numberPad bool) bool { tearLine.OnKeyDown(func (key input.Key, numberPad bool) bool {
if key != input.KeyEnter && key != input.Key(' ') { return false } if !isClickingKey(key) { return false }
return true return true
}) })
tearLine.OnKeyUp(func (key input.Key, numberPad bool) bool { tearLine.OnKeyUp(func (key input.Key, numberPad bool) bool {
if key != input.KeyEnter && key != input.Key(' ') { return false } if !isClickingKey(key) { return false }
this.TearOff() this.TearOff()
return true return true
}) })
tearLine.OnButtonDown(func (button input.Button) bool { tearLine.OnButtonDown(func (button input.Button) bool {
if button != input.ButtonLeft { return false } if !isClickingButton(button) { return false }
return true return true
}) })
tearLine.OnButtonUp(func (button input.Button) bool { tearLine.OnButtonUp(func (button input.Button) bool {
if button != input.ButtonLeft { return false } if !isClickingButton(button) { return false }
if tearLine.Window().MousePosition().In(tearLine.Bounds()) { if tearLine.Window().MousePosition().In(tearLine.Bounds()) {
this.TearOff() this.TearOff()
} }

View File

@ -5,9 +5,11 @@ 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/layouts" import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(MenuItem)
// MenuItem is a selectable memu item. // MenuItem is a selectable memu item.
type MenuItem struct { type MenuItem struct {
tomo.ContainerBox box tomo.ContainerBox
label *Label label *Label
icon *Icon icon *Icon
@ -25,25 +27,39 @@ func NewMenuItem (text string) *MenuItem {
// NewIconMenuItem creates a new menu item with the specified icon and text. // NewIconMenuItem creates a new menu item with the specified icon and text.
func NewIconMenuItem (icon tomo.Icon, text string) *MenuItem { func NewIconMenuItem (icon tomo.Icon, text string) *MenuItem {
box := &MenuItem { menuItem := &MenuItem {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
label: NewLabel(text), label: NewLabel(text),
icon: NewIcon(icon, tomo.IconSizeSmall), icon: NewIcon(icon, tomo.IconSizeSmall),
} }
box.SetRole(tomo.R("objects", "MenuItem")) menuItem.box.SetRole(tomo.R("objects", "MenuItem"))
box.label.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle)) menuItem.label.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
box.SetAttr(tomo.ALayout(layouts.Row { false, true })) menuItem.box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
box.Add(box.icon) menuItem.box.Add(menuItem.icon)
box.Add(box.label) menuItem.box.Add(menuItem.label)
box.SetInputMask(true) menuItem.box.SetInputMask(true)
box.OnButtonDown(box.handleButtonDown) menuItem.box.OnMouseEnter(menuItem.handleMouseEnter)
box.OnButtonUp(box.handleButtonUp) menuItem.box.OnMouseLeave(menuItem.handleMouseLeave)
box.OnKeyDown(box.handleKeyDown) menuItem.box.OnButtonDown(menuItem.handleButtonDown)
box.OnKeyUp(box.handleKeyUp) menuItem.box.OnButtonUp(menuItem.handleButtonUp)
box.SetFocusable(true) menuItem.box.OnKeyDown(menuItem.handleKeyDown)
return box menuItem.box.OnKeyUp(menuItem.handleKeyUp)
menuItem.box.SetFocusable(true)
return menuItem
}
// GetBox returns the underlying box.
func (this *MenuItem) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this menu item has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *MenuItem) SetFocused (focused bool) {
this.box.SetFocused(focused)
} }
// SetText sets the text of the items's label. // SetText sets the text of the items's label.
@ -62,6 +78,14 @@ func (this *MenuItem) OnClick (callback func ()) event.Cookie {
return this.on.click.Connect(callback) return this.on.click.Connect(callback)
} }
func (this *MenuItem) handleMouseEnter () {
this.SetFocused(true)
}
func (this *MenuItem) handleMouseLeave () {
this.SetFocused(false)
}
func (this *MenuItem) handleKeyDown (key input.Key, numberPad bool) bool { func (this *MenuItem) handleKeyDown (key input.Key, numberPad bool) bool {
if key != input.KeyEnter && key != input.Key(' ') { return false } if key != input.KeyEnter && key != input.Key(' ') { return false }
return true return true
@ -80,7 +104,7 @@ func (this *MenuItem) handleButtonDown (button input.Button) bool {
func (this *MenuItem) handleButtonUp (button input.Button) bool { func (this *MenuItem) handleButtonUp (button input.Button) bool {
if button != input.ButtonLeft { return false } if button != input.ButtonLeft { return false }
if this.Window().MousePosition().In(this.Bounds()) { if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.on.click.Broadcast() this.on.click.Broadcast()
} }
return true return true

View File

@ -4,22 +4,29 @@ import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/data" import "git.tebibyte.media/tomo/tomo/data"
import "git.tebibyte.media/tomo/tomo/canvas" import "git.tebibyte.media/tomo/tomo/canvas"
var _ tomo.Object = new(MimeIcon)
// MimeIcon displays an icon of a MIME type. // MimeIcon displays an icon of a MIME type.
type MimeIcon struct { type MimeIcon struct {
tomo.Box box tomo.Box
mime data.Mime mime data.Mime
size tomo.IconSize size tomo.IconSize
} }
// NewMimeIcon creates a new icon from a MIME type. // NewMimeIcon creates a new icon from a MIME type.
func NewMimeIcon (mime data.Mime, size tomo.IconSize) *MimeIcon { func NewMimeIcon (mime data.Mime, size tomo.IconSize) *MimeIcon {
this := &MimeIcon { mimeIcon := &MimeIcon {
Box: tomo.NewBox(), box: tomo.NewBox(),
} }
this.SetRole(tomo.R("objects", "MimeIcon")) mimeIcon.box.SetRole(tomo.R("objects", "MimeIcon"))
this.SetIcon(mime, size) mimeIcon.SetIcon(mime, size)
this.OnIconSetChange(this.handleIconSetChange) mimeIcon.box.OnIconSetChange(mimeIcon.handleIconSetChange)
return this return mimeIcon
}
// GetBox returns the underlying box.
func (this *MimeIcon) GetBox () tomo.Box {
return this.box
} }
// SetIcon sets the MIME type and size of the icon. // SetIcon sets the MIME type and size of the icon.
@ -35,12 +42,12 @@ func (this *MimeIcon) handleIconSetChange () {
} }
func (this *MimeIcon) setTexture (texture canvas.Texture) { func (this *MimeIcon) setTexture (texture canvas.Texture) {
this.SetAttr(tomo.ATexture(texture)) this.box.SetAttr(tomo.ATexture(texture))
this.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter)) this.box.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter))
if texture == nil { if texture == nil {
this.SetAttr(tomo.AMinimumSize(0, 0)) this.box.SetAttr(tomo.AMinimumSize(0, 0))
} else { } else {
bounds := texture.Bounds() bounds := texture.Bounds()
this.SetAttr(tomo.AttrMinimumSize(bounds.Max.Sub(bounds.Min))) this.box.SetAttr(tomo.AttrMinimumSize(bounds.Max.Sub(bounds.Min)))
} }
} }

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)
}
}

View File

@ -3,14 +3,17 @@ package objects
import "math" import "math"
import "strconv" import "strconv"
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/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/layouts" import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(NumberInput)
// NumberInput is an editable text box which accepts only numbers, and has // NumberInput is an editable text box which accepts only numbers, and has
// controls to increment and decrement its value. // controls to increment and decrement its value.
type NumberInput struct { type NumberInput struct {
tomo.ContainerBox box tomo.ContainerBox
input *TextInput input *TextInput
increment *Button increment *Button
decrement *Button decrement *Button
@ -22,41 +25,63 @@ type NumberInput struct {
// NewNumberInput creates a new number input with the specified value. // NewNumberInput creates a new number input with the specified value.
func NewNumberInput (value float64) *NumberInput { func NewNumberInput (value float64) *NumberInput {
box := &NumberInput { numberInput := &NumberInput {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
input: NewTextInput(""), input: NewTextInput(""),
increment: NewButton(""), increment: NewButton(""),
decrement: NewButton(""), decrement: NewButton(""),
} }
box.SetRole(tomo.R("objects", "NumberInput")) numberInput.box.SetRole(tomo.R("objects", "NumberInput"))
box.Add(box.input) numberInput.box.Add(numberInput.input)
box.Add(box.decrement) numberInput.box.Add(numberInput.decrement)
box.Add(box.increment) numberInput.box.Add(numberInput.increment)
box.SetAttr(tomo.ALayout(layouts.Row { true, false, false })) numberInput.box.SetAttr(tomo.ALayout(layouts.Row { true, false, false }))
box.increment.SetIcon(tomo.IconValueIncrement) numberInput.increment.SetIcon(tomo.IconValueIncrement)
box.decrement.SetIcon(tomo.IconValueDecrement) numberInput.decrement.SetIcon(tomo.IconValueDecrement)
box.SetValue(value) numberInput.SetValue(value)
box.OnScroll(box.handleScroll) numberInput.box.OnScroll(numberInput.handleScroll)
box.OnKeyDown(box.handleKeyDown) numberInput.box.OnKeyDown(numberInput.handleKeyDown)
box.OnKeyUp(box.handleKeyUp) numberInput.box.OnKeyUp(numberInput.handleKeyUp)
box.input.OnConfirm(box.handleConfirm) numberInput.input.OnConfirm(numberInput.handleConfirm)
box.input.OnValueChange(box.on.valueChange.Broadcast) numberInput.input.OnValueChange(numberInput.on.valueChange.Broadcast)
box.increment.OnClick(func () { box.shift(1) }) numberInput.increment.OnClick(func () { numberInput.shift( 1) })
box.decrement.OnClick(func () { box.shift(-1) }) numberInput.decrement.OnClick(func () { numberInput.shift(-1) })
return box return numberInput
}
// GetBox returns the underlying box.
func (this *NumberInput) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this number input has keyboard focus. If set
// to true, this method will steal focus away from whichever object currently
// has focus.
func (this *NumberInput) SetFocused (focused bool) {
this.input.SetFocused(focused)
}
// Select sets the text cursor or selection.
func (this *NumberInput) Select (dot text.Dot) {
this.input.Select(dot)
}
// Dot returns the text cursor or selection.
func (this *NumberInput) Dot () text.Dot {
return this.input.Dot()
} }
// Value returns the value of the input. // Value returns the value of the input.
func (this *NumberInput) Value () float64 { func (this *NumberInput) Value () float64 {
value, _ := strconv.ParseFloat(this.input.Text(), 64) value, _ := strconv.ParseFloat(this.input.Value(), 64)
return value return value
} }
// SetValue sets the value of the input. // SetValue sets the value of the input.
func (this *NumberInput) SetValue (value float64) { func (this *NumberInput) SetValue (value float64) {
this.input.SetText(strconv.FormatFloat(value, 'g', -1, 64)) this.input.SetValue(strconv.FormatFloat(value, 'g', -1, 64))
} }
// OnValueChange specifies a function to be called when the user edits the input // OnValueChange specifies a function to be called when the user edits the input

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

@ -5,11 +5,24 @@ import "git.tebibyte.media/tomo/tomo"
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"
var _ tomo.Object = new(Scrollbar)
// Scrollbar is a special type of slider that can control the scroll of any // Scrollbar is a special type of slider that can control the scroll of any
// overflowing ContainerBox. // overflowing ContainerObject.
//
// Sub-components:
// - ScrollbarHandle is the grabbable handle of the scrollbar.
//
// Tags:
// - [vertical] The scrollbar is oriented vertically.
// - [horizontall] The scrollbar is oriented horizontally.
//
// ScrollbarHandle tags:
// - [vertical] The handle is oriented vertically.
// - [horizontall] The handle is oriented horizontally.
type Scrollbar struct { type Scrollbar struct {
tomo.ContainerBox box tomo.ContainerBox
handle *SliderHandle handle *sliderHandle
layout scrollbarLayout layout scrollbarLayout
dragging bool dragging bool
dragOffset image.Point dragOffset image.Point
@ -23,8 +36,8 @@ type Scrollbar struct {
func newScrollbar (orient string) *Scrollbar { func newScrollbar (orient string) *Scrollbar {
this := &Scrollbar { this := &Scrollbar {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
handle: &SliderHandle { handle: &sliderHandle {
Box: tomo.NewBox(), Box: tomo.NewBox(),
}, },
layout: scrollbarLayout { layout: scrollbarLayout {
@ -32,21 +45,21 @@ func newScrollbar (orient string) *Scrollbar {
}, },
} }
this.Add(this.handle) this.box.Add(this.handle)
this.SetFocusable(true) this.box.SetFocusable(true)
this.SetInputMask(true) this.box.SetInputMask(true)
this.OnKeyUp(this.handleKeyUp) this.box.OnKeyUp(this.handleKeyUp)
this.OnKeyDown(this.handleKeyDown) this.box.OnKeyDown(this.handleKeyDown)
this.OnButtonDown(this.handleButtonDown) this.box.OnButtonDown(this.handleButtonDown)
this.OnButtonUp(this.handleButtonUp) this.box.OnButtonUp(this.handleButtonUp)
this.OnMouseMove(this.handleMouseMove) this.box.OnMouseMove(this.handleMouseMove)
this.OnScroll(this.handleScroll) this.box.OnScroll(this.handleScroll)
this.handle.SetRole(tomo.R("objects", "SliderHandle")) this.handle.SetRole(tomo.R("objects", "ScrollbarHandle"))
this.handle.SetTag(orient, true) this.handle.SetTag(orient, true)
this.SetRole(tomo.R("objects", "Slider")) this.box.SetRole(tomo.R("objects", "Scrollbar"))
this.SetTag(orient, true) this.box.SetTag(orient, true)
return this return this
} }
@ -60,13 +73,18 @@ func NewHorizontalScrollbar () *Scrollbar {
return newScrollbar("horizontal") return newScrollbar("horizontal")
} }
// GetBox returns the underlying box.
func (this *Scrollbar) GetBox () tomo.Box {
return this.box
}
// Link assigns this scrollbar to a ContentObject. Closing the returned cookie // Link assigns this scrollbar to a ContentObject. Closing the returned cookie
// will unlink it. // will unlink it.
func (this *Scrollbar) Link (box tomo.ContentObject) event.Cookie { func (this *Scrollbar) Link (box tomo.ContentObject) event.Cookie {
this.layout.linked = box this.layout.linked = box
this.linkCookie = this.newLinkCookie ( this.linkCookie = this.newLinkCookie (
box.OnContentBoundsChange(this.handleLinkedContentBoundsChange)) box.OnContentBoundsChange(this.handleLinkedContentBoundsChange))
this.SetAttr(tomo.ALayout(this.layout)) this.box.SetAttr(tomo.ALayout(this.layout))
return this.linkCookie return this.linkCookie
} }
@ -79,7 +97,7 @@ func (this *Scrollbar) handleLinkedContentBoundsChange () {
} else { } else {
this.layout.value = this.layout.contentPos() / trackLength this.layout.value = this.layout.contentPos() / trackLength
} }
this.SetAttr(tomo.ALayout(this.layout)) this.box.SetAttr(tomo.ALayout(this.layout))
if this.layout.value != previousValue { if this.layout.value != previousValue {
this.on.valueChange.Broadcast() this.on.valueChange.Broadcast()
} }
@ -150,18 +168,18 @@ func (this *Scrollbar) handleKeyUp (key input.Key, numpad bool) bool {
} }
func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool { func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool {
modifiers := this.Window().Modifiers() modifiers := this.box.Window().Modifiers()
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())
@ -183,7 +201,7 @@ func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool {
} }
func (this *Scrollbar) handleButtonDown (button input.Button) bool { func (this *Scrollbar) handleButtonDown (button input.Button) bool {
pointer := this.Window().MousePosition() pointer := this.box.Window().MousePosition()
handle := this.handle.Bounds() handle := this.handle.Bounds()
within := pointer.In(handle) within := pointer.In(handle)
@ -199,7 +217,7 @@ func (this *Scrollbar) handleButtonDown (button input.Button) bool {
this.dragging = true this.dragging = true
this.dragOffset = this.dragOffset =
pointer.Sub(this.handle.Bounds().Min). pointer.Sub(this.handle.Bounds().Min).
Add(this.InnerBounds().Min) Add(this.box.InnerBounds().Min)
this.drag() this.drag()
} else { } else {
this.dragOffset = this.fallbackDragOffset() this.dragOffset = this.fallbackDragOffset()
@ -253,8 +271,8 @@ func (this *Scrollbar) handleScroll (x, y float64) bool {
} }
func (this *Scrollbar) drag () { func (this *Scrollbar) drag () {
pointer := this.Window().MousePosition().Sub(this.dragOffset) pointer := this.box.Window().MousePosition().Sub(this.dragOffset)
gutter := this.InnerBounds() gutter := this.box.InnerBounds()
handle := this.handle.Bounds() handle := this.handle.Bounds()
if this.layout.vertical { if this.layout.vertical {
@ -270,10 +288,10 @@ func (this *Scrollbar) drag () {
func (this *Scrollbar) fallbackDragOffset () image.Point { func (this *Scrollbar) fallbackDragOffset () image.Point {
if this.layout.vertical { if this.layout.vertical {
return this.InnerBounds().Min. return this.box.InnerBounds().Min.
Add(image.Pt(0, this.handle.Bounds().Dy() / 2)) Add(image.Pt(0, this.handle.Bounds().Dy() / 2))
} else { } else {
return this.InnerBounds().Min. return this.box.InnerBounds().Min.
Add(image.Pt(this.handle.Bounds().Dx() / 2, 0)) Add(image.Pt(this.handle.Bounds().Dx() / 2, 0))
} }
} }
@ -302,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.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

@ -37,9 +37,11 @@ func (sides ScrollSide) String () string {
} }
} }
var _ tomo.Object = new(ScrollContainer)
// ScrollContainer couples a ContentBox with one or two Scrollbars. // ScrollContainer couples a ContentBox with one or two Scrollbars.
type ScrollContainer struct { type ScrollContainer struct {
tomo.ContainerBox box tomo.ContainerBox
root tomo.ContentObject root tomo.ContentObject
horizontal *Scrollbar horizontal *Scrollbar
@ -55,30 +57,35 @@ type ScrollContainer struct {
// NewScrollContainer creates a new scroll container. // NewScrollContainer creates a new scroll container.
func NewScrollContainer (sides ScrollSide) *ScrollContainer { func NewScrollContainer (sides ScrollSide) *ScrollContainer {
this := &ScrollContainer { scrollContainer := &ScrollContainer {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
} }
if sides.Vertical() { if sides.Vertical() {
this.vertical = NewVerticalScrollbar() scrollContainer.vertical = NewVerticalScrollbar()
this.vertical.OnValueChange(this.handleValueChange) scrollContainer.vertical.OnValueChange(scrollContainer.handleValueChange)
this.Add(this.vertical) scrollContainer.box.Add(scrollContainer.vertical)
} }
if sides.Horizontal() { if sides.Horizontal() {
this.horizontal = NewHorizontalScrollbar() scrollContainer.horizontal = NewHorizontalScrollbar()
this.horizontal.OnValueChange(this.handleValueChange) scrollContainer.horizontal.OnValueChange(scrollContainer.handleValueChange)
this.Add(this.horizontal) scrollContainer.box.Add(scrollContainer.horizontal)
} }
this.OnScroll(this.handleScroll) scrollContainer.box.OnScroll(scrollContainer.handleScroll)
this.OnKeyDown(this.handleKeyDown) scrollContainer.box.OnKeyDown(scrollContainer.handleKeyDown)
this.OnKeyUp(this.handleKeyUp) scrollContainer.box.OnKeyUp(scrollContainer.handleKeyUp)
this.SetRole(tomo.R("objects", "ScrollContainer")) scrollContainer.box.SetRole(tomo.R("objects", "ScrollContainer"))
this.SetTag(sides.String(), true) scrollContainer.box.SetTag(sides.String(), true)
if sides == ScrollHorizontal { if sides == ScrollHorizontal {
this.SetAttr(tomo.ALayout(layouts.NewGrid(true)(true, false))) scrollContainer.box.SetAttr(tomo.ALayout(layouts.NewGrid(true)(true, false)))
} else { } else {
this.SetAttr(tomo.ALayout(layouts.NewGrid(true, false)(true, false))) scrollContainer.box.SetAttr(tomo.ALayout(layouts.NewGrid(true, false)(true, false)))
} }
return this return scrollContainer
}
// GetBox returns the underlying box.
func (this *ScrollContainer) GetBox () tomo.Box {
return this.box
} }
// SetRoot sets the root child of the ScrollContainer. There can only be one at // SetRoot sets the root child of the ScrollContainer. There can only be one at
@ -87,7 +94,7 @@ func NewScrollContainer (sides ScrollSide) *ScrollContainer {
func (this *ScrollContainer) SetRoot (root tomo.ContentObject) { func (this *ScrollContainer) SetRoot (root tomo.ContentObject) {
if this.root != nil { if this.root != nil {
// remove root and close cookies // remove root and close cookies
this.Remove(this.root) this.box.Remove(this.root)
if this.horizontalCookie != nil { if this.horizontalCookie != nil {
this.horizontalCookie.Close() this.horizontalCookie.Close()
this.horizontalCookie = nil this.horizontalCookie = nil
@ -102,11 +109,11 @@ func (this *ScrollContainer) SetRoot (root tomo.ContentObject) {
// insert root at the beginning (for keynav) // insert root at the beginning (for keynav)
switch { switch {
case this.vertical != nil: case this.vertical != nil:
this.Insert(root, this.vertical) this.box.Insert(root, this.vertical)
case this.horizontal != nil: case this.horizontal != nil:
this.Insert(root, this.horizontal) this.box.Insert(root, this.horizontal)
default: default:
this.Add(root) this.box.Add(root)
} }
// link root and remember cookies // link root and remember cookies
@ -191,11 +198,11 @@ func (this *ScrollContainer) handleScroll (x, y float64) bool {
} }
func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool { func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
modifiers := this.Window().Modifiers() modifiers := this.box.Window().Modifiers()
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
@ -203,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
@ -211,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
@ -219,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
@ -234,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

@ -2,16 +2,23 @@ package objects
import "git.tebibyte.media/tomo/tomo" import "git.tebibyte.media/tomo/tomo"
var _ tomo.Object = new(Separator)
// Separator is a line for visually separating elements. // Separator is a line for visually separating elements.
type Separator struct { type Separator struct {
tomo.Box box tomo.Box
} }
// NewSeparator creates a new separator line. // NewSeparator creates a new separator line.
func NewSeparator () *Separator { func NewSeparator () *Separator {
this := &Separator { this := &Separator {
Box: tomo.NewBox(), box: tomo.NewBox(),
} }
this.SetRole(tomo.R("objects", "Separator")) this.box.SetRole(tomo.R("objects", "Separator"))
return this return this
} }
// GetBox returns the underlying box.
func (this *Separator) GetBox () tomo.Box {
return this.box
}

View File

@ -6,10 +6,23 @@ import "git.tebibyte.media/tomo/tomo"
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"
var _ tomo.Object = new(Slider)
// Slider is a control that selects a numeric value between 0 and 1. // Slider is a control that selects a numeric value between 0 and 1.
//
// Sub-components:
// - SliderHandle is the grabbable handle of the slider.
//
// Tags:
// - [vertical] The slider is oriented vertically.
// - [horizontall] The slider is oriented horizontally.
//
// SliderHandle tags:
// - [vertical] The handle is oriented vertically.
// - [horizontall] The handle is oriented horizontally.
type Slider struct { type Slider struct {
tomo.ContainerBox box tomo.ContainerBox
handle *SliderHandle handle *sliderHandle
layout sliderLayout layout sliderLayout
dragging bool dragging bool
dragOffset image.Point dragOffset image.Point
@ -21,16 +34,14 @@ type Slider struct {
} }
} }
// SliderHandle is a simple object that serves as a handle for sliders and type sliderHandle struct {
// scrollbars. It is completely inert.
type SliderHandle struct {
tomo.Box tomo.Box
} }
func newSlider (orient string, value float64) *Slider { func newSlider (orient string, value float64) *Slider {
this := &Slider { slider := &Slider {
ContainerBox: tomo.NewContainerBox(), box: tomo.NewContainerBox(),
handle: &SliderHandle { handle: &sliderHandle {
Box: tomo.NewBox(), Box: tomo.NewBox(),
}, },
layout: sliderLayout { layout: sliderLayout {
@ -40,23 +51,23 @@ func newSlider (orient string, value float64) *Slider {
step: 0.05, step: 0.05,
} }
this.Add(this.handle) slider.handle.SetRole(tomo.R("objects", "SliderHandle"))
this.SetFocusable(true) slider.handle.SetTag(orient, true)
this.SetValue(value) slider.box.SetRole(tomo.R("objects", "Slider"))
slider.box.SetTag(orient, true)
this.SetInputMask(true) slider.box.Add(slider.handle)
this.OnKeyUp(this.handleKeyUp) slider.box.SetFocusable(true)
this.OnKeyDown(this.handleKeyDown) slider.SetValue(value)
this.OnButtonDown(this.handleButtonDown)
this.OnButtonUp(this.handleButtonUp)
this.OnMouseMove(this.handleMouseMove)
this.OnScroll(this.handleScroll)
this.handle.SetRole(tomo.R("objects", "SliderHandle")) slider.box.SetInputMask(true)
this.handle.SetTag(orient, true) slider.box.OnKeyUp(slider.handleKeyUp)
this.SetRole(tomo.R("objects", "Slider")) slider.box.OnKeyDown(slider.handleKeyDown)
this.SetTag(orient, true) slider.box.OnButtonDown(slider.handleButtonDown)
return this slider.box.OnButtonUp(slider.handleButtonUp)
slider.box.OnMouseMove(slider.handleMouseMove)
slider.box.OnScroll(slider.handleScroll)
return slider
} }
// NewVerticalSlider creates a new vertical slider with the specified value. // NewVerticalSlider creates a new vertical slider with the specified value.
@ -69,13 +80,25 @@ func NewHorizontalSlider (value float64) *Slider {
return newSlider("horizontal", value) return newSlider("horizontal", value)
} }
// GetBox returns the underlying box.
func (this *Slider) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this slider has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *Slider) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// SetValue sets the value of the slider between 0 and 1. // SetValue sets the value of the slider between 0 and 1.
func (this *Slider) SetValue (value float64) { func (this *Slider) SetValue (value float64) {
if value < 0 { value = 0 } if value < 0 { value = 0 }
if value > 1 { value = 1 } if value > 1 { value = 1 }
if value == this.layout.value { return } if value == this.layout.value { return }
this.layout.value = value this.layout.value = value
this.SetAttr(tomo.ALayout(this.layout)) this.box.SetAttr(tomo.ALayout(this.layout))
} }
// Value returns the value of the slider between 0 and 1. // Value returns the value of the slider between 0 and 1.
@ -104,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.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)
@ -112,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.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)
@ -142,7 +165,7 @@ func (this *Slider) handleKeyUp (key input.Key, numpad bool) bool {
} }
func (this *Slider) handleButtonDown (button input.Button) bool { func (this *Slider) handleButtonDown (button input.Button) bool {
pointer := this.Window().MousePosition() pointer := this.box.Window().MousePosition()
handle := this.handle.Bounds() handle := this.handle.Bounds()
within := pointer.In(handle) within := pointer.In(handle)
@ -158,7 +181,7 @@ func (this *Slider) handleButtonDown (button input.Button) bool {
this.dragging = true this.dragging = true
this.dragOffset = this.dragOffset =
pointer.Sub(this.handle.Bounds().Min). pointer.Sub(this.handle.Bounds().Min).
Add(this.InnerBounds().Min) Add(this.box.InnerBounds().Min)
this.drag() this.drag()
} else { } else {
this.dragOffset = this.fallbackDragOffset() this.dragOffset = this.fallbackDragOffset()
@ -211,8 +234,8 @@ func (this *Slider) handleScroll (x, y float64) bool {
} }
func (this *Slider) drag () { func (this *Slider) drag () {
pointer := this.Window().MousePosition().Sub(this.dragOffset) pointer := this.box.Window().MousePosition().Sub(this.dragOffset)
gutter := this.InnerBounds() gutter := this.box.InnerBounds()
handle := this.handle.Bounds() handle := this.handle.Bounds()
if this.layout.vertical { if this.layout.vertical {
@ -230,10 +253,10 @@ func (this *Slider) drag () {
func (this *Slider) fallbackDragOffset () image.Point { func (this *Slider) fallbackDragOffset () image.Point {
if this.layout.vertical { if this.layout.vertical {
return this.InnerBounds().Min. return this.box.InnerBounds().Min.
Add(image.Pt(0, this.handle.Bounds().Dy() / 2)) Add(image.Pt(0, this.handle.Bounds().Dy() / 2))
} else { } else {
return this.InnerBounds().Min. return this.box.InnerBounds().Min.
Add(image.Pt(this.handle.Bounds().Dx() / 2, 0)) Add(image.Pt(this.handle.Bounds().Dx() / 2, 0))
} }
} }

View File

@ -10,9 +10,11 @@ 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/tomo/objects/internal"
var _ tomo.Object = new(Swatch)
// Swatch displays a color, allowing the user to edit it by clicking on it. // Swatch displays a color, allowing the user to edit it by clicking on it.
type Swatch struct { type Swatch struct {
tomo.CanvasBox box tomo.CanvasBox
value color.Color value color.Color
editing bool editing bool
label string label string
@ -25,20 +27,32 @@ type Swatch struct {
// NewSwatch creates a new swatch with the given color. // NewSwatch creates a new swatch with the given color.
func NewSwatch (value color.Color) *Swatch { func NewSwatch (value color.Color) *Swatch {
swatch := &Swatch { swatch := &Swatch {
CanvasBox: tomo.NewCanvasBox(), box: tomo.NewCanvasBox(),
} }
swatch.SetRole(tomo.R("objects", "Swatch")) swatch.box.SetRole(tomo.R("objects", "Swatch"))
swatch.SetDrawer(swatch) swatch.box.SetDrawer(swatch)
swatch.SetValue(value) swatch.SetValue(value)
swatch.OnButtonDown(swatch.handleButtonDown) swatch.box.OnButtonDown(swatch.handleButtonDown)
swatch.OnButtonUp(swatch.handleButtonUp) swatch.box.OnButtonUp(swatch.handleButtonUp)
swatch.OnKeyDown(swatch.handleKeyDown) swatch.box.OnKeyDown(swatch.handleKeyDown)
swatch.OnKeyUp(swatch.handleKeyUp) swatch.box.OnKeyUp(swatch.handleKeyUp)
swatch.SetFocusable(true) swatch.box.SetFocusable(true)
return swatch return swatch
} }
// GetBox returns the underlying box.
func (this *Swatch) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this swatch has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *Swatch) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// Value returns the color of the swatch. // Value returns the color of the swatch.
func (this *Swatch) Value () color.Color { func (this *Swatch) Value () color.Color {
return this.value return this.value
@ -48,7 +62,7 @@ func (this *Swatch) Value () color.Color {
func (this *Swatch) SetValue (value color.Color) { func (this *Swatch) SetValue (value color.Color) {
this.value = value this.value = value
if value == nil { value = color.Transparent } if value == nil { value = color.Transparent }
this.Invalidate() this.box.Invalidate()
} }
// OnValueChange specifies a function to be called when the swatch's color // OnValueChange specifies a function to be called when the swatch's color
@ -75,10 +89,10 @@ func (this *Swatch) Choose () {
var err error var err error
var window tomo.Window var window tomo.Window
if parent := this.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)
@ -125,18 +139,16 @@ func (this *Swatch) Choose () {
}) })
okButton.OnClick(commit) okButton.OnClick(commit)
controlRow := NewInnerContainer ( window.SetRoot(NewRoot (
layouts.ContractHorizontal, layouts.Column { true, false },
cancelButton, NewContentSegment (
okButton)
controlRow.SetAttr(tomo.AAlign(tomo.AlignEnd, tomo.AlignMiddle))
window.SetRoot(NewOuterContainer (
layouts.Column { true, false }, layouts.Column { true, false },
colorPicker, colorPicker,
NewInnerContainer(layouts.Row { false, true }, NewContainer (
layouts.Row { false, true },
NewLabel("Hex"), NewLabel("Hex"),
hexInput), hexInput)),
controlRow)) NewOptionSegment(nil, cancelButton, okButton)))
window.OnClose(func () { window.OnClose(func () {
if committed { if committed {
this.on.confirm.Broadcast() this.on.confirm.Broadcast()
@ -155,13 +167,13 @@ func (this *Swatch) Draw (can canvas.Canvas) {
// transparency slash // transparency slash
pen.Stroke(color.RGBA { R: 255, A: 255 }) pen.Stroke(color.RGBA { R: 255, A: 255 })
pen.StrokeWeight(1) pen.StrokeWeight(1)
pen.Path(this.Bounds().Min, this.Bounds().Max) pen.Path(this.box.Bounds().Min, this.box.Bounds().Max)
// color // color
if this.value != nil { if this.value != nil {
pen.StrokeWeight(0) pen.StrokeWeight(0)
pen.Fill(this.value) pen.Fill(this.value)
pen.Rectangle(this.Bounds()) pen.Rectangle(this.box.Bounds())
} }
} }
@ -188,7 +200,7 @@ func (this *Swatch) handleButtonDown (button input.Button) bool {
func (this *Swatch) handleButtonUp (button input.Button) bool { func (this *Swatch) handleButtonUp (button input.Button) bool {
if !isClickingButton(button) { return false } if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) { if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.Choose() this.Choose()
} }
return true return true

View File

@ -1,135 +0,0 @@
package objects
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/objects/layouts"
type TabbedContainer struct {
tomo.ContainerBox
leftSpacer tomo.Box
rightSpacer tomo.Box
tabsRow tomo.ContainerBox
active string
tabs []*tab
}
func NewTabbedContainer () *TabbedContainer {
container := &TabbedContainer {
ContainerBox: tomo.NewContainerBox(),
}
container.SetRole(tomo.R("objects", "TabbedContainer"))
container.SetAttr(tomo.ALayout(layouts.Column { false, true }))
container.tabsRow = tomo.NewContainerBox()
container.tabsRow.SetRole(tomo.R("objects", "TabRow"))
container.Add(container.tabsRow)
container.leftSpacer = tomo.NewBox()
container.leftSpacer.SetRole(tomo.R("objects", "TabSpacer"))
container.leftSpacer.SetTag("left", true)
container.rightSpacer = tomo.NewBox()
container.rightSpacer.SetRole(tomo.R("objects", "TabSpacer"))
container.rightSpacer.SetTag("left", true)
container.ClearTabs()
container.setTabRowLayout()
return container
}
func (this *TabbedContainer) Activate (name string) {
if _, tab := this.findTab(this.active); tab != nil {
tab.setActive(false)
this.Remove(tab.root)
}
if _, tab := this.findTab(name); tab != nil {
tab.setActive(true)
this.Add(tab.root)
} else {
name = ""
}
this.active = 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)
}
}
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)
}
}
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,40 +1,132 @@
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)
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 {
tomo.TextBox box tomo.TextBox
text []rune text []rune
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
} }
} }
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)
if multiline {
textInput.box.SetAttr(tomo.AOverflow(false, true))
textInput.box.SetAttr(tomo.AWrap(true))
textInput.box.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignStart))
} else {
textInput.box.SetAttr(tomo.AOverflow(true, false))
textInput.box.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
}
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
}
// NewTextInput creates a new text input containing the specified text. // NewTextInput creates a new text input containing the specified text.
func NewTextInput (text string) *TextInput { func NewTextInput (text string) *TextInput {
this := &TextInput { TextBox: tomo.NewTextBox() } return newTextInput(text, false)
this.SetRole(tomo.R("objects", "TextInput")) }
this.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
this.SetAttr(tomo.AOverflow(true, false)) // NewMultilineTextInput creates a new multiline text input containing the
this.SetText(text) // specified text.
this.SetFocusable(true) func NewMultilineTextInput (text string) *TextInput {
this.SetSelectable(true) return newTextInput(text, true)
this.OnKeyDown(this.handleKeyDown) }
this.OnKeyUp(this.handleKeyUp)
this.OnScroll(this.handleScroll) // GetBox returns the underlying box.
return this func (this *TextInput) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this text input has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *TextInput) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// 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.
func (this *TextInput) Dot () text.Dot {
return this.box.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.on.dotChange.Connect(callback)
}
// SetAlign sets the X and Y alignment of the text input.
func (this *TextInput) SetAlign (x, y tomo.Align) {
this.box.SetAttr(tomo.AAlign(x, y))
}
// ContentBounds returns the bounds of the inner content of the text input
// relative to the input's InnerBounds.
func (this *TextInput) ContentBounds () image.Rectangle {
return this.box.ContentBounds()
}
// ScrollTo shifts the origin of the text input's content to the origin of the
// inputs's InnerBounds, offset by the given point.
func (this *TextInput) ScrollTo (position image.Point) {
this.box.ScrollTo(position)
}
// OnContentBoundsChange specifies a function to be called when the text input's
// ContentBounds or InnerBounds changes.
func (this *TextInput) OnContentBoundsChange (callback func ()) event.Cookie {
return this.box.OnContentBoundsChange(callback)
} }
// SetValue sets the text content of the input. // SetValue sets the text content of the input.
func (this *TextInput) SetValue (text string) { func (this *TextInput) SetValue (text string) {
this.text = []rune(text) this.text = []rune(text)
this.TextBox.SetText(text) this.box.SetText(text)
this.logLargeAction()
} }
// Value returns the text content of the input. // Value returns the text content of the input.
@ -42,16 +134,6 @@ func (this *TextInput) Value () string {
return string(this.text) return string(this.text)
} }
// SetText sets the text content of the input.
func (this *TextInput) SetText (text string) {
this.SetValue(text)
}
// Text returns the text content of the input.
func (this *TextInput) Text () string {
return this.Value()
}
// OnConfirm specifies a function to be called when the user presses enter // OnConfirm specifies a function to be called when the user presses enter
// within the text input. // within the text input.
func (this *TextInput) OnConfirm (callback func ()) event.Cookie { func (this *TextInput) OnConfirm (callback func ()) event.Cookie {
@ -64,43 +146,117 @@ 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.SetText(string(this.text)) this.box.SetText(string(this.text))
this.logKeystroke()
} }
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.Window().Modifiers() txt := this.text
word := modifiers.Control modifiers := this.box.Window().Modifiers()
word := modifiers.Control()
changed := false changed := false
defer func () { defer func () {
this.Select(dot)
if changed { if changed {
this.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()
} }
} () } ()
typeRune := func () {
txt, dot = text.Type(txt, dot, rune(key))
changed = true
}
if this.multiline && !modifiers.Control() {
switch {
case key == '\n', key == '\t':
typeRune()
return true
}
}
switch { switch {
case isConfirmationKey(key): case isConfirmationKey(key):
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():
this.text, dot = text.Type(this.text, dot, rune(key)) typeRune()
changed = true 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
@ -108,7 +264,15 @@ 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.Window().Modifiers() modifiers := this.box.Window().Modifiers()
if this.multiline && !modifiers.Control() {
switch {
case key == '\n', key == '\t':
return true
}
}
switch { switch {
case isConfirmationKey(key): case isConfirmationKey(key):
return true return true
@ -116,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
@ -128,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

@ -2,23 +2,83 @@ package objects
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/event"
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 {
tomo.TextBox box tomo.TextBox
} }
// NewTextView creates a new text view. // NewTextView creates a new text view.
func NewTextView (text string) *TextView { func NewTextView (text string) *TextView {
this := &TextView { TextBox: tomo.NewTextBox() } textView := &TextView { box: tomo.NewTextBox() }
this.SetRole(tomo.R("objects", "TextView")) textView.box.SetRole(tomo.R("objects", "TextView"))
this.SetFocusable(true) textView.box.SetFocusable(true)
this.SetSelectable(true) textView.box.SetSelectable(true)
this.SetText(text) textView.SetText(text)
this.SetAttr(tomo.AOverflow(false, true)) textView.box.SetAttr(tomo.AOverflow(false, true))
this.SetAttr(tomo.AWrap(true)) textView.box.SetAttr(tomo.AWrap(true))
this.OnScroll(this.handleScroll) textView.box.OnScroll(textView.handleScroll)
return this return textView
}
// GetBox returns the underlying box.
func (this *TextView) GetBox () tomo.Box {
return this.box
}
// SetFocused sets whether or not this text view has keyboard focus. If set to
// true, this method will steal focus away from whichever object currently has
// focus.
func (this *TextView) SetFocused (focused bool) {
this.box.SetFocused(focused)
}
// Select sets the text cursor or selection.
func (this *TextView) Select (dot text.Dot) {
this.box.Select(dot)
}
// Dot returns the text cursor or selection.
func (this *TextView) Dot () text.Dot {
return this.box.Dot()
}
// OnDotChange specifies a function to be called when the text cursor or
// selection changes.
func (this *TextView) OnDotChange (callback func ()) event.Cookie {
return this.box.OnDotChange(callback)
}
// SetAlign sets the X and Y alignment of the text view.
func (this *TextView) SetAlign (x, y tomo.Align) {
this.box.SetAttr(tomo.AAlign(x, y))
}
// ContentBounds returns the bounds of the inner content of the text view
// relative to the text view's InnerBounds.
func (this *TextView) ContentBounds () image.Rectangle {
return this.box.ContentBounds()
}
// ScrollTo shifts the origin of the text view's content to the origin of the
// text view's InnerBounds, offset by the given point.
func (this *TextView) ScrollTo (position image.Point) {
this.box.ScrollTo(position)
}
// OnContentBoundsChange specifies a function to be called when the text view's
// ContentBounds or InnerBounds changes.
func (this *TextView) OnContentBoundsChange (callback func ()) event.Cookie {
return this.box.OnContentBoundsChange(callback)
}
// SetText sets the text content of the view.
func (this *TextView) SetText (text string) {
this.box.SetText(text)
} }
func (this *TextView) handleScroll (x, y float64) bool { func (this *TextView) handleScroll (x, y float64) bool {