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
![Some of the objects in this package](assets/preview.png)
[![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/objects.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/objects)
Objects contains a standard collection of re-usable objects. All objects in this
module visually conform to whatever the theme is set to.
Objects contains a standard collection of re-usable objects. It should also be
viewed as a reference for how to create custom objects in Tomo.
## Styling
All objects in this module have roles of the form:
```
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/objects/layouts"
var _ tomo.Object = new(Button)
var buttonLayout = layouts.Row { true }
var iconButtonLayout = layouts.Row { true }
var bothButtonLayout = layouts.Row { false, true }
// Button is a clickable button.
//
// Tags:
// - [icon] The button has an icon.
type Button struct {
tomo.ContainerBox
box tomo.ContainerBox
label *Label
icon *Icon
@ -24,33 +29,45 @@ type Button struct {
// NewButton creates a new button with the specified text.
func NewButton (text string) *Button {
box := &Button {
ContainerBox: tomo.NewContainerBox(),
label: NewLabel(text),
button := &Button {
box: tomo.NewContainerBox(),
label: NewLabel(text),
}
box.SetRole(tomo.R("objects", "Button"))
box.label.SetAttr(tomo.AAlign(tomo.AlignMiddle, tomo.AlignMiddle))
box.SetAttr(tomo.ALayout(buttonLayout))
box.SetText(text)
button.box.SetRole(tomo.R("objects", "Button"))
button.label.SetAlign(tomo.AlignMiddle, tomo.AlignMiddle)
button.box.SetAttr(tomo.ALayout(buttonLayout))
button.SetText(text)
box.SetInputMask(true)
box.OnButtonDown(box.handleButtonDown)
box.OnButtonUp(box.handleButtonUp)
box.OnKeyDown(box.handleKeyDown)
box.OnKeyUp(box.handleKeyUp)
box.SetFocusable(true)
return box
button.box.SetInputMask(true)
button.box.OnButtonDown(button.handleButtonDown)
button.box.OnButtonUp(button.handleButtonUp)
button.box.OnKeyDown(button.handleKeyDown)
button.box.OnKeyUp(button.handleKeyUp)
button.box.SetFocusable(true)
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.
func (this *Button) SetText (text string) {
this.label.SetText(text)
if this.labelActive && text == "" {
this.Remove(this.label)
this.box.Remove(this.label)
this.labelActive = false
}
if !this.labelActive && text != "" {
this.Add(this.label)
this.box.Add(this.label)
this.labelActive = true
}
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
// remove it.
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 {
icon = NewIcon(id, tomo.IconSizeSmall)
@ -67,9 +84,9 @@ func (this *Button) SetIcon (id tomo.Icon) {
this.icon = icon
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()
}
@ -80,11 +97,11 @@ func (this *Button) OnClick (callback func ()) event.Cookie {
func (this *Button) applyLayout () {
if this.labelActive && this.icon == nil {
this.SetAttr(tomo.ALayout(buttonLayout))
this.box.SetAttr(tomo.ALayout(buttonLayout))
} else if !this.labelActive && this.icon != nil {
this.SetAttr(tomo.ALayout(iconButtonLayout))
this.box.SetAttr(tomo.ALayout(iconButtonLayout))
} 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 {
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()
}
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/objects/layouts"
var _ tomo.Object = new(Calendar)
// Calendar is an object that can display a date and allow the user to change
// 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 {
tomo.ContainerBox
box tomo.ContainerBox
grid tomo.ContainerBox
time time.Time
@ -23,11 +35,11 @@ type Calendar struct {
// NewCalendar creates a new calendar with the specified date.
func NewCalendar (tm time.Time) *Calendar {
calendar := &Calendar {
ContainerBox: tomo.NewContainerBox(),
time: tm,
box: tomo.NewContainerBox(),
time: tm,
}
calendar.SetRole(tomo.R("objects", "Calendar"))
calendar.SetAttr(tomo.ALayout(layouts.ContractVertical))
calendar.box.SetRole(tomo.R("objects", "Calendar"))
calendar.box.SetAttr(tomo.ALayout(layouts.ContractVertical))
prevButton := NewButton("")
prevButton.SetIcon(tomo.IconGoPrevious)
@ -42,23 +54,28 @@ func NewCalendar (tm time.Time) *Calendar {
calendar.on.valueChange.Broadcast()
})
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.SetRole(tomo.R("objects", "CalendarGrid"))
calendar.grid.SetAttr(tomo.ALayout(layouts.NewGrid (
true, true, true, true, true, true, true)()))
calendar.Add(NewInnerContainer (
calendar.box.Add(NewContainer (
layouts.Row { false, true, false },
prevButton, calendar.monthLabel, nextButton))
calendar.Add(calendar.grid)
calendar.box.Add(calendar.grid)
calendar.OnScroll(calendar.handleScroll)
calendar.box.OnScroll(calendar.handleScroll)
calendar.refresh()
return calendar
}
// GetBox returns the underlying box.
func (this *Calendar) GetBox () tomo.Box {
return this.box
}
// Value returns the time this calendar is displaying.
func (this *Calendar) Value () time.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/event"
var _ tomo.Object = new(Checkbox)
// 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 {
tomo.Box
box tomo.Box
value bool
on struct {
valueChange event.FuncBroadcaster
@ -15,18 +21,30 @@ type Checkbox struct {
// NewCheckbox creates a new checkbox with the specified value.
func NewCheckbox (value bool) *Checkbox {
box := &Checkbox {
Box: tomo.NewBox(),
checkbox := &Checkbox {
box: tomo.NewBox(),
}
box.SetRole(tomo.R("objects", "Checkbox"))
box.SetValue(value)
checkbox.box.SetRole(tomo.R("objects", "Checkbox"))
checkbox.SetValue(value)
box.OnButtonDown(box.handleButtonDown)
box.OnButtonUp(box.handleButtonUp)
box.OnKeyDown(box.handleKeyDown)
box.OnKeyUp(box.handleKeyUp)
box.SetFocusable(true)
return box
checkbox.box.OnButtonDown(checkbox.handleButtonDown)
checkbox.box.OnButtonUp(checkbox.handleButtonUp)
checkbox.box.OnKeyDown(checkbox.handleKeyDown)
checkbox.box.OnKeyUp(checkbox.handleKeyUp)
checkbox.box.SetFocusable(true)
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.
@ -38,8 +56,8 @@ func (this *Checkbox) Value () bool {
func (this *Checkbox) SetValue (value bool) {
this.value = value
// the theme shall decide what checked and unchecked states look like
this.SetTag("checked", value)
this.SetTag("unchecked", !value)
this.box.SetTag("checked", value)
this.box.SetTag("unchecked", !value)
}
// 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 {
if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) {
if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.Toggle()
}
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/canvas"
import "git.tebibyte.media/tomo/objects/layouts"
import "git.tebibyte.media/tomo/objects/internal"
import "git.tebibyte.media/sashakoshka/goutil/image/color"
var _ tomo.Object = new(HSVAColorPicker)
// HSVAColorPicker allows the user to pick a color by controlling its HSVA
// parameters.
//
// Sub-components:
// - ColorPickerMap is a rectangular control where the X axis controls
// saturation and the Y axis controls value.
type HSVAColorPicker struct {
tomo.ContainerBox
value internal.HSVA
box tomo.ContainerBox
value ucolor.HSVA
pickerMap *hsvaColorPickerMap
hueSlider *Slider
@ -26,15 +32,15 @@ type HSVAColorPicker struct {
// NewHSVAColorPicker creates a new color picker with the specified color.
func NewHSVAColorPicker (value color.Color) *HSVAColorPicker {
picker := &HSVAColorPicker {
ContainerBox: tomo.NewContainerBox(),
box: tomo.NewContainerBox(),
}
picker.SetRole(tomo.R("objects", "ColorPicker"))
picker.SetAttr(tomo.ALayout(layouts.Row { true, false, false }))
picker.box.SetRole(tomo.R("objects", "ColorPicker"))
picker.box.SetAttr(tomo.ALayout(layouts.Row { true, false, false }))
picker.pickerMap = newHsvaColorPickerMap(picker)
picker.Add(picker.pickerMap)
picker.box.Add(picker.pickerMap)
picker.hueSlider = NewVerticalSlider(0.0)
picker.Add(picker.hueSlider)
picker.box.Add(picker.hueSlider)
picker.hueSlider.OnValueChange(func () {
picker.value.H = picker.hueSlider.Value()
picker.on.valueChange.Broadcast()
@ -42,7 +48,7 @@ func NewHSVAColorPicker (value color.Color) *HSVAColorPicker {
})
picker.alphaSlider = NewVerticalSlider(0.0)
picker.Add(picker.alphaSlider)
picker.box.Add(picker.alphaSlider)
picker.alphaSlider.OnValueChange(func () {
picker.value.A = uint16(picker.alphaSlider.Value() * 0xFFFF)
picker.on.valueChange.Broadcast()
@ -54,6 +60,18 @@ func NewHSVAColorPicker (value color.Color) *HSVAColorPicker {
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.
func (this *HSVAColorPicker) Value () color.Color {
return this.value
@ -62,7 +80,7 @@ func (this *HSVAColorPicker) Value () color.Color {
// SetValue sets the color of the picker.
func (this *HSVAColorPicker) SetValue (value color.Color) {
if value == nil { value = color.Transparent }
this.value = internal.HSVAModel.Convert(value).(internal.HSVA)
this.value = ucolor.HSVAModel.Convert(value).(ucolor.HSVA)
this.hueSlider.SetValue(this.value.H)
this.alphaSlider.SetValue(float64(this.value.A) / 0xFFFF)
this.pickerMap.Invalidate()
@ -134,7 +152,7 @@ func (this *hsvaColorPickerMap) Draw (can canvas.Canvas) {
xx := x - bounds.Min.X
yy := y - bounds.Min.Y
pixel := internal.HSVA {
pixel := ucolor.HSVA {
H: this.parent.value.H,
S: float64(xx) / float64(bounds.Dx()),
V: 1 - float64(yy) / float64(bounds.Dy()),

View File

@ -2,50 +2,20 @@ package objects
import "git.tebibyte.media/tomo/tomo"
// Container is an object that can contain other objects. It can be used as a
// primitive for building more complex layouts. It has two variants: an outer
// container, and an inner container. The outer container has padding around
// its edges, whereas the inner container does not. The container will have a
// corresponding object role variation of either "outer" or "inner".
var _ tomo.ContentObject = new(Container)
// Container is an object that can contain other objects. It is plain looking,
// and is intended to be used within other containers as a primitive for
// building more complex layouts.
type Container struct {
tomo.ContainerBox
abstractContainer
}
func newContainer (layout tomo.Layout, children ...tomo.Object) *Container {
this := &Container {
ContainerBox: tomo.NewContainerBox(),
}
this.SetAttr(tomo.ALayout(layout))
for _, child := range children {
this.Add(child)
}
// NewContainer creates a new container.
func NewContainer (layout tomo.Layout, children ...tomo.Object) *Container {
this := &Container { }
this.init(layout, children...)
this.box.SetRole(tomo.R("objects", "Container"))
this.box.SetTag("outer", true)
return this
}
// 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.
type Dialog struct {
tomo.Window
controlRow tomo.ContainerBox
}
type clickable interface {
@ -36,11 +35,11 @@ func NewDialog (kind DialogKind, parent tomo.Window, title, message string, opti
dialog := &Dialog { }
if parent == nil {
window, err := tomo.NewWindow(image.Rectangle { })
window, err := tomo.NewWindow(tomo.WindowKindNormal, image.Rectangle { })
if err != nil { return nil, err }
dialog.Window = window
} else {
window, err := parent.NewModal(image.Rectangle { })
window, err := parent.NewChild(tomo.WindowKindModal, image.Rectangle { })
if err != nil { return nil, err }
dialog.Window = window
}
@ -53,22 +52,23 @@ func NewDialog (kind DialogKind, parent tomo.Window, title, message string, opti
case DialogError: iconId = tomo.IconDialogError
}
dialog.SetTitle(title)
dialog.SetIcon(iconId)
icon := NewIcon(iconId, tomo.IconSizeLarge)
messageText := NewLabel(message)
messageText.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
messageText.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
for _, option := range options {
if option, ok := option.(clickable); ok {
option.OnClick(dialog.Close)
option.OnClick(func () {
dialog.Close()
})
}
}
dialog.controlRow = NewInnerContainer(layouts.ContractHorizontal, options...)
dialog.controlRow.SetAttr(tomo.AAlign(tomo.AlignEnd, tomo.AlignEnd))
dialog.SetRoot(NewOuterContainer (
dialog.SetRoot(NewRoot (
layouts.Column { true, false },
NewInnerContainer(layouts.ContractHorizontal, icon, messageText),
dialog.controlRow))
NewContentSegment(layouts.ContractHorizontal, icon, messageText),
NewOptionSegment(nil, options...)))
return dialog, nil
}

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/objects/layouts"
var _ tomo.Object = new(Dropdown)
// Dropdown is a non-editable text input that allows the user to pick a value
// from a list.
type Dropdown struct {
tomo.ContainerBox
box tomo.ContainerBox
label *Label
value string
@ -24,28 +26,40 @@ type Dropdown struct {
// NewDropdown creates a new dropdown input with the specified items
func NewDropdown (items ...string) *Dropdown {
dropdown := &Dropdown {
ContainerBox: tomo.NewContainerBox(),
box: tomo.NewContainerBox(),
label: NewLabel(""),
}
dropdown.SetRole(tomo.R("objects", "Dropdown"))
dropdown.SetAttr(tomo.ALayout(layouts.Row { true, false }))
dropdown.Add(dropdown.label)
dropdown.Add(NewIcon(tomo.IconListChoose, tomo.IconSizeSmall))
dropdown.box.SetRole(tomo.R("objects", "Dropdown"))
dropdown.box.SetAttr(tomo.ALayout(layouts.Row { true, false }))
dropdown.box.Add(dropdown.label)
dropdown.box.Add(NewIcon(tomo.IconListChoose, tomo.IconSizeSmall))
dropdown.SetItems(items...)
if len(items) > 0 {
dropdown.SetValue(items[0])
}
dropdown.SetInputMask(true)
dropdown.OnButtonDown(dropdown.handleButtonDown)
dropdown.OnButtonUp(dropdown.handleButtonUp)
dropdown.OnKeyDown(dropdown.handleKeyDown)
dropdown.OnKeyUp(dropdown.handleKeyUp)
dropdown.SetFocusable(true)
dropdown.box.SetInputMask(true)
dropdown.box.OnButtonDown(dropdown.handleButtonDown)
dropdown.box.OnButtonUp(dropdown.handleButtonUp)
dropdown.box.OnKeyDown(dropdown.handleKeyDown)
dropdown.box.OnKeyUp(dropdown.handleKeyUp)
dropdown.box.SetFocusable(true)
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
// in the list of items.
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 {
if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) {
if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.Choose()
}
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
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/tomo/tomo v0.46.1/go.mod h1:WrtilgKB1y8O2Yu7X4mYcRiqOlPR8NuUnoA/ynkQWrs=
git.tebibyte.media/sashakoshka/goutil v0.3.1 h1:zvAMKS+aea96q6oTttCWfNLXqOHisI3IKAwX6BWKfY0=
git.tebibyte.media/sashakoshka/goutil v0.3.1/go.mod h1:Yo/M2sbi9IbzZCFsEj8/Fg7sNwHkDaJ6saTHOha+Dow=
git.tebibyte.media/tomo/tomo v0.48.0 h1:AE21ElHwUSPsX82ZWCnoNxJFi9Oswyd3dPDPMbxTueQ=
git.tebibyte.media/tomo/tomo v0.48.0/go.mod h1:WrtilgKB1y8O2Yu7X4mYcRiqOlPR8NuUnoA/ynkQWrs=

View File

@ -2,30 +2,72 @@ package objects
import "fmt"
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
// 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 {
tomo.TextBox
box tomo.TextBox
}
// NewHeading creates a new section heading. The level can be from 0 to 2.
func NewHeading (level int, text string) *Heading {
if level < 0 { level = 0 }
if level > 2 { level = 2 }
this := &Heading { TextBox: tomo.NewTextBox() }
this.SetRole(tomo.R("objects", "Heading"))
this.SetTag(fmt.Sprint(level), true)
this := &Heading { box: tomo.NewTextBox() }
this.box.SetRole(tomo.R("objects", "Heading"))
this.box.SetTag(fmt.Sprint(level), true)
this.SetText(text)
this.SetSelectable(true)
this.SetFocusable(true)
this.box.SetSelectable(true)
this.box.SetFocusable(true)
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.
func NewMenuHeading (text string) *Heading {
heading := NewHeading(0, text)
heading.SetTag("menu", true)
heading.box.SetTag("menu", true)
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/canvas"
var _ tomo.Object = new(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 {
tomo.Box
box tomo.Box
icon tomo.Icon
size tomo.IconSize
}
@ -21,14 +28,19 @@ func iconSizeString (size tomo.IconSize) string {
// NewIcon creates a new icon from an icon ID.
func NewIcon (icon tomo.Icon, size tomo.IconSize) *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.OnIconSetChange(this.handleIconSetChange)
this.box.OnIconSetChange(this.handleIconSetChange)
return this
}
// GetBox returns the underlying box.
func (this *Icon) GetBox () tomo.Box {
return this.box
}
// SetIcon sets the icon.
func (this *Icon) SetIcon (icon tomo.Icon, size tomo.IconSize) {
if this.icon == icon { return }
@ -42,12 +54,12 @@ func (this *Icon) handleIconSetChange () {
}
func (this *Icon) setTexture (texture canvas.Texture) {
this.SetAttr(tomo.ATexture(texture))
this.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter))
this.box.SetAttr(tomo.ATexture(texture))
this.box.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter))
if texture == nil {
this.SetAttr(tomo.AMinimumSize(0, 0))
this.box.SetAttr(tomo.AMinimumSize(0, 0))
} else {
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 "image/color"
// HSV represents a color with hue, saturation, and value components. Each
// component C is in range 0 <= C <= 1.
type HSV struct {
H float64
S float64
V float64
}
// HSVA is an HSV color with an added 8-bit alpha component. The alpha component
// ranges from 0x0000 (fully transparent) to 0xFFFF (opaque), and has no bearing
// on the other components.
type HSVA struct {
H float64
S float64
V float64
A uint16
}
var (
HSVModel color.Model = color.ModelFunc(hsvModel)
HSVAModel color.Model = color.ModelFunc(hsvaModel)
)
func (hsv HSV) RGBA () (r, g, b, a uint32) {
// Adapted from:
// https://www.cs.rit.edu/~ncs/color/t_convert.html
component := func (x float64) uint32 {
return uint32(float64(0xFFFF) * x)
}
s := clamp01(hsv.S)
v := clamp01(hsv.V)
if s == 0 {
light := component(v)
return light, light, light, 0xFFFF
}
h := clamp01(hsv.H) * 360
sector := int(h / 60)
// otherwise when given 1.0 for H, sector would overflow to 6
if sector > 5 { sector = 5 }
offset := (h / 60) - float64(sector)
p := component(v * (1 - s))
q := component(v * (1 - s * offset))
t := component(v * (1 - s * (1 - offset)))
va := component(v)
switch sector {
case 0: return va, t, p, 0xFFFF
case 1: return q, va, p, 0xFFFF
case 2: return p, va, t, 0xFFFF
case 3: return p, q, va, 0xFFFF
case 4: return t, p, va, 0xFFFF
default: return va, p, q, 0xFFFF
}
}
func (hsva HSVA) RGBA () (r, g, b, a uint32) {
r, g, b, a = HSV {
H: hsva.H,
S: hsva.S,
V: hsva.V,
}.RGBA()
a = uint32(hsva.A)
// alpha premultiplication
r = (r * a) / 0xFFFF
g = (g * a) / 0xFFFF
b = (b * a) / 0xFFFF
return
}
// Canon returns the color but with the H, S, and V fields are constrained to
// the range 0.0-1.0
func (hsv HSV) Canon () HSV {
hsv.H = clamp01(hsv.H)
hsv.S = clamp01(hsv.S)
hsv.V = clamp01(hsv.V)
return hsv
}
// Canon returns the color but with the H, S, and V fields are constrained to
// the range 0.0-1.0
func (hsva HSVA) Canon () HSVA {
hsva.H = clamp01(hsva.H)
hsva.S = clamp01(hsva.S)
hsva.V = clamp01(hsva.V)
return hsva
}
func clamp01 (x float64) float64 {
if x > 1.0 { return 1.0 }
if x < 0.0 { return 0.0 }
return x
}
func hsvModel (c color.Color) color.Color {
switch c := c.(type) {
case HSV: return c
case HSVA: return HSV { H: c.H, S: c.S, V: c.V }
default:
r, g, b, a := c.RGBA()
// alpha unpremultiplication
r = (r / a) * 0xFFFF
g = (g / a) * 0xFFFF
b = (b / a) * 0xFFFF
return rgbToHSV(r, g, b)
}
}
func hsvaModel (c color.Color) color.Color {
switch c := c.(type) {
case HSV: return HSVA { H: c.H, S: c.S, V: c.V, A: 0xFFFF }
case HSVA: return c
default:
r, g, b, a := c.RGBA()
hsv := rgbToHSV(r, g, b)
return HSVA {
H: hsv.H,
S: hsv.S,
V: hsv.V,
A: uint16(a),
}
}
}
func rgbToHSV (r, g, b uint32) HSV {
// Adapted from:
// https://www.cs.rit.edu/~ncs/color/t_convert.html
component := func (x uint32) float64 {
return clamp01(float64(x) / 0xFFFF)
}
cr := component(r)
cg := component(g)
cb := component(b)
var maxComponent float64
if cr > maxComponent { maxComponent = cr }
if cg > maxComponent { maxComponent = cg }
if cb > maxComponent { maxComponent = cb }
var minComponent = 1.0
if cr < minComponent { minComponent = cr }
if cg < minComponent { minComponent = cg }
if cb < minComponent { minComponent = cb }
hsv := HSV {
V: maxComponent,
}
delta := maxComponent - minComponent
if delta == 0 {
// hsva.S is undefined, so hue doesn't matter
return hsv
}
hsv.S = delta / maxComponent
switch {
case cr == maxComponent: hsv.H = (cg - cb) / delta
case cg == maxComponent: hsv.H = 2 + (cb - cr) / delta
case cb == maxComponent: hsv.H = 4 + (cr - cg) / delta
}
hsv.H *= 60
if hsv.H < 0 { hsv.H += 360 }
hsv.H /= 360
return hsv
}
// FormatNRGBA formats an NRGBA value into a hex string.
func FormatNRGBA (nrgba color.NRGBA) string {
return fmt.Sprintf("%02X%02X%02X%02X", nrgba.R, nrgba.G, nrgba.B, nrgba.A)

92
internal/history.go Normal file
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
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.
type Label struct {
tomo.TextBox
box tomo.TextBox
}
// NewLabel creates a new text label.
func NewLabel (text string) *Label {
this := &Label { TextBox: tomo.NewTextBox() }
this.SetRole(tomo.R("objects", "Label"))
this := &Label { box: tomo.NewTextBox() }
this.box.SetRole(tomo.R("objects", "Label"))
this.SetText(text)
this.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
this.SetSelectable(true)
this.SetFocusable(true)
this.box.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
this.box.SetSelectable(true)
this.box.SetFocusable(true)
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/objects/layouts"
var _ tomo.Object = new(LabelCheckbox)
// LabelCheckbox is a checkbox with a label.
type LabelCheckbox struct {
tomo.ContainerBox
box tomo.ContainerBox
checkbox *Checkbox
label *Label
}
@ -15,22 +17,39 @@ type LabelCheckbox struct {
// NewLabelCheckbox creates a new labeled checkbox with the specified value and
// label text.
func NewLabelCheckbox (value bool, text string) *LabelCheckbox {
box := &LabelCheckbox {
ContainerBox: tomo.NewContainerBox(),
checkbox: NewCheckbox(value),
label: NewLabel(text),
labelCheckbox := &LabelCheckbox {
box: tomo.NewContainerBox(),
checkbox: NewCheckbox(value),
label: NewLabel(text),
}
box.SetRole(tomo.R("objects", "LabelCheckbox"))
box.label.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
box.label.SetSelectable(false)
box.label.SetFocusable(false)
box.Add(box.checkbox)
box.Add(box.label)
box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
labelCheckbox.box.SetRole(tomo.R("objects", "LabelCheckbox"))
labelCheckbox.label.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
labelCheckbox.label.GetBox().(tomo.TextBox).SetSelectable(false)
labelCheckbox.label.GetBox().(tomo.TextBox).SetFocusable(false)
labelCheckbox.box.Add(labelCheckbox.checkbox)
labelCheckbox.box.Add(labelCheckbox.label)
labelCheckbox.box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
box.OnButtonDown(box.handleButtonDown)
box.OnButtonUp(box.handleButtonUp)
return box
labelCheckbox.box.OnButtonDown(labelCheckbox.handleButtonDown)
labelCheckbox.box.OnButtonUp(labelCheckbox.handleButtonUp)
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.
@ -61,7 +80,7 @@ func (this *LabelCheckbox) handleButtonDown (button input.Button) bool {
func (this *LabelCheckbox) handleButtonUp (button input.Button) bool {
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.Toggle()
}

View File

@ -6,31 +6,52 @@ 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(LabelSwatch)
// LabelSwatch is a swatch with a label.
type LabelSwatch struct {
tomo.ContainerBox
swatch *Swatch
label *Label
box tomo.ContainerBox
swatch *Swatch
label *Label
}
// NewLabelSwatch creates a new labeled swatch with the specified color and
// label text.
func NewLabelSwatch (value color.Color, text string) *LabelSwatch {
box := &LabelSwatch {
ContainerBox: tomo.NewContainerBox(),
swatch: NewSwatch(value),
label: NewLabel(text),
labelSwatch := &LabelSwatch {
box: tomo.NewContainerBox(),
swatch: NewSwatch(value),
label: NewLabel(text),
}
box.SetRole(tomo.R("objects", "LabelSwatch"))
box.swatch.label = text
box.label.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
box.Add(box.swatch)
box.Add(box.label)
box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
labelSwatch.box.SetRole(tomo.R("objects", "LabelSwatch"))
labelSwatch.swatch.label = text
labelSwatch.label.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
labelSwatch.label.GetBox().(tomo.TextBox).SetSelectable(false)
labelSwatch.label.GetBox().(tomo.TextBox).SetFocusable(false)
labelSwatch.box.Add(labelSwatch.swatch)
labelSwatch.box.Add(labelSwatch.label)
labelSwatch.box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
box.OnButtonDown(box.handleButtonDown)
box.OnButtonUp(box.handleButtonUp)
return box
labelSwatch.box.OnButtonDown(labelSwatch.handleButtonDown)
labelSwatch.box.OnButtonUp(labelSwatch.handleButtonUp)
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.
@ -61,13 +82,13 @@ func (this *LabelSwatch) OnConfirm (callback func ()) event.Cookie {
}
func (this *LabelSwatch) handleButtonDown (button input.Button) bool {
if button != input.ButtonLeft { return true }
if !isClickingButton(button) { return true }
return true
}
func (this *LabelSwatch) handleButtonUp (button input.Button) bool {
if button != input.ButtonLeft { return true }
if this.Window().MousePosition().In(this.Bounds()) {
if !isClickingButton(button) { return true }
if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.swatch.SetFocused(true)
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"
// 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 {
tomo.Window
@ -36,7 +43,7 @@ func newMenu (parent tomo.Window, bounds image.Rectangle, items ...tomo.Object)
menu := &Menu { }
menu.bounds = bounds
menu.parent = parent
window, err := menu.parent.NewMenu(menu.bounds)
window, err := menu.parent.NewChild(tomo.WindowKindMenu, menu.bounds)
if err != nil { return nil, err }
menu.Window = window
@ -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.Window.SetRoot(menu.rootContainer)
@ -71,7 +78,8 @@ func (this *Menu) TearOff () {
if this.parent == nil { return }
this.torn = true
window, err := this.parent.NewChild(this.bounds)
window, err := this.parent.NewChild(tomo.WindowKindToolbar, this.bounds)
window.SetIcon(tomo.IconListChoose)
if err != nil { return }
visible := this.Window.Visible()
@ -90,21 +98,27 @@ func (this *Menu) newTearLine () tomo.Object {
tearLine := tomo.NewBox()
tearLine.SetRole(tomo.R("objects", "TearLine"))
tearLine.SetFocusable(true)
tearLine.OnMouseEnter(func () {
tearLine.SetFocused(true)
})
tearLine.OnMouseLeave(func () {
tearLine.SetFocused(false)
})
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
})
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()
return true
})
tearLine.OnButtonDown(func (button input.Button) bool {
if button != input.ButtonLeft { return false }
if !isClickingButton(button) { return false }
return true
})
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()) {
this.TearOff()
}

View File

@ -5,12 +5,14 @@ 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(MenuItem)
// MenuItem is a selectable memu item.
type MenuItem struct {
tomo.ContainerBox
box tomo.ContainerBox
label *Label
icon *Icon
label *Label
icon *Icon
labelActive bool
on struct {
@ -25,25 +27,39 @@ func NewMenuItem (text string) *MenuItem {
// NewIconMenuItem creates a new menu item with the specified icon and text.
func NewIconMenuItem (icon tomo.Icon, text string) *MenuItem {
box := &MenuItem {
ContainerBox: tomo.NewContainerBox(),
label: NewLabel(text),
icon: NewIcon(icon, tomo.IconSizeSmall),
menuItem := &MenuItem {
box: tomo.NewContainerBox(),
label: NewLabel(text),
icon: NewIcon(icon, tomo.IconSizeSmall),
}
box.SetRole(tomo.R("objects", "MenuItem"))
box.label.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
menuItem.box.SetRole(tomo.R("objects", "MenuItem"))
menuItem.label.SetAlign(tomo.AlignStart, tomo.AlignMiddle)
menuItem.box.SetAttr(tomo.ALayout(layouts.Row { false, true }))
box.Add(box.icon)
box.Add(box.label)
menuItem.box.Add(menuItem.icon)
menuItem.box.Add(menuItem.label)
box.SetInputMask(true)
box.OnButtonDown(box.handleButtonDown)
box.OnButtonUp(box.handleButtonUp)
box.OnKeyDown(box.handleKeyDown)
box.OnKeyUp(box.handleKeyUp)
box.SetFocusable(true)
return box
menuItem.box.SetInputMask(true)
menuItem.box.OnMouseEnter(menuItem.handleMouseEnter)
menuItem.box.OnMouseLeave(menuItem.handleMouseLeave)
menuItem.box.OnButtonDown(menuItem.handleButtonDown)
menuItem.box.OnButtonUp(menuItem.handleButtonUp)
menuItem.box.OnKeyDown(menuItem.handleKeyDown)
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.
@ -62,6 +78,14 @@ func (this *MenuItem) OnClick (callback func ()) event.Cookie {
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 {
if key != input.KeyEnter && key != input.Key(' ') { return false }
return true
@ -80,7 +104,7 @@ func (this *MenuItem) handleButtonDown (button input.Button) bool {
func (this *MenuItem) handleButtonUp (button input.Button) bool {
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()
}
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/canvas"
var _ tomo.Object = new(MimeIcon)
// MimeIcon displays an icon of a MIME type.
type MimeIcon struct {
tomo.Box
box tomo.Box
mime data.Mime
size tomo.IconSize
}
// NewMimeIcon creates a new icon from a MIME type.
func NewMimeIcon (mime data.Mime, size tomo.IconSize) *MimeIcon {
this := &MimeIcon {
Box: tomo.NewBox(),
mimeIcon := &MimeIcon {
box: tomo.NewBox(),
}
this.SetRole(tomo.R("objects", "MimeIcon"))
this.SetIcon(mime, size)
this.OnIconSetChange(this.handleIconSetChange)
return this
mimeIcon.box.SetRole(tomo.R("objects", "MimeIcon"))
mimeIcon.SetIcon(mime, size)
mimeIcon.box.OnIconSetChange(mimeIcon.handleIconSetChange)
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.
@ -35,12 +42,12 @@ func (this *MimeIcon) handleIconSetChange () {
}
func (this *MimeIcon) setTexture (texture canvas.Texture) {
this.SetAttr(tomo.ATexture(texture))
this.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter))
this.box.SetAttr(tomo.ATexture(texture))
this.box.SetAttr(tomo.ATextureMode(tomo.TextureModeCenter))
if texture == nil {
this.SetAttr(tomo.AMinimumSize(0, 0))
this.box.SetAttr(tomo.AMinimumSize(0, 0))
} else {
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 "strconv"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/text"
import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/objects/layouts"
var _ tomo.Object = new(NumberInput)
// NumberInput is an editable text box which accepts only numbers, and has
// controls to increment and decrement its value.
type NumberInput struct {
tomo.ContainerBox
box tomo.ContainerBox
input *TextInput
increment *Button
decrement *Button
@ -22,41 +25,63 @@ type NumberInput struct {
// NewNumberInput creates a new number input with the specified value.
func NewNumberInput (value float64) *NumberInput {
box := &NumberInput {
ContainerBox: tomo.NewContainerBox(),
input: NewTextInput(""),
increment: NewButton(""),
decrement: NewButton(""),
numberInput := &NumberInput {
box: tomo.NewContainerBox(),
input: NewTextInput(""),
increment: NewButton(""),
decrement: NewButton(""),
}
box.SetRole(tomo.R("objects", "NumberInput"))
box.Add(box.input)
box.Add(box.decrement)
box.Add(box.increment)
box.SetAttr(tomo.ALayout(layouts.Row { true, false, false }))
box.increment.SetIcon(tomo.IconValueIncrement)
box.decrement.SetIcon(tomo.IconValueDecrement)
numberInput.box.SetRole(tomo.R("objects", "NumberInput"))
numberInput.box.Add(numberInput.input)
numberInput.box.Add(numberInput.decrement)
numberInput.box.Add(numberInput.increment)
numberInput.box.SetAttr(tomo.ALayout(layouts.Row { true, false, false }))
numberInput.increment.SetIcon(tomo.IconValueIncrement)
numberInput.decrement.SetIcon(tomo.IconValueDecrement)
box.SetValue(value)
numberInput.SetValue(value)
box.OnScroll(box.handleScroll)
box.OnKeyDown(box.handleKeyDown)
box.OnKeyUp(box.handleKeyUp)
box.input.OnConfirm(box.handleConfirm)
box.input.OnValueChange(box.on.valueChange.Broadcast)
box.increment.OnClick(func () { box.shift(1) })
box.decrement.OnClick(func () { box.shift(-1) })
return box
numberInput.box.OnScroll(numberInput.handleScroll)
numberInput.box.OnKeyDown(numberInput.handleKeyDown)
numberInput.box.OnKeyUp(numberInput.handleKeyUp)
numberInput.input.OnConfirm(numberInput.handleConfirm)
numberInput.input.OnValueChange(numberInput.on.valueChange.Broadcast)
numberInput.increment.OnClick(func () { numberInput.shift( 1) })
numberInput.decrement.OnClick(func () { numberInput.shift(-1) })
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.
func (this *NumberInput) Value () float64 {
value, _ := strconv.ParseFloat(this.input.Text(), 64)
value, _ := strconv.ParseFloat(this.input.Value(), 64)
return value
}
// SetValue sets the value of the input.
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

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/event"
var _ tomo.Object = new(Scrollbar)
// 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 {
tomo.ContainerBox
handle *SliderHandle
box tomo.ContainerBox
handle *sliderHandle
layout scrollbarLayout
dragging bool
dragOffset image.Point
@ -23,8 +36,8 @@ type Scrollbar struct {
func newScrollbar (orient string) *Scrollbar {
this := &Scrollbar {
ContainerBox: tomo.NewContainerBox(),
handle: &SliderHandle {
box: tomo.NewContainerBox(),
handle: &sliderHandle {
Box: tomo.NewBox(),
},
layout: scrollbarLayout {
@ -32,21 +45,21 @@ func newScrollbar (orient string) *Scrollbar {
},
}
this.Add(this.handle)
this.box.Add(this.handle)
this.SetFocusable(true)
this.SetInputMask(true)
this.OnKeyUp(this.handleKeyUp)
this.OnKeyDown(this.handleKeyDown)
this.OnButtonDown(this.handleButtonDown)
this.OnButtonUp(this.handleButtonUp)
this.OnMouseMove(this.handleMouseMove)
this.OnScroll(this.handleScroll)
this.box.SetFocusable(true)
this.box.SetInputMask(true)
this.box.OnKeyUp(this.handleKeyUp)
this.box.OnKeyDown(this.handleKeyDown)
this.box.OnButtonDown(this.handleButtonDown)
this.box.OnButtonUp(this.handleButtonUp)
this.box.OnMouseMove(this.handleMouseMove)
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.SetRole(tomo.R("objects", "Slider"))
this.SetTag(orient, true)
this.box.SetRole(tomo.R("objects", "Scrollbar"))
this.box.SetTag(orient, true)
return this
}
@ -60,13 +73,18 @@ func NewHorizontalScrollbar () *Scrollbar {
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
// will unlink it.
func (this *Scrollbar) Link (box tomo.ContentObject) event.Cookie {
this.layout.linked = box
this.linkCookie = this.newLinkCookie (
box.OnContentBoundsChange(this.handleLinkedContentBoundsChange))
this.SetAttr(tomo.ALayout(this.layout))
this.box.SetAttr(tomo.ALayout(this.layout))
return this.linkCookie
}
@ -79,7 +97,7 @@ func (this *Scrollbar) handleLinkedContentBoundsChange () {
} else {
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 {
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 {
modifiers := this.Window().Modifiers()
modifiers := this.box.Window().Modifiers()
switch key {
case input.KeyUp, input.KeyLeft:
if modifiers.Alt {
if modifiers.Alt() {
this.SetValue(0)
} else {
this.scrollBy(this.StepSize())
}
return true
case input.KeyDown, input.KeyRight:
if modifiers.Alt {
if modifiers.Alt() {
this.SetValue(1)
} else {
this.scrollBy(-this.StepSize())
@ -183,7 +201,7 @@ func (this *Scrollbar) handleKeyDown (key input.Key, numpad bool) bool {
}
func (this *Scrollbar) handleButtonDown (button input.Button) bool {
pointer := this.Window().MousePosition()
pointer := this.box.Window().MousePosition()
handle := this.handle.Bounds()
within := pointer.In(handle)
@ -199,7 +217,7 @@ func (this *Scrollbar) handleButtonDown (button input.Button) bool {
this.dragging = true
this.dragOffset =
pointer.Sub(this.handle.Bounds().Min).
Add(this.InnerBounds().Min)
Add(this.box.InnerBounds().Min)
this.drag()
} else {
this.dragOffset = this.fallbackDragOffset()
@ -253,8 +271,8 @@ func (this *Scrollbar) handleScroll (x, y float64) bool {
}
func (this *Scrollbar) drag () {
pointer := this.Window().MousePosition().Sub(this.dragOffset)
gutter := this.InnerBounds()
pointer := this.box.Window().MousePosition().Sub(this.dragOffset)
gutter := this.box.InnerBounds()
handle := this.handle.Bounds()
if this.layout.vertical {
@ -270,10 +288,10 @@ func (this *Scrollbar) drag () {
func (this *Scrollbar) fallbackDragOffset () image.Point {
if this.layout.vertical {
return this.InnerBounds().Min.
return this.box.InnerBounds().Min.
Add(image.Pt(0, this.handle.Bounds().Dy() / 2))
} else {
return this.InnerBounds().Min.
return this.box.InnerBounds().Min.
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 {
cookie.Close()
}
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 {

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.
type ScrollContainer struct {
tomo.ContainerBox
box tomo.ContainerBox
root tomo.ContentObject
horizontal *Scrollbar
@ -55,30 +57,35 @@ type ScrollContainer struct {
// NewScrollContainer creates a new scroll container.
func NewScrollContainer (sides ScrollSide) *ScrollContainer {
this := &ScrollContainer {
ContainerBox: tomo.NewContainerBox(),
scrollContainer := &ScrollContainer {
box: tomo.NewContainerBox(),
}
if sides.Vertical() {
this.vertical = NewVerticalScrollbar()
this.vertical.OnValueChange(this.handleValueChange)
this.Add(this.vertical)
scrollContainer.vertical = NewVerticalScrollbar()
scrollContainer.vertical.OnValueChange(scrollContainer.handleValueChange)
scrollContainer.box.Add(scrollContainer.vertical)
}
if sides.Horizontal() {
this.horizontal = NewHorizontalScrollbar()
this.horizontal.OnValueChange(this.handleValueChange)
this.Add(this.horizontal)
scrollContainer.horizontal = NewHorizontalScrollbar()
scrollContainer.horizontal.OnValueChange(scrollContainer.handleValueChange)
scrollContainer.box.Add(scrollContainer.horizontal)
}
this.OnScroll(this.handleScroll)
this.OnKeyDown(this.handleKeyDown)
this.OnKeyUp(this.handleKeyUp)
this.SetRole(tomo.R("objects", "ScrollContainer"))
this.SetTag(sides.String(), true)
scrollContainer.box.OnScroll(scrollContainer.handleScroll)
scrollContainer.box.OnKeyDown(scrollContainer.handleKeyDown)
scrollContainer.box.OnKeyUp(scrollContainer.handleKeyUp)
scrollContainer.box.SetRole(tomo.R("objects", "ScrollContainer"))
scrollContainer.box.SetTag(sides.String(), true)
if sides == ScrollHorizontal {
this.SetAttr(tomo.ALayout(layouts.NewGrid(true)(true, false)))
scrollContainer.box.SetAttr(tomo.ALayout(layouts.NewGrid(true)(true, false)))
} 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
@ -87,7 +94,7 @@ func NewScrollContainer (sides ScrollSide) *ScrollContainer {
func (this *ScrollContainer) SetRoot (root tomo.ContentObject) {
if this.root != nil {
// remove root and close cookies
this.Remove(this.root)
this.box.Remove(this.root)
if this.horizontalCookie != nil {
this.horizontalCookie.Close()
this.horizontalCookie = nil
@ -102,11 +109,11 @@ func (this *ScrollContainer) SetRoot (root tomo.ContentObject) {
// insert root at the beginning (for keynav)
switch {
case this.vertical != nil:
this.Insert(root, this.vertical)
this.box.Insert(root, this.vertical)
case this.horizontal != nil:
this.Insert(root, this.horizontal)
this.box.Insert(root, this.horizontal)
default:
this.Add(root)
this.box.Add(root)
}
// 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 {
modifiers := this.Window().Modifiers()
modifiers := this.box.Window().Modifiers()
vector := image.Point { }
switch key {
case input.KeyPageUp:
if modifiers.Shift {
if modifiers.Shift() {
vector.X -= this.PageSize().X
} else {
vector.Y -= this.PageSize().Y
@ -203,7 +210,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
this.scrollBy(vector)
return true
case input.KeyPageDown:
if modifiers.Shift {
if modifiers.Shift() {
vector.X += this.PageSize().X
} else {
vector.Y += this.PageSize().Y
@ -211,7 +218,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
this.scrollBy(vector)
return true
case input.KeyUp:
if modifiers.Shift {
if modifiers.Shift() {
vector.X -= this.StepSize().X
} else {
vector.Y -= this.StepSize().Y
@ -219,7 +226,7 @@ func (this *ScrollContainer) handleKeyDown (key input.Key, numpad bool) bool {
this.scrollBy(vector)
return true
case input.KeyDown:
if modifiers.Shift {
if modifiers.Shift() {
vector.X += this.StepSize().X
} else {
vector.Y += this.StepSize().Y
@ -234,6 +241,8 @@ func (this *ScrollContainer) handleKeyUp (key input.Key, numpad bool) bool {
switch key {
case input.KeyPageUp: return true
case input.KeyPageDown: return true
case input.KeyUp: return true
case input.KeyDown: return true
}
return false
}

89
segment.go Normal file
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"
var _ tomo.Object = new(Separator)
// Separator is a line for visually separating elements.
type Separator struct {
tomo.Box
box tomo.Box
}
// NewSeparator creates a new separator line.
func NewSeparator () *Separator {
this := &Separator {
Box: tomo.NewBox(),
box: tomo.NewBox(),
}
this.SetRole(tomo.R("objects", "Separator"))
this.box.SetRole(tomo.R("objects", "Separator"))
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/event"
var _ tomo.Object = new(Slider)
// 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 {
tomo.ContainerBox
handle *SliderHandle
box tomo.ContainerBox
handle *sliderHandle
layout sliderLayout
dragging bool
dragOffset image.Point
@ -21,16 +34,14 @@ type Slider struct {
}
}
// SliderHandle is a simple object that serves as a handle for sliders and
// scrollbars. It is completely inert.
type SliderHandle struct {
type sliderHandle struct {
tomo.Box
}
func newSlider (orient string, value float64) *Slider {
this := &Slider {
ContainerBox: tomo.NewContainerBox(),
handle: &SliderHandle {
slider := &Slider {
box: tomo.NewContainerBox(),
handle: &sliderHandle {
Box: tomo.NewBox(),
},
layout: sliderLayout {
@ -40,23 +51,23 @@ func newSlider (orient string, value float64) *Slider {
step: 0.05,
}
this.Add(this.handle)
this.SetFocusable(true)
this.SetValue(value)
this.SetInputMask(true)
this.OnKeyUp(this.handleKeyUp)
this.OnKeyDown(this.handleKeyDown)
this.OnButtonDown(this.handleButtonDown)
this.OnButtonUp(this.handleButtonUp)
this.OnMouseMove(this.handleMouseMove)
this.OnScroll(this.handleScroll)
slider.handle.SetRole(tomo.R("objects", "SliderHandle"))
slider.handle.SetTag(orient, true)
slider.box.SetRole(tomo.R("objects", "Slider"))
slider.box.SetTag(orient, true)
this.handle.SetRole(tomo.R("objects", "SliderHandle"))
this.handle.SetTag(orient, true)
this.SetRole(tomo.R("objects", "Slider"))
this.SetTag(orient, true)
return this
slider.box.Add(slider.handle)
slider.box.SetFocusable(true)
slider.SetValue(value)
slider.box.SetInputMask(true)
slider.box.OnKeyUp(slider.handleKeyUp)
slider.box.OnKeyDown(slider.handleKeyDown)
slider.box.OnButtonDown(slider.handleButtonDown)
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.
@ -69,13 +80,25 @@ func NewHorizontalSlider (value float64) *Slider {
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.
func (this *Slider) SetValue (value float64) {
if value < 0 { value = 0 }
if value > 1 { value = 1 }
if value == this.layout.value { return }
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.
@ -104,7 +127,7 @@ func (this *Slider) handleKeyDown (key input.Key, numpad bool) bool {
switch key {
case input.KeyUp, input.KeyLeft:
if this.Window().Modifiers().Alt {
if this.box.Window().Modifiers().Alt() {
this.SetValue(0)
} else {
this.SetValue(this.Value() - increment)
@ -112,7 +135,7 @@ func (this *Slider) handleKeyDown (key input.Key, numpad bool) bool {
this.on.valueChange.Broadcast()
return true
case input.KeyDown, input.KeyRight:
if this.Window().Modifiers().Alt {
if this.box.Window().Modifiers().Alt() {
this.SetValue(1)
} else {
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 {
pointer := this.Window().MousePosition()
pointer := this.box.Window().MousePosition()
handle := this.handle.Bounds()
within := pointer.In(handle)
@ -158,7 +181,7 @@ func (this *Slider) handleButtonDown (button input.Button) bool {
this.dragging = true
this.dragOffset =
pointer.Sub(this.handle.Bounds().Min).
Add(this.InnerBounds().Min)
Add(this.box.InnerBounds().Min)
this.drag()
} else {
this.dragOffset = this.fallbackDragOffset()
@ -211,8 +234,8 @@ func (this *Slider) handleScroll (x, y float64) bool {
}
func (this *Slider) drag () {
pointer := this.Window().MousePosition().Sub(this.dragOffset)
gutter := this.InnerBounds()
pointer := this.box.Window().MousePosition().Sub(this.dragOffset)
gutter := this.box.InnerBounds()
handle := this.handle.Bounds()
if this.layout.vertical {
@ -230,10 +253,10 @@ func (this *Slider) drag () {
func (this *Slider) fallbackDragOffset () image.Point {
if this.layout.vertical {
return this.InnerBounds().Min.
return this.box.InnerBounds().Min.
Add(image.Pt(0, this.handle.Bounds().Dy() / 2))
} else {
return this.InnerBounds().Min.
return this.box.InnerBounds().Min.
Add(image.Pt(this.handle.Bounds().Dx() / 2, 0))
}
}

View File

@ -10,12 +10,14 @@ import "git.tebibyte.media/tomo/tomo/canvas"
import "git.tebibyte.media/tomo/objects/layouts"
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.
type Swatch struct {
tomo.CanvasBox
value color.Color
box tomo.CanvasBox
value color.Color
editing bool
label string
label string
on struct {
valueChange event.FuncBroadcaster
confirm event.FuncBroadcaster
@ -25,20 +27,32 @@ type Swatch struct {
// NewSwatch creates a new swatch with the given color.
func NewSwatch (value color.Color) *Swatch {
swatch := &Swatch {
CanvasBox: tomo.NewCanvasBox(),
box: tomo.NewCanvasBox(),
}
swatch.SetRole(tomo.R("objects", "Swatch"))
swatch.SetDrawer(swatch)
swatch.box.SetRole(tomo.R("objects", "Swatch"))
swatch.box.SetDrawer(swatch)
swatch.SetValue(value)
swatch.OnButtonDown(swatch.handleButtonDown)
swatch.OnButtonUp(swatch.handleButtonUp)
swatch.OnKeyDown(swatch.handleKeyDown)
swatch.OnKeyUp(swatch.handleKeyUp)
swatch.SetFocusable(true)
swatch.box.OnButtonDown(swatch.handleButtonDown)
swatch.box.OnButtonUp(swatch.handleButtonUp)
swatch.box.OnKeyDown(swatch.handleKeyDown)
swatch.box.OnKeyUp(swatch.handleKeyUp)
swatch.box.SetFocusable(true)
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.
func (this *Swatch) Value () color.Color {
return this.value
@ -48,7 +62,7 @@ func (this *Swatch) Value () color.Color {
func (this *Swatch) SetValue (value color.Color) {
this.value = value
if value == nil { value = color.Transparent }
this.Invalidate()
this.box.Invalidate()
}
// OnValueChange specifies a function to be called when the swatch's color
@ -75,10 +89,10 @@ func (this *Swatch) Choose () {
var err error
var window tomo.Window
if parent := this.Window(); parent != nil {
window, err = parent.NewChild(image.Rectangle { })
if parent := this.box.Window(); parent != nil {
window, err = parent.NewChild(tomo.WindowKindNormal, image.Rectangle { })
} else {
window, err = tomo.NewWindow(image.Rectangle { })
window, err = tomo.NewWindow(tomo.WindowKindNormal, image.Rectangle { })
}
if err != nil {
log.Println("objects: could not create swatch modal:", err)
@ -125,18 +139,16 @@ func (this *Swatch) Choose () {
})
okButton.OnClick(commit)
controlRow := NewInnerContainer (
layouts.ContractHorizontal,
cancelButton,
okButton)
controlRow.SetAttr(tomo.AAlign(tomo.AlignEnd, tomo.AlignMiddle))
window.SetRoot(NewOuterContainer (
window.SetRoot(NewRoot (
layouts.Column { true, false },
colorPicker,
NewInnerContainer(layouts.Row { false, true },
NewLabel("Hex"),
hexInput),
controlRow))
NewContentSegment (
layouts.Column { true, false },
colorPicker,
NewContainer (
layouts.Row { false, true },
NewLabel("Hex"),
hexInput)),
NewOptionSegment(nil, cancelButton, okButton)))
window.OnClose(func () {
if committed {
this.on.confirm.Broadcast()
@ -155,13 +167,13 @@ func (this *Swatch) Draw (can canvas.Canvas) {
// transparency slash
pen.Stroke(color.RGBA { R: 255, A: 255 })
pen.StrokeWeight(1)
pen.Path(this.Bounds().Min, this.Bounds().Max)
pen.Path(this.box.Bounds().Min, this.box.Bounds().Max)
// color
if this.value != nil {
pen.StrokeWeight(0)
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 {
if !isClickingButton(button) { return false }
if this.Window().MousePosition().In(this.Bounds()) {
if this.box.Window().MousePosition().In(this.box.Bounds()) {
this.Choose()
}
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
import "time"
import "image"
import "git.tebibyte.media/tomo/tomo"
import "git.tebibyte.media/tomo/tomo/text"
import "git.tebibyte.media/tomo/tomo/input"
import "git.tebibyte.media/tomo/tomo/event"
import "git.tebibyte.media/tomo/objects/internal"
const textInputHistoryMaximum = 64
const textInputHistoryMaxAge = time.Second / 4
var _ tomo.ContentObject = new(TextInput)
type textHistoryItem struct {
text string
dot text.Dot
}
// TextInput is a single-line editable text box.
type TextInput struct {
tomo.TextBox
text []rune
box tomo.TextBox
text []rune
multiline bool
history *internal.History[textHistoryItem]
on struct {
dotChange event.FuncBroadcaster
valueChange 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.
func NewTextInput (text string) *TextInput {
this := &TextInput { TextBox: tomo.NewTextBox() }
this.SetRole(tomo.R("objects", "TextInput"))
this.SetAttr(tomo.AAlign(tomo.AlignStart, tomo.AlignMiddle))
this.SetAttr(tomo.AOverflow(true, false))
this.SetText(text)
this.SetFocusable(true)
this.SetSelectable(true)
this.OnKeyDown(this.handleKeyDown)
this.OnKeyUp(this.handleKeyUp)
this.OnScroll(this.handleScroll)
return this
return newTextInput(text, false)
}
// NewMultilineTextInput creates a new multiline text input containing the
// specified text.
func NewMultilineTextInput (text string) *TextInput {
return newTextInput(text, true)
}
// GetBox returns the underlying box.
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.
func (this *TextInput) SetValue (text string) {
this.text = []rune(text)
this.TextBox.SetText(text)
this.box.SetText(text)
this.logLargeAction()
}
// Value returns the text content of the input.
@ -42,16 +134,6 @@ func (this *TextInput) Value () string {
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
// within the text input.
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)
}
// Undo undoes the last action.
func (this *TextInput) Undo () {
this.recoverHistoryItem(this.history.Undo())
}
// Redo redoes the last previously undone action.
func (this *TextInput) Redo () {
this.recoverHistoryItem(this.history.Redo())
}
// Type types a character at the current dot position.
func (this *TextInput) Type (char rune) {
dot := this.Dot()
this.historySwapDot()
this.text, dot = text.Type(this.text, dot, rune(char))
this.Select(dot)
this.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 {
dot := this.Dot()
modifiers := this.Window().Modifiers()
word := modifiers.Control
changed := false
dot := this.Dot()
txt := this.text
modifiers := this.box.Window().Modifiers()
word := modifiers.Control()
changed := false
defer func () {
this.Select(dot)
if changed {
this.SetText(string(this.text))
this.historySwapDot()
this.text = txt
this.box.SetText(string(txt))
this.box.Select(dot)
this.on.valueChange.Broadcast()
this.on.dotChange.Broadcast()
this.logKeystroke()
}
} ()
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 {
case isConfirmationKey(key):
this.on.confirm.Broadcast()
return true
case key == input.KeyBackspace:
this.text, dot = text.Backspace(this.text, dot, word)
txt, dot = text.Backspace(txt, dot, word)
changed = true
return true
case key == input.KeyDelete:
this.text, dot = text.Delete(this.text, dot, word)
txt, dot = text.Delete(txt, dot, word)
changed = true
return true
case key.Printable() && !modifiers.Control:
this.text, dot = text.Type(this.text, dot, rune(key))
changed = true
case key.Printable() && !modifiers.Control():
typeRune()
return true
case key == 'z' || key == 'Z' && modifiers.Control():
if modifiers.Shift() {
this.Redo()
} else {
this.Undo()
}
return true
case key == 'y' && modifiers.Control():
this.Redo()
return true
default:
return false
@ -108,7 +264,15 @@ func (this *TextInput) handleKeyDown (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 {
case isConfirmationKey(key):
return true
@ -116,7 +280,11 @@ func (this *TextInput) handleKeyUp (key input.Key, numpad bool) bool {
return true
case key == input.KeyDelete:
return true
case key.Printable() && !modifiers.Control:
case key.Printable() && !modifiers.Control():
return true
case key == 'z' && modifiers.Control():
return true
case key == 'y' && modifiers.Control():
return true
default:
return false
@ -128,3 +296,8 @@ func (this *TextInput) handleScroll (x, y float64) bool {
this.ScrollTo(this.ContentBounds().Min.Sub(image.Pt(int(x), int(y))))
return true
}
func (this *TextInput) handleDotChange () {
this.historySwapDot()
this.on.dotChange.Broadcast()
}

View File

@ -2,23 +2,83 @@ package objects
import "image"
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.
type TextView struct {
tomo.TextBox
box tomo.TextBox
}
// NewTextView creates a new text view.
func NewTextView (text string) *TextView {
this := &TextView { TextBox: tomo.NewTextBox() }
this.SetRole(tomo.R("objects", "TextView"))
this.SetFocusable(true)
this.SetSelectable(true)
this.SetText(text)
this.SetAttr(tomo.AOverflow(false, true))
this.SetAttr(tomo.AWrap(true))
this.OnScroll(this.handleScroll)
return this
textView := &TextView { box: tomo.NewTextBox() }
textView.box.SetRole(tomo.R("objects", "TextView"))
textView.box.SetFocusable(true)
textView.box.SetSelectable(true)
textView.SetText(text)
textView.box.SetAttr(tomo.AOverflow(false, true))
textView.box.SetAttr(tomo.AWrap(true))
textView.box.OnScroll(textView.handleScroll)
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 {