Merge pull request 'restructure-config' (#8) from restructure-config into main

Reviewed-on: sashakoshka/tomo#8
This commit is contained in:
Sasha Koshka 2023-02-09 02:07:08 +00:00
commit 5c7e243566
39 changed files with 1896 additions and 838 deletions

View File

@ -165,6 +165,7 @@ func (drawer *TextDrawer) LineHeight () (height fixed.Int26_6) {
// have its maximum width set to the given width. This does not alter the
// drawer's state.
func (drawer *TextDrawer) ReccomendedHeightFor (width int) (height int) {
if drawer.face == nil { return }
if !drawer.layoutClean { drawer.recalculate() }
metrics := drawer.face.Metrics()
dot := fixed.Point26_6 { 0, metrics.Height }

View File

@ -2,6 +2,8 @@ package tomo
import "errors"
import "git.tebibyte.media/sashakoshka/tomo/data"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/elements"
// Backend represents a connection to a display server, or something similar.
@ -28,6 +30,12 @@ type Backend interface {
// Paste returns the data currently in the clipboard.
Paste (accept []data.Mime) (data.Data)
// SetTheme sets the theme of all open windows.
SetTheme (theme.Theme)
// SetConfig sets the configuration of all open windows.
SetConfig (config.Config)
}
// BackendFactory represents a function capable of constructing a backend

View File

@ -8,6 +8,8 @@ import "github.com/jezek/xgbutil/xevent"
import "github.com/jezek/xgbutil/xwindow"
import "github.com/jezek/xgbutil/xgraphics"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/elements"
@ -20,6 +22,9 @@ type Window struct {
onClose func ()
skipChildDrawCallback bool
theme theme.Theme
config config.Config
metrics struct {
width int
height int
@ -69,6 +74,9 @@ func (backend *Backend) NewWindow (
Connect(backend.connection, window.xWindow.Id)
xevent.MotionNotifyFun(window.handleMotionNotify).
Connect(backend.connection, window.xWindow.Id)
window.SetTheme(backend.theme)
window.SetConfig(backend.config)
window.metrics.width = width
window.metrics.height = height
@ -100,6 +108,12 @@ func (window *Window) Adopt (child elements.Element) {
// adopt new child
window.child = child
if newChild, ok := child.(elements.Themeable); ok {
newChild.SetTheme(window.theme)
}
if newChild, ok := child.(elements.Configurable); ok {
newChild.SetConfig(window.config)
}
if newChild, ok := child.(elements.Flexible); ok {
newChild.OnFlexibleHeightChange(window.resizeChildToFit)
}
@ -196,6 +210,20 @@ func (window *Window) OnClose (callback func ()) {
window.onClose = callback
}
func (window *Window) SetTheme (theme theme.Theme) {
window.theme = theme
if child, ok := window.child.(elements.Themeable); ok {
child.SetTheme(theme)
}
}
func (window *Window) SetConfig (config config.Config) {
window.config = config
if child, ok := window.child.(elements.Configurable); ok {
child.SetConfig(config)
}
}
func (window *Window) reallocateCanvas () {
window.canvas.Reallocate(window.metrics.width, window.metrics.height)

View File

@ -2,6 +2,8 @@ package x
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/data"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "github.com/jezek/xgbutil"
import "github.com/jezek/xgb/xproto"
@ -25,6 +27,9 @@ type Backend struct {
hyper uint16
}
theme theme.Theme
config config.Config
windows map[xproto.Window] *Window
}
@ -33,6 +38,8 @@ func NewBackend () (output tomo.Backend, err error) {
backend := &Backend {
windows: map[xproto.Window] *Window { },
doChannel: make(chan func (), 0),
theme: theme.Default { },
config: config.Default { },
}
// connect to X
@ -66,7 +73,11 @@ func (backend *Backend) Run () (err error) {
// Stop gracefully closes the connection and stops the event loop.
func (backend *Backend) Stop () {
backend.assert()
toClose := []*Window { }
for _, window := range backend.windows {
toClose = append(toClose, window)
}
for _, window := range toClose {
window.Close()
}
xevent.Quit(backend.connection)
@ -95,6 +106,25 @@ func (backend *Backend) Paste (accept []data.Mime) (data data.Data) {
return
}
// SetTheme sets the theme of all open windows.
func (backend *Backend) SetTheme (theme theme.Theme) {
backend.assert()
backend.theme = theme
for _, window := range backend.windows {
window.SetTheme(theme)
}
}
// SetConfig sets the configuration of all open windows.
func (backend *Backend) SetConfig (config config.Config) {
backend.assert()
backend.config = config
for _, window := range backend.windows {
window.SetConfig(config)
}
}
func (backend *Backend) assert () {
if backend == nil { panic("nil backend") }
}

View File

@ -1,22 +1,93 @@
package config
// Padding returns the amount of internal padding elements should have. An
// element's inner content (such as text) should be inset by this amount,
// in addition to the inset returned by the pattern of its background. When
// using the aforementioned inset values to calculate the element's minimum size
// or the position and alignment of its content, all parameters in the
// PatternState should be unset except for Case.
func Padding () int {
// Config can return global configuration parameters.
type Config interface {
// Padding returns the amount of internal padding elements should have.
// An element's inner content (such as text) should be inset by this
// amount, in addition to the inset returned by the pattern of its
// background.
Padding () int
// Margin returns how much space should be put in between elements.
Margin () int
// HandleWidth returns how large grab handles should typically be. This
// is important for accessibility reasons.
HandleWidth () int
// ScrollVelocity returns how many pixels should be scrolled every time
// a scroll button is pressed.
ScrollVelocity () int
// ThemePath returns the directory path to the theme.
ThemePath () string
}
// Default specifies default configuration values.
type Default struct { }
// Padding returns the default padding value.
func (Default) Padding () int {
return 7
}
// Margin returns how much space should be put in between elements.
func Margin () int {
// Margin returns the default margin value.
func (Default) Margin () int {
return 8
}
// HandleWidth returns how large grab handles should typically be. This is
// important for accessibility reasons.
func HandleWidth () int {
// HandleWidth returns the default handle width value.
func (Default) HandleWidth () int {
return 16
}
// ScrollVelocity returns the default scroll velocity value.
func (Default) ScrollVelocity () int {
return 16
}
// ThemePath returns the default theme path.
func (Default) ThemePath () (string) {
return ""
}
// Wrapped wraps a configuration and uses Default if it is nil.
type Wrapped struct {
Config
}
// Padding returns the amount of internal padding elements should have.
// An element's inner content (such as text) should be inset by this
// amount, in addition to the inset returned by the pattern of its
// background.
func (wrapped Wrapped) Padding () int {
return wrapped.ensure().Padding()
}
// Margin returns how much space should be put in between elements.
func (wrapped Wrapped) Margin () int {
return wrapped.ensure().Margin()
}
// HandleWidth returns how large grab handles should typically be. This
// is important for accessibility reasons.
func (wrapped Wrapped) HandleWidth () int {
return wrapped.ensure().HandleWidth()
}
// ScrollVelocity returns how many pixels should be scrolled every time
// a scroll button is pressed.
func (wrapped Wrapped) ScrollVelocity () int {
return wrapped.ensure().ScrollVelocity()
}
// ThemePath returns the directory path to the theme.
func (wrapped Wrapped) ThemePath () string {
return wrapped.ensure().ThemePath()
}
func (wrapped Wrapped) ensure () (real Config) {
real = wrapped.Config
if real == nil { real = Default { } }
return
}

9
config/parse.go Normal file
View File

@ -0,0 +1,9 @@
package config
import "io"
// Parse parses one or more configuration files and returns them as a Config.
func Parse (sources ...io.Reader) (config Config) {
// TODO
return Default { }
}

82
dirs/dirs.go Normal file
View File

@ -0,0 +1,82 @@
package dirs
import "os"
import "strings"
import "path/filepath"
var homeDirectory string
var configHome string
var configDirs []string
var dataHome string
var dataDirs []string
var cacheHome string
func init () {
var err error
homeDirectory, err = os.UserHomeDir()
if err != nil {
panic("could not get user home directory: " + err.Error())
}
configHome = os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
configHome = filepath.Join(homeDirectory, "/.config/")
}
configDirsString := os.Getenv("XDG_CONFIG_DIRS")
if configDirsString == "" {
configDirsString = "/etc/xdg/"
}
configDirs = append(strings.Split(configDirsString, ":"), configHome)
dataHome = os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
dataHome = filepath.Join(homeDirectory, "/.local/share/")
}
dataDirsString := os.Getenv("XDG_CONFIG_DIRS")
if dataDirsString == "" {
dataDirsString = "/usr/local/share/:/usr/share/"
}
configDirs = append(strings.Split(configDirsString, ":"), configHome)
cacheHome = os.Getenv("XDG_CACHE_HOME")
if cacheHome == "" {
cacheHome = filepath.Join(homeDirectory, "/.cache/")
}
}
// ConfigHome returns the path to the directory where user configuration files
// should be stored.
func ConfigHome (name string) (home string) {
return filepath.Join(configHome, name)
}
// ConfigDirs returns all paths where configuration files might exist.
func ConfigDirs (name string) (dirs []string) {
dirs = make([]string, len(configDirs))
for index, dir := range configDirs {
dirs[index] = filepath.Join(dir, name)
}
return
}
// DataHome returns the path to the directory where user data should be stored.
func DataHome (name string) (home string) {
return filepath.Join(dataHome, name)
}
// DataDirs returns all paths where data files might exist.
func DataDirs (name string) (dirs []string) {
dirs = make([]string, len(dataDirs))
for index, dir := range dataDirs {
dirs[index] = filepath.Join(dir, name)
}
return
}
// CacheHome returns the path to the directory where user cache files should be
// stored.
func CacheHome (name string) (home string) {
return filepath.Join(cacheHome, name)
}

View File

@ -3,11 +3,10 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var buttonCase = theme.C("basic", "button")
// Button is a clickable button.
type Button struct {
*core.Core
@ -19,21 +18,19 @@ type Button struct {
pressed bool
text string
config config.Wrapped
theme theme.Wrapped
onClick func ()
}
// NewButton creates a new button with the specified label text.
func NewButton (text string) (element *Button) {
element = &Button { }
element.theme.Case = theme.C("basic", "button")
element.Core, element.core = core.NewCore(element.draw)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore (func () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
})
element.drawer.SetFace(theme.FontFaceRegular())
element.focusableControl = core.NewFocusableCore(element.redo)
element.SetText(text)
return
}
@ -43,19 +40,13 @@ func (element *Button) HandleMouseDown (x, y int, button input.Button) {
if !element.Focused() { element.Focus() }
if button != input.ButtonLeft { return }
element.pressed = true
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.redo()
}
func (element *Button) HandleMouseUp (x, y int, button input.Button) {
if button != input.ButtonLeft { return }
element.pressed = false
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.redo()
within := image.Point { x, y }.
In(element.Bounds())
@ -73,20 +64,14 @@ func (element *Button) HandleKeyDown (key input.Key, modifiers input.Modifiers)
if !element.Enabled() { return }
if key == input.KeyEnter {
element.pressed = true
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.redo()
}
}
func (element *Button) HandleKeyUp(key input.Key, modifiers input.Modifiers) {
if key == input.KeyEnter && element.pressed {
element.pressed = false
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.redo()
if !element.Enabled() { return }
if element.onClick != nil {
element.onClick()
@ -110,10 +95,36 @@ func (element *Button) SetText (text string) {
element.text = text
element.drawer.SetText([]rune(text))
element.updateMinimumSize()
element.redo()
}
// SetTheme sets the element's theme.
func (element *Button) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.drawer.SetFace (element.theme.FontFace (
theme.FontStyleRegular,
theme.FontSizeNormal))
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Button) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *Button) updateMinimumSize () {
textBounds := element.drawer.LayoutBounds()
_, inset := theme.ButtonPattern(theme.PatternState { Case: buttonCase })
minimumSize := inset.Inverse().Apply(textBounds).Inset(-theme.Padding())
minimumSize := textBounds.Inset(-element.config.Padding())
element.core.SetMinimumSize(minimumSize.Dx(), minimumSize.Dy())
}
func (element *Button) redo () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
@ -123,21 +134,20 @@ func (element *Button) SetText (text string) {
func (element *Button) draw () {
bounds := element.Bounds()
pattern, inset := theme.ButtonPattern(theme.PatternState {
Case: buttonCase,
state := theme.PatternState {
Disabled: !element.Enabled(),
Focused: element.Focused(),
Focused: element.Focused(),
Pressed: element.pressed,
})
}
pattern := element.theme.Pattern(theme.PatternButton, state)
artist.FillRectangle(element, pattern, bounds)
innerBounds := inset.Apply(bounds)
textBounds := element.drawer.LayoutBounds()
offset := image.Point {
X: innerBounds.Min.X + (innerBounds.Dx() - textBounds.Dx()) / 2,
Y: innerBounds.Min.Y + (innerBounds.Dy() - textBounds.Dy()) / 2,
X: bounds.Min.X + (bounds.Dx() - textBounds.Dx()) / 2,
Y: bounds.Min.Y + (bounds.Dy() - textBounds.Dy()) / 2,
}
// account for the fact that the bounding rectangle will be shifted over
@ -145,9 +155,10 @@ func (element *Button) draw () {
offset.Y -= textBounds.Min.Y
offset.X -= textBounds.Min.X
foreground, _ := theme.ForegroundPattern (theme.PatternState {
Case: buttonCase,
Disabled: !element.Enabled(),
})
if element.pressed {
offset = offset.Add(element.theme.Sink(theme.PatternButton))
}
foreground := element.theme.Pattern(theme.PatternForeground, state)
element.drawer.Draw(element, foreground, offset)
}

View File

@ -3,11 +3,10 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var checkboxCase = theme.C("basic", "checkbox")
// Checkbox is a toggle-able checkbox with a label.
type Checkbox struct {
*core.Core
@ -20,21 +19,19 @@ type Checkbox struct {
checked bool
text string
config config.Wrapped
theme theme.Wrapped
onToggle func ()
}
// NewCheckbox creates a new cbeckbox with the specified label text.
func NewCheckbox (text string, checked bool) (element *Checkbox) {
element = &Checkbox { checked: checked }
element.theme.Case = theme.C("basic", "checkbox")
element.Core, element.core = core.NewCore(element.draw)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore (func () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
})
element.drawer.SetFace(theme.FontFaceRegular())
element.focusableControl = core.NewFocusableCore(element.redo)
element.SetText(text)
return
}
@ -116,16 +113,45 @@ func (element *Checkbox) SetText (text string) {
element.text = text
element.drawer.SetText([]rune(text))
textBounds := element.drawer.LayoutBounds()
element.updateMinimumSize()
if text == "" {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
// SetTheme sets the element's theme.
func (element *Checkbox) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.drawer.SetFace (element.theme.FontFace (
theme.FontStyleRegular,
theme.FontSizeNormal))
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Checkbox) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *Checkbox) updateMinimumSize () {
textBounds := element.drawer.LayoutBounds()
if element.text == "" {
element.core.SetMinimumSize(textBounds.Dy(), textBounds.Dy())
} else {
element.core.SetMinimumSize (
textBounds.Dy() + theme.Padding() + textBounds.Dx(),
textBounds.Dy() + element.config.Padding() + textBounds.Dx(),
textBounds.Dy())
}
}
func (element *Checkbox) redo () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
@ -136,35 +162,28 @@ func (element *Checkbox) draw () {
bounds := element.Bounds()
boxBounds := image.Rect(0, 0, bounds.Dy(), bounds.Dy()).Add(bounds.Min)
backgroundPattern, _ := theme.BackgroundPattern(theme.PatternState {
Case: checkboxCase,
})
artist.FillRectangle(element, backgroundPattern, bounds)
pattern, inset := theme.ButtonPattern(theme.PatternState {
Case: checkboxCase,
state := theme.PatternState {
Disabled: !element.Enabled(),
Focused: element.Focused(),
Pressed: element.pressed,
})
On: element.checked,
}
backgroundPattern := element.theme.Pattern (
theme.PatternBackground, state)
artist.FillRectangle(element, backgroundPattern, bounds)
pattern := element.theme.Pattern(theme.PatternButton, state)
artist.FillRectangle(element, pattern, boxBounds)
textBounds := element.drawer.LayoutBounds()
offset := bounds.Min.Add(image.Point {
X: bounds.Dy() + theme.Padding(),
X: bounds.Dy() + element.config.Padding(),
})
offset.Y -= textBounds.Min.Y
offset.X -= textBounds.Min.X
foreground, _ := theme.ForegroundPattern (theme.PatternState {
Case: checkboxCase,
Disabled: !element.Enabled(),
})
foreground := element.theme.Pattern(theme.PatternForeground, state)
element.drawer.Draw(element, foreground, offset)
if element.checked {
checkBounds := inset.Apply(boxBounds).Inset(2)
artist.FillRectangle(element, foreground, checkBounds)
}
}

View File

@ -3,14 +3,13 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/layouts"
import "git.tebibyte.media/sashakoshka/tomo/elements"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var containerCase = theme.C("basic", "container")
// Container is an element capable of containg other elements, and arranging
// them in a layout.
type Container struct {
@ -25,6 +24,9 @@ type Container struct {
focusable bool
flexible bool
config config.Wrapped
theme theme.Wrapped
onFocusRequest func () (granted bool)
onFocusMotionRequest func (input.KeynavDirection) (granted bool)
onFlexibleHeightChange func ()
@ -33,6 +35,7 @@ type Container struct {
// NewContainer creates a new container.
func NewContainer (layout layouts.Layout) (element *Container) {
element = &Container { }
element.theme.Case = theme.C("basic", "container")
element.Core, element.core = core.NewCore(element.redoAll)
element.SetLayout(layout)
return
@ -52,6 +55,12 @@ func (element *Container) SetLayout (layout layouts.Layout) {
// whatever way is defined by the current layout.
func (element *Container) Adopt (child elements.Element, expand bool) {
// set event handlers
if child0, ok := child.(elements.Themeable); ok {
child0.SetTheme(element.theme.Theme)
}
if child0, ok := child.(elements.Configurable); ok {
child0.SetConfig(element.config.Config)
}
child.OnDamage (func (region canvas.Canvas) {
element.core.DamageRegion(region.Bounds())
})
@ -205,9 +214,9 @@ func (element *Container) redoAll () {
// draw a background
bounds := element.Bounds()
pattern, _ := theme.BackgroundPattern (theme.PatternState {
Case: containerCase,
})
pattern := element.theme.Pattern (
theme.PatternBackground,
theme.PatternState { })
artist.FillRectangle(element, pattern, bounds)
// cut our canvas up and give peices to child elements
@ -216,6 +225,33 @@ func (element *Container) redoAll () {
}
}
// SetTheme sets the element's theme.
func (element *Container) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
for _, child := range element.children {
if child0, ok := child.Element.(elements.Themeable); ok {
child0.SetTheme(element.theme.Theme)
}
}
element.updateMinimumSize()
element.redoAll()
}
// SetConfig sets the element's configuration.
func (element *Container) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
for _, child := range element.children {
if child0, ok := child.Element.(elements.Configurable); ok {
child0.SetConfig(element.config)
}
}
element.updateMinimumSize()
element.redoAll()
}
func (element *Container) HandleMouseDown (x, y int, button input.Button) {
child, handlesMouse := element.ChildAt(image.Pt(x, y)).(elements.MouseTarget)
if !handlesMouse { return }
@ -266,7 +302,7 @@ func (element *Container) HandleKeyUp (key input.Key, modifiers input.Modifiers)
func (element *Container) FlexibleHeightFor (width int) (height int) {
return element.layout.FlexibleHeightFor (
element.children,
theme.Margin(), width)
element.config.Margin(), width)
}
func (element *Container) OnFlexibleHeightChange (callback func ()) {
@ -469,15 +505,15 @@ func (element *Container) childFocusRequestCallback (
func (element *Container) updateMinimumSize () {
width, height := element.layout.MinimumSize (
element.children, theme.Margin())
element.children, element.config.Margin())
if element.flexible {
height = element.layout.FlexibleHeightFor (
element.children, theme.Margin(), width)
element.children, element.config.Margin(), width)
}
element.core.SetMinimumSize(width, height)
}
func (element *Container) recalculate () {
element.layout.Arrange (
element.children, theme.Margin(), element.Bounds())
element.children, element.config.Margin(), element.Bounds())
}

View File

@ -1,11 +1,10 @@
package basicElements
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var labelCase = theme.C("basic", "label")
// Label is a simple text box.
type Label struct {
*core.Core
@ -15,21 +14,38 @@ type Label struct {
text string
drawer artist.TextDrawer
config config.Wrapped
theme theme.Wrapped
onFlexibleHeightChange func ()
}
// NewLabel creates a new label. If wrap is set to true, the text inside will be
// wrapped.
func NewLabel (text string, wrap bool) (element *Label) {
element = &Label { }
element = &Label { }
element.theme.Case = theme.C("basic", "label")
element.Core, element.core = core.NewCore(element.handleResize)
face := theme.FontFaceRegular()
element.drawer.SetFace(face)
element.SetWrap(wrap)
element.SetText(text)
return
}
func (element *Label) redo () {
face := element.theme.FontFace (
theme.FontStyleRegular,
theme.FontSizeNormal)
element.drawer.SetFace(face)
element.updateMinimumSize()
bounds := element.Bounds()
if element.wrap {
element.drawer.SetMaxWidth(bounds.Dx())
element.drawer.SetMaxHeight(bounds.Dy())
}
element.draw()
element.core.DamageAll()
}
func (element *Label) handleResize () {
bounds := element.Bounds()
if element.wrap {
@ -90,10 +106,37 @@ func (element *Label) SetWrap (wrap bool) {
}
}
// SetTheme sets the element's theme.
func (element *Label) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.drawer.SetFace (element.theme.FontFace (
theme.FontStyleRegular,
theme.FontSizeNormal))
element.updateMinimumSize()
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
// SetConfig sets the element's configuration.
func (element *Label) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *Label) updateMinimumSize () {
if element.wrap {
em := element.drawer.Em().Round()
if em < 1 { em = theme.Padding() }
if em < 1 { em = element.config.Padding() }
element.core.SetMinimumSize (
em, element.drawer.LineHeight().Round())
if element.onFlexibleHeightChange != nil {
@ -108,15 +151,15 @@ func (element *Label) updateMinimumSize () {
func (element *Label) draw () {
bounds := element.Bounds()
pattern, _ := theme.BackgroundPattern(theme.PatternState {
Case: labelCase,
})
pattern := element.theme.Pattern (
theme.PatternBackground,
theme.PatternState { })
artist.FillRectangle(element, pattern, bounds)
textBounds := element.drawer.LayoutBounds()
foreground, _ := theme.ForegroundPattern (theme.PatternState {
Case: labelCase,
})
element.drawer.Draw (element, foreground, bounds.Min.Sub(textBounds.Min))
foreground := element.theme.Pattern (
theme.PatternForeground,
theme.PatternState { })
element.drawer.Draw(element, foreground, bounds.Min.Sub(textBounds.Min))
}

View File

@ -4,12 +4,11 @@ import "fmt"
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var listCase = theme.C("basic", "list")
// List is an element that contains several objects that a user can select.
type List struct {
*core.Core
@ -27,6 +26,9 @@ type List struct {
scroll int
entries []ListEntry
config config.Wrapped
theme theme.Wrapped
onScrollBoundsChange func ()
onNoEntrySelected func ()
}
@ -34,6 +36,7 @@ type List struct {
// NewList creates a new list element with the specified entries.
func NewList (entries ...ListEntry) (element *List) {
element = &List { selectedEntry: -1 }
element.theme.Case = theme.C("basic", "list")
element.Core, element.core = core.NewCore(element.handleResize)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore (func () {
@ -63,6 +66,44 @@ func (element *List) handleResize () {
}
}
// SetTheme sets the element's theme.
func (element *List) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
for index, entry := range element.entries {
entry.SetTheme(element.theme.Theme)
element.entries[index] = entry
}
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *List) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
for index, entry := range element.entries {
entry.SetConfig(element.config)
element.entries[index] = entry
}
element.updateMinimumSize()
element.redo()
}
func (element *List) redo () {
for index, entry := range element.entries {
element.entries[index] = element.resizeEntryToFit(entry)
}
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
if element.onScrollBoundsChange != nil {
element.onScrollBoundsChange()
}
}
// Collapse forces a minimum width and height upon the list. If a zero value is
// given for a dimension, its minimum will be determined by the list's content.
// If the list's height goes beyond the forced size, it will need to be accessed
@ -164,9 +205,7 @@ func (element *List) ScrollAxes () (horizontal, vertical bool) {
}
func (element *List) scrollViewportHeight () (height int) {
_, inset := theme.ListPattern(theme.PatternState {
Case: listCase,
})
inset := element.theme.Inset(theme.PatternSunken)
return element.Bounds().Dy() - inset[0] - inset[2]
}
@ -198,6 +237,8 @@ func (element *List) CountEntries () (count int) {
func (element *List) Append (entry ListEntry) {
// append
entry.Collapse(element.forcedMinimumWidth)
entry.SetTheme(element.theme.Theme)
entry.SetConfig(element.config)
element.entries = append(element.entries, entry)
// recalculate, redraw, notify
@ -290,7 +331,7 @@ func (element *List) Replace (index int, entry ListEntry) {
}
func (element *List) selectUnderMouse (x, y int) (updated bool) {
_, inset := theme.ListPattern(theme.PatternState { })
inset := element.theme.Inset(theme.PatternSunken)
bounds := inset.Apply(element.Bounds())
mousePoint := image.Pt(x, y)
dot := image.Pt (
@ -332,9 +373,7 @@ func (element *List) changeSelectionBy (delta int) (updated bool) {
}
func (element *List) resizeEntryToFit (entry ListEntry) (resized ListEntry) {
_, inset := theme.ListPattern(theme.PatternState {
Case: listCase,
})
inset := element.theme.Inset(theme.PatternSunken)
entry.Collapse(element.forcedMinimumWidth - inset[3] - inset[1])
return entry
}
@ -361,9 +400,7 @@ func (element *List) updateMinimumSize () {
minimumHeight = element.contentHeight
}
_, inset := theme.ListPattern(theme.PatternState {
Case: listCase,
})
inset := element.theme.Inset(theme.PatternSunken)
minimumHeight += inset[0] + inset[2]
element.core.SetMinimumSize(minimumWidth, minimumHeight)
@ -372,8 +409,8 @@ func (element *List) updateMinimumSize () {
func (element *List) draw () {
bounds := element.Bounds()
pattern, inset := theme.ListPattern(theme.PatternState {
Case: listCase,
inset := element.theme.Inset(theme.PatternSunken)
pattern := element.theme.Pattern (theme.PatternSunken, theme.PatternState {
Disabled: !element.Enabled(),
Focused: element.Focused(),
})

View File

@ -2,11 +2,10 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/artist"
var listEntryCase = theme.C("basic", "listEntry")
// ListEntry is an item that can be added to a list.
type ListEntry struct {
drawer artist.TextDrawer
@ -14,6 +13,10 @@ type ListEntry struct {
textPoint image.Point
text string
forcedMinimumWidth int
config config.Wrapped
theme theme.Wrapped
onSelect func ()
}
@ -22,8 +25,8 @@ func NewListEntry (text string, onSelect func ()) (entry ListEntry) {
text: text,
onSelect: onSelect,
}
entry.theme.Case = theme.C("basic", "listEntry")
entry.drawer.SetText([]rune(text))
entry.drawer.SetFace(theme.FontFaceRegular())
entry.updateBounds()
return
}
@ -34,6 +37,20 @@ func (entry *ListEntry) Collapse (width int) {
entry.updateBounds()
}
func (entry *ListEntry) SetTheme (new theme.Theme) {
if new == entry.theme.Theme { return }
entry.theme.Theme = new
entry.drawer.SetFace (entry.theme.FontFace (
theme.FontStyleRegular,
theme.FontSizeNormal))
entry.updateBounds()
}
func (entry *ListEntry) SetConfig (new config.Config) {
if new == entry.config.Config { return }
entry.config.Config = new
}
func (entry *ListEntry) updateBounds () {
entry.bounds = image.Rectangle { }
entry.bounds.Max.Y = entry.drawer.LineHeight().Round()
@ -43,8 +60,7 @@ func (entry *ListEntry) updateBounds () {
entry.bounds.Max.X = entry.drawer.LayoutBounds().Dx()
}
_, inset := theme.ItemPattern(theme.PatternState {
})
inset := entry.theme.Inset(theme.PatternRaised)
entry.bounds.Max.Y += inset[0] + inset[2]
entry.textPoint =
@ -60,20 +76,16 @@ func (entry *ListEntry) Draw (
) (
updatedRegion image.Rectangle,
) {
pattern, _ := theme.ItemPattern(theme.PatternState {
Case: listEntryCase,
state := theme.PatternState {
Focused: focused,
On: on,
})
}
pattern := entry.theme.Pattern (theme.PatternRaised, state)
artist.FillRectangle (
destination,
pattern,
entry.Bounds().Add(offset))
foreground, _ := theme.ForegroundPattern (theme.PatternState {
Case: listEntryCase,
Focused: focused,
On: on,
})
foreground := entry.theme.Pattern (theme.PatternForeground, state)
return entry.drawer.Draw (
destination,
foreground,

View File

@ -2,6 +2,7 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
@ -10,14 +11,17 @@ type ProgressBar struct {
*core.Core
core core.CoreControl
progress float64
config config.Wrapped
theme theme.Wrapped
}
// NewProgressBar creates a new progress bar displaying the given progress
// level.
func NewProgressBar (progress float64) (element *ProgressBar) {
element = &ProgressBar { progress: progress }
element.theme.Case = theme.C("basic", "progressBar")
element.Core, element.core = core.NewCore(element.draw)
element.core.SetMinimumSize(theme.Padding() * 2, theme.Padding() * 2)
return
}
@ -31,16 +35,50 @@ func (element *ProgressBar) SetProgress (progress float64) {
}
}
// SetTheme sets the element's theme.
func (element *ProgressBar) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *ProgressBar) SetConfig (new config.Config) {
if new == nil || new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element (ProgressBar)) updateMinimumSize() {
element.core.SetMinimumSize (
element.config.Padding() * 2,
element.config.Padding() * 2)
}
func (element *ProgressBar) redo () {
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
func (element *ProgressBar) draw () {
bounds := element.Bounds()
pattern, inset := theme.SunkenPattern(theme.PatternState { })
pattern := element.theme.Pattern (
theme.PatternSunken,
theme.PatternState { })
inset := element.theme.Inset(theme.PatternSunken)
artist.FillRectangle(element, pattern, bounds)
bounds = inset.Apply(bounds)
meterBounds := image.Rect (
bounds.Min.X, bounds.Min.Y,
bounds.Min.X + int(float64(bounds.Dx()) * element.progress),
bounds.Max.Y)
accent, _ := theme.AccentPattern(theme.PatternState { })
accent := element.theme.Pattern (
theme.PatternAccent,
theme.PatternState { })
artist.FillRectangle(element, accent, meterBounds)
}

View File

@ -3,15 +3,12 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var scrollContainerCase = theme.C("basic", "scrollContainer")
var scrollBarHorizontalCase = theme.C("basic", "scrollBarHorizontal")
var scrollBarVerticalCase = theme.C("basic", "scrollBarVertical")
// ScrollContainer is a container that is capable of holding a scrollable
// element.
type ScrollContainer struct {
@ -23,6 +20,7 @@ type ScrollContainer struct {
childWidth, childHeight int
horizontal struct {
theme theme.Wrapped
exists bool
enabled bool
dragging bool
@ -33,6 +31,7 @@ type ScrollContainer struct {
}
vertical struct {
theme theme.Wrapped
exists bool
enabled bool
dragging bool
@ -41,6 +40,9 @@ type ScrollContainer struct {
track image.Rectangle
bar image.Rectangle
}
config config.Wrapped
theme theme.Wrapped
onFocusRequest func () (granted bool)
onFocusMotionRequest func (input.KeynavDirection) (granted bool)
@ -50,8 +52,11 @@ type ScrollContainer struct {
// bars.
func NewScrollContainer (horizontal, vertical bool) (element *ScrollContainer) {
element = &ScrollContainer { }
element.theme.Case = theme.C("basic", "scrollContainer")
element.horizontal.theme.Case = theme.C("basic", "scrollBarHorizontal")
element.vertical.theme.Case = theme.C("basic", "scrollBarVertical")
element.Core, element.core = core.NewCore(element.handleResize)
element.updateMinimumSize()
element.horizontal.exists = horizontal
element.vertical.exists = vertical
return
@ -75,6 +80,12 @@ func (element *ScrollContainer) Adopt (child elements.Scrollable) {
// adopt new child
element.child = child
if child != nil {
if child0, ok := child.(elements.Themeable); ok {
child0.SetTheme(element.theme.Theme)
}
if child0, ok := child.(elements.Configurable); ok {
child0.SetConfig(element.config.Config)
}
child.OnDamage(element.childDamageCallback)
child.OnMinimumSizeChange(element.updateMinimumSize)
child.OnScrollBoundsChange(element.childScrollBoundsChangeCallback)
@ -85,8 +96,6 @@ func (element *ScrollContainer) Adopt (child elements.Scrollable) {
element.childFocusMotionRequestCallback)
}
// TODO: somehow inform the core that we do not in fact want to
// redraw the element.
element.updateMinimumSize()
element.horizontal.enabled,
@ -98,6 +107,34 @@ func (element *ScrollContainer) Adopt (child elements.Scrollable) {
}
}
// SetTheme sets the element's theme.
func (element *ScrollContainer) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
if child, ok := element.child.(elements.Themeable); ok {
child.SetTheme(element.theme.Theme)
}
if element.core.HasImage() {
element.recalculate()
element.resizeChildToFit()
element.draw()
}
}
// SetConfig sets the element's configuration.
func (element *ScrollContainer) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
if child, ok := element.child.(elements.Configurable); ok {
child.SetConfig(element.config.Config)
}
if element.core.HasImage() {
element.recalculate()
element.resizeChildToFit()
element.draw()
}
}
func (element *ScrollContainer) HandleKeyDown (key input.Key, modifiers input.Modifiers) {
if child, ok := element.child.(elements.KeyboardTarget); ok {
child.HandleKeyDown(key, modifiers)
@ -111,6 +148,7 @@ func (element *ScrollContainer) HandleKeyUp (key input.Key, modifiers input.Modi
}
func (element *ScrollContainer) HandleMouseDown (x, y int, button input.Button) {
velocity := element.config.ScrollVelocity()
point := image.Pt(x, y)
if point.In(element.horizontal.bar) {
element.horizontal.dragging = true
@ -123,9 +161,9 @@ func (element *ScrollContainer) HandleMouseDown (x, y int, button input.Button)
// FIXME: x backend and scroll container should pull these
// values from the same place
if x > element.horizontal.bar.Min.X {
element.scrollChildBy(16, 0)
element.scrollChildBy(velocity, 0)
} else {
element.scrollChildBy(-16, 0)
element.scrollChildBy(-velocity, 0)
}
} else if point.In(element.vertical.bar) {
@ -137,9 +175,9 @@ func (element *ScrollContainer) HandleMouseDown (x, y int, button input.Button)
} else if point.In(element.vertical.gutter) {
if y > element.vertical.bar.Min.Y {
element.scrollChildBy(0, 16)
element.scrollChildBy(0, velocity)
} else {
element.scrollChildBy(0, -16)
element.scrollChildBy(0, -velocity)
}
} else if child, ok := element.child.(elements.MouseTarget); ok {
@ -281,22 +319,19 @@ func (element *ScrollContainer) resizeChildToFit () {
}
func (element *ScrollContainer) recalculate () {
_, gutterInsetHorizontal := theme.GutterPattern(theme.PatternState {
Case: scrollBarHorizontalCase,
})
_, gutterInsetVertical := theme.GutterPattern(theme.PatternState {
Case: scrollBarHorizontalCase,
})
horizontal := &element.horizontal
vertical := &element.vertical
gutterInsetHorizontal := horizontal.theme.Inset(theme.PatternGutter)
gutterInsetVertical := vertical.theme.Inset(theme.PatternGutter)
bounds := element.Bounds()
thicknessHorizontal :=
theme.HandleWidth() +
element.config.HandleWidth() +
gutterInsetHorizontal[3] +
gutterInsetHorizontal[1]
thicknessVertical :=
theme.HandleWidth() +
element.config.HandleWidth() +
gutterInsetVertical[3] +
gutterInsetVertical[1]
@ -373,9 +408,8 @@ func (element *ScrollContainer) recalculate () {
func (element *ScrollContainer) draw () {
artist.Paste(element, element.child, image.Point { })
deadPattern, _ := theme.DeadPattern(theme.PatternState {
Case: scrollContainerCase,
})
deadPattern := element.theme.Pattern (
theme.PatternDead, theme.PatternState { })
artist.FillRectangle (
element, deadPattern,
image.Rect (
@ -388,32 +422,26 @@ func (element *ScrollContainer) draw () {
}
func (element *ScrollContainer) drawHorizontalBar () {
gutterPattern, _ := theme.GutterPattern (theme.PatternState {
Case: scrollBarHorizontalCase,
Disabled: !element.horizontal.enabled,
})
artist.FillRectangle(element, gutterPattern, element.horizontal.gutter)
handlePattern, _ := theme.HandlePattern (theme.PatternState {
Case: scrollBarHorizontalCase,
state := theme.PatternState {
Disabled: !element.horizontal.enabled,
Pressed: element.horizontal.dragging,
})
}
gutterPattern := element.horizontal.theme.Pattern(theme.PatternGutter, state)
artist.FillRectangle(element, gutterPattern, element.horizontal.gutter)
handlePattern := element.horizontal.theme.Pattern(theme.PatternHandle, state)
artist.FillRectangle(element, handlePattern, element.horizontal.bar)
}
func (element *ScrollContainer) drawVerticalBar () {
gutterPattern, _ := theme.GutterPattern (theme.PatternState {
Case: scrollBarVerticalCase,
Disabled: !element.vertical.enabled,
})
artist.FillRectangle(element, gutterPattern, element.vertical.gutter)
handlePattern, _ := theme.HandlePattern (theme.PatternState {
Case: scrollBarVerticalCase,
state := theme.PatternState {
Disabled: !element.vertical.enabled,
Pressed: element.vertical.dragging,
})
}
gutterPattern := element.vertical.theme.Pattern(theme.PatternGutter, state)
artist.FillRectangle(element, gutterPattern, element.vertical.gutter)
handlePattern := element.vertical.theme.Pattern(theme.PatternHandle, state)
artist.FillRectangle(element, handlePattern, element.vertical.bar)
}
@ -436,19 +464,15 @@ func (element *ScrollContainer) dragVerticalBar (mousePosition image.Point) {
}
func (element *ScrollContainer) updateMinimumSize () {
_, gutterInsetHorizontal := theme.GutterPattern(theme.PatternState {
Case: scrollBarHorizontalCase,
})
_, gutterInsetVertical := theme.GutterPattern(theme.PatternState {
Case: scrollBarHorizontalCase,
})
gutterInsetHorizontal := element.horizontal.theme.Inset(theme.PatternGutter)
gutterInsetVertical := element.vertical.theme.Inset(theme.PatternGutter)
thicknessHorizontal :=
theme.HandleWidth() +
element.config.HandleWidth() +
gutterInsetHorizontal[3] +
gutterInsetHorizontal[1]
thicknessVertical :=
theme.HandleWidth() +
element.config.HandleWidth() +
gutterInsetVertical[3] +
gutterInsetVertical[1]

View File

@ -1,16 +1,18 @@
package basicElements
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var spacerCase = theme.C("basic", "spacer")
// Spacer can be used to put space between two elements..
type Spacer struct {
*core.Core
core core.CoreControl
line bool
config config.Wrapped
theme theme.Wrapped
}
// NewSpacer creates a new spacer. If line is set to true, the spacer will be
@ -18,6 +20,7 @@ type Spacer struct {
// will appear as a line.
func NewSpacer (line bool) (element *Spacer) {
element = &Spacer { line: line }
element.theme.Case = theme.C("basic", "spacer")
element.Core, element.core = core.NewCore(element.draw)
element.core.SetMinimumSize(1, 1)
return
@ -33,20 +36,39 @@ func (element *Spacer) SetLine (line bool) {
}
}
// SetTheme sets the element's theme.
func (element *Spacer) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Spacer) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.redo()
}
func (element *Spacer) redo () {
if !element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
func (element *Spacer) draw () {
bounds := element.Bounds()
if element.line {
pattern, _ := theme.ForegroundPattern(theme.PatternState {
Case: spacerCase,
Disabled: true,
})
pattern := element.theme.Pattern (
theme.PatternForeground,
theme.PatternState { })
artist.FillRectangle(element, pattern, bounds)
} else {
pattern, _ := theme.BackgroundPattern(theme.PatternState {
Case: spacerCase,
Disabled: true,
})
pattern := element.theme.Pattern (
theme.PatternBackground,
theme.PatternState { })
artist.FillRectangle(element, pattern, bounds)
}
}

View File

@ -3,11 +3,10 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var switchCase = theme.C("basic", "switch")
// Switch is a toggle-able on/off switch with an optional label. It is
// functionally identical to Checkbox, but plays a different semantic role.
type Switch struct {
@ -21,23 +20,24 @@ type Switch struct {
checked bool
text string
config config.Wrapped
theme theme.Wrapped
onToggle func ()
}
// NewSwitch creates a new switch with the specified label text.
func NewSwitch (text string, on bool) (element *Switch) {
element = &Switch { checked: on, text: text }
element = &Switch {
checked: on,
text: text,
}
element.theme.Case = theme.C("basic", "switch")
element.Core, element.core = core.NewCore(element.draw)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore (func () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
})
element.drawer.SetFace(theme.FontFaceRegular())
element.focusableControl = core.NewFocusableCore(element.redo)
element.drawer.SetText([]rune(text))
element.calculateMinimumSize()
element.updateMinimumSize()
return
}
@ -45,10 +45,7 @@ func (element *Switch) HandleMouseDown (x, y int, button input.Button) {
if !element.Enabled() { return }
element.Focus()
element.pressed = true
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.redo()
}
func (element *Switch) HandleMouseUp (x, y int, button input.Button) {
@ -76,10 +73,7 @@ func (element *Switch) HandleMouseScroll (x, y int, deltaX, deltaY float64) { }
func (element *Switch) HandleKeyDown (key input.Key, modifiers input.Modifiers) {
if key == input.KeyEnter {
element.pressed = true
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.redo()
}
}
@ -87,10 +81,7 @@ func (element *Switch) HandleKeyUp (key input.Key, modifiers input.Modifiers) {
if key == input.KeyEnter && element.pressed {
element.pressed = false
element.checked = !element.checked
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
element.redo()
if element.onToggle != nil {
element.onToggle()
}
@ -118,15 +109,37 @@ func (element *Switch) SetText (text string) {
element.text = text
element.drawer.SetText([]rune(text))
element.calculateMinimumSize()
element.updateMinimumSize()
element.redo()
}
// SetTheme sets the element's theme.
func (element *Switch) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.drawer.SetFace (element.theme.FontFace (
theme.FontStyleRegular,
theme.FontSizeNormal))
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Switch) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *Switch) redo () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *Switch) calculateMinimumSize () {
func (element *Switch) updateMinimumSize () {
textBounds := element.drawer.LayoutBounds()
lineHeight := element.drawer.LineHeight().Round()
@ -134,7 +147,9 @@ func (element *Switch) calculateMinimumSize () {
element.core.SetMinimumSize(lineHeight * 2, lineHeight)
} else {
element.core.SetMinimumSize (
lineHeight * 2 + theme.Padding() + textBounds.Dx(),
lineHeight * 2 +
element.config.Padding() +
textBounds.Dx(),
lineHeight)
}
}
@ -143,9 +158,14 @@ func (element *Switch) draw () {
bounds := element.Bounds()
handleBounds := image.Rect(0, 0, bounds.Dy(), bounds.Dy()).Add(bounds.Min)
gutterBounds := image.Rect(0, 0, bounds.Dy() * 2, bounds.Dy()).Add(bounds.Min)
backgroundPattern, _ := theme.BackgroundPattern(theme.PatternState {
Case: switchCase,
})
state := theme.PatternState {
Disabled: !element.Enabled(),
Focused: element.Focused(),
Pressed: element.pressed,
}
backgroundPattern := element.theme.Pattern (
theme.PatternBackground, state)
artist.FillRectangle (element, backgroundPattern, bounds)
if element.checked {
@ -162,33 +182,23 @@ func (element *Switch) draw () {
}
}
gutterPattern, _ := theme.GutterPattern(theme.PatternState {
Case: switchCase,
Disabled: !element.Enabled(),
Focused: element.Focused(),
Pressed: element.pressed,
})
gutterPattern := element.theme.Pattern (
theme.PatternGutter, state)
artist.FillRectangle(element, gutterPattern, gutterBounds)
handlePattern, _ := theme.HandlePattern(theme.PatternState {
Case: switchCase,
Disabled: !element.Enabled(),
Focused: element.Focused(),
Pressed: element.pressed,
})
handlePattern := element.theme.Pattern (
theme.PatternHandle, state)
artist.FillRectangle(element, handlePattern, handleBounds)
textBounds := element.drawer.LayoutBounds()
offset := bounds.Min.Add(image.Point {
X: bounds.Dy() * 2 + theme.Padding(),
X: bounds.Dy() * 2 + element.config.Padding(),
})
offset.Y -= textBounds.Min.Y
offset.X -= textBounds.Min.X
foreground, _ := theme.ForegroundPattern (theme.PatternState {
Case: switchCase,
Disabled: !element.Enabled(),
})
foreground := element.theme.Pattern (
theme.PatternForeground, state)
element.drawer.Draw(element, foreground, offset)
}

View File

@ -3,12 +3,11 @@ package basicElements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/textmanip"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var textBoxCase = theme.C("basic", "textBox")
// TextBox is a single-line text input.
type TextBox struct {
*core.Core
@ -24,6 +23,9 @@ type TextBox struct {
placeholderDrawer artist.TextDrawer
valueDrawer artist.TextDrawer
config config.Wrapped
theme theme.Wrapped
onKeyDown func (key input.Key, modifiers input.Modifiers) (handled bool)
onChange func ()
onScrollBoundsChange func ()
@ -34,6 +36,7 @@ type TextBox struct {
// text.
func NewTextBox (placeholder, value string) (element *TextBox) {
element = &TextBox { }
element.theme.Case = theme.C("basic", "textBox")
element.Core, element.core = core.NewCore(element.handleResize)
element.FocusableCore,
element.focusableControl = core.NewFocusableCore (func () {
@ -42,8 +45,6 @@ func NewTextBox (placeholder, value string) (element *TextBox) {
element.core.DamageAll()
}
})
element.placeholderDrawer.SetFace(theme.FontFaceRegular())
element.valueDrawer.SetFace(theme.FontFaceRegular())
element.placeholder = placeholder
element.placeholderDrawer.SetText([]rune(placeholder))
element.updateMinimumSize()
@ -130,9 +131,8 @@ func (element *TextBox) HandleKeyDown(key input.Key, modifiers input.Modifiers)
element.onScrollBoundsChange()
}
if altered && element.core.HasImage () {
element.draw()
element.core.DamageAll()
if altered {
element.redo()
}
}
@ -145,10 +145,7 @@ func (element *TextBox) SetPlaceholder (placeholder string) {
element.placeholderDrawer.SetText([]rune(placeholder))
element.updateMinimumSize()
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
element.redo()
}
func (element *TextBox) SetValue (text string) {
@ -161,11 +158,7 @@ func (element *TextBox) SetValue (text string) {
element.cursor = element.valueDrawer.Length()
}
element.scrollToCursor()
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
element.redo()
}
func (element *TextBox) Value () (value string) {
@ -203,7 +196,7 @@ func (element *TextBox) ScrollViewportBounds () (bounds image.Rectangle) {
}
func (element *TextBox) scrollViewportWidth () (width int) {
return element.Bounds().Inset(theme.Padding()).Dx()
return element.Bounds().Inset(element.config.Padding()).Dx()
}
// ScrollTo scrolls the viewport to the specified point relative to
@ -218,10 +211,7 @@ func (element *TextBox) ScrollTo (position image.Point) {
maxPosition := contentBounds.Max.X - element.scrollViewportWidth()
if element.scroll > maxPosition { element.scroll = maxPosition }
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
element.redo()
if element.onScrollBoundsChange != nil {
element.onScrollBoundsChange()
}
@ -236,18 +226,6 @@ func (element *TextBox) OnScrollBoundsChange (callback func ()) {
element.onScrollBoundsChange = callback
}
func (element *TextBox) updateMinimumSize () {
textBounds := element.placeholderDrawer.LayoutBounds()
_, inset := theme.InputPattern(theme.PatternState {
Case: textBoxCase,
})
element.core.SetMinimumSize (
textBounds.Dx() +
theme.Padding() * 2 + inset[3] + inset[1],
element.placeholderDrawer.LineHeight().Round() +
theme.Padding() * 2 + inset[0] + inset[2])
}
func (element *TextBox) runOnChange () {
if element.onChange != nil {
element.onChange()
@ -257,7 +235,7 @@ func (element *TextBox) runOnChange () {
func (element *TextBox) scrollToCursor () {
if !element.core.HasImage() { return }
bounds := element.Bounds().Inset(theme.Padding())
bounds := element.Bounds().Inset(element.config.Padding())
bounds = bounds.Sub(bounds.Min)
bounds.Max.X -= element.valueDrawer.Em().Round()
cursorPosition := element.valueDrawer.PositionOf(element.cursor)
@ -272,28 +250,64 @@ func (element *TextBox) scrollToCursor () {
}
}
// SetTheme sets the element's theme.
func (element *TextBox) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
face := element.theme.FontFace (
theme.FontStyleRegular,
theme.FontSizeNormal)
element.placeholderDrawer.SetFace(face)
element.valueDrawer.SetFace(face)
element.updateMinimumSize()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *TextBox) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.redo()
}
func (element *TextBox) updateMinimumSize () {
textBounds := element.placeholderDrawer.LayoutBounds()
element.core.SetMinimumSize (
textBounds.Dx() +
element.config.Padding() * 2,
element.placeholderDrawer.LineHeight().Round() +
element.config.Padding() * 2)
}
func (element *TextBox) redo () {
if element.core.HasImage () {
element.draw()
element.core.DamageAll()
}
}
func (element *TextBox) draw () {
bounds := element.Bounds()
// FIXME: take index into account
pattern, inset := theme.InputPattern(theme.PatternState {
Case: textBoxCase,
state := theme.PatternState {
Disabled: !element.Enabled(),
Focused: element.Focused(),
})
}
pattern := element.theme.Pattern(theme.PatternSunken, state)
artist.FillRectangle(element, pattern, bounds)
if len(element.text) == 0 && !element.Focused() {
// draw placeholder
textBounds := element.placeholderDrawer.LayoutBounds()
offset := bounds.Min.Add (image.Point {
X: theme.Padding() + inset[3],
Y: theme.Padding() + inset[0],
})
foreground, _ := theme.ForegroundPattern(theme.PatternState {
Case: textBoxCase,
Disabled: true,
X: element.config.Padding(),
Y: element.config.Padding(),
})
foreground := element.theme.Pattern (
theme.PatternForeground,
theme.PatternState { Disabled: true })
element.placeholderDrawer.Draw (
element,
foreground,
@ -302,13 +316,11 @@ func (element *TextBox) draw () {
// draw input value
textBounds := element.valueDrawer.LayoutBounds()
offset := bounds.Min.Add (image.Point {
X: theme.Padding() + inset[3] - element.scroll,
Y: theme.Padding() + inset[0],
})
foreground, _ := theme.ForegroundPattern(theme.PatternState {
Case: textBoxCase,
Disabled: !element.Enabled(),
X: element.config.Padding() - element.scroll,
Y: element.config.Padding(),
})
foreground := element.theme.Pattern (
theme.PatternForeground, state)
element.valueDrawer.Draw (
element,
foreground,
@ -318,9 +330,6 @@ func (element *TextBox) draw () {
// cursor
cursorPosition := element.valueDrawer.PositionOf (
element.cursor)
foreground, _ := theme.ForegroundPattern(theme.PatternState {
Case: textBoxCase,
})
artist.Line (
element,
foreground, 1,

View File

@ -14,14 +14,21 @@ type Core struct {
minimumHeight int
}
drawSizeChange func ()
drawSizeChange func ()
onMinimumSizeChange func ()
onDamage func (region canvas.Canvas)
}
// NewCore creates a new element core and its corresponding control.
func NewCore (drawSizeChange func ()) (core *Core, control CoreControl) {
core = &Core { drawSizeChange: drawSizeChange }
func NewCore (
drawSizeChange func (),
) (
core *Core,
control CoreControl,
) {
core = &Core {
drawSizeChange: drawSizeChange,
}
control = CoreControl { core: core }
return
}

View File

@ -2,7 +2,9 @@ package elements
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/canvas"
import "git.tebibyte.media/sashakoshka/tomo/config"
// Element represents a basic on-screen object.
type Element interface {
@ -37,7 +39,8 @@ type Element interface {
type Focusable interface {
Element
// Focused returns whether or not this element is currently focused.
// Focused returns whether or not this element or any of its children
// are currently focused.
Focused () (selected bool)
// Focus focuses this element, if its parent element grants the
@ -157,3 +160,23 @@ type Scrollable interface {
// ScrollContentBounds, ScrollViewportBounds, or ScrollAxes are changed.
OnScrollBoundsChange (callback func ())
}
// Themeable represents an element that can modify its appearance to fit within
// a theme.
type Themeable interface {
Element
// SetTheme sets the element's theme to something fulfilling the
// theme.Theme interface.
SetTheme (theme.Theme)
}
// Configurable represents an element that can modify its behavior to fit within
// a set of configuration parameters.
type Configurable interface {
Element
// SetConfig sets the element's configuration to something fulfilling
// the config.Config interface.
SetConfig (config.Config)
}

View File

@ -4,21 +4,24 @@ import "time"
import "math"
import "image"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
var clockCase = theme.C("fun", "clock")
// AnalogClock can display the time of day in an analog format.
type AnalogClock struct {
*core.Core
core core.CoreControl
time time.Time
config config.Wrapped
theme theme.Wrapped
}
// NewAnalogClock creates a new analog clock that displays the specified time.
func NewAnalogClock (newTime time.Time) (element *AnalogClock) {
element = &AnalogClock { }
element.theme.Case = theme.C("fun", "clock")
element.Core, element.core = core.NewCore(element.draw)
element.core.SetMinimumSize(64, 64)
return
@ -28,6 +31,24 @@ func NewAnalogClock (newTime time.Time) (element *AnalogClock) {
func (element *AnalogClock) SetTime (newTime time.Time) {
if newTime == element.time { return }
element.time = newTime
element.redo()
}
// SetTheme sets the element's theme.
func (element *AnalogClock) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.redo()
}
// SetConfig sets the element's configuration.
func (element *AnalogClock) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.redo()
}
func (element *AnalogClock) redo () {
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
@ -37,19 +58,15 @@ func (element *AnalogClock) SetTime (newTime time.Time) {
func (element *AnalogClock) draw () {
bounds := element.Bounds()
pattern, inset := theme.SunkenPattern(theme.PatternState {
Case: clockCase,
})
state := theme.PatternState { }
pattern := element.theme.Pattern(theme.PatternSunken, state)
inset := element.theme.Inset(theme.PatternSunken)
artist.FillRectangle(element, pattern, bounds)
bounds = inset.Apply(bounds)
foreground, _ := theme.ForegroundPattern(theme.PatternState {
Case: clockCase,
})
accent, _ := theme.AccentPattern(theme.PatternState {
Case: clockCase,
})
foreground := element.theme.Pattern(theme.PatternForeground, state)
accent := element.theme.Pattern(theme.PatternAccent, state)
for hour := 0; hour < 12; hour ++ {
element.radialLine (
@ -71,7 +88,7 @@ func (element *AnalogClock) FlexibleHeightFor (width int) (height int) {
return width
}
// OnFlexibleHeightChange sets a function to be calle dwhen the parameters
// OnFlexibleHeightChange sets a function to be called when the parameters
// affecting the clock's flexible height change.
func (element *AnalogClock) OnFlexibleHeightChange (func ()) { }

244
elements/fun/piano.go Normal file
View File

@ -0,0 +1,244 @@
package fun
import "image"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
// Octave represents a MIDI octave.
type Octave int
// Note returns the note at the specified scale degree in the chromatic scale.
func (octave Octave) Note (degree int) Note {
return Note(int(octave + 1) * 12 + degree)
}
// Note represents a MIDI note.
type Note int
// Octave returns the octave of the note
func (note Note) Octave () int {
return int(note / 12 - 1)
}
// Degree returns the scale degree of the note in the chromatic scale.
func (note Note) Degree () int {
mod := note % 12
if mod < 0 { mod += 12 }
return int(mod)
}
// IsSharp returns whether or not the note is a sharp.
func (note Note) IsSharp () bool {
degree := note.Degree()
return degree == 1 ||
degree == 3 ||
degree == 6 ||
degree == 8 ||
degree == 10
}
const pianoKeyWidth = 18
type pianoKey struct {
image.Rectangle
Note
}
type Piano struct {
*core.Core
core core.CoreControl
low, high Octave
config config.Wrapped
theme theme.Wrapped
flatKeys []pianoKey
sharpKeys []pianoKey
pressed *pianoKey
onPress func (Note)
onRelease func (Note)
}
func NewPiano (low, high Octave) (element *Piano) {
element = &Piano {
low: low,
high: high,
}
element.theme.Case = theme.C("fun", "piano")
element.Core, element.core = core.NewCore (func () {
element.recalculate()
element.draw()
})
element.updateMinimumSize()
return
}
// OnPress sets a function to be called when a key is pressed.
func (element *Piano) OnPress (callback func (note Note)) {
element.onPress = callback
}
// OnRelease sets a function to be called when a key is released.
func (element *Piano) OnRelease (callback func (note Note)) {
element.onRelease = callback
}
func (element *Piano) HandleMouseDown (x, y int, button input.Button) {
if button != input.ButtonLeft { return }
element.pressUnderMouseCursor(image.Pt(x, y))
}
func (element *Piano) HandleMouseUp (x, y int, button input.Button) {
if button != input.ButtonLeft { return }
if element.onRelease != nil {
element.onRelease((*element.pressed).Note)
}
element.pressed = nil
element.redo()
}
func (element *Piano) HandleMouseMove (x, y int) {
if element.pressed == nil { return }
element.pressUnderMouseCursor(image.Pt(x, y))
}
func (element *Piano) HandleMouseScroll (x, y int, deltaX, deltaY float64) { }
func (element *Piano) pressUnderMouseCursor (point image.Point) {
// release previous note
if element.pressed != nil && element.onRelease != nil {
element.onRelease((*element.pressed).Note)
}
// find out which note is being pressed
newKey := (*pianoKey)(nil)
for index, key := range element.flatKeys {
if point.In(key.Rectangle) {
newKey = &element.flatKeys[index]
break
}
}
for index, key := range element.sharpKeys {
if point.In(key.Rectangle) {
newKey = &element.sharpKeys[index]
break
}
}
if newKey == nil { return }
if newKey != element.pressed {
// press new note
element.pressed = newKey
if element.onPress != nil {
element.onPress((*element.pressed).Note)
}
element.redo()
}
}
// SetTheme sets the element's theme.
func (element *Piano) SetTheme (new theme.Theme) {
if new == element.theme.Theme { return }
element.theme.Theme = new
element.updateMinimumSize()
element.recalculate()
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Piano) SetConfig (new config.Config) {
if new == element.config.Config { return }
element.config.Config = new
element.updateMinimumSize()
element.recalculate()
element.redo()
}
func (element *Piano) updateMinimumSize () {
element.core.SetMinimumSize (
pianoKeyWidth * 7 * element.countOctaves(), 64)
}
func (element *Piano) countOctaves () int {
return int(element.high - element.low + 1)
}
func (element *Piano) countFlats () int {
return element.countOctaves() * 8
}
func (element *Piano) countSharps () int {
return element.countOctaves() * 5
}
func (element *Piano) redo () {
if element.core.HasImage() {
element.draw()
element.core.DamageAll()
}
}
func (element *Piano) recalculate () {
element.flatKeys = make([]pianoKey, element.countFlats())
element.sharpKeys = make([]pianoKey, element.countSharps())
bounds := element.Bounds()
dot := bounds.Min
note := element.low.Note(0)
limit := element.high.Note(12)
flatIndex := 0
sharpIndex := 0
for note < limit {
if note.IsSharp() {
element.sharpKeys[sharpIndex].Rectangle = image.Rect (
-(pianoKeyWidth * 3) / 7, 0,
(pianoKeyWidth * 3) / 7,
bounds.Dy() / 2).Add(dot)
element.sharpKeys[sharpIndex].Note = note
sharpIndex ++
} else {
element.flatKeys[flatIndex].Rectangle = image.Rect (
0, 0, pianoKeyWidth, bounds.Dy()).Add(dot)
dot.X += pianoKeyWidth
element.flatKeys[flatIndex].Note = note
flatIndex ++
}
note ++
}
}
func (element *Piano) draw () {
for _, key := range element.flatKeys {
element.drawFlat (
key.Rectangle,
element.pressed != nil &&
(*element.pressed).Note == key.Note)
}
for _, key := range element.sharpKeys {
element.drawSharp (
key.Rectangle,
element.pressed != nil &&
(*element.pressed).Note == key.Note)
}
}
func (element *Piano) drawFlat (bounds image.Rectangle, pressed bool) {
state := theme.PatternState {
Pressed: pressed,
}
pattern := element.theme.Pattern(theme.PatternButton, state)
artist.FillRectangle(element, pattern, bounds)
}
func (element *Piano) drawSharp (bounds image.Rectangle, pressed bool) {
state := theme.PatternState {
Pressed: pressed,
}
pattern := element.theme.Pattern(theme.PatternButton, state)
artist.FillRectangle(element, pattern, bounds)
}

View File

@ -26,6 +26,8 @@ func NewArtist () (element *Artist) {
func (element *Artist) draw () {
bounds := element.Bounds()
artist.FillRectangle(element, artist.NewUniform(hex(0)), bounds)
element.cellBounds.Max.X = bounds.Min.X + bounds.Dx() / 5
element.cellBounds.Max.Y = bounds.Min.Y + (bounds.Dy() - 48) / 8

View File

@ -3,6 +3,7 @@ package testing
import "image"
import "image/color"
import "git.tebibyte.media/sashakoshka/tomo/input"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/elements/core"
@ -15,20 +16,45 @@ type Mouse struct {
drawing bool
color artist.Pattern
lastMousePos image.Point
config config.Config
theme theme.Theme
c theme.Case
}
// NewMouse creates a new mouse test element.
func NewMouse () (element *Mouse) {
element = &Mouse { }
element = &Mouse { c: theme.C("testing", "mouse") }
element.Core, element.core = core.NewCore(element.draw)
element.core.SetMinimumSize(32, 32)
element.color = artist.NewUniform(color.Black)
return
}
// SetTheme sets the element's theme.
func (element *Mouse) SetTheme (new theme.Theme) {
element.theme = new
element.redo()
}
// SetConfig sets the element's configuration.
func (element *Mouse) SetConfig (new config.Config) {
element.config = new
element.redo()
}
func (element *Mouse) redo () {
if !element.core.HasImage() { return }
element.draw()
element.core.DamageAll()
}
func (element *Mouse) draw () {
bounds := element.Bounds()
pattern, _ := theme.AccentPattern(theme.PatternState { })
pattern := element.theme.Pattern (
theme.PatternAccent,
element.c,
theme.PatternState { })
artist.FillRectangle(element, pattern, bounds)
artist.StrokeRectangle (
element,

View File

@ -15,7 +15,7 @@ func main () {
func run () {
window, _ := tomo.NewWindow(2, 2)
window.SetTitle("clock")
window.SetTitle("Clock")
container := basicElements.NewContainer(basicLayouts.Vertical { true, true })
window.Adopt(container)

26
examples/piano/main.go Normal file
View File

@ -0,0 +1,26 @@
package main
import "git.tebibyte.media/sashakoshka/tomo"
import "git.tebibyte.media/sashakoshka/tomo/layouts/basic"
import "git.tebibyte.media/sashakoshka/tomo/elements/fun"
import "git.tebibyte.media/sashakoshka/tomo/elements/basic"
import _ "git.tebibyte.media/sashakoshka/tomo/backends/x"
func main () {
tomo.Run(run)
}
func run () {
window, _ := tomo.NewWindow(2, 2)
window.SetTitle("Piano")
container := basicElements.NewContainer(basicLayouts.Vertical { true, true })
window.Adopt(container)
label := basicElements.NewLabel("Play a song!", false)
container.Adopt(label, false)
piano := fun.NewPiano(3, 5)
container.Adopt(piano, true)
window.OnClose(tomo.Stop)
window.Show()
}

View File

@ -1,48 +0,0 @@
package theme
import "git.tebibyte.media/sashakoshka/tomo/artist"
var buttonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var selectedButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var pressedButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x4B5B59FF)),
artist.NewUniform(hex(0x8D9894FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var pressedSelectedButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x4B5B59FF)),
artist.NewUniform(hex(0x8D9894FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var disabledButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })

179
theme/default.go Normal file
View File

@ -0,0 +1,179 @@
package theme
import "image"
import "golang.org/x/image/font"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/defaultfont"
// Default is the default theme.
type Default struct { }
// FontFace returns the default font face.
func (Default) FontFace (style FontStyle, size FontSize, c Case) font.Face {
switch style {
case FontStyleBold:
return defaultfont.FaceBold
case FontStyleItalic:
return defaultfont.FaceItalic
case FontStyleBoldItalic:
return defaultfont.FaceBoldItalic
default:
return defaultfont.FaceRegular
}
}
// Icon returns an icon from the default set corresponding to the given name.
func (Default) Icon (string, Case) artist.Pattern {
// TODO
return uhex(0)
}
// Pattern returns a pattern from the default theme corresponding to the given
// pattern ID.
func (Default) Pattern (
pattern Pattern,
c Case,
state PatternState,
) artist.Pattern {
switch pattern {
case PatternAccent:
return accentPattern
case PatternBackground:
return backgroundPattern
case PatternForeground:
if state.Disabled || c == C("basic", "spacer") {
return weakForegroundPattern
} else {
return foregroundPattern
}
case PatternDead:
return deadPattern
case PatternRaised:
if c == C("basic", "listEntry") {
if state.Focused {
if state.On {
return focusedOnListEntryPattern
} else {
return focusedListEntryPattern
}
} else {
if state.On {
return onListEntryPattern
} else {
return listEntryPattern
}
}
} else {
if state.Focused {
return selectedRaisedPattern
} else {
return raisedPattern
}
}
case PatternSunken:
if c == C("basic", "list") {
if state.Focused {
return focusedListPattern
} else {
return listPattern
}
} else if c == C("basic", "textBox") {
if state.Disabled {
return disabledInputPattern
} else {
if state.Focused {
return selectedInputPattern
} else {
return inputPattern
}
}
} else {
return sunkenPattern
}
case PatternPinboard:
return texturedSunkenPattern
case PatternButton:
if state.Disabled {
return disabledButtonPattern
} else {
if state.Pressed || state.On && c == C("basic", "checkbox") {
if state.Focused {
return pressedSelectedButtonPattern
} else {
return pressedButtonPattern
}
} else {
if state.Focused {
return selectedButtonPattern
} else {
return buttonPattern
}
}
}
case PatternInput:
if state.Disabled {
return disabledInputPattern
} else {
if state.Focused {
return selectedInputPattern
} else {
return inputPattern
}
}
case PatternGutter:
if state.Disabled {
return disabledScrollGutterPattern
} else {
return scrollGutterPattern
}
case PatternHandle:
if state.Disabled {
return disabledScrollBarPattern
} else {
if state.Focused {
if state.Pressed {
return pressedSelectedScrollBarPattern
} else {
return selectedScrollBarPattern
}
} else {
if state.Pressed {
return pressedScrollBarPattern
} else {
return scrollBarPattern
}
}
}
default:
return uhex(0)
}
}
// Inset returns the default inset value for the given pattern.
func (Default) Inset (pattern Pattern, c Case) Inset {
switch pattern {
case PatternRaised:
if c == C("basic", "listEntry") {
return Inset { 4, 6, 4, 6 }
} else {
return Inset { 1, 1, 1, 1 }
}
case PatternSunken:
if c == C("basic", "list") {
return Inset { 2, 1, 2, 1 }
} else if c == C("basic", "progressBar") {
return Inset { 2, 1, 1, 2 }
} else {
return Inset { 1, 1, 1, 1 }
}
case PatternInput, PatternButton, PatternHandle, PatternPinboard:
return Inset { 1, 1, 1, 1}
default: return Inset { }
}
}
// Sink returns the default sink vector for the given pattern.
func (Default) Sink (pattern Pattern, c Case) image.Point {
return image.Point { 1, 1 }
}

237
theme/defaultpatterns.go Normal file
View File

@ -0,0 +1,237 @@
package theme
import "image/color"
import "git.tebibyte.media/sashakoshka/tomo/artist"
var accentPattern = artist.NewUniform(hex(0x408090FF))
var backgroundPattern = artist.NewUniform(color.Gray16 { 0xAAAA })
var foregroundPattern = artist.NewUniform(color.Gray16 { 0x0000 })
var weakForegroundPattern = artist.NewUniform(color.Gray16 { 0x4444 })
var strokePattern = artist.NewUniform(color.Gray16 { 0x0000 })
var sunkenPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x3b534eFF)),
artist.NewUniform(hex(0x97a09cFF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x97a09cFF)) })
var texturedSunkenPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x3b534eFF)),
artist.NewUniform(hex(0x97a09cFF)),
},
},
// artist.Stroke { Pattern: artist.Striped {
// First: artist.Stroke {
// Weight: 2,
// Pattern: artist.NewUniform(hex(0x97a09cFF)),
// },
// Second: artist.Stroke {
// Weight: 1,
// Pattern: artist.NewUniform(hex(0x6e8079FF)),
// },
// }})
artist.Stroke { Pattern: artist.Noisy {
Low: artist.NewUniform(hex(0x97a09cFF)),
High: artist.NewUniform(hex(0x6e8079FF)),
}})
var raisedPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xDBDBDBFF)),
artist.NewUniform(hex(0x383C3AFF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0xAAAAAAFF)) })
var selectedRaisedPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xDBDBDBFF)),
artist.NewUniform(hex(0x383C3AFF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0xAAAAAAFF)) })
var deadPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x97a09cFF)) })
var buttonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var selectedButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var pressedButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x4B5B59FF)),
artist.NewUniform(hex(0x8D9894FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var pressedSelectedButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x4B5B59FF)),
artist.NewUniform(hex(0x8D9894FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var disabledButtonPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })
var inputPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x89925AFF)),
artist.NewUniform(hex(0xD2CB9AFF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0xD2CB9AFF)) })
var selectedInputPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0xD2CB9AFF)) })
var disabledInputPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })
var listPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
uhex(0x383C3AFF),
uhex(0x999C99FF),
},
},
artist.Stroke { Pattern: uhex(0x999C99FF) })
var focusedListPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: uhex(0x999C99FF) })
var listEntryPattern = artist.Padded {
Stroke: uhex(0x383C3AFF),
Fill: uhex(0x999C99FF),
Sides: []int { 0, 0, 0, 1 },
}
var onListEntryPattern = artist.Padded {
Stroke: uhex(0x383C3AFF),
Fill: uhex(0x6e8079FF),
Sides: []int { 0, 0, 0, 1 },
}
var focusedListEntryPattern = artist.Padded {
Stroke: accentPattern,
Fill: uhex(0x999C99FF),
Sides: []int { 0, 1, 0, 1 },
}
var focusedOnListEntryPattern = artist.Padded {
Stroke: accentPattern,
Fill: uhex(0x6e8079FF),
Sides: []int { 0, 1, 0, 1 },
}
var scrollGutterPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x3b534eFF)),
artist.NewUniform(hex(0x6e8079FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x6e8079FF)) })
var disabledScrollGutterPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })
var scrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var selectedScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var pressedScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: artist.NewUniform(hex(0x8D9894FF)) },
artist.Stroke { Pattern: artist.NewUniform(hex(0x7f8c89FF)) })
var pressedSelectedScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x7f8c89FF)) })
var disabledScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })

View File

@ -1,21 +0,0 @@
package theme
import "git.tebibyte.media/sashakoshka/tomo/artist"
var inputPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x89925AFF)),
artist.NewUniform(hex(0xD2CB9AFF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0xD2CB9AFF)) })
var selectedInputPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0xD2CB9AFF)) })
var disabledInputPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })

42
theme/inset.go Normal file
View File

@ -0,0 +1,42 @@
package theme
import "image"
// Inset represents an inset amount for all four sides of a rectangle. The top
// side is at index zero, the right at index one, the bottom at index two, and
// the left at index three. These values may be negative.
type Inset [4]int
// Apply returns the given rectangle, shrunk on all four sides by the given
// inset. If a measurment of the inset is negative, that side will instead be
// expanded outward. If the rectangle's dimensions cannot be reduced any
// further, an empty rectangle near its center will be returned.
func (inset Inset) Apply (bigger image.Rectangle) (smaller image.Rectangle) {
smaller = bigger
if smaller.Dx() < inset[3] + inset[1] {
smaller.Min.X = (smaller.Min.X + smaller.Max.X) / 2
smaller.Max.X = smaller.Min.X
} else {
smaller.Min.X += inset[3]
smaller.Max.X -= inset[1]
}
if smaller.Dy() < inset[0] + inset[2] {
smaller.Min.Y = (smaller.Min.Y + smaller.Max.Y) / 2
smaller.Max.Y = smaller.Min.Y
} else {
smaller.Min.Y += inset[0]
smaller.Max.Y -= inset[2]
}
return
}
// Inverse returns a negated version of the inset.
func (inset Inset) Inverse () (prime Inset) {
return Inset {
inset[0] * -1,
inset[1] * -1,
inset[2] * -1,
inset[3] * -1,
}
}

View File

@ -1,43 +0,0 @@
package theme
import "git.tebibyte.media/sashakoshka/tomo/artist"
var listPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
uhex(0x383C3AFF),
uhex(0x999C99FF),
},
},
artist.Stroke { Pattern: uhex(0x999C99FF) })
var focusedListPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: uhex(0x999C99FF) })
var listEntryPattern = artist.Padded {
Stroke: uhex(0x383C3AFF),
Fill: uhex(0x999C99FF),
Sides: []int { 0, 0, 0, 1 },
}
var onListEntryPattern = artist.Padded {
Stroke: uhex(0x383C3AFF),
Fill: uhex(0x6e8079FF),
Sides: []int { 0, 0, 0, 1 },
}
var focusedListEntryPattern = artist.Padded {
Stroke: accentPattern,
Fill: uhex(0x999C99FF),
Sides: []int { 0, 1, 0, 1 },
}
var focusedOnListEntryPattern = artist.Padded {
Stroke: accentPattern,
Fill: uhex(0x6e8079FF),
Sides: []int { 0, 1, 0, 1 },
}

9
theme/parse.go Normal file
View File

@ -0,0 +1,9 @@
package theme
import "io"
// Parse parses one or more theme files and returns them as a Theme.
func Parse (sources ...io.Reader) (Theme) {
// TODO
return Default { }
}

View File

@ -1,239 +0,0 @@
package theme
import "image"
import "git.tebibyte.media/sashakoshka/tomo/artist"
// Case sepecifies what kind of element is using a pattern. It contains a
// namespace parameter and an element parameter. The element parameter does not
// necissarily need to match an element name, but if it can, it should. Both
// parameters should be written in camel case. Themes can change their styling
// based on this parameter for fine-grained control over the look and feel of
// specific elements.
type Case struct { Namespace, Element string }
// C can be used as shorthand to generate a case struct as used in PatternState.
func C (namespace, element string) (c Case) {
return Case {
Namespace: namespace,
Element: element,
}
}
// PatternState lists parameters which can change the appearance of some
// patterns. For example, passing a PatternState with Selected set to true may
// result in a pattern that has a colored border within it.
type PatternState struct {
Case
// On should be set to true if the element that is using this pattern is
// in some sort of "on" state, such as if a checkbox is checked or a
// switch is toggled on. This is only necessary if the element in
// question is capable of being toggled.
On bool
// Focused should be set to true if the element that is using this
// pattern is currently focused.
Focused bool
// Pressed should be set to true if the element that is using this
// pattern is being pressed down by the mouse. This is only necessary if
// the element in question processes mouse button events.
Pressed bool
// Disabled should be set to true if the element that is using this
// pattern is locked and cannot be interacted with. Disabled variations
// of patterns are typically flattened and greyed-out.
Disabled bool
// Invalid should be set to true if th element that is using this
// pattern wants to warn the user of an invalid interaction or data
// entry. Invalid variations typically have some sort of reddish tint
// or outline.
Invalid bool
}
// Inset represents an inset amount for all four sides of a rectangle. The top
// side is at index zero, the right at index one, the bottom at index two, and
// the left at index three. These values may be negative.
type Inset [4]int
// Apply returns the given rectangle, shrunk on all four sides by the given
// inset. If a measurment of the inset is negative, that side will instead be
// expanded outward. If the rectangle's dimensions cannot be reduced any
// further, an empty rectangle near its center will be returned.
func (inset Inset) Apply (bigger image.Rectangle) (smaller image.Rectangle) {
smaller = bigger
if smaller.Dx() < inset[3] + inset[1] {
smaller.Min.X = (smaller.Min.X + smaller.Max.X) / 2
smaller.Max.X = smaller.Min.X
} else {
smaller.Min.X += inset[3]
smaller.Max.X -= inset[1]
}
if smaller.Dy() < inset[0] + inset[2] {
smaller.Min.Y = (smaller.Min.Y + smaller.Max.Y) / 2
smaller.Max.Y = smaller.Min.Y
} else {
smaller.Min.Y += inset[0]
smaller.Max.Y -= inset[2]
}
return
}
// Inverse returns a negated version of the inset.
func (inset Inset) Inverse () (prime Inset) {
return Inset {
inset[0] * -1,
inset[1] * -1,
inset[2] * -1,
inset[3] * -1,
}
}
// AccentPattern returns the accent pattern, which is usually just a solid
// color.
func AccentPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
return accentPattern, Inset { }
}
// BackgroundPattern returns the main background pattern.
func BackgroundPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
return backgroundPattern, Inset { }
}
// DeadPattern returns a pattern that can be used to mark an area or gap that
// serves no purpose, but still needs aesthetic structure.
func DeadPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
return deadPattern, Inset { }
}
// ForegroundPattern returns the color text should be.
func ForegroundPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Disabled {
return weakForegroundPattern, Inset { }
} else {
return foregroundPattern, Inset { }
}
}
// InputPattern returns a background pattern for any input field that can be
// edited by typing with the keyboard.
func InputPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Disabled {
return disabledInputPattern, Inset { 1, 1, 1, 1 }
} else {
if state.Focused {
return selectedInputPattern, Inset { 1, 1, 1, 1 }
} else {
return inputPattern, Inset { 1, 1, 1, 1 }
}
}
}
// ListPattern returns a background pattern for a list of things.
func ListPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Focused {
pattern = focusedListPattern
inset = Inset { 2, 1, 2, 1 }
} else {
pattern = listPattern
inset = Inset { 2, 1, 1, 1 }
}
return
}
// ItemPattern returns a background pattern for a list item.
func ItemPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Focused {
if state.On {
pattern = focusedOnListEntryPattern
} else {
pattern = focusedListEntryPattern
}
} else {
if state.On {
pattern = onListEntryPattern
} else {
pattern = listEntryPattern
}
}
inset = Inset { 4, 6, 4, 6 }
return
}
// ButtonPattern returns a pattern to be displayed on buttons.
func ButtonPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Disabled {
return disabledButtonPattern, Inset { 1, 1, 1, 1 }
} else {
if state.Pressed {
if state.Focused {
return pressedSelectedButtonPattern, Inset {
2, 0, 0, 2 }
} else {
return pressedButtonPattern, Inset { 2, 0, 0, 2 }
}
} else {
if state.Focused {
return selectedButtonPattern, Inset { 1, 1, 1, 1 }
} else {
return buttonPattern, Inset { 1, 1, 1, 1 }
}
}
}
}
// GutterPattern returns a pattern to be used to mark a track along which
// something slides.
func GutterPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Disabled {
return disabledScrollGutterPattern, Inset { 0, 0, 0, 0 }
} else {
return scrollGutterPattern, Inset { 0, 0, 0, 0 }
}
}
// HandlePattern returns a pattern to be displayed on a grab handle that slides
// along a gutter.
func HandlePattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Disabled {
return disabledScrollBarPattern, Inset { 1, 1, 1, 1 }
} else {
if state.Focused {
if state.Pressed {
return pressedSelectedScrollBarPattern, Inset { 1, 1, 1, 1 }
} else {
return selectedScrollBarPattern, Inset { 1, 1, 1, 1 }
}
} else {
if state.Pressed {
return pressedScrollBarPattern, Inset { 1, 1, 1, 1 }
} else {
return scrollBarPattern, Inset { 1, 1, 1, 1 }
}
}
}
}
// SunkenPattern returns a general purpose pattern that is sunken/engraved into
// the background.
func SunkenPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
return sunkenPattern, Inset { 1, 1, 1, 1 }
}
// RaisedPattern returns a general purpose pattern that is raised up out of the
// background.
func RaisedPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
if state.Focused {
return selectedRaisedPattern, Inset { 1, 1, 1, 1 }
} else {
return raisedPattern, Inset { 1, 1, 1, 1 }
}
}
// PinboardPattern returns a textured backdrop pattern. Anything drawn within it
// should have its own background pattern.
func PinboardPattern (state PatternState) (pattern artist.Pattern, inset Inset) {
return texturedSunkenPattern, Inset { 1, 1, 1, 1 }
}

