From 00d75d44883f31b1cbeb1cced66c8b89241b6b3f Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Mon, 9 Jan 2023 01:03:19 -0500 Subject: [PATCH] Initial commit --- artist/artist.go | 2 + artist/chisel.go | 127 ++++++++++++ artist/line.go | 106 ++++++++++ artist/rectangle.go | 105 ++++++++++ artist/text.go | 265 +++++++++++++++++++++++++ artist/uniform.go | 71 +++++++ artist/wrap.go | 99 ++++++++++ backend.go | 54 +++++ backends/x/encoding.go | 389 +++++++++++++++++++++++++++++++++++++ backends/x/event.go | 235 ++++++++++++++++++++++ backends/x/window.go | 328 +++++++++++++++++++++++++++++++ backends/x/x.go | 85 ++++++++ defaultfont/defaultfont.go | 47 +++++ elements/basic/button.go | 176 +++++++++++++++++ elements/basic/core.go | 138 +++++++++++++ elements/basic/label.go | 114 +++++++++++ elements/basic/test.go | 90 +++++++++ event.go | 88 +++++++++ examples/button/main.go | 29 +++ examples/label/main.go | 19 ++ examples/test/main.go | 17 ++ go.mod | 14 ++ go.sum | 34 ++++ input.go | 112 +++++++++++ iterator/iterator.go | 6 + theme/theme.go | 172 ++++++++++++++++ tomo.go | 114 +++++++++++ 27 files changed, 3036 insertions(+) create mode 100644 artist/artist.go create mode 100644 artist/chisel.go create mode 100644 artist/line.go create mode 100644 artist/rectangle.go create mode 100644 artist/text.go create mode 100644 artist/uniform.go create mode 100644 artist/wrap.go create mode 100644 backend.go create mode 100644 backends/x/encoding.go create mode 100644 backends/x/event.go create mode 100644 backends/x/window.go create mode 100644 backends/x/x.go create mode 100644 defaultfont/defaultfont.go create mode 100644 elements/basic/button.go create mode 100644 elements/basic/core.go create mode 100644 elements/basic/label.go create mode 100644 elements/basic/test.go create mode 100644 event.go create mode 100644 examples/button/main.go create mode 100644 examples/label/main.go create mode 100644 examples/test/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 input.go create mode 100644 iterator/iterator.go create mode 100644 theme/theme.go create mode 100644 tomo.go diff --git a/artist/artist.go b/artist/artist.go new file mode 100644 index 0000000..f956174 --- /dev/null +++ b/artist/artist.go @@ -0,0 +1,2 @@ +package artist + diff --git a/artist/chisel.go b/artist/chisel.go new file mode 100644 index 0000000..1fe1a16 --- /dev/null +++ b/artist/chisel.go @@ -0,0 +1,127 @@ +package artist + +import "image" +import "image/color" +import "git.tebibyte.media/sashakoshka/tomo" + +// ShadingProfile contains shading information that can be used to draw chiseled +// objects. +type ShadingProfile struct { + Highlight tomo.Image + Shadow tomo.Image + Stroke tomo.Image + Fill tomo.Image + StrokeWeight int + ShadingWeight int +} + +// Engraved reverses the shadown and highlight colors of the ShadingProfile to +// produce a new ShadingProfile with an engraved appearance. +func (profile ShadingProfile) Engraved () (reversed ShadingProfile) { + reversed = profile + reversed.Highlight = profile.Shadow + reversed.Shadow = profile.Highlight + return +} + +// ChiseledRectangle draws a rectangle with a chiseled/embossed appearance, +// according to the ShadingProfile passed to it. +func ChiseledRectangle ( + destination tomo.Canvas, + profile ShadingProfile, + bounds image.Rectangle, +) ( + updatedRegion image.Rectangle, +) { + // FIXME: this breaks when the bounds are smaller than the border or + // shading weight + + stroke := profile.Stroke + highlight := profile.Highlight + shadow := profile.Shadow + fill := profile.Fill + strokeWeight := profile.StrokeWeight + shadingWeight := profile.ShadingWeight + + bounds = bounds.Canon() + updatedRegion = bounds + + strokeWeightVector := image.Point { strokeWeight, strokeWeight } + shadingWeightVector := image.Point { shadingWeight, shadingWeight } + + shadingBounds := bounds + shadingBounds.Min = shadingBounds.Min.Add(strokeWeightVector) + shadingBounds.Max = shadingBounds.Max.Sub(strokeWeightVector) + shadingBounds = shadingBounds.Canon() + + fillBounds := shadingBounds + fillBounds.Min = fillBounds.Min.Add(shadingWeightVector) + fillBounds.Max = fillBounds.Max.Sub(shadingWeightVector) + fillBounds = fillBounds.Canon() + + strokeImageMin := stroke.Bounds().Min + highlightImageMin := highlight.Bounds().Min + shadowImageMin := shadow.Bounds().Min + fillImageMin := fill.Bounds().Min + + width := float64(bounds.Dx()) + height := float64(bounds.Dy()) + + yy := 0 + for y := bounds.Min.Y; y < bounds.Max.Y; y ++ { + xx := 0 + for x := bounds.Min.X; x < bounds.Max.X; x ++ { + var pixel color.RGBA + point := image.Point { x, y } + switch { + case point.In(fillBounds): + pixel = fill.RGBAAt ( + xx - strokeWeight - shadingWeight + + fillImageMin.X, + yy - strokeWeight - shadingWeight + + fillImageMin.Y) + + case point.In(shadingBounds): + var highlighted bool + // FIXME: this doesn't work quite right, the + // slope of the line is somewhat off. + bottomCorner := + float64(xx) < float64(yy) * + (width / height) + if bottomCorner { + highlighted = + float64(xx) < + height - float64(yy) + } else { + highlighted = + width - float64(xx) > + float64(yy) + } + + if highlighted { + pixel = highlight.RGBAAt ( + xx - strokeWeight + + highlightImageMin.X, + yy - strokeWeight + + highlightImageMin.Y) + } else { + pixel = shadow.RGBAAt ( + xx - strokeWeight + + shadowImageMin.X, + yy - strokeWeight + + shadowImageMin.Y) + } + + default: + pixel = stroke.RGBAAt ( + xx + strokeImageMin.X, + yy + strokeImageMin.Y) + } + destination.SetRGBA(x, y, pixel) + xx ++ + } + yy ++ + } + + return +} diff --git a/artist/line.go b/artist/line.go new file mode 100644 index 0000000..7370520 --- /dev/null +++ b/artist/line.go @@ -0,0 +1,106 @@ +package artist + +import "image" +import "git.tebibyte.media/sashakoshka/tomo" + +func Line ( + destination tomo.Canvas, + source tomo.Image, + weight int, + min image.Point, + max image.Point, +) ( + updatedRegion image.Rectangle, +) { + // TODO: respect weight + + updatedRegion = image.Rectangle { Min: min, Max: max }.Canon() + updatedRegion.Max.X ++ + updatedRegion.Max.Y ++ + + if abs(max.Y - min.Y) < + abs(max.X - min.X) { + + if max.X < min.X { + temp := min + min = max + max = temp + } + lineLow(destination, source, weight, min, max) + } else { + + if max.Y < min.Y { + temp := min + min = max + max = temp + } + lineHigh(destination, source, weight, min, max) + } + return +} + +func lineLow ( + destination tomo.Canvas, + source tomo.Image, + weight int, + min image.Point, + max image.Point, +) { + deltaX := max.X - min.X + deltaY := max.Y - min.Y + yi := 1 + + if deltaY < 0 { + yi = -1 + deltaY *= -1 + } + + D := (2 * deltaY) - deltaX + y := min.Y + + for x := min.X; x < max.X; x ++ { + destination.SetRGBA(x, y, source.RGBAAt(x, y)) + if D > 0 { + y += yi + D += 2 * (deltaY - deltaX) + } else { + D += 2 * deltaY + } + } +} + +func lineHigh ( + destination tomo.Canvas, + source tomo.Image, + weight int, + min image.Point, + max image.Point, +) { + deltaX := max.X - min.X + deltaY := max.Y - min.Y + xi := 1 + + if deltaX < 0 { + xi = -1 + deltaX *= -1 + } + + D := (2 * deltaX) - deltaY + x := min.X + + for y := min.Y; y < max.Y; y ++ { + destination.SetRGBA(x, y, source.RGBAAt(x, y)) + if D > 0 { + x += xi + D += 2 * (deltaX - deltaY) + } else { + D += 2 * deltaX + } + } +} + +func abs (in int) (out int) { + if in < 0 { in *= -1} + out = in + return +} diff --git a/artist/rectangle.go b/artist/rectangle.go new file mode 100644 index 0000000..3a56bdb --- /dev/null +++ b/artist/rectangle.go @@ -0,0 +1,105 @@ +package artist + +import "image" +import "image/color" +import "git.tebibyte.media/sashakoshka/tomo" + +// Paste transfers one image onto another, offset by the specified point. +func Paste ( + destination tomo.Canvas, + source tomo.Image, + offset image.Point, +) ( + updatedRegion image.Rectangle, +) { + sourceBounds := source.Bounds().Canon() + updatedRegion = sourceBounds.Add(offset) + for y := sourceBounds.Min.Y; y < sourceBounds.Max.Y; y ++ { + for x := sourceBounds.Min.X; x < sourceBounds.Max.X; x ++ { + destination.SetRGBA ( + x + offset.X, y + offset.Y, + source.RGBAAt(x, y)) + }} + + return +} + +// Rectangle draws a rectangle with an inset border. If the border image is nil, +// no border will be drawn. Likewise, if the fill image is nil, the rectangle +// will have no fill. +func Rectangle ( + destination tomo.Canvas, + fill tomo.Image, + stroke tomo.Image, + weight int, + bounds image.Rectangle, +) ( + updatedRegion image.Rectangle, +) { + bounds = bounds.Canon() + updatedRegion = bounds + + fillBounds := bounds + fillBounds.Min = fillBounds.Min.Add(image.Point { weight, weight }) + fillBounds.Max = fillBounds.Max.Sub(image.Point { weight, weight }) + fillBounds = fillBounds.Canon() + + for y := bounds.Min.Y; y < bounds.Max.Y; y ++ { + for x := bounds.Min.X; x < bounds.Max.X; x ++ { + var pixel color.RGBA + if (image.Point { x, y }).In(fillBounds) { + pixel = fill.RGBAAt(x, y) + } else { + pixel = stroke.RGBAAt(x, y) + } + destination.SetRGBA(x, y, pixel) + }} + + return +} + +// OffsetRectangle is the same as Rectangle, but offsets the border image to the +// top left corner of the border and the fill image to the top left corner of +// the fill. +func OffsetRectangle ( + destination tomo.Canvas, + fill tomo.Image, + stroke tomo.Image, + weight int, + bounds image.Rectangle, +) ( + updatedRegion image.Rectangle, +) { + bounds = bounds.Canon() + updatedRegion = bounds + + fillBounds := bounds + fillBounds.Min = fillBounds.Min.Add(image.Point { weight, weight }) + fillBounds.Max = fillBounds.Max.Sub(image.Point { weight, weight }) + fillBounds = fillBounds.Canon() + + strokeImageMin := stroke.Bounds().Min + fillImageMin := fill.Bounds().Min + + yy := 0 + for y := bounds.Min.Y; y < bounds.Max.Y; y ++ { + xx := 0 + for x := bounds.Min.X; x < bounds.Max.X; x ++ { + var pixel color.RGBA + if (image.Point { x, y }).In(fillBounds) { + pixel = fill.RGBAAt ( + xx - weight + fillImageMin.X, + yy - weight + fillImageMin.Y) + } else { + pixel = stroke.RGBAAt ( + xx + strokeImageMin.X, + yy + strokeImageMin.Y) + } + destination.SetRGBA(x, y, pixel) + xx ++ + } + yy ++ + } + + return +} diff --git a/artist/text.go b/artist/text.go new file mode 100644 index 0000000..ef75143 --- /dev/null +++ b/artist/text.go @@ -0,0 +1,265 @@ +package artist + +// import "fmt" +import "image" +import "unicode" +import "image/draw" +import "golang.org/x/image/font" +import "golang.org/x/image/math/fixed" +import "git.tebibyte.media/sashakoshka/tomo" + +type characterLayout struct { + x int + character rune +} + +type wordLayout struct { + position image.Point + width int + text []characterLayout +} + +// Align specifies a text alignment method. +type Align int + +const ( + // AlignLeft aligns the start of each line to the beginning point + // of each dot. + AlignLeft Align = iota + AlignRight + AlignCenter + AlignJustify +) + +// TextDrawer is a struct that is capable of efficient rendering of wrapped +// text, and calculating text bounds. It avoids doing redundant work +// automatically. +type TextDrawer struct { + text string + runes []rune + face font.Face + width int + height int + align Align + wrap bool + cut bool + + layout []wordLayout + layoutClean bool + layoutBounds image.Rectangle +} + +// SetText sets the text of the text drawer. +func (drawer *TextDrawer) SetText (text string) { + if drawer.text == text { return } + drawer.text = text + drawer.runes = []rune(text) + drawer.layoutClean = false +} + +// SetFace sets the font face of the text drawer. +func (drawer *TextDrawer) SetFace (face font.Face) { + if drawer.face == face { return } + drawer.face = face + drawer.layoutClean = false +} + +// SetMaxWidth sets a maximum width for the text drawer, and recalculates the +// layout if needed. If zero is given, there will be no width limit and the text +// will not wrap. +func (drawer *TextDrawer) SetMaxWidth (width int) { + if drawer.width == width { return } + drawer.width = width + drawer.wrap = width != 0 + drawer.layoutClean = false +} + +// SetMaxHeight sets a maximum height for the text drawer. Lines that are +// entirely below this height will not be drawn, and lines that are on the cusp +// of this maximum height will be clipped at the point that they cross it. +func (drawer *TextDrawer) SetMaxHeight (height int) { + if drawer.height == height { return } + drawer.height = height + drawer.cut = height != 0 + drawer.layoutClean = false +} + +// SetAlignment specifies how the drawer should align its text. For this to have +// an effect, a maximum width must have been set. +func (drawer *TextDrawer) SetAlignment (align Align) { + if drawer.align == align { return } + drawer.align = align + drawer.layoutClean = false +} + +// Draw draws the drawer's text onto the specified canvas at the given offset. +func (drawer *TextDrawer) Draw ( + destination tomo.Canvas, + source tomo.Image, + offset image.Point, +) ( + updatedRegion image.Rectangle, +) { + if !drawer.layoutClean { drawer.recalculate() } + for _, word := range drawer.layout { + for _, character := range word.text { + destinationRectangle, + mask, maskPoint, _, ok := drawer.face.Glyph ( + fixed.P ( + offset.X + word.position.X + character.x, + offset.Y + word.position.Y), + character.character) + if !ok { continue } + + // FIXME: clip destination rectangle if we are on the cusp of + // the maximum height. + + draw.DrawMask ( + destination, + destinationRectangle, + source, image.Point { }, + mask, maskPoint, + draw.Over) + + updatedRegion = updatedRegion.Union(destinationRectangle) + }} + return +} + +// LayoutBounds returns a semantic bounding box for text to be used to determine +// an offset for drawing. If a maximum width or height has been set, those will +// be used as the width and height of the bounds respectively. The origin point +// (0, 0) of the returned bounds will be equivalent to the baseline at the start +// of the first line. As such, the minimum of the bounds will be negative. +func (drawer *TextDrawer) LayoutBounds () (bounds image.Rectangle) { + if !drawer.layoutClean { drawer.recalculate() } + bounds = drawer.layoutBounds + return +} + +func (drawer *TextDrawer) recalculate () { + drawer.layoutClean = true + drawer.layout = nil + drawer.layoutBounds = image.Rectangle { } + if drawer.runes == nil { return } + if drawer.face == nil { return } + + metrics := drawer.face.Metrics() + dot := fixed.Point26_6 { 0, 0 } + index := 0 + horizontalExtent := 0 + + previousCharacter := rune(-1) + for index < len(drawer.runes) { + word := wordLayout { } + word.position.X = dot.X.Round() + word.position.Y = dot.Y.Round() + + // process a word + currentCharacterX := fixed.Int26_6(0) + wordWidth := fixed.Int26_6(0) + for index < len(drawer.runes) && !unicode.IsSpace(drawer.runes[index]) { + character := drawer.runes[index] + _, advance, ok := drawer.face.GlyphBounds(character) + index ++ + if !ok { continue } + + word.text = append(word.text, characterLayout { + x: currentCharacterX.Round(), + character: character, + }) + + dot.X += advance + wordWidth += advance + currentCharacterX += advance + if dot.X.Round () > horizontalExtent { + horizontalExtent = dot.X.Round() + } + if previousCharacter >= 0 { + dot.X += drawer.face.Kern ( + previousCharacter, + character) + } + previousCharacter = character + } + word.width = wordWidth.Round() + + // detect if the word that was just processed goes out of + // bounds, and if it does, wrap it + if drawer.wrap && + word.width + word.position.X > drawer.width && + word.position.X > 0 { + + word.position.Y += metrics.Height.Round() + word.position.X = 0 + dot.Y += metrics.Height + dot.X = wordWidth + } + + // add the word to the layout + drawer.layout = append(drawer.layout, word) + + // skip over whitespace, going onto a new line if there is a + // newline character + for index < len(drawer.runes) && unicode.IsSpace(drawer.runes[index]) { + character := drawer.runes[index] + if character == '\n' { + dot.Y += metrics.Height + dot.X = 0 + previousCharacter = character + index ++ + } else { + _, advance, ok := drawer.face.GlyphBounds(character) + index ++ + if !ok { continue } + + dot.X += advance + if previousCharacter >= 0 { + dot.X += drawer.face.Kern ( + previousCharacter, + character) + } + previousCharacter = character + } + } + + // if there is a set maximum height, and we have crossed it, + // stop processing more words. and remove any words that have + // also crossed the line. + if + drawer.cut && + (dot.Y - metrics.Ascent - metrics.Descent).Round() > + drawer.height { + + for + index := len(drawer.layout) - 1; + index >= 0; index -- { + + if drawer.layout[index].position.Y < dot.Y.Round() { + break + } + drawer.layout = drawer.layout[:index] + } + break + } + } + + if drawer.wrap { + drawer.layoutBounds.Max.X = drawer.width + } else { + drawer.layoutBounds.Max.X = horizontalExtent + } + + if drawer.cut { + drawer.layoutBounds.Min.Y = 0 - metrics.Ascent.Round() + drawer.layoutBounds.Max.Y = drawer.height - metrics.Ascent.Round() + } else { + drawer.layoutBounds.Min.Y = 0 - metrics.Ascent.Round() + drawer.layoutBounds.Max.Y = dot.Y.Round() + metrics.Descent.Round() + } + + // TODO: + // for each line, calculate the bounds as if the words are left aligned, + // and then at the end of the process go through each line and re-align + // everything. this will make the process far simpler. +} diff --git a/artist/uniform.go b/artist/uniform.go new file mode 100644 index 0000000..2f99385 --- /dev/null +++ b/artist/uniform.go @@ -0,0 +1,71 @@ +package artist + +import "image" +import "image/color" + +// Uniform is an infinite-sized Image of uniform color. It implements the +// color.Color, color.Model, and tomo.Image interfaces. +type Uniform struct { + C color.RGBA +} + +// NewUniform returns a new Uniform image of the given color. +func NewUniform (c color.Color) (uniform *Uniform) { + uniform = &Uniform { } + r, g, b, a := c.RGBA() + uniform.C.R = uint8(r >> 8) + uniform.C.G = uint8(g >> 8) + uniform.C.B = uint8(b >> 8) + uniform.C.A = uint8(a >> 8) + return +} + +func (uniform *Uniform) RGBA () (r, g, b, a uint32) { + r = uint32(uniform.C.R) << 8 | uint32(uniform.C.R) + g = uint32(uniform.C.G) << 8 | uint32(uniform.C.G) + b = uint32(uniform.C.B) << 8 | uint32(uniform.C.B) + a = uint32(uniform.C.A) << 8 | uint32(uniform.C.A) + return +} + +func (uniform *Uniform) ColorModel () (model color.Model) { + model = uniform + return +} + +func (uniform *Uniform) Convert (in color.Color) (out color.Color) { + out = uniform.C + return +} + +func (uniform *Uniform) Bounds () (rectangle image.Rectangle) { + rectangle.Min = image.Point { -1e9, -1e9 } + rectangle.Max = image.Point { 1e9, 1e9 } + return +} + +func (uniform *Uniform) At (x, y int) (c color.Color) { + c = uniform.C + return +} + +func (uniform *Uniform) RGBAAt (x, y int) (c color.RGBA) { + c = uniform.C + return +} + +func (uniform *Uniform) RGBA64At (x, y int) (c color.RGBA64) { + r := uint16(uniform.C.R) << 8 | uint16(uniform.C.R) + g := uint16(uniform.C.G) << 8 | uint16(uniform.C.G) + b := uint16(uniform.C.B) << 8 | uint16(uniform.C.B) + a := uint16(uniform.C.A) << 8 | uint16(uniform.C.A) + + c = color.RGBA64 { R: r, G: g, B: b, A: a } + return +} + +// Opaque scans the entire image and reports whether it is fully opaque. +func (uniform *Uniform) Opaque () (opaque bool) { + opaque = uniform.C.A == 0xFF + return +} diff --git a/artist/wrap.go b/artist/wrap.go new file mode 100644 index 0000000..e6a4ed5 --- /dev/null +++ b/artist/wrap.go @@ -0,0 +1,99 @@ +package artist + +import "git.tebibyte.media/sashakoshka/tomo" + +import "image" +import "image/draw" +import "image/color" + +// WrappedImage wraps an image.Image and allows it to satisfy tomo.Image. +type WrappedImage struct { Underlying image.Image } + +// WrapImage wraps a generic image.Image and allows it to satisfy tomo.Image. +// Do not use this function to wrap images that already satisfy tomo.Image, +// because the resulting wrapped image will be rather slow in comparison. +func WrapImage (underlying image.Image) (wrapped tomo.Image) { + wrapped = WrappedImage { Underlying: underlying } + return +} + +func (wrapped WrappedImage) Bounds () (bounds image.Rectangle) { + bounds = wrapped.Underlying.Bounds() + return +} + +func (wrapped WrappedImage) ColorModel () (model color.Model) { + model = wrapped.Underlying.ColorModel() + return +} + +func (wrapped WrappedImage) At (x, y int) (pixel color.Color) { + pixel = wrapped.Underlying.At(x, y) + return +} + +func (wrapped WrappedImage) RGBAAt (x, y int) (pixel color.RGBA) { + r, g, b, a := wrapped.Underlying.At(x, y).RGBA() + pixel.R = uint8(r >> 8) + pixel.G = uint8(g >> 8) + pixel.B = uint8(b >> 8) + pixel.A = uint8(a >> 8) + return +} + +// WrappedCanvas wraps a draw.Image and allows it to satisfy tomo.Canvas. +type WrappedCanvas struct { Underlying draw.Image } + +// WrapCanvas wraps a generic draw.Image and allows it to satisfy tomo.Canvas. +// Do not use this function to wrap images that already satisfy tomo.Canvas, +// because the resulting wrapped image will be rather slow in comparison. +func WrapCanvas (underlying draw.Image) (wrapped tomo.Canvas) { + wrapped = WrappedCanvas { Underlying: underlying } + return +} + +func (wrapped WrappedCanvas) Bounds () (bounds image.Rectangle) { + bounds = wrapped.Underlying.Bounds() + return +} + +func (wrapped WrappedCanvas) ColorModel () (model color.Model) { + model = wrapped.Underlying.ColorModel() + return +} + +func (wrapped WrappedCanvas) At (x, y int) (pixel color.Color) { + pixel = wrapped.Underlying.At(x, y) + return +} + +func (wrapped WrappedCanvas) RGBAAt (x, y int) (pixel color.RGBA) { + r, g, b, a := wrapped.Underlying.At(x, y).RGBA() + pixel.R = uint8(r >> 8) + pixel.G = uint8(g >> 8) + pixel.B = uint8(b >> 8) + pixel.A = uint8(a >> 8) + return +} + +func (wrapped WrappedCanvas) Set (x, y int, pixel color.Color) { + wrapped.Underlying.Set(x, y, pixel) +} + +func (wrapped WrappedCanvas) SetRGBA (x, y int, pixel color.RGBA) { + wrapped.Underlying.Set(x, y, pixel) +} + +// ToRGBA clones an existing image.Image into an image.RGBA struct, which +// directly satisfies tomo.Image. This is useful for things like icons and +// textures. +func ToRGBA (input image.Image) (output *image.RGBA) { + bounds := input.Bounds() + output = image.NewRGBA(bounds) + + for y := bounds.Min.Y; y < bounds.Max.Y; y ++ { + for x := bounds.Min.X; x < bounds.Max.X; x ++ { + output.Set(x, y, input.At(x, y)) + }} + return +} diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..c29d054 --- /dev/null +++ b/backend.go @@ -0,0 +1,54 @@ +package tomo + +import "errors" + +// Backend represents a connection to a display server, or something similar. +// It is capable of managing an event loop, and creating windows. +type Backend interface { + // Run runs the backend's event loop. It must block until the backend + // experiences a fatal error, or Stop() is called. + Run () (err error) + + // Stop stops the backend's event loop. + Stop () + + // Do executes the specified callback within the main thread as soon as + // possible. This method must be safe to call from other threads. + Do (callback func ()) + + // NewWindow creates a new window with the specified width and height, + // and returns a struct representing it that fulfills the Window + // interface. + NewWindow (width, height int) (window Window, err error) +} + +// BackendFactory represents a function capable of constructing a backend +// struct. Any connections should be initialized within this function. If there +// any errors encountered during this process, the function should immediately +// stop, clean up any resources, and return an error. +type BackendFactory func () (backend Backend, err error) + +// RegisterBackend registers a backend factory. When an application calls +// tomo.Run(), the first registered backend that does not throw an error will be +// used. +func RegisterBackend (factory BackendFactory) { + factories = append(factories, factory) +} + +var factories []BackendFactory + +func instantiateBackend () (backend Backend, err error) { + // find a suitable backend + for _, factory := range factories { + backend, err = factory() + if err == nil && backend != nil { return } + } + + // if none were found, but there was no error produced, produce an + // error + if err == nil { + err = errors.New("no available backends") + } + + return +} diff --git a/backends/x/encoding.go b/backends/x/encoding.go new file mode 100644 index 0000000..b8230d7 --- /dev/null +++ b/backends/x/encoding.go @@ -0,0 +1,389 @@ +package x + +import "unicode" +import "github.com/jezek/xgb/xproto" +import "github.com/jezek/xgbutil/keybind" +import "git.tebibyte.media/sashakoshka/tomo" + +// when making changes to this file, look at keysymdef.h and +// https://tronche.com/gui/x/xlib/input/keyboard-encoding.html + +var buttonCodeTable = map[xproto.Keysym] tomo.Key { + 0xFFFFFF: tomo.KeyNone, + + 0xFF63: tomo.KeyInsert, + 0xFF67: tomo.KeyMenu, + 0xFF61: tomo.KeyPrintScreen, + 0xFF6B: tomo.KeyPause, + 0xFFE5: tomo.KeyCapsLock, + 0xFF14: tomo.KeyScrollLock, + 0xFF7F: tomo.KeyNumLock, + 0xFF08: tomo.KeyBackspace, + 0xFF09: tomo.KeyTab, + 0xFF0D: tomo.KeyEnter, + 0xFF1B: tomo.KeyEscape, + + 0xFF52: tomo.KeyUp, + 0xFF54: tomo.KeyDown, + 0xFF51: tomo.KeyLeft, + 0xFF53: tomo.KeyRight, + 0xFF55: tomo.KeyPageUp, + 0xFF56: tomo.KeyPageDown, + 0xFF50: tomo.KeyHome, + 0xFF57: tomo.KeyEnd, + + 0xFFE1: tomo.KeyLeftShift, + 0xFFE2: tomo.KeyRightShift, + 0xFFE3: tomo.KeyLeftControl, + 0xFFE4: tomo.KeyRightControl, + + 0xFFE7: tomo.KeyLeftMeta, + 0xFFE8: tomo.KeyRightMeta, + 0xFFE9: tomo.KeyLeftAlt, + 0xFFEA: tomo.KeyRightAlt, + 0xFFEB: tomo.KeyLeftSuper, + 0xFFEC: tomo.KeyRightSuper, + 0xFFED: tomo.KeyLeftHyper, + 0xFFEE: tomo.KeyRightHyper, + + 0xFFFF: tomo.KeyDelete, + + 0xFFBE: tomo.KeyF1, + 0xFFBF: tomo.KeyF2, + 0xFFC0: tomo.KeyF3, + 0xFFC1: tomo.KeyF4, + 0xFFC2: tomo.KeyF5, + 0xFFC3: tomo.KeyF6, + 0xFFC4: tomo.KeyF7, + 0xFFC5: tomo.KeyF8, + 0xFFC6: tomo.KeyF9, + 0xFFC7: tomo.KeyF10, + 0xFFC8: tomo.KeyF11, + 0xFFC9: tomo.KeyF12, + + // TODO: send this whenever a compose key, dead key, etc is pressed, + // and then send the resulting character while witholding the key + // presses that were used to compose it. As far as the program is + // concerned, a magical key with the final character was pressed and the + // KeyDead key is just so that the program might provide some visual + // feedback to the user while input is being waited for. + 0xFF20: tomo.KeyDead, +} + +var keypadCodeTable = map[xproto.Keysym] tomo.Key { + 0xff80: tomo.Key(' '), + 0xff89: tomo.KeyTab, + 0xff8d: tomo.KeyEnter, + 0xff91: tomo.KeyF1, + 0xff92: tomo.KeyF2, + 0xff93: tomo.KeyF3, + 0xff94: tomo.KeyF4, + 0xff95: tomo.KeyHome, + 0xff96: tomo.KeyLeft, + 0xff97: tomo.KeyUp, + 0xff98: tomo.KeyRight, + 0xff99: tomo.KeyDown, + 0xff9a: tomo.KeyPageUp, + 0xff9b: tomo.KeyPageDown, + 0xff9c: tomo.KeyEnd, + 0xff9d: tomo.KeyHome, + 0xff9e: tomo.KeyInsert, + 0xff9f: tomo.KeyDelete, + 0xffbd: tomo.Key('='), + 0xffaa: tomo.Key('*'), + 0xffab: tomo.Key('+'), + 0xffac: tomo.Key(','), + 0xffad: tomo.Key('-'), + 0xffae: tomo.Key('.'), + 0xffaf: tomo.Key('/'), + + 0xffb0: tomo.Key('0'), + 0xffb1: tomo.Key('1'), + 0xffb2: tomo.Key('2'), + 0xffb3: tomo.Key('3'), + 0xffb4: tomo.Key('4'), + 0xffb5: tomo.Key('5'), + 0xffb6: tomo.Key('6'), + 0xffb7: tomo.Key('7'), + 0xffb8: tomo.Key('8'), + 0xffb9: tomo.Key('9'), +} + +// initializeKeymapInformation grabs keyboard mapping information from the X +// server. +func (backend *Backend) initializeKeymapInformation () { + keybind.Initialize(backend.connection) + backend.modifierMasks.capsLock = backend.keysymToMask(0xFFE5) + backend.modifierMasks.shiftLock = backend.keysymToMask(0xFFE6) + backend.modifierMasks.numLock = backend.keysymToMask(0xFF7F) + backend.modifierMasks.modeSwitch = backend.keysymToMask(0xFF7E) + + backend.modifierMasks.hyper = backend.keysymToMask(0xffed) + backend.modifierMasks.super = backend.keysymToMask(0xffeb) + backend.modifierMasks.meta = backend.keysymToMask(0xffe7) + backend.modifierMasks.alt = backend.keysymToMask(0xffe9) +} + +// keysymToKeycode converts an X keysym to an X keycode, instead of the other +// way around. +func (backend *Backend) keysymToKeycode ( + symbol xproto.Keysym, +) ( + code xproto.Keycode, +) { + mapping := keybind.KeyMapGet(backend.connection) + + for index, testSymbol := range mapping.Keysyms { + if testSymbol == symbol { + code = xproto.Keycode ( + index / + int(mapping.KeysymsPerKeycode) + + int(backend.connection.Setup().MinKeycode)) + break + } + } + + return +} + +// keysymToMask returns the X modmask for a given modifier key. +func (backend *Backend) keysymToMask ( + symbol xproto.Keysym, +) ( + mask uint16, +) { + mask = keybind.ModGet ( + backend.connection, + backend.keysymToKeycode(symbol)) + + return +} + +// keycodeToButton converts an X keycode to a tomo keycode. It implements a more +// fleshed out version of some of the logic found in xgbutil/keybind/encoding.go +// to get a full keycode to keysym conversion, but eliminates redundant work by +// going straight to a tomo keycode. +func (backend *Backend) keycodeToKey ( + keycode xproto.Keycode, + state uint16, +) ( + button tomo.Key, + numberPad bool, +) { + // PARAGRAPH 3 + // + // A list of KeySyms is associated with each KeyCode. The list is + // intended to convey the set of symbols on the corresponding key. If + // the list (ignoring trailing NoSymbol entries) is a single KeySym + // ``K'', then the list is treated as if it were the list ``K NoSymbol + // K NoSymbol''. If the list (ignoring trailing NoSymbol entries) is a + // pair of KeySyms ``K1 K2'', then the list is treated as if it were the + // list ``K1 K2 K1 K2''. If the list (ignoring trailing NoSymbol + // entries) is a triple of KeySyms ``K1 K2 K3'', then the list is + // treated as if it were the list ``K1 K2 K3 NoSymbol''. When an + // explicit ``void'' element is desired in the list, the value + // VoidSymbol can be used. + symbol1 := keybind.KeysymGet(backend.connection, keycode, 0) + symbol2 := keybind.KeysymGet(backend.connection, keycode, 1) + symbol3 := keybind.KeysymGet(backend.connection, keycode, 2) + symbol4 := keybind.KeysymGet(backend.connection, keycode, 3) + switch { + case symbol2 == 0 && symbol3 == 0 && symbol4 == 0: + symbol3 = symbol1 + case symbol3 == 0 && symbol4 == 0: + symbol3 = symbol1 + symbol4 = symbol2 + case symbol4 == 0: + symbol4 = 0 + } + symbol1Rune := keysymToRune(symbol1) + symbol2Rune := keysymToRune(symbol2) + symbol3Rune := keysymToRune(symbol3) + symbol4Rune := keysymToRune(symbol4) + + // PARAGRAPH 4 + // + // The first four elements of the list are split into two groups of + // KeySyms. Group 1 contains the first and second KeySyms; Group 2 + // contains the third and fourth KeySyms. Within each group, if the + // second element of the group is NoSymbol , then the group should be + // treated as if the second element were the same as the first element, + // except when the first element is an alphabetic KeySym ``K'' for which + // both lowercase and uppercase forms are defined. In that case, the + // group should be treated as if the first element were the lowercase + // form of ``K'' and the second element were the uppercase form of + // ``K.'' + cased := false + if symbol2 == 0 { + upper := unicode.IsUpper(symbol1Rune) + lower := unicode.IsLower(symbol1Rune) + if upper || lower { + symbol1Rune = unicode.ToLower(symbol1Rune) + symbol2Rune = unicode.ToUpper(symbol1Rune) + cased = true + } else { + symbol2 = symbol1 + symbol2Rune = symbol1Rune + } + } + if symbol4 == 0 { + upper := unicode.IsUpper(symbol3Rune) + lower := unicode.IsLower(symbol3Rune) + if upper || lower { + symbol3Rune = unicode.ToLower(symbol3Rune) + symbol4Rune = unicode.ToUpper(symbol3Rune) + cased = true + } else { + symbol4 = symbol3 + symbol4Rune = symbol3Rune + } + } + + // PARAGRAPH 5 + // + // The standard rules for obtaining a KeySym from a KeyPress event make + // use of only the Group 1 and Group 2 KeySyms; no interpretation of/ + // other KeySyms in the list is given. Which group to use is determined + // by the modifier state. Switching between groups is controlled by the + // KeySym named MODE SWITCH, by attaching that KeySym to some KeyCode + // and attaching that KeyCode to any one of the modifiers Mod1 through + // Mod5. This modifier is called the group modifier. For any KeyCode, + // Group 1 is used when the group modifier is off, and Group 2 is used + // when the group modifier is on. + modeSwitch := state & backend.modifierMasks.modeSwitch > 0 + if modeSwitch { + symbol1 = symbol3 + symbol1Rune = symbol3Rune + symbol2 = symbol4 + symbol2Rune = symbol4Rune + + } + + // PARAGRAPH 6 + // + // The Lock modifier is interpreted as CapsLock when the KeySym named + // XK_Caps_Lock is attached to some KeyCode and that KeyCode is attached + // to the Lock modifier. The Lock modifier is interpreted as ShiftLock + // when the KeySym named XK_Shift_Lock is attached to some KeyCode and + // that KeyCode is attached to the Lock modifier. If the Lock modifier + // could be interpreted as both CapsLock and ShiftLock, the CapsLock + // interpretation is used. + shift := + state & xproto.ModMaskShift > 0 || + state & backend.modifierMasks.shiftLock > 0 + capsLock := state & backend.modifierMasks.capsLock > 0 + + // PARAGRAPH 7 + // + // The operation of keypad keys is controlled by the KeySym named + // XK_Num_Lock, by attaching that KeySym to some KeyCode and attaching + // that KeyCode to any one of the modifiers Mod1 through Mod5 . This + // modifier is called the numlock modifier. The standard KeySyms with + // the prefix ``XK_KP_'' in their name are called keypad KeySyms; these + // are KeySyms with numeric value in the hexadecimal range 0xFF80 to + // 0xFFBD inclusive. In addition, vendor-specific KeySyms in the + // hexadecimal range 0x11000000 to 0x1100FFFF are also keypad KeySyms. + numLock := state & backend.modifierMasks.numLock > 0 + + // PARAGRAPH 8 + // + // Within a group, the choice of KeySym is determined by applying the + // first rule that is satisfied from the following list: + var selectedKeysym xproto.Keysym + var selectedRune rune + _, symbol2IsNumPad := keypadCodeTable[symbol2] + switch { + case numLock && symbol2IsNumPad: + // The numlock modifier is on and the second KeySym is a keypad + // KeySym. In this case, if the Shift modifier is on, or if the + // Lock modifier is on and is interpreted as ShiftLock, then the + // first KeySym is used, otherwise the second KeySym is used. + if shift { + selectedKeysym = symbol1 + selectedRune = symbol1Rune + } else { + selectedKeysym = symbol2 + selectedRune = symbol2Rune + } + + case !shift && !capsLock: + // The Shift and Lock modifiers are both off. In this case, the + // first KeySym is used. + selectedKeysym = symbol1 + selectedRune = symbol1Rune + + case !shift && capsLock: + // The Shift modifier is off, and the Lock modifier is on and is + // interpreted as CapsLock. In this case, the first KeySym is + // used, but if that KeySym is lowercase alphabetic, then the + // corresponding uppercase KeySym is used instead. + if cased && unicode.IsLower(symbol1Rune) { + selectedRune = symbol2Rune + } else { + selectedKeysym = symbol1 + selectedRune = symbol1Rune + } + + case shift && capsLock: + // The Shift modifier is on, and the Lock modifier is on and is + // interpreted as CapsLock. In this case, the second KeySym is + // used, but if that KeySym is lowercase alphabetic, then the + // corresponding uppercase KeySym is used instead. + if cased && unicode.IsLower(symbol2Rune) { + selectedRune = unicode.ToUpper(symbol2Rune) + } else { + selectedKeysym = symbol2 + selectedRune = symbol2Rune + } + + case shift: + // The Shift modifier is on, or the Lock modifier is on and is + // interpreted as ShiftLock, or both. In this case, the second + // KeySym is used. + selectedKeysym = symbol2 + selectedRune = symbol2Rune + } + + //////////////////////////////////////////////////////////////// + // all of the below stuff is specific to tomo's button codes. // + //////////////////////////////////////////////////////////////// + + // look up in control code table + var isControl bool + button, isControl = buttonCodeTable[selectedKeysym] + if isControl { return } + + // look up in keypad table + button, numberPad = keypadCodeTable[selectedKeysym] + if numberPad { return } + + // otherwise, use the rune + button = tomo.Key(selectedRune) + + return +} + +// keysymToRune takes in an X keysym and outputs a utf32 code point. This +// function does not and should not handle keypad keys, as those are handled +// by Backend.keycodeToButton. +func keysymToRune (keysym xproto.Keysym) (character rune) { + // X keysyms like 0xFF.. or 0xFE.. are non-character keys. these cannot + // be converted so we return a zero. + if (keysym >> 8) == 0xFF || (keysym >> 8) == 0xFE { + character = 0 + return + } + + // some X keysyms have a single bit set to 1 here. i believe this is to + // prevent conflicts with existing codes. if we mask it off we will get + // a correct utf-32 code point. + if keysym & 0xF000000 == 0x1000000 { + character = rune(keysym & 0x0111111) + return + } + + // if none of these things happened, we can safely (i think) assume that + // the keysym is an exact utf-32 code point. + character = rune(keysym) + return +} diff --git a/backends/x/event.go b/backends/x/event.go new file mode 100644 index 0000000..221e5a3 --- /dev/null +++ b/backends/x/event.go @@ -0,0 +1,235 @@ +package x + +import "git.tebibyte.media/sashakoshka/tomo" + +import "github.com/jezek/xgbutil" +import "github.com/jezek/xgb/xproto" +import "github.com/jezek/xgbutil/xevent" + +type scrollSum struct { + x, y int +} + +func (sum *scrollSum) add (button xproto.Button) { + switch button { + case 4: + sum.y -- + case 5: + sum.y ++ + case 6: + sum.x -- + case 7: + sum.x ++ + } +} + +func (window *Window) handleConfigureNotify ( + connection *xgbutil.XUtil, + event xevent.ConfigureNotifyEvent, +) { + configureEvent := *event.ConfigureNotifyEvent + + newWidth := int(configureEvent.Width) + newHeight := int(configureEvent.Height) + sizeChanged := + window.metrics.width != newWidth || + window.metrics.height != newHeight + window.metrics.width = newWidth + window.metrics.height = newHeight + + if sizeChanged { + configureEvent = window.compressConfigureNotify(configureEvent) + window.metrics.width = int(configureEvent.Width) + window.metrics.height = int(configureEvent.Height) + window.reallocateCanvas() + window.resizeChildToFit() + } +} + +func (window *Window) handleKeyPress ( + connection *xgbutil.XUtil, + event xevent.KeyPressEvent, +) { + if window.child == nil { return} + + keyEvent := *event.KeyPressEvent + key, numberPad := window.backend.keycodeToKey(keyEvent.Detail, keyEvent.State) + modifiers := tomo.Modifiers { + Shift: + (keyEvent.State & xproto.ModMaskShift) > 0 || + (keyEvent.State & window.backend.modifierMasks.shiftLock) > 0, + Control: (keyEvent.State & xproto.ModMaskControl) > 0, + Alt: (keyEvent.State & window.backend.modifierMasks.alt) > 0, + Meta: (keyEvent.State & window.backend.modifierMasks.meta) > 0, + Super: (keyEvent.State & window.backend.modifierMasks.super) > 0, + Hyper: (keyEvent.State & window.backend.modifierMasks.hyper) > 0, + NumberPad: numberPad, + } + + window.child.Handle (tomo.EventKeyDown { + Key: key, + Modifiers: modifiers, + Repeated: false, // FIXME: return correct value here + }) +} + +func (window *Window) handleKeyRelease ( + connection *xgbutil.XUtil, + event xevent.KeyReleaseEvent, +) { + if window.child == nil { return } + + keyEvent := *event.KeyReleaseEvent + key, numberPad := window.backend.keycodeToKey(keyEvent.Detail, keyEvent.State) + modifiers := tomo.Modifiers { + Shift: + (keyEvent.State & xproto.ModMaskShift) > 0 || + (keyEvent.State & window.backend.modifierMasks.shiftLock) > 0, + Control: (keyEvent.State & xproto.ModMaskControl) > 0, + Alt: (keyEvent.State & window.backend.modifierMasks.alt) > 0, + Meta: (keyEvent.State & window.backend.modifierMasks.meta) > 0, + Super: (keyEvent.State & window.backend.modifierMasks.super) > 0, + Hyper: (keyEvent.State & window.backend.modifierMasks.hyper) > 0, + NumberPad: numberPad, + } + + window.child.Handle (tomo.EventKeyUp { + Key: key, + Modifiers: modifiers, + }) +} + +func (window *Window) handleButtonPress ( + connection *xgbutil.XUtil, + event xevent.ButtonPressEvent, +) { + if window.child == nil { return } + + buttonEvent := *event.ButtonPressEvent + if buttonEvent.Detail >= 4 && buttonEvent.Detail <= 7 { + sum := scrollSum { } + sum.add(buttonEvent.Detail) + window.compressScrollSum(buttonEvent, &sum) + window.child.Handle (tomo.EventScroll { + X: int(buttonEvent.EventX), + Y: int(buttonEvent.EventY), + ScrollX: sum.x, + ScrollY: sum.y, + }) + } else { + window.child.Handle (tomo.EventMouseDown { + Button: tomo.Button(buttonEvent.Detail), + X: int(buttonEvent.EventX), + Y: int(buttonEvent.EventY), + }) + } +} + +func (window *Window) handleButtonRelease ( + connection *xgbutil.XUtil, + event xevent.ButtonReleaseEvent, +) { + buttonEvent := *event.ButtonReleaseEvent + if buttonEvent.Detail >= 4 && buttonEvent.Detail <= 7 { return } + window.child.Handle (tomo.EventMouseUp { + Button: tomo.Button(buttonEvent.Detail), + X: int(buttonEvent.EventX), + Y: int(buttonEvent.EventY), + }) +} + +func (window *Window) handleMotionNotify ( + connection *xgbutil.XUtil, + event xevent.MotionNotifyEvent, +) { + motionEvent := window.compressMotionNotify(*event.MotionNotifyEvent) + window.child.Handle (tomo.EventMouseMove { + X: int(motionEvent.EventX), + Y: int(motionEvent.EventY), + }) +} + + +func (window *Window) compressConfigureNotify ( + firstEvent xproto.ConfigureNotifyEvent, +) ( + lastEvent xproto.ConfigureNotifyEvent, +) { + window.backend.connection.Sync() + xevent.Read(window.backend.connection, false) + lastEvent = firstEvent + + for index, untypedEvent := range xevent.Peek(window.backend.connection) { + if untypedEvent.Err != nil { continue } + + typedEvent, ok := untypedEvent.Event.(xproto.ConfigureNotifyEvent) + if !ok { continue } + + if firstEvent.Event == typedEvent.Event && + firstEvent.Window == typedEvent.Window { + + lastEvent = typedEvent + defer func (index int) { + xevent.DequeueAt(window.backend.connection, index) + } (index) + } + } + + return +} + +func (window *Window) compressScrollSum ( + firstEvent xproto.ButtonPressEvent, + sum *scrollSum, +) { + window.backend.connection.Sync() + xevent.Read(window.backend.connection, false) + + for index, untypedEvent := range xevent.Peek(window.backend.connection) { + if untypedEvent.Err != nil { continue } + + typedEvent, ok := untypedEvent.Event.(xproto.ButtonPressEvent) + if !ok { continue } + + if firstEvent.Event == typedEvent.Event && + typedEvent.Detail >= 4 && + typedEvent.Detail <= 7 { + + sum.add(typedEvent.Detail) + defer func (index int) { + xevent.DequeueAt(window.backend.connection, index) + } (index) + } + } + + return +} + +func (window *Window) compressMotionNotify ( + firstEvent xproto.MotionNotifyEvent, +) ( + lastEvent xproto.MotionNotifyEvent, +) { + window.backend.connection.Sync() + xevent.Read(window.backend.connection, false) + lastEvent = firstEvent + + for index, untypedEvent := range xevent.Peek(window.backend.connection) { + if untypedEvent.Err != nil { continue } + + typedEvent, ok := untypedEvent.Event.(xproto.MotionNotifyEvent) + if !ok { continue } + + if firstEvent.Event == typedEvent.Event && + typedEvent.Detail >= 4 && + typedEvent.Detail <= 7 { + + lastEvent = typedEvent + defer func (index int) { + xevent.DequeueAt(window.backend.connection, index) + } (index) + } + } + + return +} diff --git a/backends/x/window.go b/backends/x/window.go new file mode 100644 index 0000000..cacf926 --- /dev/null +++ b/backends/x/window.go @@ -0,0 +1,328 @@ +package x + +import "image" +import "image/color" +import "github.com/jezek/xgb/xproto" +import "github.com/jezek/xgbutil/ewmh" +import "github.com/jezek/xgbutil/icccm" +import "github.com/jezek/xgbutil/xevent" +import "github.com/jezek/xgbutil/xwindow" +import "github.com/jezek/xgbutil/xgraphics" +import "git.tebibyte.media/sashakoshka/tomo" + +type Window struct { + backend *Backend + xWindow *xwindow.Window + xCanvas *xgraphics.Image + child tomo.Element + onClose func () + drawCallback func (region tomo.Image) + minimumSizeChangeCallback func (width, height int) + skipChildDrawCallback bool + + metrics struct { + width int + height int + } +} + +func (backend *Backend) NewWindow ( + width, height int, +) ( + output tomo.Window, + err error, +) { + if backend == nil { panic("nil backend") } + + window := &Window { backend: backend } + + window.xWindow, err = xwindow.Generate(backend.connection) + if err != nil { return } + window.xWindow.Create ( + backend.connection.RootWin(), + 0, 0, width, height, 0) + err = window.xWindow.Listen ( + xproto.EventMaskStructureNotify, + xproto.EventMaskPointerMotion, + xproto.EventMaskKeyPress, + xproto.EventMaskKeyRelease, + xproto.EventMaskButtonPress, + xproto.EventMaskButtonRelease) + if err != nil { return } + + window.xWindow.WMGracefulClose (func (xWindow *xwindow.Window) { + window.Close() + }) + + xevent.ConfigureNotifyFun(window.handleConfigureNotify). + Connect(backend.connection, window.xWindow.Id) + xevent.KeyPressFun(window.handleKeyPress). + Connect(backend.connection, window.xWindow.Id) + xevent.KeyReleaseFun(window.handleKeyRelease). + Connect(backend.connection, window.xWindow.Id) + xevent.ButtonPressFun(window.handleButtonPress). + Connect(backend.connection, window.xWindow.Id) + xevent.ButtonReleaseFun(window.handleButtonRelease). + Connect(backend.connection, window.xWindow.Id) + xevent.MotionNotifyFun(window.handleMotionNotify). + Connect(backend.connection, window.xWindow.Id) + + window.metrics.width = width + window.metrics.height = height + window.childMinimumSizeChangeCallback(8, 8) + + window.reallocateCanvas() + + backend.windows[window.xWindow.Id] = window + output = window + return +} + +func (window *Window) ColorModel () (model color.Model) { + return color.RGBAModel +} + +func (window *Window) At (x, y int) (pixel color.Color) { + pixel = window.xCanvas.At(x, y) + return +} + +func (window *Window) RGBAAt (x, y int) (pixel color.RGBA) { + sourcePixel := window.xCanvas.At(x, y).(xgraphics.BGRA) + pixel = color.RGBA { + R: sourcePixel.R, + G: sourcePixel.G, + B: sourcePixel.B, + A: sourcePixel.A, + } + return +} + +func (window *Window) Bounds () (bounds image.Rectangle) { + bounds.Max = image.Point { + X: window.metrics.width, + Y: window.metrics.height, + } + return +} + +func (window *Window) Handle (event tomo.Event) () { + switch event.(type) { + case tomo.EventResize: + resizeEvent := event.(tomo.EventResize) + // we will receive a resize event from X later which will be + // handled by our event handler callbacks. + if resizeEvent.Width < window.MinimumWidth() { + resizeEvent.Width = window.MinimumWidth() + } + if resizeEvent.Height < window.MinimumHeight() { + resizeEvent.Height = window.MinimumHeight() + } + window.xWindow.Resize(resizeEvent.Width, resizeEvent.Height) + default: + if window.child != nil { window.child.Handle(event) } + } + return +} + +func (window *Window) SetDrawCallback (draw func (region tomo.Image)) { + window.drawCallback = draw +} + +func (window *Window) SetMinimumSizeChangeCallback ( + notify func (width, height int), +) { + window.minimumSizeChangeCallback = notify +} + +func (window *Window) Selectable () (selectable bool) { + if window.child != nil { selectable = window.child.Selectable() } + return +} + +func (window *Window) MinimumWidth () (minimum int) { + if window.child != nil { minimum = window.child.MinimumWidth() } + minimum = 8 + return +} + +func (window *Window) MinimumHeight () (minimum int) { + if window.child != nil { minimum = window.child.MinimumHeight() } + minimum = 8 + return +} + +func (window *Window) Adopt (child tomo.Element) { + if window.child != nil { + window.child.SetDrawCallback(nil) + window.child.SetMinimumSizeChangeCallback(nil) + } + window.child = child + if child != nil { + child.SetDrawCallback(window.childDrawCallback) + child.SetMinimumSizeChangeCallback ( + window.childMinimumSizeChangeCallback) + window.resizeChildToFit() + } + window.childMinimumSizeChangeCallback ( + child.MinimumWidth(), + child.MinimumHeight()) +} + +func (window *Window) Child () (child tomo.Element) { + child = window.child + return +} + +func (window *Window) SetTitle (title string) { + ewmh.WmNameSet ( + window.backend.connection, + window.xWindow.Id, + title) +} + +func (window *Window) SetIcon (sizes []image.Image) { + wmIcons := []ewmh.WmIcon { } + + for _, icon := range sizes { + width := icon.Bounds().Max.X + height := icon.Bounds().Max.Y + wmIcon := ewmh.WmIcon { + Width: uint(width), + Height: uint(height), + Data: make ([]uint, width * height), + } + + // manually convert image data beacuse of course we have to do + // this + index := 0 + for y := 0; y < height; y ++ { + for x := 0; x < width; x ++ { + r, g, b, a := icon.At(x, y).RGBA() + r >>= 8 + g >>= 8 + b >>= 8 + a >>= 8 + wmIcon.Data[index] = + (uint(a) << 24) | + (uint(r) << 16) | + (uint(g) << 8) | + (uint(b) << 0) + index ++ + }} + + wmIcons = append(wmIcons, wmIcon) + } + + ewmh.WmIconSet ( + window.backend.connection, + window.xWindow.Id, + wmIcons) +} + +func (window *Window) Show () { + if window.child == nil { + window.xCanvas.For (func (x, y int) xgraphics.BGRA { + return xgraphics.BGRA { } + }) + + window.pushRegion(window.xCanvas.Bounds()) + } + + window.xWindow.Map() +} + +func (window *Window) Hide () { + window.xWindow.Unmap() +} + +func (window *Window) Close () { + delete(window.backend.windows, window.xWindow.Id) + if window.onClose != nil { window.onClose() } + xevent.Detach(window.xWindow.X, window.xWindow.Id) + window.xWindow.Destroy() +} + +func (window *Window) OnClose (callback func ()) { + window.onClose = callback +} + +func (window *Window) reallocateCanvas () { + if window.xCanvas != nil { + window.xCanvas.Destroy() + } + window.xCanvas = xgraphics.New ( + window.backend.connection, + image.Rect ( + 0, 0, + window.metrics.width, + window.metrics.height)) + + window.xCanvas.XSurfaceSet(window.xWindow.Id) +} + +func (window *Window) redrawChildEntirely () { + window.xCanvas.For (func (x, y int) (c xgraphics.BGRA) { + rgba := window.child.RGBAAt(x, y) + c.R, c.G, c.B, c.A = rgba.R, rgba.G, rgba.B, rgba.A + return + }) + + window.pushRegion(window.xCanvas.Bounds()) +} + +func (window *Window) resizeChildToFit () { + window.skipChildDrawCallback = true + window.child.Handle(tomo.EventResize { + Width: window.metrics.width, + Height: window.metrics.height, + }) + window.skipChildDrawCallback = false + window.redrawChildEntirely() +} + +func (window *Window) childDrawCallback (region tomo.Image) { + if window.skipChildDrawCallback { return } + + bounds := region.Bounds() + for x := bounds.Min.X; x < bounds.Max.X; x ++ { + for y := bounds.Min.Y; y < bounds.Max.Y; y ++ { + rgba := region.RGBAAt(x, y) + window.xCanvas.SetBGRA (x, y, xgraphics.BGRA { + R: rgba.R, + G: rgba.G, + B: rgba.B, + A: rgba.A, + }) + }} + + window.pushRegion(region.Bounds()) +} + +func (window *Window) childMinimumSizeChangeCallback (width, height int) { + icccm.WmNormalHintsSet ( + window.backend.connection, + window.xWindow.Id, + &icccm.NormalHints { + Flags: icccm.SizeHintPMinSize, + MinWidth: uint(width), + MinHeight: uint(height), + }) + newWidth := window.metrics.width + newHeight := window.metrics.height + if newWidth < width { newWidth = width } + if newHeight < height { newHeight = height } + if newWidth != window.metrics.width || + newHeight != window.metrics.height { + window.xWindow.Resize(newWidth, newHeight) + } +} + +func (window *Window) pushRegion (region image.Rectangle) { + if window.xCanvas == nil { panic("whoopsie!!!!!!!!!!!!!!") } + image, ok := window.xCanvas.SubImage(region).(*xgraphics.Image) + if ok { + image.XDraw() + window.xCanvas.XPaint(window.xWindow.Id) + } +} diff --git a/backends/x/x.go b/backends/x/x.go new file mode 100644 index 0000000..cdea721 --- /dev/null +++ b/backends/x/x.go @@ -0,0 +1,85 @@ +package x + +import "git.tebibyte.media/sashakoshka/tomo" + +import "github.com/jezek/xgbutil" +import "github.com/jezek/xgb/xproto" +import "github.com/jezek/xgbutil/xevent" + +// Backend is an instance of an X backend. +type Backend struct { + connection *xgbutil.XUtil + + doChannel chan(func ()) + + modifierMasks struct { + capsLock uint16 + shiftLock uint16 + numLock uint16 + modeSwitch uint16 + + alt uint16 + meta uint16 + super uint16 + hyper uint16 + } + + windows map[xproto.Window] *Window +} + +// NewBackend instantiates an X backend. +func NewBackend () (output tomo.Backend, err error) { + backend := &Backend { + windows: map[xproto.Window] *Window { }, + } + + // connect to X + backend.connection, err = xgbutil.NewConn() + if err != nil { return } + backend.initializeKeymapInformation() + + output = backend + return +} + +// Run runs the backend's event loop. This method will not exit until Stop() is +// called, or the backend experiences a fatal error. +func (backend *Backend) Run () (err error) { + backend.assert() + pingBefore, + pingAfter, + pingQuit := xevent.MainPing(backend.connection) + for { + select { + case <- pingBefore: <- pingAfter + case callback := <- backend.doChannel: + callback() + case <- pingQuit: return + } + } +} + +// Stop gracefully closes the connection and stops the event loop. +func (backend *Backend) Stop () { + backend.assert() + for _, window := range backend.windows { + window.Close() + } + xevent.Quit(backend.connection) + backend.connection.Conn().Close() +} + +// Do executes the specified callback within the main thread as soon as +// possible. This function can be safely called from other threads. +func (backend *Backend) Do (callback func ()) { + backend.assert() + backend.doChannel <- callback +} + +func (backend *Backend) assert () { + if backend == nil { panic("nil backend") } +} + +func init () { + tomo.RegisterBackend(NewBackend) +} diff --git a/defaultfont/defaultfont.go b/defaultfont/defaultfont.go new file mode 100644 index 0000000..e1041fe --- /dev/null +++ b/defaultfont/defaultfont.go @@ -0,0 +1,47 @@ +package defaultfont + +import "golang.org/x/image/font/basicfont" + +var FaceRegular = basicfont.Face7x13 + +// FIXME: make bold, italic, and bold italic masks by processing the Face7x13 +// mask. + +var FaceBold = &basicfont.Face { + Advance: 7, + Width: 6, + Height: 13, + Ascent: 11, + Descent: 2, + Mask: FaceRegular.Mask, // TODO + Ranges: []basicfont.Range { + { '\u0020', '\u007f', 0 }, + { '\ufffd', '\ufffe', 95 }, + }, +} + +var FaceItalic = &basicfont.Face { + Advance: 7, + Width: 6, + Height: 13, + Ascent: 11, + Descent: 2, + Mask: FaceRegular.Mask, // TODO + Ranges: []basicfont.Range { + { '\u0020', '\u007f', 0 }, + { '\ufffd', '\ufffe', 95 }, + }, +} + +var FaceBoldItalic = &basicfont.Face { + Advance: 7, + Width: 6, + Height: 13, + Ascent: 11, + Descent: 2, + Mask: FaceRegular.Mask, // TODO + Ranges: []basicfont.Range { + { '\u0020', '\u007f', 0 }, + { '\ufffd', '\ufffe', 95 }, + }, +} diff --git a/elements/basic/button.go b/elements/basic/button.go new file mode 100644 index 0000000..3dc09a6 --- /dev/null +++ b/elements/basic/button.go @@ -0,0 +1,176 @@ +package basic + +import "image" +import "image/color" +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/theme" +import "git.tebibyte.media/sashakoshka/tomo/artist" + +type Button struct { + core Core + pressed bool + enabled bool + onClick func () + + text string + drawer artist.TextDrawer +} + +func NewButton (text string) (element *Button) { + element = &Button { enabled: true } + element.core = NewCore(element) + element.drawer.SetFace(theme.FontFaceRegular()) + element.SetText(text) + return +} + +func (element *Button) Handle (event tomo.Event) { + switch event.(type) { + case tomo.EventResize: + resizeEvent := event.(tomo.EventResize) + element.core.AllocateCanvas ( + resizeEvent.Width, + resizeEvent.Height) + element.draw() + + case tomo.EventMouseDown: + if !element.enabled { break } + + mouseDownEvent := event.(tomo.EventMouseDown) + if mouseDownEvent.Button != tomo.ButtonLeft { break } + element.pressed = true + if element.core.HasImage() { + element.draw() + element.core.PushAll() + } + + case tomo.EventMouseUp: + if !element.enabled { break } + + mouseUpEvent := event.(tomo.EventMouseUp) + if mouseUpEvent.Button != tomo.ButtonLeft { break } + element.pressed = false + if element.core.HasImage() { + element.draw() + element.core.PushAll() + } + + within := image.Point { mouseUpEvent.X, mouseUpEvent.Y }. + In(element.Bounds()) + + if within && element.onClick != nil { + element.onClick() + } + // TODO: handle selection events, and the enter key + } + return +} + +func (element *Button) SetEnabled (enabled bool) { + if element.enabled == enabled { return } + element.enabled = enabled + if element.core.HasImage () { + element.draw() + element.core.PushAll() + } +} + +func (element *Button) SetText (text string) { + if element.text == text { return } + + element.text = text + element.drawer.SetText(text) + textBounds := element.drawer.LayoutBounds() + element.core.SetMinimumSize ( + theme.Padding() * 2 + textBounds.Dx(), + theme.Padding() * 2 + textBounds.Dy()) + if element.core.HasImage () { + element.draw() + element.core.PushAll() + } +} + +func (element *Button) OnClick (callback func ()) { + element.onClick = callback +} + +func (element *Button) ColorModel () (model color.Model) { + return color.RGBAModel +} + +func (element *Button) At (x, y int) (pixel color.Color) { + pixel = element.core.At(x, y) + return +} + +func (element *Button) RGBAAt (x, y int) (pixel color.RGBA) { + pixel = element.core.RGBAAt(x, y) + return +} + +func (element *Button) Bounds () (bounds image.Rectangle) { + bounds = element.core.Bounds() + return +} + +func (element *Button) SetDrawCallback (draw func (region tomo.Image)) { + element.core.SetDrawCallback(draw) +} + +func (element *Button) SetMinimumSizeChangeCallback ( + notify func (width, height int), +) { + element.core.SetMinimumSizeChangeCallback(notify) +} + +func (element *Button) Selectable () (selectable bool) { + selectable = true + return +} + +func (element *Button) MinimumWidth () (minimum int) { + minimum = element.core.MinimumWidth() + return +} + +func (element *Button) MinimumHeight () (minimum int) { + minimum = element.core.MinimumHeight() + return +} + +func (element *Button) draw () { + bounds := element.core.Bounds() + + artist.ChiseledRectangle ( + element.core, + theme.RaisedProfile(element.pressed, element.enabled), + bounds) + + innerBounds := bounds + innerBounds.Min.X += theme.Padding() + innerBounds.Min.Y += theme.Padding() + innerBounds.Max.X -= theme.Padding() + innerBounds.Max.Y -= theme.Padding() + + textBounds := element.drawer.LayoutBounds() + offset := image.Point { + X: theme.Padding() + (innerBounds.Dx() - textBounds.Dx()) / 2, + Y: theme.Padding() + (innerBounds.Dy() - textBounds.Dy()) / 2, + } + + // account for the fact that the bounding rectangle will be shifted over + // due to the bounds origin being at the baseline of the first line + offset.Y -= textBounds.Min.Y + offset.X -= textBounds.Min.X + + if element.pressed { + offset = offset.Add(theme.SinkOffsetVector()) + } + + foreground := theme.ForegroundImage() + if !element.enabled { + foreground = theme.DisabledForegroundImage() + } + + element.drawer.Draw(element.core, foreground, offset) +} diff --git a/elements/basic/core.go b/elements/basic/core.go new file mode 100644 index 0000000..f37cfeb --- /dev/null +++ b/elements/basic/core.go @@ -0,0 +1,138 @@ +package basic + +import "image" +import "image/color" +import "git.tebibyte.media/sashakoshka/tomo" + +// Core is a struct that implements some core functionality common to most +// widgets. It is possible to embed this directly into a struct, but this is not +// reccomended as it exposes internal functionality. +type Core struct { + *image.RGBA + parent tomo.Element + + drawCallback func (region tomo.Image) + minimumSizeChangeCallback func (width, height int) + + metrics struct { + minimumWidth int + minimumHeight int + } +} + +// Core creates a new element core. +func NewCore (parent tomo.Element) (core Core) { + core = Core { parent: parent } + return +} + +func (core Core) ColorModel () (model color.Model) { + return color.RGBAModel +} + +func (core Core) At (x, y int) (pixel color.Color) { + if core.RGBA == nil { return color.RGBA { } } + pixel = core.RGBA.At(x, y) + return +} + +func (core Core) RGBAAt (x, y int) (pixel color.RGBA) { + if core.RGBA == nil { return color.RGBA { } } + pixel = core.RGBA.RGBAAt(x, y) + return +} + +func (core Core) Bounds () (bounds image.Rectangle) { + if core.RGBA != nil { bounds = core.RGBA.Bounds() } + return +} + +func (core *Core) SetDrawCallback (draw func (region tomo.Image)) { + core.drawCallback = draw +} + +func (core *Core) SetMinimumSizeChangeCallback ( + notify func (width, height int), +) { + core.minimumSizeChangeCallback = notify +} + +func (core Core) HasImage () (has bool) { + has = core.RGBA != nil + return +} + +func (core Core) PushRegion (bounds image.Rectangle) { + if core.drawCallback != nil { + core.drawCallback(core.SubImage(bounds). + (*image.RGBA)) + } +} + +func (core Core) PushAll () { + core.PushRegion(core.Bounds()) +} + +func (core *Core) AllocateCanvas (width, height int) { + width, height, _ = core.ConstrainSize(width, height) + core.RGBA = image.NewRGBA(image.Rect (0, 0, width, height)) +} + +func (core Core) MinimumWidth () (minimum int) { + minimum = core.metrics.minimumWidth + return +} + +func (core Core) MinimumHeight () (minimum int) { + minimum = core.metrics.minimumHeight + return +} + +func (core *Core) SetMinimumSize (width, height int) { + if width != core.metrics.minimumWidth || + height != core.metrics.minimumHeight { + + core.metrics.minimumWidth = width + core.metrics.minimumHeight = height + + if core.minimumSizeChangeCallback != nil { + core.minimumSizeChangeCallback(width, height) + } + + // if there is an image buffer, and the current size is less + // than this new minimum size, send core.parent a resize event. + if core.HasImage() { + bounds := core.Bounds() + imageWidth, + imageHeight, + constrained := core.ConstrainSize ( + bounds.Dx(), + bounds.Dy()) + if constrained { + core.parent.Handle (tomo.EventResize { + Width: imageWidth, + Height: imageHeight, + }) + } + } + } +} + +func (core Core) ConstrainSize ( + inWidth, inHeight int, +) ( + outWidth, outHeight int, + constrained bool, +) { + outWidth = inWidth + outHeight = inHeight + if outWidth < core.metrics.minimumWidth { + outWidth = core.metrics.minimumWidth + constrained = true + } + if outHeight < core.metrics.minimumHeight { + outHeight = core.metrics.minimumHeight + constrained = true + } + return +} diff --git a/elements/basic/label.go b/elements/basic/label.go new file mode 100644 index 0000000..7f91913 --- /dev/null +++ b/elements/basic/label.go @@ -0,0 +1,114 @@ +package basic + +import "image" +import "image/color" +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/theme" +import "git.tebibyte.media/sashakoshka/tomo/artist" + +type Label struct { + core Core + text string + drawer artist.TextDrawer +} + +func NewLabel (text string) (element *Label) { + element = &Label { } + element.core = NewCore(element) + face := theme.FontFaceRegular() + element.drawer.SetFace(face) + element.SetText(text) + // FIXME: set the minimum size to one char + metrics := face.Metrics() + emspace, _ := face.GlyphAdvance('M') + intEmspace := emspace.Round() + if intEmspace < 1 { intEmspace = theme.Padding()} + element.core.SetMinimumSize(intEmspace, metrics.Height.Round()) + return +} + +func (element *Label) Handle (event tomo.Event) { + switch event.(type) { + case tomo.EventResize: + resizeEvent := event.(tomo.EventResize) + element.core.AllocateCanvas ( + resizeEvent.Width, + resizeEvent.Height) + element.drawer.SetMaxWidth (resizeEvent.Width) + element.drawer.SetMaxHeight(resizeEvent.Height) + element.draw() + } + return +} + +func (element *Label) SetText (text string) { + if element.text == text { return } + + element.text = text + element.drawer.SetText(text) + if element.core.HasImage () { + element.draw() + element.core.PushAll() + } +} + +func (element *Label) ColorModel () (model color.Model) { + return color.RGBAModel +} + +func (element *Label) At (x, y int) (pixel color.Color) { + pixel = element.core.At(x, y) + return +} + +func (element *Label) RGBAAt (x, y int) (pixel color.RGBA) { + pixel = element.core.RGBAAt(x, y) + return +} + +func (element *Label) Bounds () (bounds image.Rectangle) { + bounds = element.core.Bounds() + return +} + +func (element *Label) SetDrawCallback (draw func (region tomo.Image)) { + element.core.SetDrawCallback(draw) +} + +func (element *Label) SetMinimumSizeChangeCallback ( + notify func (width, height int), +) { + element.core.SetMinimumSizeChangeCallback(notify) +} + +func (element *Label) Selectable () (selectable bool) { + return +} + +func (element *Label) MinimumWidth () (minimum int) { + minimum = element.core.MinimumWidth() + return +} + +func (element *Label) MinimumHeight () (minimum int) { + minimum = element.core.MinimumHeight() + return +} + +func (element *Label) draw () { + bounds := element.core.Bounds() + + artist.Rectangle ( + element.core, + theme.BackgroundImage(), + nil, 0, + bounds) + + textBounds := element.drawer.LayoutBounds() + + foreground := theme.ForegroundImage() + element.drawer.Draw (element.core, foreground, image.Point { + X: 0 - textBounds.Min.X, + Y: 0 - textBounds.Min.Y, + }) +} diff --git a/elements/basic/test.go b/elements/basic/test.go new file mode 100644 index 0000000..b43ebcb --- /dev/null +++ b/elements/basic/test.go @@ -0,0 +1,90 @@ +package basic + +import "image" +import "image/color" +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/artist" + +// Test is a simple element that can be used as a placeholder. +type Test struct { + core Core +} + +// NewTest creates a new test element. +func NewTest () (element *Test) { + element = &Test { } + element.core = NewCore(element) + element.core.SetMinimumSize(32, 32) + return +} + +func (element *Test) Handle (event tomo.Event) { + switch event.(type) { + case tomo.EventResize: + resizeEvent := event.(tomo.EventResize) + element.core.AllocateCanvas ( + resizeEvent.Width, + resizeEvent.Height) + for y := 0; y < resizeEvent.Height; y ++ { + for x := 0; x < resizeEvent.Width; x ++ { + pixel := color.RGBA { + R: 0x40, G: 0x80, B: 0x90, A: 0xFF, + } + element.core.SetRGBA (x, y, pixel) + }} + artist.Line ( + element.core, artist.NewUniform(color.White), 1, + image.Pt(0, 0), + image.Pt(resizeEvent.Width, resizeEvent.Height)) + artist.Line ( + element.core, artist.NewUniform(color.White), 1, + image.Pt(0, resizeEvent.Height), + image.Pt(resizeEvent.Width, 0)) + + default: + } + return +} + +func (element *Test) ColorModel () (model color.Model) { + return color.RGBAModel +} + +func (element *Test) At (x, y int) (pixel color.Color) { + pixel = element.core.At(x, y) + return +} + +func (element *Test) RGBAAt (x, y int) (pixel color.RGBA) { + pixel = element.core.RGBAAt(x, y) + return +} + +func (element *Test) Bounds () (bounds image.Rectangle) { + bounds = element.core.Bounds() + return +} + +func (element *Test) SetDrawCallback (draw func (region tomo.Image)) { + element.core.SetDrawCallback(draw) +} + +func (element *Test) SetMinimumSizeChangeCallback ( + notify func (width, height int), +) { + element.core.SetMinimumSizeChangeCallback(notify) +} + +func (element *Test) Selectable () (selectable bool) { + return +} + +func (element *Test) MinimumWidth () (minimum int) { + minimum = element.core.MinimumWidth() + return +} + +func (element *Test) MinimumHeight () (minimum int) { + minimum = element.core.MinimumHeight() + return +} diff --git a/event.go b/event.go new file mode 100644 index 0000000..dd2400a --- /dev/null +++ b/event.go @@ -0,0 +1,88 @@ +package tomo + +// Event represents any event. Use a type switch to figure out what sort of +// event it is. +type Event interface { } + +// EventResize is sent to an element when its parent decides to resize it. +// Elements should not do anything if the width and height do not change. +type EventResize struct { + // The width and height the element should not be less than the + // element's reported minimum width and height. If by some chance they + // are anyways, the element should use its minimum width and height + // instead. + Width, Height int +} + +// EventKeyDown is sent to the currently selected element when a key on the +// keyboard is pressed. Containers must propagate this event downwards. +type EventKeyDown struct { + Key + Modifiers + Repeated bool +} + +// EventKeyDown is sent to the currently selected element when a key on the +// keyboard is released. Containers must propagate this event downwards. +type EventKeyUp struct { + Key + Modifiers +} + +// EventMouseDown is sent to the element the mouse is positioned over when it is +// clicked. Containers must propagate this event downwards, with X and Y values +// relative to the top left corner of the child element. +type EventMouseDown struct { + // The button that was released + Button + + // The X and Y position of the mouse cursor at the time of the event, + // relative to the top left corner of the element + X, Y int +} + +// EventMouseUp is sent to the element that was positioned under the mouse the +// last time this particular mouse button was pressed down when it is released. +// Containers must propagate this event downwards, with X and Y values relative +// to the top left corner of the child element. +type EventMouseUp struct { + // The button that was released + Button + + // The X and Y position of the mouse cursor at the time of the event, + // relative to the top left corner of the element + X, Y int +} + +// EventMouseMove is sent to the element positioned under the mouse cursor when +// the mouse moves, or if a mouse button is currently being pressed, the element +// that the mouse was positioned under when it was pressed down. Containers must +// propogate this event downwards, with X and Y values relative to the top left +// corner of the child element. +type EventMouseMove struct { + // The X and Y position of the mouse cursor at the time of the event, + // relative to the top left corner of the element + X, Y int +} + +// EventScroll is sent to the element positioned under the mouse cursor when the +// scroll wheel (or equivalent) is spun. Containers must propogate this event +// downwards. +type EventScroll struct { + // The X and Y position of the mouse cursor at the time of the event, + // relative to the top left corner of the element + X, Y int + + // The X and Y amount the scroll wheel moved + ScrollX, ScrollY int +} + +// EventSelect is sent to selectable elements when they become selected, whether +// by a mouse click or by keyboard navigation. Containers must propagate this +// event downwards. +type EventSelect struct { } + +// EventDeselect is sent to selectable elements when they stop being selected, +// whether by a mouse click or by keyboard navigation. Containers must propagate +// this event downwards. +type EventDeselect struct { } diff --git a/examples/button/main.go b/examples/button/main.go new file mode 100644 index 0000000..2b860b3 --- /dev/null +++ b/examples/button/main.go @@ -0,0 +1,29 @@ +package main + +import "git.tebibyte.media/sashakoshka/tomo" +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("example button") + button := basic.NewButton("hello tomo!") + button.OnClick (func () { + // when we set the button's text to something longer, the window + // will automatically resize to accomodate it. + button.SetText("you clicked me.\nwow, there are two lines!") + button.OnClick (func () { + button.SetText ( + "stop clicking me you idiot!\n" + + "you've already seen it all!") + button.OnClick(tomo.Stop) + }) + }) + window.Adopt(button) + window.OnClose(tomo.Stop) + window.Show() +} diff --git a/examples/label/main.go b/examples/label/main.go new file mode 100644 index 0000000..7c6a1f8 --- /dev/null +++ b/examples/label/main.go @@ -0,0 +1,19 @@ +package main + +import "git.tebibyte.media/sashakoshka/tomo" +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(480, 360) + window.SetTitle("example label") + window.Adopt(basic.NewLabel(text)) + window.OnClose(tomo.Stop) + window.Show() +} + +const text = "Resize the window to see the text wrap:\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Fermentum et sollicitudin ac orci phasellus egestas tellus rutrum. Aliquam vestibulum morbi blandit cursus risus at ultrices mi. Gravida dictum fusce ut placerat. Cursus metus aliquam eleifend mi in nulla posuere. Sit amet nulla facilisi morbi tempus iaculis urna id. Amet volutpat consequat mauris nunc congue nisi vitae. Varius duis at consectetur lorem donec massa sapien faucibus et. Vitae elementum curabitur vitae nunc sed velit dignissim. In hac habitasse platea dictumst quisque sagittis purus. Enim nulla aliquet porttitor lacus luctus accumsan tortor. Lectus magna fringilla urna porttitor rhoncus dolor purus non.\n\nNon pulvinar neque laoreet suspendisse. Viverra adipiscing at in tellus integer. Vulputate dignissim suspendisse in est ante. Purus in mollis nunc sed id semper. In est ante in nibh mauris cursus. Risus pretium quam vulputate dignissim suspendisse in est. Blandit aliquam etiam erat velit scelerisque in dictum. Lectus quam id leo in. Odio tempor orci dapibus ultrices in iaculis. Pharetra sit amet aliquam id. Elit ut aliquam purus sit. Egestas dui id ornare arcu odio ut sem nulla pharetra. Massa tempor nec feugiat nisl pretium fusce id. Dui accumsan sit amet nulla facilisi morbi. A lacus vestibulum sed arcu non odio euismod. Nam libero justo laoreet sit amet cursus. Mattis rhoncus urna neque viverra justo nec. Mauris augue neque gravida in fermentum et sollicitudin ac. Vulputate mi sit amet mauris. Ut sem nulla pharetra diam sit amet." diff --git a/examples/test/main.go b/examples/test/main.go new file mode 100644 index 0000000..59efab1 --- /dev/null +++ b/examples/test/main.go @@ -0,0 +1,17 @@ +package main + +import "git.tebibyte.media/sashakoshka/tomo" +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(128, 128) + window.SetTitle("hellorld!") + window.Adopt(basic.NewTest()) + window.OnClose(tomo.Stop) + window.Show() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3cdf9dd --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module git.tebibyte.media/sashakoshka/tomo + +go 1.19 + +require ( + github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66 + golang.org/x/image v0.3.0 +) + +require ( + github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect + github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect + github.com/jezek/xgb v1.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..488db99 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJXZHsaM8b6OLVo6muQUQd4CwkH/D3fnnbHXA= +github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= +github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g= +github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= +github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= +github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66 h1:+wPhoJD8EH0/bXipIq8Lc2z477jfox9zkXPCJdhvHj8= +github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66/go.mod h1:KACeV+k6b+aoLTVrrurywEbu3UpqoQcQywj4qX8aQKM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg= +golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/input.go b/input.go new file mode 100644 index 0000000..6337058 --- /dev/null +++ b/input.go @@ -0,0 +1,112 @@ +package tomo + +import "unicode" + +// Key represents a keyboard key. +type Key int + +const ( + KeyNone Key = 0 + + KeyInsert Key = 1 + KeyMenu Key = 2 + KeyPrintScreen Key = 3 + KeyPause Key = 4 + KeyCapsLock Key = 5 + KeyScrollLock Key = 6 + KeyNumLock Key = 7 + KeyBackspace Key = 8 + KeyTab Key = 9 + KeyEnter Key = 10 + KeyEscape Key = 11 + + KeyUp Key = 12 + KeyDown Key = 13 + KeyLeft Key = 14 + KeyRight Key = 15 + KeyPageUp Key = 16 + KeyPageDown Key = 17 + KeyHome Key = 18 + KeyEnd Key = 19 + + KeyLeftShift Key = 20 + KeyRightShift Key = 21 + KeyLeftControl Key = 22 + KeyRightControl Key = 23 + KeyLeftAlt Key = 24 + KeyRightAlt Key = 25 + KeyLeftMeta Key = 26 + KeyRightMeta Key = 27 + KeyLeftSuper Key = 28 + KeyRightSuper Key = 29 + KeyLeftHyper Key = 30 + KeyRightHyper Key = 31 + + KeyDelete Key = 127 + + KeyDead Key = 128 + + KeyF1 Key = 129 + KeyF2 Key = 130 + KeyF3 Key = 131 + KeyF4 Key = 132 + KeyF5 Key = 133 + KeyF6 Key = 134 + KeyF7 Key = 135 + KeyF8 Key = 136 + KeyF9 Key = 137 + KeyF10 Key = 138 + KeyF11 Key = 139 + KeyF12 Key = 140 +) + +// Button represents a mouse button. +type Button int + +const ( + ButtonNone Button = iota + + Button1 + Button2 + Button3 + Button4 + Button5 + Button6 + Button7 + Button8 + Button9 + + ButtonLeft Button = Button1 + ButtonMiddle Button = Button2 + ButtonRight Button = Button3 + ButtonBack Button = Button8 + ButtonForward Button = Button9 +) + +// Printable returns whether or not the key's character could show up on screen. +// If this function returns true, the key can be cast to a rune and used as +// such. +func (key Key) Printable () (printable bool) { + printable = unicode.IsPrint(rune(key)) + return +} + +// Modifiers lists what modifier keys are being pressed. This is used in +// conjunction with a Key code in a Key press event. These should be used +// instead of attempting to track the state of the modifier keys, because there +// is no guarantee that one press event will be coupled with one release event. +type Modifiers struct { + Shift bool + Control bool + Alt bool + Meta bool + Super bool + Hyper bool + + // NumberPad does not represent a key, but it behaves like one. If it is + // set to true, the Key was pressed on the number pad. It is treated + // as a modifier key because if you don't care whether a key was pressed + // on the number pad or not, you can just ignore this value. + NumberPad bool +} + diff --git a/iterator/iterator.go b/iterator/iterator.go new file mode 100644 index 0000000..9ffbba8 --- /dev/null +++ b/iterator/iterator.go @@ -0,0 +1,6 @@ +package iterator + +type Iterator[ELEMENT_TYPE any] interface { + Length() int + Next() ELEMENT_TYPE +} diff --git a/theme/theme.go b/theme/theme.go new file mode 100644 index 0000000..b328e54 --- /dev/null +++ b/theme/theme.go @@ -0,0 +1,172 @@ +package theme + +import "image" +import "image/color" +import "golang.org/x/image/font" +import "git.tebibyte.media/sashakoshka/tomo" +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. + +var foregroundImage = artist.NewUniform(color.Gray16 { 0x0000}) +var disabledForegroundImage = artist.NewUniform(color.Gray16 { 0x5555}) +var accentImage = artist.NewUniform(color.RGBA { 0xFF, 0x22, 0x00, 0xFF}) +var highlightImage = artist.NewUniform(color.Gray16 { 0xEEEE }) +var shadowImage = artist.NewUniform(color.Gray16 { 0x3333 }) + +var backgroundImage = artist.NewUniform(color.Gray16 { 0xAAAA}) +var backgroundProfile = artist.ShadingProfile { + Highlight: highlightImage, + Shadow: shadowImage, + Stroke: artist.NewUniform(color.Gray16 { 0x0000 }), + Fill: backgroundImage, + StrokeWeight: 1, + ShadingWeight: 1, +} +var engravedBackgroundProfile = backgroundProfile.Engraved() + +var raisedImage = artist.NewUniform(color.RGBA { 0x8D, 0x98, 0x94, 0xFF}) +var raisedProfile = artist.ShadingProfile { + Highlight: highlightImage, + Shadow: shadowImage, + Stroke: artist.NewUniform(color.Gray16 { 0x0000 }), + Fill: raisedImage, + StrokeWeight: 1, + ShadingWeight: 1, +} +var engravedRaisedProfile = artist.ShadingProfile { + Highlight: artist.NewUniform(color.Gray16 { 0x7777 }), + Shadow: raisedImage, + Stroke: artist.NewUniform(color.Gray16 { 0x0000 }), + Fill: raisedImage, + StrokeWeight: 1, + ShadingWeight: 1, +} +var disabledRaisedProfile = artist.ShadingProfile { + Highlight: artist.NewUniform(color.Gray16 { 0x7777 }), + Shadow: artist.NewUniform(color.Gray16 { 0x7777 }), + Stroke: artist.NewUniform(color.Gray16 { 0x3333 }), + Fill: raisedImage, + StrokeWeight: 1, + ShadingWeight: 0, +} + +var inputImage = artist.NewUniform(color.Gray16 { 0xFFFF }) +var inputProfile = artist.ShadingProfile { + Highlight: shadowImage, + Shadow: highlightImage, + Stroke: artist.NewUniform(color.Gray16 { 0x0000 }), + Fill: inputImage, + StrokeWeight: 1, + ShadingWeight: 1, +} +var disabledInputProfile = artist.ShadingProfile { + Highlight: artist.NewUniform(color.Gray16 { 0x7777 }), + Shadow: artist.NewUniform(color.Gray16 { 0x7777 }), + Stroke: artist.NewUniform(color.Gray16 { 0x3333 }), + Fill: inputImage, + StrokeWeight: 1, + ShadingWeight: 0, +} + +// BackgroundProfile returns the shading profile to be used for backgrounds. +func BackgroundProfile (engraved bool) artist.ShadingProfile { + if engraved { + return engravedBackgroundProfile + } else { + return backgroundProfile + } +} + +// RaisedProfile returns the shading profile to be used for raised objects such +// as buttons. +func RaisedProfile (engraved bool, enabled bool) artist.ShadingProfile { + if enabled { + if engraved { + return engravedRaisedProfile + } else { + return raisedProfile + } + } else { + return disabledRaisedProfile + } +} + +// InputProfile returns the shading profile to be used for input fields. +func InputProfile (enabled bool) artist.ShadingProfile { + if enabled { + return inputProfile + } else { + return disabledInputProfile + } +} + +// BackgroundImage returns the texture/color used for the fill of +// BackgroundProfile. +func BackgroundImage () tomo.Image { + return backgroundImage +} + +// RaisedImage returns the texture/color used for the fill of RaisedProfile. +func RaisedImage () tomo.Image { + return raisedImage +} + +// InputImage returns the texture/color used for the fill of InputProfile. +func InputImage () tomo.Image { + return inputImage +} + +// ForegroundImage returns the texture/color text and monochromatic icons should +// be drawn with. +func ForegroundImage () tomo.Image { + return foregroundImage +} + +// DisabledForegroundImage returns the texture/color text and monochromatic +// icons should be drawn with if they are disabled. +func DisabledForegroundImage () tomo.Image { + return disabledForegroundImage +} + +// AccentImage returns the accent texture/color. +func AccentImage () tomo.Image { + return accentImage +} + +// TODO: load fonts from an actual source instead of using basicfont + +// 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 +} + +// Padding returns how spaced out things should be on the screen. Generally, +// text should be offset from its container on all sides by this amount. +func Padding () int { + return 8 +} + +// SinkOffsetVector specifies a vector for things such as text to move by when a +// "sinking in" effect is desired, such as a button label during a button press. +func SinkOffsetVector () image.Point { + return image.Point { 1, 1 } +} diff --git a/tomo.go b/tomo.go new file mode 100644 index 0000000..a1879e5 --- /dev/null +++ b/tomo.go @@ -0,0 +1,114 @@ +package tomo + +import "image" +import "errors" +import "image/draw" +import "image/color" +// import "git.tebibyte.media/sashakoshka/tomo/iterator" + +// Image represents a simple image buffer that fulfills the image.Image +// interface while also having methods that do away with the use of the +// color.Color interface to facilitate more efficient drawing. This interface +// can be easily satisfied using an image.RGBA struct. +type Image interface { + image.Image + RGBAAt (x, y int) (c color.RGBA) +} + +// Canvas is like Image but also requires a SetRGBA method. This interface can +// be easily satisfied using an image.RGBA struct. +type Canvas interface { + draw.Image + RGBAAt (x, y int) (c color.RGBA) + SetRGBA (x, y int, c color.RGBA) +} + +// Element represents a basic on-screen object. +type Element interface { + // Element must implement the Image interface. Elements should start out + // with a completely blank image buffer, and only set its size and draw + // on it for the first time when sent an EventResize event. + Image + + // Handle handles an event, propagating it to children if necessary. + Handle (event Event) + + // Selectable returns whether this element can be selected. If this + // element contains other selectable elements, it must return true. + Selectable () (bool) + + // SetDrawCallback sets a function to be called when a part of the + // element's surface is updated. The updated region will be passed to + // the callback as a sub-image. + SetDrawCallback (draw func (region Image)) + + // SetMinimumSizeChangeCallback sets a function to be called when the + // element's minimum width and/or height changes. When this function is + // called, the element will have already been resized and there is no + // need to send it a resize event. + SetMinimumSizeChangeCallback (notify func (width, height int)) + + // MinimumWidth specifies the minimum amount of pixels this element's + // width may be set to. If the element is resized to an amount smaller + // that MinimumWidth, it will instead set its width to MinimumWidth. + MinimumWidth () (minimum int) + + // MinimumHeight specifies the minimum amount of pixels this element's + // height may be set to. If the element is resized to an amount smaller + // that MinimumHeight, it will instead set its height to MinimumHeight. + MinimumHeight () (minimum int) +} + +// Window represents a top-level container generated by the currently running +// backend. It can contain a single element. It is hidden by default, and must +// be explicitly shown with the Show() method. If it contains no element, it +// displays a black (or transprent) background. +type Window interface { + Element + Adopt (child Element) + Child () (child Element) + SetTitle (title string) + SetIcon (sizes []image.Image) + Show () + Hide () + Close () + OnClose (func ()) +} + +var backend Backend + +// Run initializes a backend, calls the callback function, and begins the event +// loop in that order. This function does not return until Stop() is called, or +// the backend experiences a fatal error. +func Run (callback func ()) (err error) { + backend, err = instantiateBackend() + if callback != nil { callback() } + err = backend.Run() + backend = nil + return +} + +// Stop gracefully stops the event loop and shuts the backend down. Call this +// before closing your application. +func Stop () { + if backend != nil { backend.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 ()) { + +} + +// NewWindow creates a new window using the current backend, and returns it as a +// Window. If the window could not be created, an error is returned explaining +// why. If this function is called without a running backend, an error is +// returned as well. +func NewWindow (width, height int) (window Window, err error) { + if backend == nil { + err = errors.New("no backend is running.") + return + } + window, err = backend.NewWindow(width, height) + return +}