View File

@ -1,63 +0,0 @@
package theme
import "git.tebibyte.media/sashakoshka/tomo/artist"
var scrollGutterPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x3b534eFF)),
artist.NewUniform(hex(0x6e8079FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x6e8079FF)) })
var disabledScrollGutterPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })
var scrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var selectedScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x8D9894FF)) })
var pressedScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: artist.NewUniform(hex(0x8D9894FF)) },
artist.Stroke { Pattern: artist.NewUniform(hex(0x7f8c89FF)) })
var pressedSelectedScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xCCD5D2FF)),
artist.NewUniform(hex(0x4B5B59FF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x7f8c89FF)) })
var disabledScrollBarPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: weakForegroundPattern },
artist.Stroke { Pattern: backgroundPattern })

78
theme/state.go Normal file
View File

@ -0,0 +1,78 @@
package theme
// Case sepecifies what kind of element is using a pattern. It contains a
// namespace parameter and an element parameter. The element parameter does not
// necissarily need to match an element name, but if it can, it should. Both
// parameters should be written in camel case. Themes can change their styling
// based on this parameter for fine-grained control over the look and feel of
// specific elements.
type Case struct { Namespace, Element string }
// C can be used as shorthand to generate a case struct as used in PatternState.
func C (namespace, element string) (c Case) {
return Case {
Namespace: namespace,
Element: element,
}
}
// PatternState lists parameters which can change the appearance of some
// patterns. For example, passing a PatternState with Selected set to true may
// result in a pattern that has a colored border within it.
type PatternState struct {
// On should be set to true if the element that is using this pattern is
// in some sort of "on" state, such as if a checkbox is checked or a
// switch is toggled on. This is only necessary if the element in
// question is capable of being toggled.
On bool
// Focused should be set to true if the element that is using this
// pattern is currently focused.
Focused bool
// Pressed should be set to true if the element that is using this
// pattern is being pressed down by the mouse. This is only necessary if
// the element in question processes mouse button events.
Pressed bool
// Disabled should be set to true if the element that is using this
// pattern is locked and cannot be interacted with. Disabled variations
// of patterns are typically flattened and greyed-out.
Disabled bool
// Invalid should be set to true if th element that is using this
// pattern wants to warn the user of an invalid interaction or data
// entry. Invalid variations typically have some sort of reddish tint
// or outline.
Invalid bool
}
// FontStyle specifies stylistic alterations to a font face.
type FontStyle int; const (
FontStyleRegular FontStyle = 0
FontStyleBold FontStyle = 1
FontStyleItalic FontStyle = 2
FontStyleBoldItalic FontStyle = 1 | 2
)
// FontSize specifies the general size of a font face in a semantic way.
type FontSize int; const (
// FontSizeNormal is the default font size that should be used for most
// things.
FontSizeNormal FontSize = iota
// FontSizeLarge is a larger font size suitable for things like section
// headings.
FontSizeLarge
// FontSizeHuge is a very large font size suitable for things like
// titles, wizard step names, digital clocks, etc.
FontSizeHuge
// FontSizeSmall is a smaller font size. Try not to use this unless it
// makes a lot of sense to do so, because it can negatively impact
// accessibility. It is useful for things like copyright notices at the
// bottom of some window that the average user doesn't actually care
// about.
FontSizeSmall
)

View File

@ -1,113 +1,116 @@
package theme
import "image/color"
import "image"
import "golang.org/x/image/font"
import "git.tebibyte.media/sashakoshka/tomo/artist"
import "git.tebibyte.media/sashakoshka/tomo/defaultfont"
// none of these colors are final! TODO: generate these values from a theme
// file at startup.
// Pattern lists a number of cannonical pattern types, each with its own ID.
// This allows custom elements to follow themes, even those that do not
// explicitly support them.
type Pattern int; const (
// PatternAccent is the accent color of the theme. It is safe to assume
// that this is, by default, a solid color.
PatternAccent Pattern = iota
func hex (color uint32) (c color.RGBA) {
c.A = uint8(color)
c.B = uint8(color >> 8)
c.G = uint8(color >> 16)
c.R = uint8(color >> 24)
// PatternBackground is the background color of the theme. It is safe to
// assume that this is, by default, a solid color.
PatternBackground
// PatternForeground is the foreground text color of the theme. It is
// safe to assume that this is, by default, a solid color.
PatternForeground
// PatternDead is a pattern that is displayed on a "dead area" where no
// controls exist, but there still must be some indication of visual
// structure (such as in the corner between two scroll bars).
PatternDead
// PatternRaised is a generic raised pattern.
PatternRaised
// PatternSunken is a generic sunken pattern.
PatternSunken
// PatternPinboard is similar to PatternSunken, but it is textured.
PatternPinboard
// PatternButton is a button pattern.
PatternButton
// PatternInput is a pattern for input fields, editable text areas, etc.
PatternInput
// PatternGutter is a track for things to slide on.
PatternGutter
// PatternHandle is a handle that slides along a gutter.
PatternHandle
)
// Theme represents a visual style configuration,
type Theme interface {
// FontFace returns the proper font for a given style, size, and case.
FontFace (FontStyle, FontSize, Case) font.Face
// Icon returns an appropriate icon given an icon name and case.
Icon (string, Case) artist.Pattern
// Pattern returns an appropriate pattern given a pattern name, case,
// and state.
Pattern (Pattern, Case, PatternState) artist.Pattern
// Inset returns the area on all sides of a given pattern that is not
// meant to be drawn on.
Inset (Pattern, Case) Inset
// Sink returns a vector that should be added to an element's inner
// content when it is pressed down (if applicable) to simulate a 3D
// sinking effect.
Sink (Pattern, Case) image.Point
}
// Wrapped wraps any theme and injects a case into it automatically so that it
// doesn't need to be specified for each query. Additionally, if the underlying
// theme is nil, it just uses the default theme instead.
type Wrapped struct {
Theme
Case
}
// FontFace returns the proper font for a given style and size.
func (wrapped Wrapped) FontFace (style FontStyle, size FontSize) font.Face {
real := wrapped.ensure()
return real.FontFace(style, size, wrapped.Case)
}
// Icon returns an appropriate icon given an icon name.
func (wrapped Wrapped) Icon (name string) artist.Pattern {
real := wrapped.ensure()
return real.Icon(name, wrapped.Case)
}
// Pattern returns an appropriate pattern given a pattern name and state.
func (wrapped Wrapped) Pattern (id Pattern, state PatternState) artist.Pattern {
real := wrapped.ensure()
return real.Pattern(id, wrapped.Case, state)
}
// Inset returns the area on all sides of a given pattern that is not meant to
// be drawn on.
func (wrapped Wrapped) Inset (id Pattern) Inset {
real := wrapped.ensure()
return real.Inset(id, wrapped.Case)
}
// Sink returns a vector that should be added to an element's inner content when
// it is pressed down (if applicable) to simulate a 3D sinking effect.
func (wrapped Wrapped) Sink (id Pattern) image.Point {
real := wrapped.ensure()
return real.Sink(id, wrapped.Case)
}
func (wrapped Wrapped) ensure () (real Theme) {
real = wrapped.Theme
if real == nil { real = Default { } }
return
}
func uhex (color uint32) (pattern artist.Pattern) {
return artist.NewUniform(hex(color))
}
var accentPattern = artist.NewUniform(hex(0x408090FF))
var backgroundPattern = artist.NewUniform(color.Gray16 { 0xAAAA })
var foregroundPattern = artist.NewUniform(color.Gray16 { 0x0000 })
var weakForegroundPattern = artist.NewUniform(color.Gray16 { 0x4444 })
var strokePattern = artist.NewUniform(color.Gray16 { 0x0000 })
var sunkenPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x3b534eFF)),
artist.NewUniform(hex(0x97a09cFF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0x97a09cFF)) })
var texturedSunkenPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0x3b534eFF)),
artist.NewUniform(hex(0x97a09cFF)),
},
},
// artist.Stroke { Pattern: artist.Striped {
// First: artist.Stroke {
// Weight: 2,
// Pattern: artist.NewUniform(hex(0x97a09cFF)),
// },
// Second: artist.Stroke {
// Weight: 1,
// Pattern: artist.NewUniform(hex(0x6e8079FF)),
// },
// }})
artist.Stroke { Pattern: artist.Noisy {
Low: artist.NewUniform(hex(0x97a09cFF)),
High: artist.NewUniform(hex(0x6e8079FF)),
}})
var raisedPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xDBDBDBFF)),
artist.NewUniform(hex(0x383C3AFF)),
},
},
artist.Stroke { Pattern: artist.NewUniform(hex(0xAAAAAAFF)) })
var selectedRaisedPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke {
Weight: 1,
Pattern: artist.Beveled {
artist.NewUniform(hex(0xDBDBDBFF)),
artist.NewUniform(hex(0x383C3AFF)),
},
},
artist.Stroke { Weight: 1, Pattern: accentPattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0xAAAAAAFF)) })
var deadPattern = artist.NewMultiBordered (
artist.Stroke { Weight: 1, Pattern: strokePattern },
artist.Stroke { Pattern: artist.NewUniform(hex(0x97a09cFF)) })
// TODO: load fonts from an actual source instead of using defaultfont
// FontFaceRegular returns the font face to be used for normal text.
func FontFaceRegular () font.Face {
return defaultfont.FaceRegular
}
// FontFaceBold returns the font face to be used for bolded text.
func FontFaceBold () font.Face {
return defaultfont.FaceBold
}
// FontFaceItalic returns the font face to be used for italicized text.
func FontFaceItalic () font.Face {
return defaultfont.FaceItalic
}
// FontFaceBoldItalic returns the font face to be used for text that is both
// bolded and italicized.
func FontFaceBoldItalic () font.Face {
return defaultfont.FaceBoldItalic
}

16
theme/util.go Normal file
View File

@ -0,0 +1,16 @@
package theme
import "image/color"
import "git.tebibyte.media/sashakoshka/tomo/artist"
func hex (color uint32) (c color.RGBA) {
c.A = uint8(color)
c.B = uint8(color >> 8)
c.G = uint8(color >> 16)
c.R = uint8(color >> 24)
return
}
func uhex (color uint32) (pattern artist.Pattern) {
return artist.NewUniform(hex(color))
}

89
tomo.go
View File

@ -1,7 +1,12 @@
package tomo
import "errors"
import "os"
import "io"
import "path/filepath"
import "git.tebibyte.media/sashakoshka/tomo/dirs"
import "git.tebibyte.media/sashakoshka/tomo/data"
import "git.tebibyte.media/sashakoshka/tomo/theme"
import "git.tebibyte.media/sashakoshka/tomo/config"
import "git.tebibyte.media/sashakoshka/tomo/elements"
var backend Backend
@ -11,6 +16,9 @@ var backend Backend
// the backend experiences a fatal error.
func Run (callback func ()) (err error) {
backend, err = instantiateBackend()
config := parseConfig()
backend.SetConfig(config)
backend.SetTheme(parseTheme(config.ThemePath()))
if callback != nil { callback() }
err = backend.Run()
backend = nil
@ -26,7 +34,7 @@ func Stop () {
// Do executes the specified callback within the main thread as soon as
// possible. This function can be safely called from other threads.
func Do (callback func ()) {
if backend == nil { panic("no backend is running") }
assertBackend()
backend.Do(callback)
}
@ -35,22 +43,87 @@ func Do (callback func ()) {
// why. If this function is called without a running backend, an error is
// returned as well.
func NewWindow (width, height int) (window elements.Window, err error) {
if backend == nil {
err = errors.New("no backend is running.")
return
}
assertBackend()
return backend.NewWindow(width, height)
}
// Copy puts data into the clipboard.
func Copy (data data.Data) {
if backend == nil { panic("no backend is running") }
assertBackend()
backend.Copy(data)
}
// Paste returns the data currently in the clipboard. This method may
// return nil.
func Paste (accept []data.Mime) (data.Data) {
if backend == nil { panic("no backend is running") }
assertBackend()
return backend.Paste(accept)
}
// SetTheme sets the theme of all open windows.
func SetTheme (theme theme.Theme) {
backend.SetTheme(theme)
}
// SetConfig sets the configuration of all open windows.
func SetConfig (config config.Config) {
backend.SetConfig(config)
}
func parseConfig () (config.Config) {
return parseMany [config.Config] (
dirs.ConfigDirs("tomo/tomo.conf"),
config.Parse,
config.Default { })
}
func parseTheme (path string) (theme.Theme) {
if path == "" { return theme.Default { } }
path = filepath.Join(path, "tomo")
// find all tomo pattern graph files in the directory
directory, err := os.Open(path)
if err != nil { return theme.Default { } }
names, _ := directory.Readdirnames(0)
paths := []string { }
for _, name := range names {
if filepath.Ext(name) == ".tpg" {
paths = append(paths, filepath.Join(path, name))
}
}
// parse them
return parseMany [theme.Theme] (
paths,
theme.Parse,
theme.Default { })
}
func parseMany [OBJECT any] (
paths []string,
parser func (...io.Reader) OBJECT,
fallback OBJECT,
) (
object OBJECT,
) {
// convert all paths into readers
sources := []io.Reader { }
for _, path := range paths {
file, err := os.Open(path)
if err != nil { continue }
sources = append(sources, file)
defer file.Close()
}
if sources == nil {
// if there are no readers, return the fallback object
return fallback
} else {
// if there are readers, parse them
return parser(sources...)
}
}
func assertBackend () {
if backend == nil { panic("no backend is running") }
}