├── LICENSE ├── README.md ├── backend.go ├── doc.go ├── draw ├── color.go ├── draw.go ├── font.go └── rect.go ├── examples ├── editor │ └── main.go ├── synth │ ├── audio.go │ ├── keyboard.go │ ├── main.go │ ├── slider.go │ └── synth.go └── test │ └── main.go ├── go.mod ├── go.sum ├── impl ├── gldraw │ ├── buffer.go │ ├── draw.go │ ├── font.go │ ├── icon.go │ ├── image.go │ ├── shadow.go │ └── texture.go ├── gofont │ └── gofont.go ├── icons │ └── icons.go └── sdl │ └── backend.go ├── keycode.go ├── state.go ├── text ├── text.go └── word.go ├── toolkit ├── bar.go ├── button.go ├── checkbox.go ├── combobox.go ├── container.go ├── dialog.go ├── divider.go ├── file.go ├── file_others.go ├── file_windows.go ├── form.go ├── frame.go ├── label.go ├── list.go ├── menu.go ├── root.go ├── scroll.go ├── stack.go ├── tab.go ├── textarea.go ├── textfield.go ├── theme.go ├── util.go └── wrap.go └── ui.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Johann Freymuth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # package ui 2 | 3 | Lightweight go desktop ui 4 | 5 | **this package is highly experimental, drastic changes may occur** 6 | 7 | ### What this package tries to do 8 | 9 | - be intuitive for users; applications created with this packge should superficially behave like other ui toolkits, even in subtle ways 10 | - be intuitive for programmers; writing applications should be straightforward and not require much boilerplate or magic incantations 11 | - make it easy to create custom components 12 | - be the basis of a potential ecosystem; many parts of this package can easily be replaced, and the replacements should work well together (e.g. someone could write a different renderer, or an alternative library of components) 13 | - not be dependent on cgo in principle; while the current backend uses cgo, one could write e.g. an X11 backend in pure go. 14 | 15 | ### What this package does *not* try to do 16 | 17 | - be the "official" go ui package 18 | - be useful in all situations (a big limitation right now is that applications can only use a single window) 19 | - be suited for mobile or web applications 20 | 21 | ## Overview 22 | 23 | The design of this package is inspired by immediate mode ui and uses similar techniques to minimize the complexity of components. Many ui libraries make heavy use of inheritance, which can be imitated in go through struct embedding. However this approach has disadvantages and is generally considered unidiomatic. This package follows a different approach by hiding most of the complexity in the `ui.State` struct instead of an abstract base class. This leads to the `Component` interface being very small, and components can be extremly lightweight. 24 | 25 | There are three parts to this package: 26 | 27 | ### `ui` and `ui/draw` 28 | 29 | `ui` defines important interfaces and constants. It also provides the type `State`, which is central to the functionality of the library. 30 | 31 | `ui/draw` contains data structures for drawing. Components do not draw directly, but rather create a list of commands that will be passed on to the renderer. 32 | 33 | ### `ui/toolkit` 34 | 35 | `ui/toolkit` is a collection of widgets. It provides common widgets (buttons, text fields etc.), simple layouts and basic themeing support. 36 | 37 | ### `ui/impl/...` 38 | 39 | The packages in `ui/impl` make up the backend. They are designed to be replaced by better packages later. These are the only packages with external dependencies. 40 | 41 | It consists of the following packages (so far): 42 | - `ui/impl/gofont` supports truetype fonts via [github.com/golang/freetype](https://github.com/golang/freetype) and includes the [go fonts](https://blog.golang.org/go-fonts) as default fonts 43 | - `ui/impl/icons` provides some of google's material design icons through [golang.org/x/exp/shiny/iconvg](https://godoc.org/golang.org/x/exp/shiny/iconvg) 44 | - `ui/impl/gldraw` renders using OpenGL 3.3 ([github.com/go-gl/gl](https://github.com/go-gl/gl)) 45 | - `ui/impl/sdl` is the main backend, it handles window creation and user input. It uses [github.com/veandco/go-sdl2](https://github.com/veandco/go-sdl2) 46 | 47 | ## Usage 48 | 49 | ```go 50 | import ( 51 | "github.com/jfreymuth/ui" 52 | "github.com/jfreymuth/ui/impl/gofont" 53 | "github.com/jfreymuth/ui/impl/icons" 54 | "github.com/jfreymuth/ui/impl/sdl" 55 | "github.com/jfreymuth/ui/toolkit" 56 | ) 57 | 58 | func main() { 59 | button := toolkit.NewButton("Button", func(state *ui.State) { 60 | toolkit.ShowMessageDialog(state, "Message", "The button was pressed!", "Ok") 61 | }) 62 | 63 | root := toolkit.NewRoot(button) 64 | 65 | sdl.Show(sdl.Options{ 66 | Title: "Example", 67 | Width: 400, Height: 300, 68 | FontLookup: gofont.Lookup, 69 | IconLookup: &icons.Lookup{}, 70 | Root: root, 71 | }) 72 | } 73 | ``` 74 | 75 | ### Examples 76 | 77 | - [`examples/editor`](examples/editor/main.go): a basic text editor, showcases the text area and demonstrates how to implement "save before quitting" comfirmations 78 | - [`examples/synth`](examples/synth): a very simple synthesizer, demonstrates custom widgets and also some concurrency 79 | - [`examples/test`](examples/test/main.go): rather messy, but uses almost every single widget 80 | -------------------------------------------------------------------------------- /backend.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | "time" 6 | 7 | "github.com/jfreymuth/ui/draw" 8 | ) 9 | 10 | type BackendState struct { 11 | State 12 | last time.Time 13 | } 14 | 15 | func (s *BackendState) ResetEvents() { 16 | s.scroll = image.Point{} 17 | s.visible = image.Rectangle{} 18 | s.textInput = "" 19 | for _, k := range s.keyPresses { 20 | if k == KeyTab { 21 | s.focusNext = true 22 | } 23 | } 24 | s.keyPresses = s.keyPresses[:0] 25 | s.clicks = 0 26 | s.clickButtons = 0 27 | if s.drop { 28 | s.drag = nil 29 | s.drop = false 30 | } 31 | s.time = 0 32 | } 33 | 34 | func (s *BackendState) SetModifiers(m Modifier) { s.modifiers = m } 35 | func (s *BackendState) SetMousePosition(x, y int) { s.mousePos = image.Pt(x, y) } 36 | func (s *BackendState) SetMouseButtons(b MouseButton) { s.mouseButtons = b } 37 | func (s *BackendState) SetMouseClicks(clicks int) { s.clicks = clicks } 38 | func (s *BackendState) SetHovered(h bool) { s.hovered = h } 39 | func (s *BackendState) AddScroll(x, y int) { s.scroll = s.scroll.Add(image.Pt(x, y)) } 40 | func (s *BackendState) AddKeyPress(k Key) { s.keyPresses = append(s.keyPresses, k) } 41 | func (s *BackendState) AddTextInput(text string) { s.textInput += text } 42 | func (s *BackendState) SetBlink(b bool) { s.blink = b } 43 | func (s *BackendState) SetWindowSize(w, h int) { s.windowSize = draw.WH(w, h) } 44 | 45 | func (s *BackendState) GrabMouse() { 46 | s.grabbed = s.hoveredC 47 | } 48 | func (s *BackendState) ReleaseMouse(b MouseButton) { 49 | if s.grabbed == s.hoveredC { 50 | s.clickButtons = b 51 | } 52 | s.grabbed = nil 53 | if s.drag != nil { 54 | s.drop = true 55 | } 56 | } 57 | 58 | func (state *BackendState) ResetRequests() { 59 | now := time.Now() 60 | if state.animation { 61 | state.time = float32(now.Sub(state.last).Seconds()) 62 | } 63 | state.last = now 64 | state.update = false 65 | state.animation = false 66 | state.refocus = false 67 | state.cursor = CursorNormal 68 | } 69 | 70 | func (s *BackendState) Cursor() Cursor { return s.cursor } 71 | func (s *BackendState) WindowTitle() string { return s.windowTitle } 72 | func (s *BackendState) UpdateRequested() bool { return s.update || s.focusNext } 73 | func (s *BackendState) AnimationRequested() bool { return s.animation } 74 | func (s *BackendState) RefocusRequested() bool { return s.refocus } 75 | func (s *BackendState) QuitRequested() bool { return s.quit } 76 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | package ui 2 | -------------------------------------------------------------------------------- /draw/color.go: -------------------------------------------------------------------------------- 1 | package draw 2 | 3 | // Color is a 32 bit RGBA color with premultiplied alpha. 4 | type Color [4]byte 5 | 6 | // RGBA creates a color from float RGBA values. 7 | // The color components should be in range [0,1] regardless of the alpha value; 8 | // this constructor will multiply them with the alpha value. 9 | func RGBA(r, g, b, a float32) Color { 10 | r *= a 11 | g *= a 12 | b *= a 13 | var c Color 14 | if r >= 1 { 15 | c[0] = 255 16 | } else if r > 0 { 17 | c[0] = byte(r * 255) 18 | } 19 | if g >= 1 { 20 | c[1] = 255 21 | } else if g > 0 { 22 | c[1] = byte(g * 255) 23 | } 24 | if b >= 1 { 25 | c[2] = 255 26 | } else if b > 0 { 27 | c[2] = byte(b * 255) 28 | } 29 | if a >= 1 { 30 | c[3] = 255 31 | } else if a > 0 { 32 | c[3] = byte(a * 255) 33 | } 34 | return c 35 | } 36 | 37 | // Gray creates an opaque gray color from an intensity in range [0,1], where 0 means black and 1 means white. 38 | func Gray(i float32) Color { 39 | b := byte(i * 255) 40 | if i > 1 { 41 | b = 255 42 | } else if i < 0 { 43 | b = 0 44 | } 45 | return Color{b, b, b, 255} 46 | } 47 | 48 | // R returns the color's red component 49 | func (c Color) R() float32 { return float32(c[0]) / 255 } 50 | 51 | // R returns the color's green component 52 | func (c Color) G() float32 { return float32(c[1]) / 255 } 53 | 54 | // R returns the color's blue component 55 | func (c Color) B() float32 { return float32(c[2]) / 255 } 56 | 57 | // R returns the color's alpha component 58 | func (c Color) A() float32 { return float32(c[3]) / 255 } 59 | 60 | // Blend mixes two colors. f must be in range [0,1] 61 | func Blend(c, d Color, f float32) Color { 62 | r, g, b, a := c.R(), c.G(), c.B(), c.A() 63 | return Color{byte((r + (d.R()-r)*f) * 255), byte((g + (d.G()-g)*f) * 255), byte((b + (d.B()-b)*f) * 255), byte((a + (d.A()-a)*f) * 255)} 64 | } 65 | 66 | var ( 67 | Black = Color{0, 0, 0, 255} 68 | White = Color{255, 255, 255, 255} 69 | Transparent = Color{0, 0, 0, 0} 70 | ) 71 | -------------------------------------------------------------------------------- /draw/draw.go: -------------------------------------------------------------------------------- 1 | package draw 2 | 3 | import "image" 4 | 5 | type Command interface{} 6 | 7 | type Fill struct { 8 | Rect image.Rectangle 9 | Color Color 10 | } 11 | 12 | type Outline struct { 13 | Rect image.Rectangle 14 | Color Color 15 | } 16 | 17 | type Text struct { 18 | Position image.Point 19 | Text string 20 | Font Font 21 | Color Color 22 | } 23 | 24 | type Shadow struct { 25 | Rect image.Rectangle 26 | Color Color 27 | Size int 28 | } 29 | 30 | type Icon struct { 31 | Rect image.Rectangle 32 | Icon string 33 | Color Color 34 | } 35 | 36 | type Image struct { 37 | Rect image.Rectangle 38 | Image *image.RGBA 39 | Color Color 40 | Update bool 41 | } 42 | 43 | type CommandList struct { 44 | Commands []Command 45 | Offset image.Point 46 | Clip image.Rectangle 47 | } 48 | 49 | // A Buffer contains a list of commands. 50 | type Buffer struct { 51 | Commands []Command 52 | FontLookup FontLookup 53 | All []CommandList 54 | state 55 | stack []state 56 | } 57 | 58 | type state struct { 59 | clip image.Rectangle 60 | bounds image.Rectangle 61 | } 62 | 63 | // Reset clears the command list and sets the size of the drawing area. 64 | func (b *Buffer) Reset(w, h int) { 65 | b.All = b.All[:0] 66 | b.Commands = nil 67 | b.stack = b.stack[:0] 68 | b.bounds = WH(w, h) 69 | b.clip = WH(w, h) 70 | } 71 | 72 | // Push constrains drawing to a rectangle. 73 | // Subsequent operations will be translated and clipped. 74 | // Subsequent calls to Size will return the rectangle's size. 75 | func (b *Buffer) Push(r image.Rectangle) { 76 | b.flush() 77 | b.stack = append(b.stack, b.state) 78 | r = r.Add(b.bounds.Min) 79 | b.clip = b.clip.Intersect(r) 80 | b.bounds = r 81 | } 82 | 83 | // Pop undoes the last call to Push. 84 | func (b *Buffer) Pop() { 85 | b.flush() 86 | l := len(b.stack) - 1 87 | if l >= 0 { 88 | b.state, b.stack = b.stack[l], b.stack[:l] 89 | } 90 | } 91 | 92 | // Size returns the current size of the drawing area. 93 | func (b *Buffer) Size() (int, int) { 94 | return b.bounds.Dx(), b.bounds.Dy() 95 | } 96 | 97 | // Add adds commands to the buffer. 98 | func (b *Buffer) Add(c ...Command) { 99 | b.Commands = append(b.Commands, c...) 100 | } 101 | 102 | // Fill adds a command to fill an area with a solid color. 103 | func (b *Buffer) Fill(r image.Rectangle, c Color) { 104 | if !b.clip.Empty() { 105 | b.Commands = append(b.Commands, Fill{Rect: r, Color: c}) 106 | } 107 | } 108 | 109 | // Outline adds a command to outline a rectangle. 110 | func (b *Buffer) Outline(r image.Rectangle, c Color) { 111 | if !b.clip.Empty() { 112 | b.Commands = append(b.Commands, Outline{Rect: r, Color: c}) 113 | } 114 | } 115 | 116 | // Text adds a command to render text. 117 | func (b *Buffer) Text(p image.Point, text string, c Color, font Font) { 118 | if !b.clip.Empty() { 119 | b.Commands = append(b.Commands, Text{Position: p, Color: c, Text: text, Font: font}) 120 | } 121 | } 122 | 123 | // Shadow adds a command to draw a drop shadow. 124 | func (b *Buffer) Shadow(r image.Rectangle, c Color, size int) { 125 | if !b.clip.Empty() { 126 | b.Commands = append(b.Commands, Shadow{Rect: r, Color: c, Size: size}) 127 | } 128 | } 129 | 130 | // Icon adds a command to draw an icon. 131 | func (b *Buffer) Icon(r image.Rectangle, id string, c Color) { 132 | if !b.clip.Empty() { 133 | b.Commands = append(b.Commands, Icon{Rect: r, Color: c, Icon: id}) 134 | } 135 | } 136 | 137 | // Image adds a command to draw an image. 138 | // update should be true if the image has changed since it was last drawn. 139 | func (b *Buffer) Image(r image.Rectangle, i *image.RGBA, c Color, update bool) { 140 | if !b.clip.Empty() { 141 | b.Commands = append(b.Commands, Image{Rect: r, Color: c, Image: i, Update: update}) 142 | } 143 | } 144 | 145 | // SubImage adds a command to draw part of an image. 146 | // update should be true if the image has changed since it was last drawn. 147 | func (b *Buffer) SubImage(r image.Rectangle, i *image.RGBA, sub image.Rectangle, c Color, update bool) { 148 | if sub.Empty() || b.clip.Empty() { 149 | return 150 | } 151 | w := r.Dx() * i.Rect.Dx() / sub.Dx() 152 | h := r.Dy() * i.Rect.Dy() / sub.Dy() 153 | x := r.Min.X - sub.Min.X*r.Dx()/sub.Dx() 154 | y := r.Min.Y - sub.Min.Y*r.Dy()/sub.Dy() 155 | b.Push(r) 156 | b.Commands = append(b.Commands, Image{Rect: XYWH(x, y, w, h), Color: c, Image: i, Update: update}) 157 | b.Pop() 158 | } 159 | 160 | func (b *Buffer) flush() { 161 | if len(b.Commands) > 0 { 162 | b.All = append(b.All, CommandList{b.Commands, b.bounds.Min, b.clip}) 163 | b.Commands = nil 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /draw/font.go: -------------------------------------------------------------------------------- 1 | package draw 2 | 3 | // Font describes a font face. 4 | type Font struct { 5 | Name string 6 | Size float32 7 | } 8 | 9 | // FontMetrics can be used to query the geometry of a font face. 10 | type FontMetrics interface { 11 | Ascent() int 12 | Descent() int 13 | LineHeight() int 14 | Advance(s string) float32 15 | Index(s string, pos float32) int 16 | } 17 | 18 | // A FontLookup provides information about fonts. 19 | type FontLookup interface { 20 | // GetClosest takes a font face description and returns the best match that is actually supported. 21 | // If a font has multiple names, it should return a consistent, canonical name. 22 | GetClosest(Font) Font 23 | // Metrics returns the FontMetrics for the specified font. 24 | // Implementations must not assume that the argument has been passed through GetClosest. 25 | Metrics(Font) FontMetrics 26 | } 27 | -------------------------------------------------------------------------------- /draw/rect.go: -------------------------------------------------------------------------------- 1 | package draw 2 | 3 | import "image" 4 | 5 | // WH creates a rectangle at (0,0) with the given size. 6 | func WH(w, h int) image.Rectangle { 7 | return image.Rect(0, 0, w, h) 8 | } 9 | 10 | // XYXY creates a rectangle with the given minimum and maximum points. 11 | func XYXY(x, y, x1, y1 int) image.Rectangle { 12 | return image.Rect(x, y, x1, y1) 13 | } 14 | 15 | // XYWH creates a rectangle at the given point with the given size. 16 | func XYWH(x, y, w, h int) image.Rectangle { 17 | return image.Rect(x, y, x+w, y+h) 18 | } 19 | -------------------------------------------------------------------------------- /examples/editor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/jfreymuth/ui" 8 | "github.com/jfreymuth/ui/draw" 9 | "github.com/jfreymuth/ui/impl/gofont" 10 | "github.com/jfreymuth/ui/impl/icons" 11 | "github.com/jfreymuth/ui/impl/sdl" 12 | . "github.com/jfreymuth/ui/toolkit" 13 | ) 14 | 15 | type Editor struct { 16 | editor *TextArea 17 | files *FileChooser 18 | 19 | unsavedChanges bool 20 | filePath string 21 | } 22 | 23 | func main() { 24 | var e Editor 25 | e.editor = NewTextArea() 26 | e.editor.Font = draw.Font{Name: "gomono", Size: 11} 27 | e.files = NewFileChooser() 28 | e.files.SetPath(".") 29 | 30 | menuBar := NewMenuBar() 31 | fileMenu := menuBar.AddMenu("File") 32 | fileMenu.AddItemIcon("add", "New", e.New) 33 | fileMenu.AddItemIcon("open", "Open...", e.ShowOpenDialog) 34 | fileMenu.AddItemIcon("save", "Save", e.Save) 35 | fileMenu.AddItemIcon("save", "Save As...", e.ShowSaveDialog) 36 | fileMenu.AddItemIcon("close", "Exit", func(state *ui.State) { e.DoDestructive(state, (*ui.State).Quit) }) 37 | editMenu := menuBar.AddMenu("Edit") 38 | editMenu.AddItemIcon("", "Select All", e.editor.SelectAll) 39 | editMenu.AddItemIcon("cut", "Cut", e.editor.Cut) 40 | editMenu.AddItemIcon("copy", "Copy", e.editor.Copy) 41 | editMenu.AddItemIcon("paste", "Paste", e.editor.Paste) 42 | 43 | root := NewRoot(&Container{ 44 | Top: menuBar, 45 | Center: NewScrollView(e.editor), 46 | }) 47 | 48 | sdl.Show(sdl.Options{ 49 | Title: "Editor", 50 | Width: 600, Height: 450, 51 | Root: root, 52 | FontLookup: gofont.Lookup, 53 | IconLookup: &icons.Lookup{}, 54 | Init: func(state *ui.State) { 55 | // Handle command line arguments in the Init function, so we can e.g. show an error dialog 56 | if len(os.Args) == 2 { 57 | e.files.SetPath(filepath.Dir(os.Args[1])) 58 | e.Open(state, os.Args[1]) 59 | state.RequestUpdate() 60 | } 61 | }, 62 | Update: func(state *ui.State) { 63 | // Handle custom keyboard shortcuts (Ctrl+O, Ctrl+S) 64 | if state.HasModifiers(ui.Control) { 65 | for _, k := range state.PeekKeyPresses() { 66 | switch k { 67 | case ui.KeyS: 68 | if state.HasModifiers(ui.Shift) { 69 | e.ShowSaveDialog(state) 70 | } else { 71 | e.Save(state) 72 | } 73 | case ui.KeyO: 74 | e.ShowOpenDialog(state) 75 | } 76 | } 77 | } 78 | // Handle default keyboard shortcuts (copy, paste etc.) 79 | ui.HandleKeyboardShortcuts(state) 80 | }, 81 | Close: func(state *ui.State) { 82 | // If the Close function is set, it must call state.Quit() somewhere. 83 | // In this case, show a confirmation dialog. 84 | e.DoDestructive(state, (*ui.State).Quit) 85 | }, 86 | }) 87 | } 88 | 89 | func (e *Editor) ShowOpenDialog(state *ui.State) { 90 | e.DoDestructive(state, func(state *ui.State) { 91 | ShowOpenDialog(state, e.files, "Open", "Open", "Cancel", e.Open) 92 | }) 93 | } 94 | 95 | func (e *Editor) ShowSaveDialog(state *ui.State) { 96 | ShowSaveDialog(state, e.files, "Save As", "Save", "Cancel", e.SaveAs) 97 | } 98 | 99 | func (e *Editor) New(state *ui.State) { 100 | e.DoDestructive(state, func(state *ui.State) { 101 | e.filePath = "" 102 | e.editor.SetText("") 103 | e.editor.Changed() 104 | e.unsavedChanges = false 105 | }) 106 | } 107 | 108 | func (e *Editor) Open(state *ui.State, path string) { 109 | file, err := os.Open(path) 110 | if err != nil { 111 | e.Error(state, err.Error()) 112 | return 113 | } 114 | err = e.editor.SetTextFromReader(file) 115 | file.Close() 116 | if err != nil { 117 | e.Error(state, err.Error()) 118 | return 119 | } 120 | e.filePath = path 121 | e.editor.Changed() 122 | e.unsavedChanges = false 123 | } 124 | 125 | func (e *Editor) Save(state *ui.State) { 126 | if e.filePath == "" { 127 | e.ShowSaveDialog(state) 128 | return 129 | } 130 | e.SaveAs(state, e.filePath) 131 | } 132 | 133 | func (e *Editor) SaveAs(state *ui.State, path string) { 134 | file, err := os.Create(path) 135 | if err != nil { 136 | e.Error(state, err.Error()) 137 | return 138 | } 139 | err = e.editor.WriteTextTo(file) 140 | file.Close() 141 | if err != nil { 142 | e.Error(state, err.Error()) 143 | return 144 | } 145 | e.editor.Changed() 146 | e.unsavedChanges = false 147 | e.filePath = path 148 | } 149 | 150 | // If there are unsaved changes, asks the user for confirmation. 151 | // Calls action only after a successful save or with the user's approval. 152 | func (e *Editor) DoDestructive(state *ui.State, action func(*ui.State)) { 153 | if e.editor.Changed() { 154 | e.unsavedChanges = true 155 | } 156 | if e.unsavedChanges { 157 | ShowYesNoDialog(state, "Save Changes", "Save Changes?", "Save", "Discard", "Cancel", e.SaveAndThen(action), action) 158 | } else { 159 | action(state) 160 | } 161 | } 162 | 163 | // Returns a callback that will show a save dialog and only call action after a successful save. 164 | func (e *Editor) SaveAndThen(action func(*ui.State)) func(*ui.State) { 165 | return func(state *ui.State) { 166 | if e.filePath == "" { 167 | ShowSaveDialog(state, e.files, "Save As", "Save", "Cancel", func(state *ui.State, path string) { 168 | e.SaveAs(state, path) 169 | if !e.unsavedChanges { 170 | action(state) 171 | } 172 | }) 173 | return 174 | } else { 175 | e.SaveAs(state, e.filePath) 176 | if !e.unsavedChanges { 177 | action(state) 178 | } 179 | } 180 | } 181 | } 182 | 183 | func (e *Editor) Error(state *ui.State, msg string) { 184 | ShowErrorDialog(state, "Error", msg, "Close") 185 | } 186 | -------------------------------------------------------------------------------- /examples/synth/audio.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "sync" 7 | "unsafe" 8 | 9 | "github.com/veandco/go-sdl2/sdl" 10 | ) 11 | 12 | // Use SDL for audio output, because we already depend on it. 13 | 14 | // typedef unsigned char Uint8; 15 | // void AudioCallback(void *userdata, Uint8 *stream, int len); 16 | import "C" 17 | 18 | //export AudioCallback 19 | func AudioCallback(userdata unsafe.Pointer, stream *C.Uint8, length C.int) { 20 | n := int(length) 21 | hdr := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(stream)), Len: n / 4, Cap: n / 4} 22 | buf := *(*[]float32)(unsafe.Pointer(&hdr)) 23 | 24 | player.L.Lock() 25 | copy(buf, player.buf) 26 | player.ready = true 27 | player.L.Unlock() 28 | player.Signal() 29 | } 30 | 31 | type AudioPlayer struct { 32 | dev sdl.AudioDeviceID 33 | buf []float32 34 | ready bool 35 | sync.Cond 36 | } 37 | 38 | var player *AudioPlayer 39 | 40 | func (a *AudioPlayer) Init(size int) { 41 | a.L = &sync.Mutex{} 42 | player = a 43 | sdl.Init(sdl.INIT_AUDIO) 44 | a.open(size) 45 | } 46 | 47 | func (a *AudioPlayer) ChangeBufferSize(size int) { 48 | sdl.PauseAudioDevice(a.dev, true) 49 | sdl.CloseAudioDevice(a.dev) 50 | a.open(size) 51 | } 52 | 53 | func (a *AudioPlayer) open(samples int) { 54 | spec := &sdl.AudioSpec{ 55 | Freq: SampleRate, 56 | Format: sdl.AUDIO_F32, 57 | Channels: 1, 58 | Samples: uint16(samples), 59 | Callback: sdl.AudioCallback(C.AudioCallback), 60 | } 61 | id, err := sdl.OpenAudioDevice("", false, spec, spec, 0) 62 | if err != nil { 63 | fmt.Println(err) 64 | } 65 | a.dev = id 66 | a.buf = make([]float32, spec.Samples) 67 | sdl.PauseAudioDevice(id, false) 68 | } 69 | -------------------------------------------------------------------------------- /examples/synth/keyboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | ) 7 | 8 | type Keyboard struct { 9 | NoteOn, NoteOff func(int) 10 | KeyWidth int 11 | last int 12 | sustain bool 13 | notesOn [2]uint64 14 | } 15 | 16 | func NewKeyboard() *Keyboard { 17 | return &Keyboard{KeyWidth: 36} 18 | } 19 | 20 | func (k *Keyboard) PreferredSize(fonts draw.FontLookup) (int, int) { 21 | return (6*7 + 3) * k.KeyWidth, 160 22 | } 23 | 24 | func (k *Keyboard) Update(g *draw.Buffer, state *ui.State) { 25 | pitch := 0 26 | hovered := 0 27 | if state.HasMouseFocus() { 28 | mouse := state.MousePos() 29 | hovered = k.getPitch(mouse.X+12*k.KeyWidth, mouse.Y) 30 | if hovered < 21 || hovered > 96 { 31 | hovered = 0 32 | } 33 | if state.MouseButtonDown(ui.MouseLeft) { 34 | pitch = hovered 35 | } 36 | } 37 | if k.last != pitch { 38 | if k.last != 0 && !state.HasModifiers(ui.Shift) { 39 | k.noteOff(k.last) 40 | } 41 | if pitch != 0 { 42 | k.noteOn(pitch) 43 | k.last = pitch 44 | } 45 | } 46 | if k.sustain && !state.HasModifiers(ui.Shift) { 47 | k.releaseSustain(pitch) 48 | } 49 | k.last = pitch 50 | k.sustain = state.HasModifiers(ui.Shift) 51 | k.draw(g, hovered) 52 | } 53 | 54 | func (k *Keyboard) draw(g *draw.Buffer, hovered int) { 55 | kw := k.KeyWidth 56 | k.drawKeyW(g, 0, hovered == 21) 57 | k.drawKeyW(g, kw, hovered == 23) 58 | k.drawKeyB(g, 6*kw/7, hovered == 22) 59 | for o := 0; o < 6; o++ { 60 | x := (o*7 + 2) * kw 61 | for i := 0; i < 3; i++ { 62 | k.drawKeyW(g, x+i*kw, hovered == (o+2)*12+i*2) 63 | } 64 | for i := 3; i < 7; i++ { 65 | k.drawKeyW(g, x+i*kw, hovered == (o+2)*12+i*2-1) 66 | } 67 | 68 | k.drawKeyB(g, x+3*kw/5, hovered == (o+2)*12+1) 69 | k.drawKeyB(g, x+9*kw/5, hovered == (o+2)*12+3) 70 | k.drawKeyB(g, x+25*kw/7, hovered == (o+2)*12+6) 71 | k.drawKeyB(g, x+33*kw/7, hovered == (o+2)*12+8) 72 | k.drawKeyB(g, x+41*kw/7, hovered == (o+2)*12+10) 73 | } 74 | k.drawKeyW(g, 44*kw, hovered == 96) 75 | } 76 | 77 | func (k *Keyboard) drawKeyW(g *draw.Buffer, x int, h bool) { 78 | g.Outline(draw.XYWH(x, 0, k.KeyWidth, 160), draw.Black) 79 | if h { 80 | g.Fill(draw.XYWH(x+1, 1, k.KeyWidth-2, 158), draw.Gray(.9)) 81 | } 82 | } 83 | 84 | func (k *Keyboard) drawKeyB(g *draw.Buffer, x int, h bool) { 85 | if h { 86 | g.Fill(draw.XYWH(x, 0, k.KeyWidth*3/5, 100), draw.Gray(.3)) 87 | } else { 88 | g.Fill(draw.XYWH(x, 0, k.KeyWidth*3/5, 100), draw.Black) 89 | } 90 | } 91 | 92 | func (k *Keyboard) getPitch(x, y int) int { 93 | kw := k.KeyWidth 94 | octave := x / (7 * kw) 95 | x -= octave * 7 * kw 96 | if y < 100 { 97 | if x < 3*kw { 98 | return octave*12 + x*5/(3*kw) 99 | } else { 100 | return octave*12 + 5 + (x-3*kw)*7/(4*kw) 101 | } 102 | } 103 | w := x / kw 104 | if w < 3 { 105 | return octave*12 + w*2 106 | } else { 107 | return octave*12 + w*2 - 1 108 | } 109 | } 110 | 111 | func (k *Keyboard) noteOn(p int) { 112 | k.notesOn[p>>6] |= 1 << uint(p&63) 113 | if k.NoteOn != nil { 114 | k.NoteOn(p) 115 | } 116 | } 117 | 118 | func (k *Keyboard) noteOff(p int) { 119 | k.notesOn[p>>6] &^= 1 << uint(p&63) 120 | if k.NoteOff != nil { 121 | k.NoteOff(p) 122 | } 123 | } 124 | 125 | func (k *Keyboard) releaseSustain(p int) { 126 | for i := 0; i < 128; i++ { 127 | if i != p && k.notesOn[i>>6]&(1< 1 { 49 | v = 1 50 | } 51 | if s.value != v { 52 | s.value = v 53 | if s.Changed != nil { 54 | s.Changed(v) 55 | } 56 | } 57 | } 58 | 59 | // draw text 60 | s.label.DrawCentered(g, draw.XYXY(0, 0, w, lh), s.Label, draw.Font{Name: "default", Size: 12}, draw.Black) 61 | s.text.DrawCentered(g, draw.XYXY(0, h-lh, w, h), fmt.Sprintf("%.2f", s.value), draw.Font{Name: "default", Size: 12}, draw.Black) 62 | 63 | // draw the track 64 | x := w / 2 65 | g.Fill(draw.XYWH(x-1, 10+lh, 2, l), draw.RGBA(0, 0, 0, .4)) 66 | // draw the handle 67 | y := 10 + lh + int(s.value*float32(l)) 68 | g.Shadow(draw.XYWH(x-5, y-5, 10, 10), draw.Black, 5) 69 | g.Fill(draw.XYWH(x-5, y-5, 10, 10), draw.White) 70 | } 71 | -------------------------------------------------------------------------------- /examples/synth/synth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "sync" 6 | ) 7 | 8 | const SampleRate = 44100 9 | 10 | type Synth struct { 11 | Channels []Channel 12 | NextChannel int 13 | 14 | Volume float32 15 | Attack float32 16 | Release float32 17 | Modulation float32 18 | sync.Mutex 19 | } 20 | 21 | func NewSynth(channels int) *Synth { 22 | s := &Synth{Channels: make([]Channel, channels)} 23 | for i := range s.Channels { 24 | s.Channels[i].s = s 25 | } 26 | return s 27 | } 28 | 29 | func (s *Synth) NoteOn(p int) { 30 | s.Lock() 31 | c := &s.Channels[s.NextChannel] 32 | c.Pitch = p 33 | c.Frequency = 440 * float32(math.Pow(2, float64(p-69)/12)) / SampleRate 34 | c.On = true 35 | 36 | s.NextChannel++ 37 | if s.NextChannel >= len(s.Channels) { 38 | s.NextChannel = 0 39 | } 40 | s.Unlock() 41 | } 42 | 43 | func (s *Synth) NoteOff(p int) { 44 | s.Lock() 45 | for i := range s.Channels { 46 | c := &s.Channels[i] 47 | if c.Pitch == p { 48 | c.On = false 49 | } 50 | } 51 | s.Unlock() 52 | } 53 | 54 | func (s *Synth) SetVolume(volume float32) { 55 | s.Lock() 56 | s.Volume = volume * .1 57 | s.Unlock() 58 | } 59 | 60 | func (s *Synth) SetAttack(attack float32) { 61 | s.Lock() 62 | s.Attack = 1 - float32(math.Pow(0.1, 1.0/(float64(attack)*SampleRate))) 63 | s.Unlock() 64 | } 65 | 66 | func (s *Synth) SetRelease(release float32) { 67 | s.Lock() 68 | s.Release = 1 - float32(math.Pow(0.1, 1.0/(float64(release)*SampleRate))) 69 | s.Unlock() 70 | } 71 | 72 | func (s *Synth) SetModulation(mod float32) { 73 | s.Lock() 74 | s.Modulation = mod 75 | s.Unlock() 76 | } 77 | 78 | func (s *Synth) Process(out []float32) { 79 | s.Lock() 80 | for i := range s.Channels { 81 | s.Channels[i].Process(out) 82 | } 83 | for i := range out { 84 | out[i] *= s.Volume 85 | } 86 | s.Unlock() 87 | } 88 | 89 | type Channel struct { 90 | Pitch int 91 | Frequency float32 92 | On bool 93 | 94 | s *Synth 95 | 96 | phase float32 97 | env float32 98 | mod float32 99 | x1, x2 float32 100 | y1, y2 float32 101 | } 102 | 103 | func (c *Channel) Process(out []float32) { 104 | if c.Frequency == 0 { 105 | return 106 | } 107 | for i := range out { 108 | // calculate the phase 109 | c.phase += c.Frequency 110 | if c.phase >= 1 { 111 | c.phase-- 112 | } 113 | 114 | // calculate the envelope 115 | if c.On { 116 | c.env += (1 - c.env) * c.s.Attack 117 | } else { 118 | c.env -= c.env * c.s.Release 119 | } 120 | 121 | // modulate the pulse width 122 | c.mod += 4.0 / SampleRate 123 | if c.mod >= 3 { 124 | c.mod -= 4 125 | } 126 | w := c.mod 127 | if w > 1 { 128 | w = 2 - w 129 | } 130 | w *= c.s.Modulation / 2 131 | 132 | // calculate the waveform 133 | x := sqr(c.phase, 0.5+w, c.Frequency) * c.env 134 | 135 | // apply a low pass filter 136 | y := 0.001995109*x + 0.003990218*c.x1 + 0.001995109*c.x2 + 1.9334036*c.y1 - 0.9413841*c.y2 137 | c.x1, c.x2, c.y1, c.y2 = x, c.x1, y, c.y1 138 | 139 | out[i] += y 140 | } 141 | } 142 | 143 | func saw(p, f float32) float32 { 144 | q := p + f 145 | r := q + f 146 | if r > 1 { 147 | r-- 148 | if q > 1 { 149 | q-- 150 | } 151 | } 152 | return (((2*p-3)*p+1)*p - ((4*q-6)*q+2)*q + ((2*r-3)*r+1)*r) / (6 * f * f) 153 | } 154 | 155 | func sqr(p, w, f float32) float32 { 156 | q := p + w 157 | if q > 1 { 158 | q-- 159 | } 160 | return saw(p, f) - saw(q, f) 161 | } 162 | -------------------------------------------------------------------------------- /examples/test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/jfreymuth/ui" 8 | "github.com/jfreymuth/ui/draw" 9 | "github.com/jfreymuth/ui/impl/gofont" 10 | "github.com/jfreymuth/ui/impl/icons" 11 | "github.com/jfreymuth/ui/impl/sdl" 12 | . "github.com/jfreymuth/ui/toolkit" 13 | ) 14 | 15 | func fatal(err error) { 16 | if err != nil { 17 | panic(err) 18 | } 19 | } 20 | 21 | func main() { 22 | 23 | themeButton := NewButtonIcon("refresh", "Light Theme", nil) 24 | tabs := NewTabContainer() 25 | 26 | mb := NewMenuBar() 27 | { 28 | menu := mb.AddMenu("Test") 29 | menu.AddItem("Test", nil) 30 | sm := menu.AddMenu("Menu") 31 | sm.AddItem("Submenu1", nil) 32 | sm.AddItem("Submenu2", nil) 33 | sm = sm.AddMenu("Submenu3") 34 | sm.AddItem("Submenu1", nil) 35 | sm.AddItem("Submenu2", nil) 36 | sm = sm.AddMenu("Submenu3") 37 | sm.AddItem("Submenu1", nil) 38 | sm.AddItem("Submenu2", nil) 39 | menu.AddItem("Open Tab", func(*ui.State) { 40 | tabs.AddClosableTab("New Tab", NewLabel("Content"), nil) 41 | }) 42 | menu.AddItem("Quit", (*ui.State).Quit) 43 | } 44 | { 45 | menu := mb.AddMenu("Dialog") 46 | menu.AddItem("Message", func(state *ui.State) { 47 | ShowMessageDialog(state, "Message", "This is a message.", "Ok") 48 | }) 49 | menu.AddItem("Error", func(state *ui.State) { 50 | ShowErrorDialog(state, "Error", "This is an error.\nSeriously.", "Close") 51 | }) 52 | menu.AddItem("Confirm", func(state *ui.State) { 53 | ShowConfirmDialog(state, "Confirm", "Quit?", "Quit", "Cancel", func(state *ui.State) { 54 | ShowMessageDialog(state, "Message", "Quit.", "Ok") 55 | }) 56 | }) 57 | menu.AddItem("Yes/No", func(state *ui.State) { 58 | ShowYesNoDialog(state, "Confirm", "Save before quitting?", "Save", "Don't Save", "Cancel", func(state *ui.State) { 59 | ShowMessageDialog(state, "Message", "Save and Quit", "Ok") 60 | }, func(state *ui.State) { 61 | ShowMessageDialog(state, "Message", "Quit without saving", "Ok") 62 | }) 63 | }) 64 | menu.AddItem("Input", func(state *ui.State) { 65 | ShowInputDialog(state, "Input", "Input something", "Ok", "Cancel", func(state *ui.State, text string) { 66 | ShowMessageDialog(state, "Message", fmt.Sprint("Your input: ", text), "Ok") 67 | }) 68 | }) 69 | menu.AddItem("Open", func(state *ui.State) { 70 | ShowOpenDialog(state, NewFileChooser(), "Open", "Open", "Cancel", func(state *ui.State, path string) { 71 | ShowMessageDialog(state, "Message", fmt.Sprint("Your input: ", path), "Ok") 72 | }) 73 | }) 74 | menu.AddItem("Save", func(state *ui.State) { 75 | ShowSaveDialog(state, NewFileChooser(), "Save As", "Save", "Cancel", func(state *ui.State, path string) { 76 | ShowMessageDialog(state, "Message", fmt.Sprint("Your input: ", path), "Ok") 77 | }) 78 | }) 79 | } 80 | 81 | ta := NewTextArea() 82 | ta.Font = draw.Font{Name: "mono", Size: 11} 83 | ta.SetText(ipsum) 84 | font := NewTextField() 85 | font.Text = "mono" 86 | size := NewTextField() 87 | size.Text = "11" 88 | size.MinWidth = 30 89 | update := func(state *ui.State, _ string) { 90 | sz, err := strconv.ParseFloat(size.Text, 32) 91 | if err != nil { 92 | ShowErrorDialog(state, "Error", fmt.Sprint("\"", size.Text, "\" is not a number"), "Close") 93 | return 94 | } 95 | ta.Font = draw.Font{Name: font.Text, Size: float32(sz)} 96 | } 97 | font.Action = update 98 | size.Action = update 99 | 100 | form := NewForm() 101 | form.AddField("TextField:", NewTextField()) 102 | form.AddField("CheckBox:", NewCheckBox("Enabled")) 103 | cb := NewComboBox() 104 | cb.Items = []ListItem{ 105 | {Text: "Text", Icon: "file.text"}, 106 | {Text: "Image", Icon: "file.image"}, 107 | {Text: "Audio", Icon: "file.audio"}, 108 | {Text: "Video", Icon: "file.video"}, 109 | {Text: "Archive", Icon: "file.archive"}, 110 | {Text: "Executable", Icon: "file.exec"}, 111 | } 112 | form.AddField("ComboBox:", cb) 113 | form.AddField("Theme:", themeButton) 114 | 115 | text := &Container{ 116 | Center: NewScrollView(ta), 117 | Bottom: NewBar(100, 118 | NewButtonIcon("cut", "", ta.Cut), 119 | NewButtonIcon("copy", "", ta.Copy), 120 | NewButtonIcon("paste", "", ta.Paste), 121 | NewSeparator(3, 1), 122 | NewLabel(" Font: "), font, 123 | NewLabel(" Size: "), size, 124 | ), 125 | } 126 | tabs.AddTab("Test", NewHorizontalDivider(NewScrollView(form), text)) 127 | tabs.AddClosableTab("More", NewLabel("Second tab"), nil) 128 | tabs.AddClosableTab("Tabs", NewLabel("Third tab"), func(state *ui.State, tabIndex int) { 129 | ShowConfirmDialog(state, "Confirm", "Close the tab?", "Close", "Cancel", func(*ui.State) { tabs.CloseTab(tabIndex) }) 130 | }) 131 | root := NewRoot(&Container{ 132 | Top: mb, 133 | Center: tabs, 134 | }) 135 | themeButton.Action = func(state *ui.State) { 136 | if DefaultTheme == LightTheme { 137 | root.SetTheme(DarkTheme) 138 | DefaultTheme = DarkTheme 139 | themeButton.Text = "Dark Theme" 140 | } else { 141 | root.SetTheme(LightTheme) 142 | DefaultTheme = LightTheme 143 | themeButton.Text = "Light Theme" 144 | } 145 | } 146 | sdl.Show(sdl.Options{ 147 | Title: "Test", 148 | Height: 500, 149 | FontLookup: gofont.Lookup, 150 | IconLookup: &icons.Lookup{}, 151 | Root: root, 152 | Update: ui.HandleKeyboardShortcuts, 153 | }) 154 | } 155 | 156 | const todo = `TODO: 157 | word wrap for text area 158 | improve file chooser 159 | file type detection?` 160 | 161 | const ipsum = `Lorem ipsum dolor sit amet, consectetur 162 | adipiscing elit. Nam ac massa et leo 163 | vestibulum euismod. Sed neque nisi, 164 | consectetur at magna in, convallis 165 | fermentum metus. Sed vehicula metus urna, 166 | vel dapibus sem vestibulum quis. Mauris 167 | faucibus nunc erat. Curabitur laoreet 168 | dictum turpis, ut ornare felis facilisis 169 | at. Suspendisse volutpat aliquet erat, 170 | vitae feugiat risus ultrices eu. Nulla a 171 | interdum leo. Etiam vitae rutrum mauris. 172 | Proin ex justo, tempor vel porta id, 173 | interdum ut purus. Fusce eget libero eget 174 | sapien ultricies facilisis non vel leo. 175 | Duis dui urna, porttitor eu facilisis eget, 176 | imperdiet a nulla. Nullam lorem ante, 177 | ornare quis bibendum ac, maximus id elit. 178 | Curabitur sollicitudin maximus felis, nec 179 | faucibus dui scelerisque quis. Maecenas 180 | eget odio venenatis, varius magna et, 181 | tempor lorem. Curabitur et urna 182 | condimentum, tempor arcu ac, vestibulum 183 | tortor. 184 | 185 | Sed mattis lectus ex, eu rhoncus leo 186 | iaculis id. Aliquam erat volutpat. Donec 187 | auctor sapien blandit turpis accumsan 188 | auctor eu eu erat. Sed blandit, nibh 189 | vehicula tristique aliquet, orci nisl 190 | molestie neque, tincidunt maximus quam odio 191 | luctus lacus. Etiam eu laoreet lectus. 192 | Pellentesque congue mollis tristique. Ut 193 | nunc massa, mattis nec justo ac, mattis 194 | semper tellus. 195 | 196 | Cras sed erat eu magna vehicula vulputate 197 | eu ac justo. Donec magna est, fermentum non 198 | ex ac, ultricies mattis enim. Integer 199 | pharetra, enim eget fringilla aliquet, ex 200 | eros tempor sem, in porta magna lorem et 201 | ante. Donec eleifend, tortor a tristique 202 | eleifend, velit risus imperdiet metus, 203 | vitae lacinia enim odio a lorem. Duis 204 | mattis bibendum porttitor. Praesent vitae 205 | velit dui. Nam id interdum massa. Ut dictum 206 | justo in nulla tristique pellentesque. Nunc 207 | vel nisl fringilla, condimentum ligula ut, 208 | auctor arcu. Quisque non risus ut felis 209 | placerat venenatis eget ac enim. 210 | Suspendisse at erat quis diam bibendum 211 | rhoncus. 212 | 213 | Morbi eu diam orci. Sed sollicitudin luctus 214 | mollis. In hac habitasse platea dictumst. 215 | In feugiat vulputate dui sit amet 216 | sollicitudin. Phasellus id leo sapien. 217 | Nulla dapibus vel est a rhoncus. Nulla 218 | facilisi. Suspendisse sed urna ut ex 219 | maximus rhoncus sed ut lacus. Duis suscipit 220 | libero ut eleifend sollicitudin. Mauris 221 | interdum rutrum fringilla. Phasellus in 222 | orci a leo consectetur vehicula non 223 | vehicula mauris. Sed pharetra nec neque 224 | quis tempus. Donec massa diam, varius eget 225 | metus eget, convallis mattis tellus. Sed 226 | ultricies semper mauris sit amet euismod. 227 | 228 | Donec dapibus tincidunt volutpat. Proin 229 | finibus vitae metus a venenatis. Praesent 230 | tincidunt urna ac quam accumsan, quis 231 | ultricies tellus pharetra. Nam in orci 232 | purus. Vestibulum non diam lectus. In hac 233 | habitasse platea dictumst. Interdum et 234 | malesuada fames ac ante ipsum primis in 235 | faucibus. In tellus massa, consequat quis 236 | fermentum vitae, laoreet in metus. Duis 237 | auctor nec urna ac elementum. Integer 238 | consequat, augue et molestie commodo, metus 239 | tellus ultricies neque, ac venenatis nisi 240 | magna a leo. Suspendisse et odio eleifend, 241 | ornare libero at, iaculis risus. In nec 242 | arcu a augue fermentum posuere nec eget 243 | elit. 244 | ` 245 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jfreymuth/ui 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 7 | github.com/go-gl/mathgl v0.0.0-20180804195959-cdf14b6b8f8a 8 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 9 | github.com/veandco/go-sdl2 v0.3.3 10 | golang.org/x/exp v0.0.0-20190321205749-f0864edee7f3 11 | golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 2 | github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 h1:SCYMcCJ89LjRGwEa0tRluNRiMjZHalQZrVrvTbPh+qw= 3 | github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= 4 | github.com/go-gl/mathgl v0.0.0-20180804195959-cdf14b6b8f8a h1:2n5w2v3knlspzjJWyQPC0j88Mwvq0SZV0Jdws34GJwc= 5 | github.com/go-gl/mathgl v0.0.0-20180804195959-cdf14b6b8f8a/go.mod h1:dvrdneKbyWbK2skTda0nM4B9zSlS2GZSnjX7itr/skQ= 6 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 7 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 8 | github.com/veandco/go-sdl2 v0.3.3 h1:4/TirgB2MQ7oww3pM3Yfgf1YbChMlAQAmiCPe5koK0I= 9 | github.com/veandco/go-sdl2 v0.3.3/go.mod h1:FB+kTpX9YTE+urhYiClnRzpOXbiWgaU3+5F2AB78DPg= 10 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 11 | golang.org/x/exp v0.0.0-20190321205749-f0864edee7f3 h1:Ep4L2ibjtJcW6IP73KbcJAU0cpNKsLNSSP2jE1xlCys= 12 | golang.org/x/exp v0.0.0-20190321205749-f0864edee7f3/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= 13 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 14 | golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f h1:FO4MZ3N56GnxbqxGKqh+YTzUWQ2sDwtFQEZgLOxh9Jc= 15 | golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 16 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 17 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 18 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 19 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 21 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 22 | -------------------------------------------------------------------------------- /impl/gldraw/buffer.go: -------------------------------------------------------------------------------- 1 | package gldraw 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "os" 7 | "strings" 8 | 9 | "github.com/jfreymuth/ui/draw" 10 | 11 | "github.com/go-gl/gl/v3.3-core/gl" 12 | m "github.com/go-gl/mathgl/mgl32" 13 | ) 14 | 15 | type buffer struct { 16 | vao, vbo uint32 17 | buf []vertex 18 | program uint32 19 | sizeLoc int32 20 | } 21 | 22 | func (b *buffer) init(cap int) { 23 | b.buf = make([]vertex, 0, cap) 24 | b.program = createProgram(vss, fss) 25 | gl.UseProgram(b.program) 26 | b.sizeLoc = gl.GetUniformLocation(b.program, gl.Str("screenSize\x00")) 27 | gl.GenBuffers(1, &b.vbo) 28 | gl.BindBuffer(gl.ARRAY_BUFFER, b.vbo) 29 | gl.BufferData(gl.ARRAY_BUFFER, vertexSize*cap, nil, gl.STREAM_DRAW) 30 | gl.GenVertexArrays(1, &b.vao) 31 | gl.BindVertexArray(b.vao) 32 | gl.EnableVertexAttribArray(0) 33 | gl.VertexAttribPointer(0, 2, gl.INT, false, vertexSize, gl.PtrOffset(0)) 34 | gl.EnableVertexAttribArray(1) 35 | gl.VertexAttribPointer(1, 2, gl.FLOAT, false, vertexSize, gl.PtrOffset(8)) 36 | gl.EnableVertexAttribArray(2) 37 | gl.VertexAttribPointer(2, 4, gl.UNSIGNED_BYTE, true, vertexSize, gl.PtrOffset(16)) 38 | } 39 | 40 | func (b *buffer) setScreenSize(w, h int) { 41 | gl.UseProgram(b.program) 42 | gl.Uniform2f(b.sizeLoc, float32(w), float32(h)) 43 | } 44 | 45 | func (b *buffer) allocate(n int) []vertex { 46 | if n > b.free() { 47 | b.flush() 48 | } 49 | i := len(b.buf) 50 | b.buf = b.buf[:i+n] 51 | return b.buf[i : i+n] 52 | } 53 | 54 | func (b *buffer) free() int { 55 | return cap(b.buf) - len(b.buf) 56 | } 57 | 58 | func (b *buffer) rect(r image.Rectangle, tmin, tmax m.Vec2, color draw.Color) { 59 | if r.Empty() { 60 | return 61 | } 62 | v := b.allocate(6) 63 | v[0] = vertex{[2]int32{int32(r.Min.X), int32(r.Min.Y)}, tmin, color} 64 | v[1] = vertex{[2]int32{int32(r.Min.X), int32(r.Max.Y)}, m.Vec2{tmin[0], tmax[1]}, color} 65 | v[2] = vertex{[2]int32{int32(r.Max.X), int32(r.Min.Y)}, m.Vec2{tmax[0], tmin[1]}, color} 66 | v[3] = v[2] 67 | v[4] = vertex{[2]int32{int32(r.Max.X), int32(r.Max.Y)}, tmax, color} 68 | v[5] = v[1] 69 | } 70 | 71 | func (b *buffer) flush() { 72 | if len(b.buf) == 0 { 73 | return 74 | } 75 | gl.UseProgram(b.program) 76 | gl.BindBuffer(gl.ARRAY_BUFFER, b.vbo) 77 | gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(b.buf)*vertexSize, gl.Ptr(b.buf)) 78 | gl.BindVertexArray(b.vao) 79 | gl.DrawArrays(gl.TRIANGLES, 0, int32(len(b.buf))) 80 | b.buf = b.buf[:0] 81 | } 82 | 83 | type vertex struct { 84 | pos [2]int32 85 | tc m.Vec2 86 | color [4]uint8 87 | } 88 | 89 | const vertexSize = 20 90 | 91 | func createProgram(vss, fss string) uint32 { 92 | program := gl.CreateProgram() 93 | shaders := []struct { 94 | source string 95 | typ uint32 96 | }{{vss, gl.VERTEX_SHADER}, {fss, gl.FRAGMENT_SHADER}} 97 | for _, shader := range shaders { 98 | s := gl.CreateShader(shader.typ) 99 | csource, length := gl.Str(shader.source), int32(len(shader.source)) 100 | gl.ShaderSource(s, 1, &csource, &length) 101 | gl.CompileShader(s) 102 | 103 | var logLen int32 104 | gl.GetShaderiv(s, gl.INFO_LOG_LENGTH, &logLen) 105 | if logLen > 0 { 106 | log := strings.Repeat("\x00", int(logLen)) 107 | gl.GetShaderInfoLog(s, logLen, nil, gl.Str(log)) 108 | log = log[:len(log)-1] 109 | fmt.Printf("GL shader compilation:\n%s\n", log) 110 | } 111 | 112 | var status int32 113 | gl.GetShaderiv(s, gl.COMPILE_STATUS, &status) 114 | if status == gl.FALSE { 115 | gl.DeleteShader(s) 116 | fmt.Println("GL shader compilation failed") 117 | os.Exit(1) 118 | } 119 | gl.AttachShader(program, s) 120 | } 121 | gl.LinkProgram(program) 122 | 123 | var logLength int32 124 | gl.GetProgramiv(program, gl.INFO_LOG_LENGTH, &logLength) 125 | if logLength > 0 { 126 | log := strings.Repeat("\x00", int(logLength)) 127 | gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log)) 128 | log = log[:len(log)-1] 129 | fmt.Printf("GL shader compilation:\n%s\n", log) 130 | } 131 | 132 | var status int32 133 | gl.GetProgramiv(program, gl.LINK_STATUS, &status) 134 | if status == gl.FALSE { 135 | gl.DeleteProgram(program) 136 | fmt.Println("GL shader compilation failed") 137 | os.Exit(1) 138 | } 139 | 140 | return program 141 | } 142 | 143 | var vss = `#version 330 144 | layout(location = 0) in vec2 pos; 145 | layout(location = 1) in vec2 tc; 146 | layout(location = 2) in vec4 color; 147 | 148 | out vec2 ftc; 149 | out vec4 fcol; 150 | 151 | uniform vec2 screenSize; 152 | 153 | void main() { 154 | gl_Position = vec4(vec2(-1, 1) + pos/screenSize*vec2(2, -2), 0, 1); 155 | ftc = tc; 156 | fcol = color; 157 | } 158 | ` + "\x00" 159 | 160 | var fss = `#version 330 161 | in vec2 ftc; 162 | in vec4 fcol; 163 | 164 | out vec4 outcol; 165 | 166 | uniform sampler2D image; 167 | 168 | void main() { 169 | outcol = fcol * texture(image, ftc); 170 | } 171 | ` + "\x00" 172 | -------------------------------------------------------------------------------- /impl/gldraw/draw.go: -------------------------------------------------------------------------------- 1 | package gldraw 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui/draw" 7 | 8 | "github.com/go-gl/gl/v3.3-core/gl" 9 | m "github.com/go-gl/mathgl/mgl32" 10 | ) 11 | 12 | type Context struct { 13 | buffer buffer 14 | sbuffer sbuffer 15 | images map[*image.RGBA]*entry 16 | empty *Texture 17 | 18 | currentTexture uint32 19 | 20 | fontContext 21 | iconContext 22 | time uint 23 | } 24 | 25 | func (c *Context) Init(f FontLookup) { 26 | if f == nil { 27 | panic("gldraw: FontLookup must not be nil") 28 | } 29 | c.buffer.init(1024) 30 | c.sbuffer.init(512) 31 | c.images = make(map[*image.RGBA]*entry) 32 | c.empty = NewTextureAlpha(&image.Alpha{Pix: []uint8{255}, Stride: 1, Rect: image.Rect(0, 0, 1, 1)}) 33 | c.empty.c = c 34 | c.fontLookup = f 35 | c.initFonts() 36 | } 37 | 38 | func (c *Context) FontLookup() draw.FontLookup { 39 | return c.fontLookup 40 | } 41 | 42 | func (c *Context) SetIconLookup(l IconLookup) { 43 | if l != nil { 44 | c.iconLookup = l 45 | } 46 | } 47 | 48 | func (c *Context) Draw(w, h int, cmd []draw.CommandList) { 49 | gl.Viewport(0, 0, int32(w), int32(h)) 50 | gl.Enable(gl.BLEND) 51 | gl.BlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) 52 | c.buffer.setScreenSize(w, h) 53 | c.sbuffer.setScreenSize(w, h) 54 | c.currentTexture = 0 55 | for _, l := range cmd { 56 | for _, cmd := range l.Commands { 57 | switch cmd := cmd.(type) { 58 | case draw.Fill: 59 | c.prepare(c.empty.tex) 60 | c.buffer.rect(cmd.Rect.Add(l.Offset).Intersect(l.Clip), m.Vec2{}, m.Vec2{}, cmd.Color) 61 | case draw.Outline: 62 | cmd.Rect = cmd.Rect.Add(l.Offset) 63 | r := cmd.Rect 64 | c.prepare(c.empty.tex) 65 | r.Max.X = r.Min.X + 1 66 | c.buffer.rect(r.Intersect(l.Clip), m.Vec2{}, m.Vec2{}, cmd.Color) 67 | r.Max.X, r.Min.X = cmd.Rect.Max.X, cmd.Rect.Max.X-1 68 | c.buffer.rect(r.Intersect(l.Clip), m.Vec2{}, m.Vec2{}, cmd.Color) 69 | r.Min.X, r.Max.Y = cmd.Rect.Min.X, r.Min.Y+1 70 | c.buffer.rect(r.Intersect(l.Clip), m.Vec2{}, m.Vec2{}, cmd.Color) 71 | r.Max.Y, r.Min.Y = cmd.Rect.Max.Y, cmd.Rect.Max.Y-1 72 | c.buffer.rect(r.Intersect(l.Clip), m.Vec2{}, m.Vec2{}, cmd.Color) 73 | case draw.Text: 74 | ff := c.getFontFace(cmd.Font) 75 | ff.write(cmd.Text, float32(l.Offset.X+cmd.Position.X), float32(l.Offset.Y+cmd.Position.Y), c, 1, l.Clip, cmd.Color) 76 | case draw.Shadow: 77 | c.buffer.flush() 78 | r := cmd.Rect.Add(l.Offset).Intersect(l.Clip.Inset(cmd.Size)) 79 | c.sbuffer.rect(m.Vec2{float32(r.Min.X), float32(r.Min.Y)}, m.Vec2{float32(r.Max.X), float32(r.Max.Y)}, cmd.Rect.Add(l.Offset), float32(cmd.Size), cmd.Color) 80 | case draw.Icon: 81 | c.drawIcon(cmd.Rect.Add(l.Offset), l.Clip, cmd.Icon, cmd.Color) 82 | case draw.Image: 83 | t := c.getImage(cmd.Image, !cmd.Update) 84 | c.prepare(t) 85 | c.rect(cmd.Rect.Add(l.Offset), l.Clip, [4]float32{0, 0, 1, 1}, cmd.Color) 86 | } 87 | } 88 | } 89 | c.buffer.flush() 90 | c.sbuffer.flush() 91 | c.time++ 92 | } 93 | 94 | func (c *Context) prepare(t uint32) { 95 | c.sbuffer.flush() 96 | if t != c.currentTexture { 97 | c.buffer.flush() 98 | gl.BindTexture(gl.TEXTURE_2D, t) 99 | c.currentTexture = t 100 | } 101 | } 102 | 103 | func (c *Context) rect(r, clip image.Rectangle, tr [4]float32, color draw.Color) { 104 | if clip.Min.X >= r.Max.X { 105 | return 106 | } 107 | if clip.Min.Y >= r.Max.Y { 108 | return 109 | } 110 | if clip.Max.X <= r.Min.X { 111 | return 112 | } 113 | if clip.Max.Y <= r.Min.Y { 114 | return 115 | } 116 | if clip.Min.X > r.Min.X { 117 | tr[0] += (tr[2] - tr[0]) * float32(clip.Min.X-r.Min.X) / float32(r.Dx()) 118 | r.Min.X = clip.Min.X 119 | } 120 | if clip.Min.Y > r.Min.Y { 121 | tr[1] += (tr[3] - tr[1]) * (float32(clip.Min.Y-r.Min.Y) / float32(r.Dy())) 122 | r.Min.Y = clip.Min.Y 123 | } 124 | if clip.Max.X < r.Max.X { 125 | tr[2] -= (tr[2] - tr[0]) * (float32(r.Max.X-clip.Max.X) / float32(r.Dx())) 126 | r.Max.X = clip.Max.X 127 | } 128 | if clip.Max.Y < r.Max.Y { 129 | tr[3] -= (tr[3] - tr[1]) * (float32(r.Max.Y-clip.Max.Y) / float32(r.Dy())) 130 | r.Max.Y = clip.Max.Y 131 | } 132 | tmin := m.Vec2{tr[0], tr[1]} 133 | tmax := m.Vec2{tr[2], tr[3]} 134 | c.buffer.rect(r, tmin, tmax, color) 135 | } 136 | -------------------------------------------------------------------------------- /impl/gldraw/font.go: -------------------------------------------------------------------------------- 1 | package gldraw 2 | 3 | import ( 4 | "image" 5 | idraw "image/draw" 6 | 7 | "github.com/jfreymuth/ui/draw" 8 | 9 | "golang.org/x/image/font" 10 | "golang.org/x/image/math/fixed" 11 | ) 12 | 13 | type FontLookup interface { 14 | draw.FontLookup 15 | LoadFont(draw.Font) font.Face 16 | } 17 | 18 | type fontContext struct { 19 | fontLookup FontLookup 20 | fontMap map[draw.Font]*fontFace 21 | fontFaces []*fontFace 22 | fontTextures []fontTexture 23 | nextGC int 24 | } 25 | 26 | type fontFace struct { 27 | draw.Font 28 | f font.Face 29 | height int 30 | img image.Alpha 31 | glyphs map[glyphSpec]glyph 32 | texture *fontTexture 33 | line *fontLine 34 | metrics font.Metrics 35 | } 36 | 37 | type fontTexture struct { 38 | lines *fontLine 39 | free *fontLine 40 | img *Texture 41 | space float32 42 | } 43 | 44 | type fontLine struct { 45 | draw.Font 46 | y, h int 47 | used uint 48 | x int 49 | next *fontLine 50 | } 51 | 52 | type glyphSpec struct { 53 | r rune 54 | sp byte 55 | } 56 | 57 | type glyph struct { 58 | v [4]float32 59 | t [4]float32 60 | adv fixed.Int26_6 61 | line *fontLine 62 | } 63 | 64 | const fontTextureWidth = 1024 65 | const fontTextureHeight = 1024 66 | const fontGCThreshold = 10 67 | 68 | func (c *fontContext) initFonts() { 69 | c.fontMap = make(map[draw.Font]*fontFace) 70 | } 71 | 72 | func (c *fontContext) getFontFace(s draw.Font) *fontFace { 73 | if f, ok := c.fontMap[s]; ok { 74 | return f 75 | } 76 | n := c.fontLookup.GetClosest(s) 77 | if f, ok := c.fontMap[n]; ok { 78 | c.fontMap[s] = f 79 | return f 80 | } 81 | f := &fontFace{Font: n, f: c.fontLookup.LoadFont(n), glyphs: make(map[glyphSpec]glyph)} 82 | f.metrics = f.f.Metrics() 83 | f.height = f.metrics.Ascent.Ceil() + f.metrics.Descent.Ceil() + 1 84 | f.img = *image.NewAlpha(image.Rect(0, 0, f.height*2, f.height)) 85 | c.fontMap[n] = f 86 | c.fontFaces = append(c.fontFaces, f) 87 | return f 88 | } 89 | 90 | func (c *Context) getFontTexture(h int) *fontTexture { 91 | if len(c.fontTextures) > 0 { 92 | for i := range c.fontTextures { 93 | t := &c.fontTextures[i] 94 | if t.space < .8 { 95 | return t 96 | } 97 | } 98 | c.nextGC++ 99 | if c.nextGC >= len(c.fontTextures) { 100 | c.nextGC = 0 101 | } 102 | i := c.nextGC 103 | for { 104 | t := &c.fontTextures[i] 105 | t.gc(c) 106 | if t.space < .8 { 107 | return t 108 | } 109 | i++ 110 | if i == len(c.fontTextures) { 111 | i = 0 112 | } 113 | if i == c.nextGC { 114 | break 115 | } 116 | } 117 | } 118 | return c.newFontTexture(fontTextureWidth, fontTextureHeight) 119 | } 120 | 121 | func (c *Context) newFontTexture(w, h int) *fontTexture { 122 | t := NewTextureAlphaEmpty(w, h) 123 | t.c = c 124 | c.fontTextures = append(c.fontTextures, fontTexture{img: t}) 125 | return &c.fontTextures[len(c.fontTextures)-1] 126 | } 127 | 128 | func (t *fontTexture) alloc(s draw.Font, h int, c *Context) *fontLine { 129 | if t.lines == nil { 130 | next := &fontLine{y: h, h: t.img.height - h} 131 | t.lines = &fontLine{Font: s, h: h, next: next} 132 | t.space += float32(h) / float32(t.img.height) 133 | return t.lines 134 | } else { 135 | l := t.lines 136 | for l.Name != "" || l.h < h { 137 | l = l.next 138 | if l == nil { 139 | //t.gc(c, 1) 140 | t.freeOld(c) 141 | return t.alloc(s, h, c) 142 | } 143 | } 144 | if l.h == h { 145 | l.Font = s 146 | t.space += float32(h) / float32(t.img.height) 147 | return l 148 | } else { 149 | new := &fontLine{y: l.y + h, h: l.h - h, next: l.next} 150 | l.Font = s 151 | l.h = h 152 | l.next = new 153 | t.space += float32(h) / float32(t.img.height) 154 | return l 155 | } 156 | } 157 | } 158 | 159 | func (t *fontTexture) gc(c *Context) { 160 | var p, l *fontLine = nil, t.lines 161 | for l != nil { 162 | if l.Name != "" && l.used < c.time-fontGCThreshold { 163 | l = t.remove(c, p, l) 164 | } 165 | p, l = l, l.next 166 | } 167 | } 168 | 169 | func (t *fontTexture) remove(c *Context, p *fontLine, l *fontLine) *fontLine { 170 | for _, face := range c.fontFaces { 171 | if face.Font == l.Font { 172 | if face.line == l { 173 | face.line = nil 174 | } 175 | i := 0 176 | for r, g := range face.glyphs { 177 | if g.line == l { 178 | delete(face.glyphs, r) 179 | i++ 180 | } 181 | } 182 | } 183 | } 184 | t.space -= float32(l.h) / float32(t.img.height) 185 | if p != nil && p.Name == "" { 186 | if l.next != nil && l.next.Name == "" { 187 | p.h += l.h + l.next.h 188 | p.next = l.next.next 189 | } else { 190 | p.h += l.h 191 | p.next = l.next 192 | } 193 | return p 194 | } else { 195 | if l.next != nil && l.next.Name == "" { 196 | l.next.y -= l.h 197 | l.next.h += l.h 198 | if p != nil { 199 | p.next = l.next 200 | } else { 201 | t.lines = l.next 202 | } 203 | return l.next 204 | } else { 205 | l.Name = "" 206 | return l 207 | } 208 | } 209 | } 210 | 211 | func (t *fontTexture) freeOld(c *Context) { 212 | var best, bestP *fontLine 213 | var bestT uint = ^uint(0) 214 | p, l := t.lines, t.lines 215 | for l != nil { 216 | if l.Name != "" && l.used < bestT { 217 | best, bestP = l, p 218 | bestT = l.used 219 | } 220 | p, l = l, l.next 221 | } 222 | t.remove(c, bestP, best) 223 | } 224 | 225 | func (f *fontFace) load(r glyphSpec, c *Context) glyph { 226 | if g, ok := f.glyphs[r]; ok { 227 | g.line.used = c.time 228 | return g 229 | } 230 | dr, mask, maskp, adv, ok := f.f.Glyph(fixed.Point26_6{X: fixed.Int26_6(r.sp << (6 - f.subpixels())), Y: 0}, r.r) 231 | if !ok { 232 | err := glyph{} 233 | f.glyphs[r] = err 234 | return err 235 | } 236 | if f.line == nil || f.line.x+dr.Dx() > f.texture.img.width { 237 | if len(f.glyphs) == 0 { 238 | f.texture = c.getFontTexture(f.height) 239 | } 240 | f.line = f.texture.alloc(f.Font, f.height, c) 241 | f.line.x = 0 242 | } 243 | g := glyph{line: f.line, adv: adv} 244 | g.v = [4]float32{float32(dr.Min.X), float32(dr.Min.Y), float32(dr.Max.X), float32(dr.Max.Y)} 245 | rect := image.Rect(f.line.x, f.line.y, f.line.x+dr.Dx(), f.line.y+dr.Dy()) 246 | w, h := float32(f.texture.img.width), float32(f.texture.img.height) 247 | x, y := float32(rect.Min.X)/w, float32(rect.Min.Y)/h 248 | g.t = [4]float32{x, y, x + (g.v[2]-g.v[0])/w, y + (g.v[3]-g.v[1])/h} 249 | f.line.x += dr.Dx() + 1 250 | 251 | if img, ok := mask.(*image.Alpha); ok { 252 | img.Rect = img.Rect.Intersect(f.img.Rect) 253 | f.texture.img.UpdateAlpha(img, rect.Min) 254 | } else { 255 | idraw.Draw(&f.img, f.img.Rect, mask, maskp, idraw.Src) 256 | f.texture.img.UpdateAlpha(&f.img, rect.Min) 257 | } 258 | f.glyphs[r] = g 259 | f.line.used = c.time 260 | return g 261 | } 262 | 263 | func (f *fontFace) advance(r rune) fixed.Int26_6 { 264 | if g, ok := f.glyphs[glyphSpec{r, 0}]; ok { 265 | return g.adv 266 | } 267 | adv, _ := f.f.GlyphAdvance(r) 268 | return adv 269 | } 270 | 271 | func (f *fontFace) subpixels() byte { 272 | if f.Size < 20 { 273 | return 2 274 | } else if f.Size < 30 { 275 | return 1 276 | } 277 | return 0 278 | } 279 | 280 | func (f *fontFace) write(s string, xf, yf float32, c *Context, scale float32, clip image.Rectangle, color draw.Color) (float32, string) { 281 | x, y := fixed.Int26_6(xf/scale*64), fixed.Int26_6(yf/scale*64) 282 | var last rune 283 | for _, r := range s { 284 | kern := f.f.Kern(last, r) 285 | x += kern 286 | var adv fixed.Int26_6 287 | { 288 | x, y := float32(x)/64, float32(y)/64 289 | var subp byte = 0 290 | xint := float32(int(x+100)) - 100 291 | xfrac := x - xint 292 | sp := float32(int(1) << f.subpixels()) 293 | subp = byte(xfrac * sp) 294 | x = xint 295 | y = float32(int(y + .5)) 296 | g := f.load(glyphSpec{r, subp}, c) 297 | adv = g.adv 298 | c.prepare(f.texture.img.tex) 299 | c.rect(image.Rect(int(x+g.v[0]), int(y+g.v[1]), int(x+g.v[2]), int(y+g.v[3])), clip, g.t, color) 300 | } 301 | x += adv 302 | last = r 303 | } 304 | return float32(x)/64*scale - xf, "" 305 | } 306 | 307 | func (c *Context) DebugShowFontTextures(s float32) { 308 | for i := range c.fontTextures { 309 | c.prepare(c.fontTextures[i].img.tex) 310 | c.rect(draw.XYWH(int(float32(i*fontTextureWidth)*s), 0, int(fontTextureWidth*s), int(fontTextureHeight*s)), draw.WH(2000, 2000), [4]float32{0, 0, 1, 1}, draw.Black) 311 | } 312 | c.buffer.flush() 313 | } 314 | 315 | func (f *fontFace) Ascent() int { return f.metrics.Ascent.Ceil() } 316 | func (f *fontFace) Descent() int { return f.metrics.Descent.Ceil() } 317 | func (f *fontFace) LineHeight() int { return f.metrics.Height.Ceil() } 318 | 319 | func (f *fontFace) Advance(s string) float32 { 320 | var x fixed.Int26_6 321 | var last rune 322 | for _, r := range s { 323 | x += f.f.Kern(last, r) 324 | x += f.advance(r) 325 | last = r 326 | } 327 | return float32(x) / 64 328 | } 329 | 330 | func (f *fontFace) Index(s string, t float32) int { 331 | var x fixed.Int26_6 332 | var last rune 333 | for i, r := range s { 334 | x += f.f.Kern(last, r) 335 | adv := f.advance(r) 336 | if t < float32(x+adv/2)/64 { 337 | return i 338 | } 339 | x += adv 340 | last = r 341 | } 342 | return len(s) 343 | } 344 | -------------------------------------------------------------------------------- /impl/gldraw/icon.go: -------------------------------------------------------------------------------- 1 | package gldraw 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui/draw" 7 | ) 8 | 9 | type IconLookup interface { 10 | // IconSize should return the next smaller supported icon size. 11 | IconSize(int) int 12 | // DrawIcon draws an icon. The size of the image can be assumed to be a value returned by IconSize. 13 | DrawIcon(*image.Alpha, string) 14 | } 15 | 16 | type iconContext struct { 17 | iconLookup IconLookup 18 | iconSheets map[int]*iconSheet 19 | } 20 | 21 | type iconSheet struct { 22 | *iconContext 23 | size int 24 | img image.Alpha 25 | tex *Texture 26 | icons map[string]int 27 | } 28 | 29 | func (c *iconSheet) load(icon string) int { 30 | if n, ok := c.icons[icon]; ok { 31 | return n 32 | } 33 | c.iconLookup.DrawIcon(&c.img, icon) 34 | n := len(c.icons) 35 | x, y := n%16, n/16 36 | c.tex.UpdateAlpha(&c.img, image.Pt(x*c.size, y*c.size)) 37 | c.icons[icon] = n 38 | return n 39 | } 40 | 41 | func (c *Context) getIconSheet(size int) *iconSheet { 42 | size = c.iconLookup.IconSize(size) 43 | if s, ok := c.iconSheets[size]; ok { 44 | return s 45 | } 46 | s := c.newIconSheet(size) 47 | if c.iconSheets == nil { 48 | c.iconSheets = make(map[int]*iconSheet) 49 | } 50 | c.iconSheets[size] = s 51 | return s 52 | } 53 | 54 | func (c *Context) newIconSheet(size int) *iconSheet { 55 | c.prepare(0) 56 | return &iconSheet{ 57 | iconContext: &c.iconContext, 58 | size: size, 59 | img: *image.NewAlpha(image.Rect(0, 0, size, size)), 60 | tex: NewTextureAlphaEmpty(size*16, size*16), 61 | icons: make(map[string]int), 62 | } 63 | } 64 | 65 | func (c *Context) drawIcon(r, clip image.Rectangle, icon string, color draw.Color) { 66 | if c.iconLookup == nil || icon == "" { 67 | return 68 | } 69 | s := r.Dx() 70 | if r.Dy() < s { 71 | s = r.Dy() 72 | } 73 | sheet := c.getIconSheet(s) 74 | s = sheet.size 75 | dx := (r.Dx() - s) / 2 76 | dy := (r.Dy() - s) / 2 77 | r = draw.XYWH(r.Min.X+dx, r.Min.Y+dy, s, s) 78 | n := sheet.load(icon) 79 | x, y := float32(n%16), float32(n/16) 80 | c.prepare(sheet.tex.tex) 81 | c.rect(r, clip, [4]float32{x / 16, y / 16, (x + 1) / 16, (y + 1) / 16}, color) 82 | } 83 | -------------------------------------------------------------------------------- /impl/gldraw/image.go: -------------------------------------------------------------------------------- 1 | package gldraw 2 | 3 | import ( 4 | "hash/crc32" 5 | "image" 6 | ) 7 | 8 | type entry struct { 9 | t *Texture 10 | lastUsed uint 11 | checksum uint32 12 | } 13 | 14 | func (c *Context) getImage(i *image.RGBA, static bool) uint32 { 15 | if e, ok := c.images[i]; ok { 16 | e.lastUsed = c.time 17 | if static { 18 | return e.t.tex 19 | } else { 20 | ch := checksum(i) 21 | if ch != e.checksum { 22 | e.t.Update(i, image.Point{}) 23 | e.checksum = ch 24 | } 25 | return e.t.tex 26 | } 27 | } 28 | t := NewTexture(i) 29 | t.c = c 30 | var ch uint32 31 | if !static { 32 | ch = checksum(i) 33 | } 34 | c.images[i] = &entry{t, c.time, ch} 35 | return t.tex 36 | } 37 | 38 | func checksum(i *image.RGBA) uint32 { 39 | return crc32.ChecksumIEEE(i.Pix[:i.Rect.Dx()*i.Rect.Dy()*4]) 40 | } 41 | -------------------------------------------------------------------------------- /impl/gldraw/shadow.go: -------------------------------------------------------------------------------- 1 | package gldraw 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui/draw" 7 | 8 | "github.com/go-gl/gl/v3.3-core/gl" 9 | m "github.com/go-gl/mathgl/mgl32" 10 | ) 11 | 12 | type sbuffer struct { 13 | vao, vbo uint32 14 | buf []svertex 15 | program uint32 16 | sizeLoc int32 17 | } 18 | 19 | func (b *sbuffer) init(cap int) { 20 | b.buf = make([]svertex, 0, cap) 21 | b.program = createProgram(svss, sfss) 22 | gl.UseProgram(b.program) 23 | b.sizeLoc = gl.GetUniformLocation(b.program, gl.Str("screenSize\x00")) 24 | gl.GenBuffers(1, &b.vbo) 25 | gl.BindBuffer(gl.ARRAY_BUFFER, b.vbo) 26 | gl.BufferData(gl.ARRAY_BUFFER, svertexSize*cap, nil, gl.STREAM_DRAW) 27 | gl.GenVertexArrays(1, &b.vao) 28 | gl.BindVertexArray(b.vao) 29 | gl.EnableVertexAttribArray(0) 30 | gl.VertexAttribPointer(0, 3, gl.FLOAT, false, svertexSize, gl.PtrOffset(0)) 31 | gl.EnableVertexAttribArray(2) 32 | gl.VertexAttribPointer(2, 4, gl.UNSIGNED_BYTE, true, svertexSize, gl.PtrOffset(12)) 33 | gl.EnableVertexAttribArray(3) 34 | gl.VertexAttribPointer(3, 4, gl.INT, false, svertexSize, gl.PtrOffset(16)) 35 | } 36 | 37 | func (b *sbuffer) setScreenSize(w, h int) { 38 | gl.UseProgram(b.program) 39 | gl.Uniform2f(b.sizeLoc, float32(w), float32(h)) 40 | } 41 | 42 | func (b *sbuffer) allocate(n int) []svertex { 43 | if n > b.free() { 44 | b.flush() 45 | } 46 | i := len(b.buf) 47 | b.buf = b.buf[:i+n] 48 | return b.buf[i : i+n] 49 | } 50 | 51 | func (b *sbuffer) free() int { 52 | return cap(b.buf) - len(b.buf) 53 | } 54 | 55 | func (b *sbuffer) rect(min, max m.Vec2, rect image.Rectangle, r float32, color draw.Color) { 56 | if rect.Empty() { 57 | return 58 | } 59 | rect32 := [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)} 60 | v := b.allocate(6) 61 | v[0] = svertex{m.Vec3{min[0] - r, min[1] - r, r * .4}, color, rect32} 62 | v[1] = svertex{m.Vec3{min[0] - r, max[1] + r, r * .4}, color, rect32} 63 | v[2] = svertex{m.Vec3{max[0] + r, min[1] - r, r * .4}, color, rect32} 64 | v[3] = v[2] 65 | v[4] = svertex{m.Vec3{max[0] + r, max[1] + r, r * .4}, color, rect32} 66 | v[5] = v[1] 67 | } 68 | 69 | func (b *sbuffer) flush() { 70 | if len(b.buf) == 0 { 71 | return 72 | } 73 | gl.UseProgram(b.program) 74 | gl.BindBuffer(gl.ARRAY_BUFFER, b.vbo) 75 | gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(b.buf)*svertexSize, gl.Ptr(b.buf)) 76 | gl.BindVertexArray(b.vao) 77 | gl.DrawArrays(gl.TRIANGLES, 0, int32(len(b.buf))) 78 | b.buf = b.buf[:0] 79 | } 80 | 81 | type svertex struct { 82 | pos m.Vec3 83 | color [4]uint8 84 | rect [4]int32 85 | } 86 | 87 | const svertexSize = 32 88 | 89 | var svss = `#version 330 90 | layout(location = 0) in vec3 pos; 91 | layout(location = 2) in vec4 color; 92 | layout(location = 3) in vec4 rect; 93 | 94 | out vec4 fcol; 95 | out vec2 fpos; 96 | out vec4 frect; 97 | out float fr; 98 | 99 | uniform vec2 screenSize; 100 | 101 | void main() { 102 | gl_Position = vec4(vec2(-1, 1) + pos.xy/screenSize*vec2(2, -2), 0, 1); 103 | fpos = pos.xy; 104 | fr = pos.z; 105 | frect = rect; 106 | fcol = color; 107 | } 108 | ` + "\x00" 109 | 110 | var sfss = `#version 330 111 | in vec4 fcol; 112 | in vec2 fpos; 113 | in vec4 frect; 114 | in float fr; 115 | 116 | out vec4 outcol; 117 | 118 | uniform sampler2D image; 119 | 120 | vec4 erf(vec4 x) { 121 | vec4 s = sign(x), a = abs(x); 122 | x = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; 123 | x *= x; 124 | return s - s / (x * x); 125 | } 126 | 127 | float boxShadow(vec2 lower, vec2 upper, vec2 point, float sigma) { 128 | vec4 query = vec4(point - lower, point - upper); 129 | vec4 integral = 0.5 + 0.5 * erf(query * (sqrt(0.5) / sigma)); 130 | return (integral.z - integral.x) * (integral.w - integral.y); 131 | } 132 | 133 | void main() { 134 | outcol = fcol * boxShadow(frect.xy, frect.zw, fpos, fr); 135 | } 136 | ` + "\x00" 137 | -------------------------------------------------------------------------------- /impl/gldraw/texture.go: -------------------------------------------------------------------------------- 1 | package gldraw 2 | 3 | import ( 4 | "image" 5 | "image/draw" 6 | "io" 7 | "os" 8 | 9 | "github.com/go-gl/gl/v3.3-core/gl" 10 | ) 11 | 12 | type Texture struct { 13 | c *Context 14 | tex uint32 15 | width, height int 16 | alpha bool 17 | } 18 | 19 | func NewTexture(img *image.RGBA) *Texture { t := new(Texture); t.init(img); return t } 20 | func NewTextureEmpty(w, h int) *Texture { t := new(Texture); t.initEmpty(w, h); return t } 21 | func NewTextureAlpha(img *image.Alpha) *Texture { t := new(Texture); t.initAlpha(img); return t } 22 | func NewTextureAlphaEmpty(w, h int) *Texture { t := new(Texture); t.initAlphaEmpty(w, h); return t } 23 | 24 | func (t *Texture) Bounds() image.Rectangle { return image.Rect(0, 0, t.width, t.height) } 25 | 26 | func (t *Texture) initBase(w, h int) { 27 | t.width = w 28 | t.height = h 29 | gl.GenTextures(1, &t.tex) 30 | gl.BindTexture(gl.TEXTURE_2D, t.tex) 31 | gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) 32 | gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) 33 | } 34 | 35 | func (t *Texture) initBaseAlpha(w, h int) { 36 | t.alpha = true 37 | t.initBase(w, h) 38 | swizzle := [...]int32{gl.RED, gl.RED, gl.RED, gl.RED} 39 | gl.TexParameteriv(gl.TEXTURE_2D, gl.TEXTURE_SWIZZLE_RGBA, &swizzle[0]) 40 | } 41 | 42 | func (t *Texture) init(img *image.RGBA) { 43 | var old int32 44 | gl.GetIntegerv(gl.TEXTURE_BINDING_2D, &old) 45 | t.initBase(img.Rect.Dx(), img.Rect.Dy()) 46 | gl.PixelStorei(gl.UNPACK_ROW_LENGTH, int32(img.Stride/4)) 47 | gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1) 48 | gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, int32(img.Rect.Dx()), int32(img.Rect.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(img.Pix)) 49 | gl.BindTexture(gl.TEXTURE_2D, uint32(old)) 50 | } 51 | 52 | func (t *Texture) initEmpty(w, h int) { 53 | var old int32 54 | gl.GetIntegerv(gl.TEXTURE_BINDING_2D, &old) 55 | t.initBase(w, h) 56 | gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, int32(w), int32(h), 0, gl.RGBA, gl.UNSIGNED_BYTE, nil) 57 | gl.BindTexture(gl.TEXTURE_2D, uint32(old)) 58 | } 59 | 60 | func (t *Texture) initAlpha(img *image.Alpha) { 61 | var old int32 62 | gl.GetIntegerv(gl.TEXTURE_BINDING_2D, &old) 63 | t.initBaseAlpha(img.Rect.Dx(), img.Rect.Dy()) 64 | gl.PixelStorei(gl.UNPACK_ROW_LENGTH, int32(img.Stride)) 65 | gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1) 66 | gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RED, int32(img.Rect.Dx()), int32(img.Rect.Dy()), 0, gl.RED, gl.UNSIGNED_BYTE, gl.Ptr(img.Pix)) 67 | gl.BindTexture(gl.TEXTURE_2D, uint32(old)) 68 | } 69 | 70 | func (t *Texture) initAlphaEmpty(w, h int) { 71 | var old int32 72 | gl.GetIntegerv(gl.TEXTURE_BINDING_2D, &old) 73 | t.initBaseAlpha(w, h) 74 | gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RED, int32(w), int32(h), 0, gl.RED, gl.UNSIGNED_BYTE, nil) 75 | gl.BindTexture(gl.TEXTURE_2D, uint32(old)) 76 | } 77 | 78 | func (t *Texture) Update(img *image.RGBA, p image.Point) { 79 | if t.alpha { 80 | panic("wrong texture format") 81 | } 82 | if t.c != nil { 83 | if t.c.currentTexture == t.tex { 84 | t.c.buffer.flush() 85 | } else { 86 | t.c.prepare(t.tex) 87 | } 88 | } 89 | r := image.Rect(0, 0, t.width, t.height).Intersect(img.Rect.Add(p)) 90 | if r.Empty() { 91 | return 92 | } 93 | xo, yo := 0, 0 94 | if p.X < 0 { 95 | xo = -p.X 96 | } 97 | if p.Y < 0 { 98 | yo = -p.Y 99 | } 100 | gl.BindTexture(gl.TEXTURE_2D, t.tex) 101 | gl.PixelStorei(gl.UNPACK_ROW_LENGTH, int32(img.Stride/4)) 102 | gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1) 103 | gl.TexSubImage2D(gl.TEXTURE_2D, 0, int32(r.Min.X), int32(r.Min.Y), int32(r.Dx()), int32(r.Dy()), gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(img.Pix[(xo+yo*img.Stride)*4:])) 104 | } 105 | 106 | func (t *Texture) UpdateAlpha(img *image.Alpha, p image.Point) { 107 | if !t.alpha { 108 | panic("wrong texture format") 109 | } 110 | if t.c != nil { 111 | if t.c.currentTexture == t.tex { 112 | t.c.buffer.flush() 113 | } else { 114 | t.c.prepare(t.tex) 115 | } 116 | } 117 | r := image.Rect(0, 0, t.width, t.height).Intersect(img.Rect.Add(p)) 118 | if r.Empty() { 119 | return 120 | } 121 | xo, yo := 0, 0 122 | if p.X < 0 { 123 | xo = -p.X 124 | } 125 | if p.Y < 0 { 126 | yo = -p.Y 127 | } 128 | gl.BindTexture(gl.TEXTURE_2D, t.tex) 129 | gl.PixelStorei(gl.UNPACK_ROW_LENGTH, int32(img.Stride)) 130 | gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1) 131 | gl.TexSubImage2D(gl.TEXTURE_2D, 0, int32(r.Min.X), int32(r.Min.Y), int32(r.Dx()), int32(r.Dy()), gl.RED, gl.UNSIGNED_BYTE, gl.Ptr(img.Pix[(xo+yo*img.Stride):])) 132 | } 133 | 134 | func TextureFromFile(filename string) (*Texture, error) { 135 | file, err := os.Open(filename) 136 | if err != nil { 137 | return nil, err 138 | } 139 | t, err := TextureFromReader(file) 140 | file.Close() 141 | return t, err 142 | } 143 | 144 | func TextureFromReader(r io.Reader) (*Texture, error) { 145 | img, _, err := image.Decode(r) 146 | if err != nil { 147 | return nil, err 148 | } 149 | if rgba, ok := img.(*image.RGBA); ok { 150 | return NewTexture(rgba), nil 151 | } 152 | rgba := image.NewRGBA(img.Bounds()) 153 | draw.Draw(rgba, rgba.Rect, img, image.Point{}, draw.Src) 154 | return NewTexture(rgba), nil 155 | } 156 | -------------------------------------------------------------------------------- /impl/gofont/gofont.go: -------------------------------------------------------------------------------- 1 | package gofont 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/jfreymuth/ui/draw" 7 | "github.com/jfreymuth/ui/impl/gldraw" 8 | 9 | "github.com/golang/freetype/truetype" 10 | "golang.org/x/image/font" 11 | "golang.org/x/image/font/gofont/gobold" 12 | "golang.org/x/image/font/gofont/gobolditalic" 13 | "golang.org/x/image/font/gofont/goitalic" 14 | "golang.org/x/image/font/gofont/gomono" 15 | "golang.org/x/image/font/gofont/gomonobold" 16 | "golang.org/x/image/font/gofont/gomonobolditalic" 17 | "golang.org/x/image/font/gofont/gomonoitalic" 18 | "golang.org/x/image/font/gofont/goregular" 19 | "golang.org/x/image/math/fixed" 20 | ) 21 | 22 | var ( 23 | fonts map[string]entry 24 | fm map[draw.Font]*metrics 25 | ) 26 | 27 | type entry struct { 28 | realName string 29 | ttf *truetype.Font 30 | } 31 | 32 | type lookup struct { 33 | dpi float64 34 | } 35 | 36 | func initFonts() { 37 | fonts = make(map[string]entry) 38 | ttf, _ := truetype.Parse(goregular.TTF) 39 | fonts["goregular"] = entry{"goregular", ttf} 40 | ttf, _ = truetype.Parse(goitalic.TTF) 41 | fonts["goitalic"] = entry{"goitalic", ttf} 42 | ttf, _ = truetype.Parse(gobold.TTF) 43 | fonts["gobold"] = entry{"gobold", ttf} 44 | ttf, _ = truetype.Parse(gobolditalic.TTF) 45 | fonts["gobolditalic"] = entry{"gobolditalic", ttf} 46 | ttf, _ = truetype.Parse(gomono.TTF) 47 | fonts["gomono"] = entry{"gomono", ttf} 48 | ttf, _ = truetype.Parse(gomonoitalic.TTF) 49 | fonts["gomonoitalic"] = entry{"gomonoitalic", ttf} 50 | ttf, _ = truetype.Parse(gomonobold.TTF) 51 | fonts["gomonobold"] = entry{"gomonobold", ttf} 52 | ttf, _ = truetype.Parse(gomonobolditalic.TTF) 53 | fonts["gomonobolditalic"] = entry{"gomonobolditalic", ttf} 54 | } 55 | 56 | func Lookup(dpi float32) gldraw.FontLookup { 57 | return &lookup{dpi: float64(dpi)} 58 | } 59 | 60 | func Add(name string, ttf *truetype.Font) { 61 | if fonts == nil { 62 | initFonts() 63 | } 64 | fonts[name] = entry{name, ttf} 65 | } 66 | 67 | func AddAlias(name string, alias string) { 68 | if fonts == nil { 69 | initFonts() 70 | } 71 | if e, ok := fonts[name]; ok { 72 | fonts[alias] = e 73 | } 74 | } 75 | 76 | func (f *lookup) GetClosest(font draw.Font) draw.Font { 77 | if fonts == nil { 78 | initFonts() 79 | } 80 | if e, ok := fonts[font.Name]; ok { 81 | font.Name = e.realName 82 | } else { 83 | font.Name = fallback(font.Name) 84 | } 85 | if font.Size < 5 { 86 | font.Size = 5 87 | } else if font.Size > 72 { 88 | font.Size = 72 89 | } else { 90 | font.Size = float32(int(font.Size*2+.5)) / 2 91 | } 92 | return font 93 | } 94 | 95 | func (f *lookup) LoadFont(font draw.Font) font.Face { 96 | if fonts == nil { 97 | initFonts() 98 | } 99 | e, ok := fonts[font.Name] 100 | if !ok { 101 | font = f.GetClosest(font) 102 | e = fonts[font.Name] 103 | } 104 | return truetype.NewFace(e.ttf, &truetype.Options{Size: float64(font.Size), DPI: f.dpi, GlyphCacheEntries: 1}) 105 | } 106 | 107 | func (f *lookup) Metrics(font draw.Font) draw.FontMetrics { 108 | font = f.GetClosest(font) 109 | if m, ok := fm[font]; ok { 110 | return m 111 | } 112 | face := f.LoadFont(font) 113 | m := &metrics{face, face.Metrics()} 114 | if fm == nil { 115 | fm = make(map[draw.Font]*metrics) 116 | } 117 | fm[font] = m 118 | return m 119 | } 120 | 121 | func fallback(name string) (goName string) { 122 | if i := strings.Index(name, "mono"); i != -1 { 123 | goName += "mono" 124 | name = name[:i] + name[i+4:] 125 | } 126 | if i := strings.Index(name, "bold"); i != -1 { 127 | goName += "bold" 128 | name = name[:i] + name[i+4:] 129 | } 130 | if i := strings.Index(name, "italic"); i != -1 { 131 | goName += "italic" 132 | name = name[:i] + name[i+6:] 133 | } 134 | if goName == "" { 135 | return "goregular" 136 | } 137 | return "go" + goName 138 | } 139 | 140 | type metrics struct { 141 | font font.Face 142 | m font.Metrics 143 | } 144 | 145 | func (m *metrics) Ascent() int { return m.m.Ascent.Ceil() } 146 | func (m *metrics) Descent() int { return m.m.Descent.Ceil() } 147 | func (m *metrics) LineHeight() int { return m.m.Height.Ceil() } 148 | 149 | func (m *metrics) Advance(s string) float32 { 150 | var x fixed.Int26_6 151 | var last rune 152 | for _, r := range s { 153 | x += m.font.Kern(last, r) 154 | adv, _ := m.font.GlyphAdvance(r) 155 | x += adv 156 | last = r 157 | } 158 | return float32(x) / 64 159 | } 160 | 161 | func (m *metrics) Index(s string, t float32) int { 162 | var x fixed.Int26_6 163 | var last rune 164 | for i, r := range s { 165 | x += m.font.Kern(last, r) 166 | adv, _ := m.font.GlyphAdvance(r) 167 | if t < float32(x+adv/2)/64 { 168 | return i 169 | } 170 | x += adv 171 | last = r 172 | } 173 | return len(s) 174 | } 175 | -------------------------------------------------------------------------------- /impl/icons/icons.go: -------------------------------------------------------------------------------- 1 | package icons 2 | 3 | import ( 4 | "image" 5 | "image/draw" 6 | "strings" 7 | 8 | "golang.org/x/exp/shiny/iconvg" 9 | "golang.org/x/exp/shiny/materialdesign/icons" 10 | ) 11 | 12 | type Lookup struct { 13 | z iconvg.Rasterizer 14 | } 15 | 16 | func (l *Lookup) IconSize(s int) int { 17 | switch { 18 | case s < 24: 19 | return 18 20 | case s < 36: 21 | return 24 22 | case s < 48: 23 | return 36 24 | } 25 | return 48 26 | } 27 | 28 | func (l *Lookup) DrawIcon(dst *image.Alpha, name string) { 29 | l.z.SetDstImage(dst, dst.Rect, draw.Src) 30 | iconvg.Decode(&l.z, findIcon(name), nil) 31 | } 32 | 33 | func findIcon(name string) []byte { 34 | if b, ok := iconData[name]; ok { 35 | return b 36 | } 37 | if i := strings.LastIndexByte(name, '.'); i >= 0 { 38 | return findIcon(name[:i]) 39 | } 40 | return icons.ActionHelp 41 | } 42 | 43 | var iconData = map[string][]byte{ 44 | "down": icons.NavigationExpandMore, 45 | "down.arrow": icons.NavigationArrowDownward, 46 | "up": icons.NavigationExpandMore, 47 | "up.arrow": icons.NavigationArrowUpward, 48 | "left": icons.NavigationChevronLeft, 49 | "left.arrow": icons.NavigationArrowBack, 50 | "right": icons.NavigationChevronRight, 51 | "right.arrow": icons.NavigationArrowForward, 52 | "zoomIn": icons.ActionZoomIn, 53 | "zoomOut": icons.ActionZoomOut, 54 | 55 | "add": icons.ContentAdd, 56 | "add.file": icons.ActionNoteAdd, 57 | "remove": icons.ContentRemove, 58 | "delete": icons.ActionDelete, 59 | "remove.backspace": icons.ContentBackspace, 60 | "edit": icons.ImageEdit, 61 | 62 | "save": icons.ContentSave, 63 | "open": icons.FileFolderOpen, 64 | "folder": icons.FileFolderOpen, 65 | "file": icons.EditorInsertDriveFile, 66 | "file.text": icons.ActionDescription, 67 | "file.image": icons.ImagePhoto, 68 | "file.audio": icons.ImageMusicNote, 69 | "file.video": icons.MapsLocalMovies, 70 | "file.exec": icons.ActionSettingsApplications, 71 | "file.archive": icons.FileFolder, 72 | 73 | "info": icons.ActionInfo, 74 | "question": icons.ActionHelp, 75 | "error": icons.AlertError, 76 | "warning": icons.AlertWarning, 77 | 78 | "lock": icons.ActionLock, 79 | "unlock": icons.ActionLockOpen, 80 | 81 | "play": icons.AVPlayArrow, 82 | "pause": icons.AVPause, 83 | "stop": icons.AVStop, 84 | "rewind": icons.AVFastRewind, 85 | "fastForward": icons.AVFastForward, 86 | "skipNext": icons.AVSkipNext, 87 | "skipPrevious": icons.AVSkipPrevious, 88 | 89 | "volumeNone": icons.AVVolumeMute, 90 | "volumeLow": icons.AVVolumeDown, 91 | "volumeHigh": icons.AVVolumeUp, 92 | "volumeOff": icons.AVVolumeOff, 93 | 94 | "search": icons.ActionSearch, 95 | "refresh": icons.NavigationRefresh, 96 | 97 | "menu": icons.NavigationMenu, 98 | "settings": icons.ActionSettings, 99 | "close": icons.NavigationClose, 100 | 101 | "cut": icons.ContentContentCut, 102 | "copy": icons.ContentContentCopy, 103 | "paste": icons.ContentContentPaste, 104 | "undo": icons.ContentUndo, 105 | "redo": icons.ContentRedo, 106 | 107 | "checkbox": icons.ToggleCheckBoxOutlineBlank, 108 | "checkboxChecked": icons.ToggleCheckBox, 109 | "checkboxIndeterminate": icons.ToggleIndeterminateCheckBox, 110 | "radiobutton": icons.ToggleRadioButtonUnchecked, 111 | "radiobuttonSelected": icons.ToggleRadioButtonChecked, 112 | } 113 | -------------------------------------------------------------------------------- /impl/sdl/backend.go: -------------------------------------------------------------------------------- 1 | package sdl 2 | 3 | import ( 4 | "runtime" 5 | "time" 6 | "unsafe" 7 | 8 | "github.com/go-gl/gl/v3.3-core/gl" 9 | "github.com/jfreymuth/ui" 10 | "github.com/jfreymuth/ui/draw" 11 | "github.com/jfreymuth/ui/impl/gldraw" 12 | "github.com/veandco/go-sdl2/sdl" 13 | ) 14 | 15 | type Options struct { 16 | _ [0]byte 17 | // Title is the window's title. 18 | Title string 19 | // Width and Height set the window's size. 20 | // If either is 0, it will be replaced by the root component's preferred size. 21 | Width, Height int 22 | // Root is the root component of the window. 23 | // Must not be nil 24 | Root ui.Component 25 | // FontLookup should create a font lookup for the specified DPI setting. It will only be called once. 26 | // Must not be nil 27 | FontLookup func(dpi float32) gldraw.FontLookup 28 | // 29 | IconLookup gldraw.IconLookup 30 | // Init will be called once after the window is created. It can be used for any setup that requires access to a *ui.State. 31 | Init func(*ui.State) 32 | // Update will be called every time the application is updated. 33 | Update func(*ui.State) 34 | // Close 35 | Close func(*ui.State) 36 | // SDLInit will be called after the window is created, but before it is shown. 37 | // Can be used for any SDL-specific initialisation. 38 | SDLInit func(*sdl.Window) 39 | } 40 | 41 | // Show opens a window and blocks until it is closed. 42 | func Show(opt Options) { 43 | runtime.LockOSThread() 44 | 45 | if opt.Root == nil { 46 | panic("ui: Root must not be nil") 47 | } 48 | if opt.FontLookup == nil { 49 | panic("ui: FontLookup must not be nil") 50 | } 51 | 52 | if opt.Close == nil { 53 | opt.Close = (*ui.State).Quit 54 | } 55 | 56 | sdl.Init(sdl.INIT_VIDEO) 57 | sdl.GLSetAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, 3) 58 | sdl.GLSetAttribute(sdl.GL_CONTEXT_MINOR_VERSION, 3) 59 | 60 | win, _ := sdl.CreateWindow(opt.Title, sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, 0, 0, sdl.WINDOW_OPENGL|sdl.WINDOW_RESIZABLE|sdl.WINDOW_HIDDEN) 61 | ctx, _ := win.GLCreateContext() 62 | win.GLMakeCurrent(ctx) 63 | sdl.GLSetSwapInterval(1) 64 | gl.InitWithProcAddrFunc(sdl.GLGetProcAddress) 65 | 66 | di, _ := win.GetDisplayIndex() 67 | dpi, _, _, err := sdl.GetDisplayDPI(di) 68 | if err != nil { 69 | dpi = 96 70 | } 71 | fonts := opt.FontLookup(dpi) 72 | var c gldraw.Context 73 | c.Init(fonts) 74 | c.SetIconLookup(opt.IconLookup) 75 | w, h := opt.Root.PreferredSize(fonts) 76 | if opt.Width != 0 { 77 | w = opt.Width 78 | } 79 | if opt.Height != 0 { 80 | h = opt.Height 81 | } 82 | if r, err := sdl.GetDisplayBounds(di); err == nil { 83 | if w == 0 { 84 | w = int(r.W) / 2 85 | } else if w > int(r.W) { 86 | w = int(r.W) * 7 / 8 87 | } 88 | if h == 0 { 89 | h = int(r.H) / 2 90 | } else if h > int(r.H) { 91 | h = int(r.H) * 7 / 8 92 | } 93 | } 94 | win.SetSize(int32(w), int32(h)) 95 | if opt.SDLInit != nil { 96 | opt.SDLInit(win) 97 | } 98 | win.Show() 99 | 100 | state := &ui.BackendState{} 101 | state.SetWindowTitle(opt.Title) 102 | var g draw.Buffer 103 | g.FontLookup = fonts 104 | var cursor ui.Cursor 105 | cursorCache := make(map[ui.Cursor]*sdl.Cursor) 106 | clipboard, _ := sdl.GetClipboardText() 107 | state.SetClipboardString(clipboard) 108 | 109 | go func() { 110 | for { 111 | time.Sleep(500 * time.Millisecond) 112 | sdl.PushEvent(&sdl.UserEvent{Type: sdl.USEREVENT, Code: 1}) 113 | time.Sleep(500 * time.Millisecond) 114 | sdl.PushEvent(&sdl.UserEvent{Type: sdl.USEREVENT, Code: 2}) 115 | } 116 | }() 117 | 118 | var grabButton uint8 119 | for !state.QuitRequested() { 120 | state.ResetEvents() 121 | var e sdl.Event 122 | if state.AnimationRequested() { 123 | e = sdl.PollEvent() 124 | } else { 125 | e = sdl.WaitEvent() 126 | } 127 | quitEvent := false 128 | for e != nil { 129 | switch e := e.(type) { 130 | case *sdl.QuitEvent: 131 | quitEvent = true 132 | case *sdl.MouseMotionEvent: 133 | state.SetMousePosition(int(e.X), int(e.Y)) 134 | case *sdl.MouseWheelEvent: 135 | state.AddScroll(int(e.X), int(e.Y)) 136 | case *sdl.MouseButtonEvent: 137 | if e.State == sdl.PRESSED { 138 | if grabButton == 0 { 139 | grabButton = e.Button 140 | sdl.CaptureMouse(true) 141 | state.GrabMouse() 142 | } 143 | } else if e.State == sdl.RELEASED { 144 | if grabButton == e.Button { 145 | grabButton = 0 146 | sdl.CaptureMouse(false) 147 | state.ReleaseMouse(ui.MouseButton(e.Button)) 148 | } 149 | } 150 | state.SetMouseClicks(getClicks(e)) 151 | case *sdl.KeyboardEvent: 152 | if e.State == sdl.PRESSED { 153 | state.AddKeyPress(ui.Key(e.Keysym.Scancode)) 154 | } 155 | case *sdl.TextInputEvent: 156 | for i, c := range e.Text { 157 | if c == 0 { 158 | state.AddTextInput(string(e.Text[:i])) 159 | break 160 | } 161 | } 162 | case *sdl.WindowEvent: 163 | switch e.Event { 164 | case sdl.WINDOWEVENT_ENTER: 165 | state.SetHovered(true) 166 | case sdl.WINDOWEVENT_LEAVE: 167 | state.SetHovered(false) 168 | case sdl.WINDOWEVENT_SIZE_CHANGED: 169 | state.SetWindowSize(int(e.Data1), int(e.Data2)) 170 | default: 171 | } 172 | case *sdl.UserEvent: 173 | switch e.Code { 174 | case 1: 175 | state.SetBlink(true) 176 | case 2: 177 | state.SetBlink(false) 178 | case 3: 179 | (<-funcs)(&state.State) 180 | } 181 | default: 182 | } 183 | e = sdl.PollEvent() 184 | } 185 | _, _, mb := sdl.GetMouseState() 186 | state.SetMouseButtons(ui.MouseButton(mb)) 187 | state.SetModifiers(translateModifiers(sdl.GetModState())) 188 | 189 | w, h := win.GLGetDrawableSize() 190 | 191 | g.Reset(int(w), int(h)) 192 | state.ResetRequests() 193 | if opt.Init == nil && opt.Update != nil { 194 | opt.Update(&state.State) 195 | } 196 | state.UpdateChild(&g, draw.WH(int(w), int(h)), opt.Root) 197 | if opt.Init != nil { 198 | // Call Init after the first update, so the state has it's root set correctly. 199 | // This is important if Init wants to show a dialog. 200 | opt.Init(&state.State) 201 | opt.Init = nil 202 | } 203 | if quitEvent { 204 | opt.Close(&state.State) 205 | } 206 | 207 | if state.RefocusRequested() { 208 | state.ReleaseMouse(0) 209 | state.UpdateChild(&g, draw.WH(int(w), int(h)), opt.Root) 210 | state.GrabMouse() 211 | } 212 | 213 | if state.UpdateRequested() { 214 | state.ResetEvents() 215 | g.Reset(int(w), int(h)) 216 | state.UpdateChild(&g, draw.WH(int(w), int(h)), opt.Root) 217 | if state.UpdateRequested() { 218 | // If an application requests three updates in a row, wait one frame to prevent an infinite loop. 219 | state.RequestAnimation() 220 | } 221 | } 222 | 223 | g.Pop() 224 | gl.ClearColor(1, 1, 1, 1) 225 | gl.Clear(gl.COLOR_BUFFER_BIT) 226 | c.Draw(int(w), int(h), g.All) 227 | 228 | if state.Cursor() != cursor { 229 | cursor = state.Cursor() 230 | setCursor(cursor, cursorCache) 231 | } 232 | if state.WindowTitle() != opt.Title { 233 | opt.Title = state.WindowTitle() 234 | win.SetTitle(opt.Title) 235 | } 236 | if state.ClipboardString() != clipboard { 237 | clipboard = state.ClipboardString() 238 | sdl.SetClipboardText(clipboard) 239 | } else { 240 | clipboard, _ = sdl.GetClipboardText() 241 | state.SetClipboardString(clipboard) 242 | } 243 | 244 | win.GLSwap() 245 | } 246 | } 247 | 248 | // Do queues a function for execution on the ui goroutine. 249 | // Do should not be called before Show. 250 | func Do(f func(*ui.State)) { 251 | funcs <- f 252 | sdl.PushEvent(&sdl.UserEvent{Type: sdl.USEREVENT, Code: 3}) 253 | } 254 | 255 | var funcs = make(chan func(*ui.State), 1) 256 | 257 | func translateModifiers(m sdl.Keymod) ui.Modifier { 258 | var mod ui.Modifier 259 | if m&sdl.KMOD_SHIFT != 0 { 260 | mod |= ui.Shift 261 | } 262 | if m&sdl.KMOD_CTRL != 0 { 263 | mod |= ui.Control 264 | } 265 | if m&sdl.KMOD_ALT != 0 { 266 | mod |= ui.Alt 267 | } 268 | if m&sdl.KMOD_GUI != 0 { 269 | mod |= ui.Super 270 | } 271 | if m&sdl.KMOD_CAPS != 0 { 272 | mod |= ui.CapsLock 273 | } 274 | if m&sdl.KMOD_NUM != 0 { 275 | mod |= ui.NumLock 276 | } 277 | return mod 278 | } 279 | 280 | func setCursor(c ui.Cursor, cache map[ui.Cursor]*sdl.Cursor) { 281 | if sdlc, ok := cache[c]; ok { 282 | sdl.SetCursor(sdlc) 283 | } else { 284 | switch c { 285 | case ui.CursorNormal: 286 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_ARROW) 287 | case ui.CursorText: 288 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_IBEAM) 289 | case ui.CursorHand: 290 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_HAND) 291 | case ui.CursorCrosshair: 292 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_CROSSHAIR) 293 | case ui.CursorDisabled: 294 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_NO) 295 | case ui.CursorWait: 296 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_WAIT) 297 | case ui.CursorWaitBackground: 298 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_WAITARROW) 299 | case ui.CursorMove: 300 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_SIZEALL) 301 | case ui.CursorResizeHorizontal: 302 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_SIZEWE) 303 | case ui.CursorResizeVertical: 304 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_SIZENS) 305 | case ui.CursorResizeDiagonal: 306 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_SIZENWSE) 307 | case ui.CursorResizeDiagonal2: 308 | sdlc = sdl.CreateSystemCursor(sdl.SYSTEM_CURSOR_SIZENESW) 309 | default: 310 | return 311 | } 312 | cache[c] = sdlc 313 | sdl.SetCursor(sdlc) 314 | } 315 | } 316 | 317 | func getClicks(e *sdl.MouseButtonEvent) int { 318 | return int((*(*[2]uint8)(unsafe.Pointer(&e.State)))[1]) 319 | } 320 | -------------------------------------------------------------------------------- /keycode.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | type Key int 4 | 5 | // 6 | // 7 | // iota is (line number - 10) 8 | 9 | const ( 10 | KeyUnknown Key = iota 11 | _ 12 | _ 13 | _ 14 | KeyA 15 | KeyB 16 | KeyC 17 | KeyD 18 | KeyE 19 | KeyF 20 | KeyG 21 | KeyH 22 | KeyI 23 | KeyJ 24 | KeyK 25 | KeyL 26 | KeyM 27 | KeyN 28 | KeyO 29 | KeyP 30 | KeyQ 31 | KeyR 32 | KeyS 33 | KeyT 34 | KeyU 35 | KeyV 36 | KeyW 37 | KeyX 38 | KeyY 39 | KeyZ 40 | Key1 41 | Key2 42 | Key3 43 | Key4 44 | Key5 45 | Key6 46 | Key7 47 | Key8 48 | Key9 49 | Key0 50 | KeyEnter 51 | KeyEscape 52 | KeyBackspace 53 | KeyTab 54 | KeySpace 55 | _ 56 | _ 57 | _ 58 | _ 59 | _ 60 | _ 61 | _ 62 | _ 63 | _ 64 | _ 65 | _ 66 | _ 67 | KeyCapsLock 68 | KeyF1 69 | KeyF2 70 | KeyF3 71 | KeyF4 72 | KeyF5 73 | KeyF6 74 | KeyF7 75 | KeyF8 76 | KeyF9 77 | KeyF10 78 | KeyF11 79 | KeyF12 80 | KeyPrintScreen 81 | KeyScrollLock 82 | KeyPause 83 | KeyInsert 84 | KeyHome 85 | KeyPageUp 86 | KeyDelete 87 | KeyEnd 88 | KeyPageDown 89 | KeyRight 90 | KeyLeft 91 | KeyDown 92 | KeyUp 93 | KeyNumLock 94 | KeyNumpadDiv 95 | KeyNumpadMul 96 | KeyNumpadMinus 97 | KeyNumpadPlus 98 | KeyNumpadEnter 99 | KeyNumpad1 100 | KeyNumpad2 101 | KeyNumpad3 102 | KeyNumpad4 103 | KeyNumpad5 104 | KeyNumpad6 105 | KeyNumpad7 106 | KeyNumpad8 107 | KeyNumpad9 108 | KeyNumpad0 109 | KeyNumpadDot 110 | _ 111 | KeyMenu 112 | _ 113 | _ 114 | _ 115 | _ 116 | _ 117 | _ 118 | _ 119 | _ 120 | _ 121 | _ 122 | _ 123 | _ 124 | _ 125 | _ 126 | _ 127 | _ 128 | _ 129 | _ 130 | _ 131 | _ 132 | _ 133 | _ 134 | _ 135 | _ 136 | _ 137 | _ 138 | _ 139 | _ 140 | _ 141 | _ 142 | _ 143 | _ 144 | _ 145 | _ 146 | _ 147 | _ 148 | _ 149 | _ 150 | _ 151 | _ 152 | _ 153 | _ 154 | _ 155 | _ 156 | _ 157 | _ 158 | _ 159 | _ 160 | _ 161 | _ 162 | _ 163 | _ 164 | _ 165 | _ 166 | _ 167 | _ 168 | _ 169 | _ 170 | _ 171 | _ 172 | _ 173 | _ 174 | _ 175 | _ 176 | _ 177 | _ 178 | _ 179 | _ 180 | _ 181 | _ 182 | _ 183 | _ 184 | _ 185 | _ 186 | _ 187 | _ 188 | _ 189 | _ 190 | _ 191 | _ 192 | _ 193 | _ 194 | _ 195 | _ 196 | _ 197 | _ 198 | _ 199 | _ 200 | _ 201 | _ 202 | _ 203 | _ 204 | _ 205 | _ 206 | _ 207 | _ 208 | _ 209 | _ 210 | _ 211 | _ 212 | _ 213 | _ 214 | _ 215 | _ 216 | _ 217 | _ 218 | _ 219 | _ 220 | _ 221 | _ 222 | _ 223 | _ 224 | _ 225 | _ 226 | _ 227 | _ 228 | _ 229 | _ 230 | _ 231 | _ 232 | _ 233 | _ 234 | KeyLeftControl 235 | KeyLeftShift 236 | KeyLeftAlt 237 | KeyLeftSuper 238 | KeyRightControl 239 | KeyRightShift 240 | KeyRightAlt 241 | KeyRightSuper 242 | ) 243 | -------------------------------------------------------------------------------- /state.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui/draw" 7 | ) 8 | 9 | // The State is a components connection to the application. 10 | // It can be used to query input events and to request actions. 11 | type State struct { 12 | current Component 13 | disabled bool 14 | hovered bool // true: mouse is inside the current component 15 | focusable bool // true: current component has tried to receive keyboard events 16 | visible image.Rectangle // visibility request, relative to bounds 17 | bounds image.Rectangle // current component bounds 18 | 19 | mousePos image.Point // mouse position, absolute 20 | hoveredC Component // 21 | grabbed Component // not nil: mouse was pressed and not yet released on component 22 | drag interface{} // drag and drop, content 23 | drop bool // drag and drop, mouse just released 24 | focused Component // keyboard focus 25 | focusNext bool 26 | lastFocusable Component 27 | mouseButtons MouseButton // pressed mouse buttons 28 | clickButtons MouseButton // mouse buttons released since last update 29 | clicks int // number of mouse clicks 30 | modifiers Modifier 31 | scroll image.Point // mouse wheel input 32 | textInput string 33 | keyPresses []Key 34 | root Root 35 | cursor Cursor 36 | windowTitle string 37 | windowSize image.Rectangle // always at (0,0) 38 | clipboard string 39 | time float32 40 | blink bool 41 | 42 | // requests, set by component and read by backend 43 | update bool 44 | animation bool 45 | refocus bool 46 | quit bool 47 | } 48 | 49 | // HasMouseFocus returns true if the current component receives mouse events, i.e. if it is hovered or grabbed. 50 | func (s *State) HasMouseFocus() bool { 51 | return !s.disabled && s.drag == nil && (s.grabbed == s.current || (s.grabbed == nil && s.hovered)) 52 | } 53 | 54 | // IsHovered returns true if the cursor is inside the current component. 55 | func (s *State) IsHovered() bool { 56 | return !s.disabled && s.hovered && (s.grabbed == s.current || s.grabbed == nil) 57 | } 58 | 59 | // difference between HasMouseFocus and IsHovered: if the mouse is pressed and dragged outside the component, 60 | // HasMouseFocus returns true, but IsHovered returns false 61 | 62 | // DisableTabFocus disables focus cycling with the tab key for the current component. 63 | func (s *State) DisableTabFocus() { 64 | if s.grabbed == s.current { 65 | s.focusNext = false 66 | s.focused = s.current 67 | } 68 | s.focusable = true 69 | if s.current == s.focused { 70 | s.focusNext = false 71 | } 72 | } 73 | 74 | // SetKeyboardFocus requests that the given component will receive keyboard events. 75 | func (s *State) SetKeyboardFocus(c Component) { 76 | s.focused = c 77 | s.focusNext = false 78 | s.update = true 79 | } 80 | 81 | // SetKeyboardFocus requests that the next component will receive keyboard events. 82 | func (s *State) FocusNext() { 83 | s.focusNext = true 84 | } 85 | 86 | // SetKeyboardFocus requests that the previous component will receive keyboard events. 87 | func (s *State) FocusPrevious() { 88 | s.focused = s.lastFocusable 89 | s.update = true 90 | } 91 | 92 | // KeyboardFocus returns the component that currently receives keyboard events. 93 | func (s *State) KeyboardFocus() Component { 94 | return s.focused 95 | } 96 | 97 | // HasKeyboardFocus returns true if the current component receives keyboard events. 98 | func (s *State) HasKeyboardFocus() bool { 99 | if s.disabled { 100 | return false 101 | } 102 | if !s.focusable { 103 | s.focusable = true 104 | if s.current == s.focused { 105 | s.focusNext = false 106 | for i, k := range s.keyPresses { 107 | if k == KeyTab { 108 | if s.HasModifiers(Shift) { 109 | s.focused = s.lastFocusable 110 | s.update = true 111 | } else { 112 | s.focusNext = true 113 | } 114 | s.keyPresses = append(s.keyPresses[:i], s.keyPresses[i+1:]...) 115 | return false 116 | } 117 | } 118 | } 119 | if s.focused != s.current && (s.grabbed == s.current || s.focusNext) { 120 | s.focusNext = false 121 | s.focused = s.current 122 | s.update = true 123 | s.visible = draw.WH(s.bounds.Dx(), s.bounds.Dy()) 124 | } 125 | } 126 | return s.focused == s.current 127 | } 128 | 129 | // MouseButtonDown returns true if a given mouse button is pressed. 130 | func (s *State) MouseButtonDown(b MouseButton) bool { 131 | return s.HasMouseFocus() && s.mouseButtons&b == b 132 | } 133 | 134 | // MouseClick returns true if a given mouse button was clicked between the current and last update. 135 | func (s *State) MouseClick(b MouseButton) bool { 136 | return s.HasMouseFocus() && s.clickButtons&b != 0 137 | } 138 | 139 | // ClickCount returns the number of consecutive mouse clicks. 140 | func (s *State) ClickCount() int { 141 | return s.clicks 142 | } 143 | 144 | // HasModifiers returns true if the given modifiers are currently active. 145 | func (s *State) HasModifiers(m Modifier) bool { 146 | return s.modifiers&m == m 147 | } 148 | 149 | // MousePos returns the position of the cursor relative to the current component. 150 | func (s *State) MousePos() image.Point { 151 | return s.mousePos.Sub(s.bounds.Min) 152 | } 153 | 154 | // Scroll returns the amount of scrolling since the last update. 155 | func (s *State) Scroll() image.Point { 156 | if !s.disabled && s.hovered { 157 | return s.scroll 158 | } else { 159 | return image.Point{} 160 | } 161 | } 162 | 163 | // ConsumeScroll notifies the State that the scroll amount has been used and should not be used by any other components. 164 | func (s *State) ConsumeScroll() { 165 | if !s.disabled && s.hovered { 166 | s.scroll = image.Point{} 167 | } 168 | } 169 | 170 | // KeyPresses returns a list of key events that the current component should process. 171 | func (s *State) KeyPresses() []Key { 172 | if s.HasKeyboardFocus() { 173 | k := s.keyPresses 174 | s.keyPresses = nil 175 | return k 176 | } 177 | return nil 178 | } 179 | 180 | // PeekKeyPresses returns a list of key events. 181 | // Unlike KeyPresses, this method returns events even if they are not intended for the current component. 182 | func (s *State) PeekKeyPresses() []Key { 183 | return s.keyPresses 184 | } 185 | 186 | // TextInput returns the string that would be generated by key inputs. 187 | // Key presses that contributed to the text input will still appear in KeyPresses(). 188 | func (s *State) TextInput() string { 189 | if s.HasKeyboardFocus() { 190 | t := s.textInput 191 | s.textInput = "" 192 | return t 193 | } else { 194 | return "" 195 | } 196 | } 197 | 198 | // InitiateDrag starts a drag and drop gesture. 199 | func (s *State) InitiateDrag(content interface{}) { 200 | if s.HasMouseFocus() { 201 | s.grabbed = nil 202 | s.drag = content 203 | } 204 | } 205 | 206 | // DraggedContent returns information abount a drag and drop gesture currently in progress. 207 | // If there is no drag and drop gesture, or the mouse cursor is not above the current component, content will be nil. 208 | // Otherwise, content will be the value passed to InitiateDrag. 209 | // drop will be true if the mouse was released just before the current update. 210 | func (s *State) DraggedContent() (content interface{}, drop bool) { 211 | if s.IsHovered() { 212 | return s.drag, s.drop 213 | } 214 | return nil, false 215 | } 216 | 217 | // Blink returns the current state of blinking elements. 218 | // This is mostly indended for the cursor in text fields. 219 | func (s *State) Blink() bool { 220 | return s.HasKeyboardFocus() && s.blink 221 | } 222 | 223 | // SetBlink requests that blinking elements should be visible. 224 | func (s *State) SetBlink() { 225 | if s.HasKeyboardFocus() { 226 | s.blink = true 227 | } 228 | } 229 | 230 | func (s *State) SetRoot(r Root) { 231 | s.root = r 232 | } 233 | 234 | // OpenDialog displays the given component as a dialog. 235 | // While a dialog is open, other components do not receive any events. 236 | // Only one dialog may be open at a time. 237 | func (s *State) OpenDialog(d Component) { 238 | if s.root != nil { 239 | s.root.OpenDialog(d) 240 | s.focused = d 241 | s.update = true 242 | } 243 | } 244 | 245 | // CloseDialog closes the currently open dialog, if any. 246 | func (s *State) CloseDialog() { 247 | if s.root != nil { 248 | s.root.CloseDialog() 249 | s.update = true 250 | } 251 | } 252 | 253 | // OpenPopup displays the given component as a popup. 254 | func (s *State) OpenPopup(bounds image.Rectangle, d Component) Popup { 255 | if s.root != nil { 256 | s.focused = d 257 | s.update = true 258 | return s.root.OpenPopup(bounds.Add(s.bounds.Min), d) 259 | } 260 | return nil 261 | } 262 | 263 | // ClosePopups closes all popups. 264 | func (s *State) ClosePopups() { 265 | if s.root != nil { 266 | s.root.ClosePopups() 267 | s.update = true 268 | } 269 | } 270 | 271 | // HasPopups returns wether any popups are currently open. 272 | func (s *State) HasPopups() bool { 273 | return s.root != nil && s.root.HasPopups() 274 | } 275 | 276 | // WindowBounds returns the bounds of the window relative to the current component's origin. 277 | // This means the minimum point of the returned Rect will most likely be negative. 278 | func (s *State) WindowBounds() image.Rectangle { 279 | return s.windowSize.Sub(s.bounds.Min) 280 | } 281 | 282 | // SetCursor sets the current compnents cursor style. 283 | func (s *State) SetCursor(c Cursor) { 284 | if s.HasMouseFocus() { 285 | s.cursor = c 286 | } 287 | } 288 | 289 | // SetCursor sets the title of the window. 290 | func (s *State) SetWindowTitle(title string) { 291 | s.windowTitle = title 292 | } 293 | 294 | // RequestVisible should be called to request that the current component should be scrolled, so that the given rectangle is visible. 295 | func (s *State) RequestVisible(r image.Rectangle) { 296 | s.visible = r 297 | } 298 | 299 | // GetVisibilityRequest returns the rectangle passed to RequestVisible by a child of the current component. 300 | // This method should be called after UpdateChild. 301 | // The returned rectangle will be translated relative to the current component. 302 | // If the second return value is false, RequestVisible was not called. 303 | func (s *State) GetVisibilityRequest() (image.Rectangle, bool) { 304 | return s.visible, !s.visible.Empty() 305 | } 306 | 307 | // RequestUpdate requests that the ui should be updated again after the current update. 308 | // This method will typically be called if an input event causes a change to the layout or to components that may have already been updated. 309 | func (s *State) RequestUpdate() { 310 | s.update = true 311 | } 312 | 313 | // RequestAnimation requests that the ui should be updated again after a short amount of time. 314 | func (s *State) RequestAnimation() { 315 | s.animation = true 316 | } 317 | 318 | // RequestRefocus requests that the component receiving mouse events should be determined again. 319 | // This method should rarely be called by normal components. 320 | func (s *State) RequestRefocus() { 321 | s.refocus = true 322 | } 323 | 324 | // AnimationSpeed returns the time since the last call to RequestAnimation in seconds. 325 | func (s *State) AnimationSpeed() float32 { 326 | return s.time 327 | } 328 | 329 | // ClipboardString returns the contents of the clipboard. 330 | func (s *State) ClipboardString() string { 331 | return s.clipboard 332 | } 333 | 334 | // SetClipboardString sets the contents of the clipboard. 335 | func (s *State) SetClipboardString(c string) { 336 | s.clipboard = c 337 | } 338 | 339 | // Quit requests the application to close. 340 | func (s *State) Quit() { 341 | s.quit = true 342 | } 343 | 344 | // DrawChild draws another component without passing on any input. 345 | func (s *State) DrawChild(g *draw.Buffer, bounds image.Rectangle, c Component) { 346 | d := s.disabled 347 | this := s.current 348 | s.disabled = true 349 | s.current = c 350 | g.Push(bounds) 351 | c.Update(g, s) 352 | g.Pop() 353 | s.disabled = d 354 | s.current = this 355 | } 356 | 357 | // UpdateChild calls another component's Update method with a State that will deliver the correct events. 358 | func (s *State) UpdateChild(g *draw.Buffer, bounds image.Rectangle, c Component) { 359 | g.Push(bounds) 360 | if s.disabled { 361 | c.Update(g, s) 362 | g.Pop() 363 | return 364 | } 365 | this := s.current 366 | h := s.hovered 367 | v := s.visible 368 | f := s.focusable 369 | s.visible = image.Rectangle{} 370 | s.hovered = h && s.mousePos.Sub(s.bounds.Min).In(bounds) 371 | if s.hovered { 372 | s.hoveredC = c 373 | } 374 | b := s.bounds 375 | s.bounds = bounds.Add(s.bounds.Min) 376 | s.current = c 377 | s.focusable = false 378 | if s.focused == s.current { 379 | s.focusNext = true 380 | } 381 | c.Update(g, s) 382 | if s.focusable { 383 | s.lastFocusable = c 384 | } 385 | s.current = this 386 | s.bounds = b 387 | s.hovered = h 388 | if s.visible.Empty() { 389 | s.visible = v 390 | } else { 391 | s.visible = s.visible.Add(bounds.Min) 392 | } 393 | s.focusable = f 394 | g.Pop() 395 | } 396 | -------------------------------------------------------------------------------- /text/text.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui/draw" 7 | ) 8 | 9 | type Text struct { 10 | text string 11 | font draw.Font 12 | w, h, b int 13 | } 14 | 15 | func (t *Text) Size(text string, font draw.Font, fonts draw.FontLookup) (int, int) { 16 | t.measure(text, font, fonts) 17 | return t.w, t.h 18 | } 19 | 20 | func (t *Text) measure(text string, font draw.Font, fonts draw.FontLookup) { 21 | if text != t.text || font != t.font { 22 | t.text, t.font = text, font 23 | m := fonts.Metrics(font) 24 | t.w, t.h = int(m.Advance(text)), m.LineHeight() 25 | t.b = (m.Ascent() - m.Descent()) / 2 26 | } 27 | } 28 | 29 | func (t *Text) DrawLeft(g *draw.Buffer, r image.Rectangle, text string, font draw.Font, color draw.Color) { 30 | t.measure(text, font, g.FontLookup) 31 | g.Add(draw.Text{Position: image.Pt(r.Min.X, (r.Min.Y+r.Max.Y)/2+t.b), Text: text, Font: font, Color: color}) 32 | } 33 | 34 | func (t *Text) DrawCentered(g *draw.Buffer, r image.Rectangle, text string, font draw.Font, color draw.Color) { 35 | w, _ := t.Size(text, font, g.FontLookup) 36 | g.Add(draw.Text{Position: image.Pt(r.Min.X+(r.Dx()-w)/2, (r.Min.Y+r.Max.Y)/2+t.b), Text: text, Font: font, Color: color}) 37 | } 38 | 39 | func (t *Text) DrawRight(g *draw.Buffer, r image.Rectangle, text string, font draw.Font, color draw.Color) { 40 | w, _ := t.Size(text, font, g.FontLookup) 41 | g.Add(draw.Text{Position: image.Pt(r.Max.X-w, (r.Min.Y+r.Max.Y)/2+t.b), Text: text, Font: font, Color: color}) 42 | } 43 | 44 | func (t *Text) SizeIcon(text string, font draw.Font, icon string, gap int, fonts draw.FontLookup) (int, int) { 45 | t.measure(text, font, fonts) 46 | if icon != "" { 47 | if text == "" { 48 | return t.w + t.h, t.h 49 | } 50 | return t.w + t.h + gap, t.h 51 | } 52 | return t.w, t.h 53 | } 54 | 55 | func (t *Text) DrawLeftIcon(g *draw.Buffer, r image.Rectangle, text string, font draw.Font, color draw.Color, icon string, gap int) { 56 | _, h := t.SizeIcon(text, font, icon, gap, g.FontLookup) 57 | if icon != "" { 58 | g.Add(draw.Icon{Rect: draw.XYWH(r.Min.X, r.Min.Y, h, r.Dy()), Icon: icon, Color: color}) 59 | r.Min.X += gap + h 60 | } 61 | g.Add(draw.Text{Position: image.Pt(r.Min.X, (r.Min.Y+r.Max.Y)/2+t.b), Text: text, Font: font, Color: color}) 62 | } 63 | 64 | func (t *Text) DrawCenteredIcon(g *draw.Buffer, r image.Rectangle, text string, font draw.Font, color draw.Color, icon string, gap int) { 65 | w, h := t.Size(text, font, g.FontLookup) 66 | if icon != "" { 67 | if text == "" { 68 | gap = 0 69 | } 70 | g.Add(draw.Icon{Rect: draw.XYWH(r.Min.X+(r.Dx()-w-gap-h)/2, r.Min.Y, h, r.Dy()), Icon: icon, Color: color}) 71 | r.Min.X += gap + h 72 | } 73 | g.Add(draw.Text{Position: image.Pt(r.Min.X+(r.Dx()-w)/2, (r.Min.Y+r.Max.Y)/2+t.b), Text: text, Font: font, Color: color}) 74 | } 75 | 76 | func (t *Text) DrawRightIcon(g *draw.Buffer, r image.Rectangle, text string, font draw.Font, color draw.Color, icon string, gap int) { 77 | w, h := t.Size(text, font, g.FontLookup) 78 | if icon != "" { 79 | g.Add(draw.Icon{Rect: draw.XYWH(r.Min.X+r.Dx()-w-gap-h, r.Min.Y, h, r.Dy()), Icon: icon, Color: color}) 80 | r.Min.X += gap + h 81 | } 82 | g.Add(draw.Text{Position: image.Pt(r.Max.X-w, (r.Min.Y+r.Max.Y)/2+t.b), Text: text, Font: font, Color: color}) 83 | } 84 | -------------------------------------------------------------------------------- /text/word.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "unicode" 5 | "unicode/utf8" 6 | ) 7 | 8 | func FindWord(t string, c int) (int, int) { 9 | s := c 10 | r, size := utf8.DecodeRuneInString(t[c:]) 11 | for unicode.IsLetter(r) || unicode.IsNumber(r) { 12 | c += size 13 | r, size = utf8.DecodeRuneInString(t[c:]) 14 | } 15 | r, size = utf8.DecodeLastRuneInString(t[:s]) 16 | for unicode.IsLetter(r) || unicode.IsNumber(r) { 17 | s -= size 18 | r, size = utf8.DecodeLastRuneInString(t[:s]) 19 | } 20 | if c == s && c < len(t) { 21 | c++ 22 | } 23 | return s, c 24 | } 25 | 26 | func NextWord(t string, c int) int { 27 | if c == len(t) { 28 | return c 29 | } 30 | r, size := utf8.DecodeRuneInString(t[c:]) 31 | for unicode.IsSpace(r) { 32 | c += size 33 | r, size = utf8.DecodeRuneInString(t[c:]) 34 | } 35 | if unicode.IsLetter(r) || unicode.IsNumber(r) { 36 | for unicode.IsLetter(r) || unicode.IsNumber(r) { 37 | c += size 38 | r, size = utf8.DecodeRuneInString(t[c:]) 39 | } 40 | return c 41 | } else { 42 | for r != utf8.RuneError && !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) { 43 | c += size 44 | r, size = utf8.DecodeRuneInString(t[c:]) 45 | } 46 | return c 47 | } 48 | } 49 | 50 | func PreviousWord(t string, c int) int { 51 | if c == 0 { 52 | return c 53 | } 54 | r, size := utf8.DecodeLastRuneInString(t[:c]) 55 | for unicode.IsSpace(r) { 56 | c -= size 57 | r, size = utf8.DecodeLastRuneInString(t[:c]) 58 | } 59 | if unicode.IsLetter(r) || unicode.IsNumber(r) { 60 | for unicode.IsLetter(r) || unicode.IsNumber(r) { 61 | c -= size 62 | r, size = utf8.DecodeLastRuneInString(t[:c]) 63 | } 64 | return c 65 | } else { 66 | for r != utf8.RuneError && !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) { 67 | c -= size 68 | r, size = utf8.DecodeLastRuneInString(t[:c]) 69 | } 70 | return c 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /toolkit/bar.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | ) 7 | 8 | type Bar struct { 9 | Theme *Theme 10 | Components []ui.Component 11 | Fill int 12 | } 13 | 14 | func NewBar(fill int, c ...ui.Component) *Bar { 15 | return &Bar{Theme: DefaultTheme, Components: c, Fill: fill} 16 | } 17 | 18 | func (b *Bar) SetTheme(theme *Theme) { 19 | b.Theme = theme 20 | for _, c := range b.Components { 21 | SetTheme(c, theme) 22 | } 23 | } 24 | 25 | func (b *Bar) PreferredSize(fonts draw.FontLookup) (int, int) { 26 | w, h := 0, 0 27 | for _, c := range b.Components { 28 | cw, ch := c.PreferredSize(fonts) 29 | w += cw 30 | if ch > h { 31 | h = ch 32 | } 33 | } 34 | return w, h 35 | } 36 | 37 | func (b *Bar) Update(g *draw.Buffer, state *ui.State) { 38 | w, h := g.Size() 39 | g.Fill(draw.WH(w, h), b.Theme.Color("altBackground")) 40 | x := 0 41 | if b.Fill >= len(b.Components) { 42 | for _, c := range b.Components { 43 | cw, _ := c.PreferredSize(g.FontLookup) 44 | state.UpdateChild(g, draw.XYWH(x, 0, cw, h), c) 45 | x += cw 46 | } 47 | } else if b.Fill < 0 { 48 | ws := make([]int, len(b.Components)) 49 | for i := range ws { 50 | ws[i], _ = b.Components[b.Fill+1+i].PreferredSize(g.FontLookup) 51 | w -= ws[i] 52 | } 53 | x += w 54 | for i, c := range b.Components { 55 | cw := ws[i] 56 | state.UpdateChild(g, draw.XYWH(x, 0, cw, h), c) 57 | x += cw 58 | } 59 | } else { 60 | for _, c := range b.Components[:b.Fill] { 61 | cw, _ := c.PreferredSize(g.FontLookup) 62 | state.UpdateChild(g, draw.XYWH(x, 0, cw, h), c) 63 | x += cw 64 | } 65 | ws := make([]int, len(b.Components)-b.Fill-1) 66 | w -= x 67 | for i := range ws { 68 | ws[i], _ = b.Components[b.Fill+1+i].PreferredSize(g.FontLookup) 69 | w -= ws[i] 70 | } 71 | state.UpdateChild(g, draw.XYWH(x, 0, w, h), b.Components[b.Fill]) 72 | x += w 73 | for i := b.Fill + 1; i < len(b.Components); i++ { 74 | c := b.Components[i] 75 | cw := ws[i-b.Fill-1] 76 | state.UpdateChild(g, draw.XYWH(x, 0, cw, h), c) 77 | x += cw 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /toolkit/button.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | "github.com/jfreymuth/ui/text" 7 | ) 8 | 9 | type Button struct { 10 | Action func(*ui.State) 11 | Theme *Theme 12 | Text string 13 | Icon string 14 | text text.Text 15 | anim float32 16 | } 17 | 18 | func NewButton(text string, action func(*ui.State)) *Button { 19 | return &Button{Action: action, Theme: DefaultTheme, Text: text} 20 | } 21 | 22 | func NewButtonIcon(icon, text string, action func(*ui.State)) *Button { 23 | return &Button{Action: action, Theme: DefaultTheme, Text: text, Icon: icon} 24 | } 25 | 26 | func (b *Button) SetTheme(theme *Theme) { b.Theme = theme } 27 | 28 | func (b *Button) PreferredSize(fonts draw.FontLookup) (int, int) { 29 | w, h := b.text.SizeIcon(b.Text, b.Theme.Font("buttonText"), b.Icon, 5, fonts) 30 | return w + 20, h + 20 31 | } 32 | 33 | func (b *Button) Update(g *draw.Buffer, state *ui.State) { 34 | w, h := g.Size() 35 | animate(state, &b.anim, 8, state.IsHovered()) 36 | g.Fill(draw.WH(w, h), draw.Blend(b.Theme.Color("buttonBackground"), b.Theme.Color("buttonHovered"), b.anim)) 37 | color := b.Theme.Color("buttonText") 38 | if state.HasKeyboardFocus() { 39 | color = b.Theme.Color("buttonFocused") 40 | } 41 | b.text.DrawCenteredIcon(g, draw.WH(w, h), b.Text, b.Theme.Font("buttonText"), color, b.Icon, 5) 42 | action := state.MouseClick(ui.MouseLeft) 43 | for _, k := range state.KeyPresses() { 44 | if k == ui.KeySpace || k == ui.KeyEnter { 45 | action = true 46 | } 47 | } 48 | if b.Action != nil && action { 49 | b.Action(state) 50 | state.RequestUpdate() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /toolkit/checkbox.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | "github.com/jfreymuth/ui/text" 7 | ) 8 | 9 | type CheckBox struct { 10 | Checked bool 11 | Changed func(*ui.State, bool) 12 | Theme *Theme 13 | Text string 14 | text text.Text 15 | anim float32 16 | } 17 | 18 | func NewCheckBox(text string) *CheckBox { 19 | return &CheckBox{Theme: DefaultTheme, Text: text} 20 | } 21 | 22 | func (c *CheckBox) PreferredSize(fonts draw.FontLookup) (int, int) { 23 | w, h := c.text.Size(c.Text, DefaultTheme.Font("buttonText"), fonts) 24 | return w + h + 30, h + 20 25 | } 26 | 27 | func (c *CheckBox) Update(g *draw.Buffer, state *ui.State) { 28 | w, h := g.Size() 29 | _, s := c.text.Size(c.Text, DefaultTheme.Font("buttonText"), g.FontLookup) 30 | 31 | action := state.MouseClick(ui.MouseLeft) 32 | for _, k := range state.KeyPresses() { 33 | if k == ui.KeySpace || k == ui.KeyEnter { 34 | action = true 35 | } 36 | } 37 | if action { 38 | c.Checked = !c.Checked 39 | if c.Changed != nil { 40 | c.Changed(state, c.Checked) 41 | state.RequestUpdate() 42 | } 43 | } 44 | 45 | animate(state, &c.anim, 8, c.Checked) 46 | x := int(c.anim * float32(s+10)) 47 | g.Push(draw.XYXY(5, 5, 5+x, h-5)) 48 | g.Icon(draw.XYXY(0, 0, s+10, h-10), "checkboxChecked", DefaultTheme.Color("buttonText")) 49 | g.Pop() 50 | g.Push(draw.XYXY(5+x, 5, s+15, h-5)) 51 | g.Icon(draw.XYWH(-x, 0, s+10, h-10), "checkbox", DefaultTheme.Color("buttonText")) 52 | g.Pop() 53 | color := DefaultTheme.Color("buttonText") 54 | if state.HasKeyboardFocus() { 55 | color = DefaultTheme.Color("buttonFocused") 56 | } 57 | c.text.DrawLeft(g, draw.XYXY(s+20, 0, w, h), c.Text, DefaultTheme.Font("buttonText"), color) 58 | } 59 | -------------------------------------------------------------------------------- /toolkit/combobox.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui" 7 | "github.com/jfreymuth/ui/draw" 8 | ) 9 | 10 | type ComboBox struct { 11 | List 12 | sv *ScrollView 13 | anim float32 14 | } 15 | 16 | func NewComboBox() *ComboBox { 17 | c := &ComboBox{List: List{Theme: DefaultTheme}} 18 | c.sv = NewScrollView(&c.List) 19 | return c 20 | } 21 | 22 | func (c *ComboBox) SetTheme(theme *Theme) { c.sv.SetTheme(theme) } 23 | 24 | func (c *ComboBox) PreferredSize(fonts draw.FontLookup) (int, int) { 25 | if len(c.Items) == 0 { 26 | return 30, 16 27 | } 28 | w, h := c.List.PreferredSize(fonts) 29 | h /= len(c.Items) 30 | return w + 30 + h, h + 16 31 | } 32 | 33 | func (c *ComboBox) Update(g *draw.Buffer, state *ui.State) { 34 | w, h := g.Size() 35 | var item ListItem 36 | if len(c.Items) > 0 { 37 | if c.Selected < 0 { 38 | c.Selected = 0 39 | } else if c.Selected >= len(c.Items) { 40 | c.Selected = len(c.Items) - 1 41 | } 42 | item = c.Items[c.Selected] 43 | } 44 | animate(state, &c.anim, 8, state.IsHovered()) 45 | g.Fill(draw.WH(w, h), draw.Blend(c.Theme.Color("buttonBackground"), c.Theme.Color("buttonHovered"), c.anim)) 46 | _, th := item.text.Size(item.Text, c.Theme.Font("text"), g.FontLookup) 47 | item.text.DrawLeftIcon(g, draw.XYXY(15, 0, w-th-10, h), item.Text, c.Theme.Font("text"), c.Theme.Color("text"), item.Icon, 3) 48 | g.Icon(draw.XYXY(w-th-10, 0, w-10, h), "down", c.Theme.Color("text")) 49 | if state.MouseClick(ui.MouseLeft) { 50 | pw, ph := c.List.PreferredSize(g.FontLookup) 51 | pw, ph = pw+6, ph+6 52 | win := state.WindowBounds() 53 | spaceAbove := -win.Min.Y 54 | spaceBelow := win.Max.Y - h 55 | var r image.Rectangle 56 | if ph <= spaceBelow { 57 | r = draw.XYWH(0, h, pw, ph) 58 | } else if ph <= spaceAbove { 59 | r = draw.XYWH(0, -ph, pw, ph) 60 | } else if spaceAbove > spaceBelow { 61 | r = draw.XYWH(0, -spaceAbove, pw+15, spaceAbove) 62 | } else { 63 | r = draw.XYWH(0, h, pw+15, spaceBelow) 64 | } 65 | if r.Dx() < w { 66 | r.Max.X = r.Min.X + w 67 | } 68 | state.OpenPopup(r, &menuBackground{c.sv, c.Theme}) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /toolkit/container.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui" 7 | "github.com/jfreymuth/ui/draw" 8 | ) 9 | 10 | type Container struct { 11 | Center, Top, Bottom, Left, Right ui.Component 12 | } 13 | 14 | func (c *Container) SetTheme(theme *Theme) { 15 | SetTheme(c.Top, theme) 16 | SetTheme(c.Left, theme) 17 | SetTheme(c.Center, theme) 18 | SetTheme(c.Right, theme) 19 | SetTheme(c.Bottom, theme) 20 | } 21 | 22 | func (c *Container) PreferredSize(fonts draw.FontLookup) (int, int) { 23 | w, h := 0, 0 24 | if c.Center != nil { 25 | w, h = c.Center.PreferredSize(fonts) 26 | } 27 | if c.Left != nil { 28 | cw, ch := c.Left.PreferredSize(fonts) 29 | w += cw 30 | if ch > h { 31 | h = ch 32 | } 33 | } 34 | if c.Right != nil { 35 | cw, ch := c.Right.PreferredSize(fonts) 36 | w += cw 37 | if ch > h { 38 | h = ch 39 | } 40 | } 41 | if c.Top != nil { 42 | cw, ch := c.Top.PreferredSize(fonts) 43 | if cw > w { 44 | w = cw 45 | } 46 | h += ch 47 | } 48 | if c.Bottom != nil { 49 | cw, ch := c.Bottom.PreferredSize(fonts) 50 | if cw > w { 51 | w = cw 52 | } 53 | h += ch 54 | } 55 | return w, h 56 | } 57 | 58 | func (c *Container) Update(g *draw.Buffer, state *ui.State) { 59 | x, y := 0, 0 60 | w, h := g.Size() 61 | if c.Top != nil { 62 | _, ch := c.Top.PreferredSize(g.FontLookup) 63 | if ch > h { 64 | ch = h 65 | } 66 | state.UpdateChild(g, draw.XYWH(x, y, w, ch), c.Top) 67 | y += ch 68 | h -= ch 69 | } 70 | var bottom, right image.Rectangle 71 | if c.Bottom != nil { 72 | _, ch := c.Bottom.PreferredSize(g.FontLookup) 73 | if ch > h { 74 | ch = h 75 | } 76 | bottom = draw.XYWH(x, y+h-ch, w, ch) 77 | h -= ch 78 | } 79 | if c.Left != nil { 80 | cw, _ := c.Left.PreferredSize(g.FontLookup) 81 | if cw > w { 82 | cw = w 83 | } 84 | state.UpdateChild(g, draw.XYWH(x, y, cw, h), c.Left) 85 | x += cw 86 | w -= cw 87 | } 88 | if c.Right != nil { 89 | cw, _ := c.Right.PreferredSize(g.FontLookup) 90 | if cw > w { 91 | cw = w 92 | } 93 | right = draw.XYWH(x+w-cw, y, cw, h) 94 | w -= cw 95 | } 96 | if c.Center != nil { 97 | state.UpdateChild(g, draw.XYWH(x, y, w, h), c.Center) 98 | } 99 | if c.Right != nil { 100 | state.UpdateChild(g, right, c.Right) 101 | } 102 | if c.Bottom != nil { 103 | state.UpdateChild(g, bottom, c.Bottom) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /toolkit/dialog.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import "github.com/jfreymuth/ui" 4 | 5 | func ShowMessageDialog(state *ui.State, title, message, button string) { 6 | ta := NewTextArea() 7 | ta.Editable = false 8 | ta.SetText(message) 9 | state.OpenDialog(NewFrame("info", title, &Container{ 10 | Center: NewScrollView(ta), 11 | Bottom: NewBar(-1, NewButton(button, (*ui.State).CloseDialog)), 12 | }, DefaultTheme.Color("titleBackground"))) 13 | } 14 | 15 | func ShowErrorDialog(state *ui.State, title, message, button string) { 16 | ta := NewTextArea() 17 | ta.Editable = false 18 | ta.SetText(message) 19 | state.OpenDialog(NewFrame("warning", title, &Container{ 20 | Center: NewScrollView(ta), 21 | Bottom: NewBar(-1, NewButton(button, (*ui.State).CloseDialog)), 22 | }, DefaultTheme.Color("titleBackgroundError"))) 23 | } 24 | 25 | func ShowConfirmDialog(state *ui.State, title, message, ok, cancel string, action func(*ui.State)) { 26 | ta := NewTextArea() 27 | ta.Editable = false 28 | ta.SetText(message) 29 | state.OpenDialog(NewFrame("question", title, &Container{ 30 | Center: NewScrollView(ta), 31 | Bottom: NewBar(-1, NewButton(ok, func(state *ui.State) { 32 | state.CloseDialog() 33 | if action != nil { 34 | action(state) 35 | } 36 | }), NewButton(cancel, (*ui.State).CloseDialog)), 37 | }, DefaultTheme.Color("titleBackground"))) 38 | } 39 | 40 | func ShowYesNoDialog(state *ui.State, title, message, yes, no, cancel string, yesAction, noAction func(*ui.State)) { 41 | ta := NewTextArea() 42 | ta.Editable = false 43 | ta.SetText(message) 44 | state.OpenDialog(NewFrame("question", title, &Container{ 45 | Center: NewScrollView(ta), 46 | Bottom: NewBar(-1, NewButton(yes, func(state *ui.State) { 47 | state.CloseDialog() 48 | if yesAction != nil { 49 | yesAction(state) 50 | } 51 | }), NewButton(no, func(state *ui.State) { 52 | state.CloseDialog() 53 | if noAction != nil { 54 | noAction(state) 55 | } 56 | }), NewButton(cancel, (*ui.State).CloseDialog)), 57 | }, DefaultTheme.Color("titleBackground"))) 58 | } 59 | 60 | func ShowInputDialog(state *ui.State, title, message, button, cancel string, action func(*ui.State, string)) { 61 | ta := NewTextArea() 62 | ta.Editable = false 63 | ta.SetText(message) 64 | tf := NewTextField() 65 | tf.Action = func(state *ui.State, text string) { 66 | state.CloseDialog() 67 | if action != nil { 68 | action(state, text) 69 | } 70 | } 71 | state.OpenDialog(NewFrame("question", title, &Container{ 72 | Center: NewScrollView(ta), 73 | Bottom: NewBar(-1, tf, NewButton(button, tf.TriggerAction), NewButton(cancel, (*ui.State).CloseDialog)), 74 | }, DefaultTheme.Color("titleBackground"))) 75 | } 76 | 77 | func ShowOpenDialog(state *ui.State, fc *FileChooser, title, open, cancel string, action func(*ui.State, string)) { 78 | fc.SetLabels(open, cancel, true) 79 | fc.Action = action 80 | state.OpenDialog(NewFrame("open", title, fc, DefaultTheme.Color("titleBackground"))) 81 | } 82 | 83 | func ShowSaveDialog(state *ui.State, fc *FileChooser, title, save, cancel string, action func(*ui.State, string)) { 84 | fc.SetLabels(save, cancel, false) 85 | fc.Action = action 86 | state.OpenDialog(NewFrame("save", title, fc, DefaultTheme.Color("titleBackground"))) 87 | } 88 | -------------------------------------------------------------------------------- /toolkit/divider.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | ) 7 | 8 | type Divider struct { 9 | First, Second ui.Component 10 | Vertical bool 11 | Theme *Theme 12 | pos int 13 | } 14 | 15 | type dividerBar struct { 16 | *Divider 17 | } 18 | 19 | func NewVerticalDivider(top, bottom ui.Component) *Divider { 20 | return &Divider{top, bottom, true, DefaultTheme, -1} 21 | } 22 | 23 | func NewHorizontalDivider(left, right ui.Component) *Divider { 24 | return &Divider{left, right, false, DefaultTheme, -1} 25 | } 26 | 27 | func (d *Divider) SetTheme(theme *Theme) { 28 | d.Theme = theme 29 | SetTheme(d.First, theme) 30 | SetTheme(d.Second, theme) 31 | } 32 | 33 | func (d *Divider) PreferredSize(fonts draw.FontLookup) (int, int) { 34 | w, h := d.First.PreferredSize(fonts) 35 | w2, h2 := d.Second.PreferredSize(fonts) 36 | if d.Vertical { 37 | if w2 > w { 38 | w = w2 39 | } 40 | h += h2 + 4 41 | } else { 42 | w += w2 + 4 43 | if h2 > h { 44 | h = h2 45 | } 46 | } 47 | return w, h 48 | } 49 | 50 | func (d *Divider) Update(g *draw.Buffer, state *ui.State) { 51 | w, h := g.Size() 52 | if d.Vertical { 53 | if d.pos == -1 { 54 | _, h1 := d.First.PreferredSize(g.FontLookup) 55 | _, h2 := d.Second.PreferredSize(g.FontLookup) 56 | d.pos = int(float32(h1+2) / float32(h1+h2+4) * float32(h)) 57 | } 58 | if d.pos < 2 { 59 | d.pos = 2 60 | } else if d.pos > h-2 { 61 | d.pos = h - 2 62 | } 63 | state.UpdateChild(g, draw.WH(w, d.pos-2), d.First) 64 | state.UpdateChild(g, draw.XYXY(0, d.pos+2, w, h), d.Second) 65 | state.UpdateChild(g, draw.XYXY(0, d.pos-2, w, d.pos+2), dividerBar{d}) 66 | } else { 67 | if d.pos == -1 { 68 | w1, _ := d.First.PreferredSize(g.FontLookup) 69 | w2, _ := d.Second.PreferredSize(g.FontLookup) 70 | d.pos = int(float32(w1+2) / float32(w1+w2+4) * float32(w)) 71 | } 72 | if d.pos < 2 { 73 | d.pos = 2 74 | } else if d.pos > w-2 { 75 | d.pos = w - 2 76 | } 77 | state.UpdateChild(g, draw.WH(d.pos-2, h), d.First) 78 | state.UpdateChild(g, draw.XYXY(d.pos+2, 0, w, h), d.Second) 79 | state.UpdateChild(g, draw.XYXY(d.pos-2, 0, d.pos+2, h), dividerBar{d}) 80 | } 81 | } 82 | 83 | func (d dividerBar) PreferredSize(fonts draw.FontLookup) (int, int) { return 0, 0 } 84 | 85 | func (d dividerBar) Update(g *draw.Buffer, state *ui.State) { 86 | w, h := g.Size() 87 | g.Fill(draw.WH(w, h), d.Theme.Color("altBackground")) 88 | if d.Vertical { 89 | state.SetCursor(ui.CursorResizeVertical) 90 | if state.MouseButtonDown(ui.MouseLeft) { 91 | y := state.MousePos().Y 92 | d.pos += y - 2 93 | state.RequestUpdate() 94 | } 95 | } else { 96 | state.SetCursor(ui.CursorResizeHorizontal) 97 | if state.MouseButtonDown(ui.MouseLeft) { 98 | x := state.MousePos().X 99 | d.pos += x - 2 100 | state.RequestUpdate() 101 | } 102 | } 103 | if d.pos < 0 { 104 | d.pos = 0 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /toolkit/file.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/jfreymuth/ui" 10 | "github.com/jfreymuth/ui/draw" 11 | ) 12 | 13 | type FileChooser struct { 14 | root ui.Component 15 | files *List 16 | ab, cb *Button 17 | nameField TextField 18 | path string 19 | Action func(*ui.State, string) 20 | } 21 | 22 | func NewFileChooser() *FileChooser { 23 | f := &FileChooser{} 24 | f.ab = NewButton("", f.action) 25 | f.cb = NewButton("", (*ui.State).CloseDialog) 26 | f.files = NewList() 27 | f.files.Changed = func(state *ui.State, i ListItem) { f.nameField.Text = (i.Text) } 28 | f.files.Action = func(state *ui.State, i ListItem) { f.action(state) } 29 | f.root = &Container{ 30 | Center: NewScrollView(f.files), 31 | Bottom: NewBar(1, NewButtonIcon("left.arrow", "", f.back), &f.nameField, f.ab, f.cb), 32 | } 33 | f.nameField = *NewTextField() 34 | f.set(".") 35 | return f 36 | } 37 | 38 | func (f *FileChooser) SetTheme(theme *Theme) { 39 | SetTheme(f.root, theme) 40 | } 41 | 42 | func (f *FileChooser) SetLabels(action, cancel string, existing bool) { 43 | f.ab.Text = action 44 | f.cb.Text = cancel 45 | f.nameField.Editable = !existing 46 | } 47 | 48 | func (f *FileChooser) SetPath(path string) { 49 | f.set(path) 50 | } 51 | 52 | func (f *FileChooser) PreferredSize(fonts draw.FontLookup) (int, int) { 53 | return 400, 320 54 | } 55 | 56 | func (f *FileChooser) Update(g *draw.Buffer, state *ui.State) { 57 | w, h := g.Size() 58 | state.UpdateChild(g, draw.WH(w, h), f.root) 59 | } 60 | 61 | func (f *FileChooser) action(state *ui.State) { 62 | s := f.files.Items[f.files.Selected] 63 | if s.Text == f.nameField.Text { 64 | if s.Icon == "folder" { 65 | f.enter() 66 | } else { 67 | state.CloseDialog() 68 | if f.Action != nil { 69 | f.Action(state, filepath.Join(f.path, s.Text)) 70 | } 71 | } 72 | } else if f.nameField.Editable && f.nameField.Text != "" { 73 | state.CloseDialog() 74 | if f.Action != nil { 75 | f.Action(state, filepath.Join(f.path, f.nameField.Text)) 76 | } 77 | } 78 | } 79 | 80 | func (f *FileChooser) set(p string) { 81 | p, _ = filepath.Abs(p) 82 | dir, err := os.Open(p) 83 | if err != nil { 84 | return 85 | } 86 | files, err := dir.Readdir(0) 87 | if err != nil { 88 | return 89 | } 90 | f.files.Items = nil 91 | for _, i := range files { 92 | name := i.Name() 93 | i.Mode() 94 | if !strings.HasPrefix(name, ".") { 95 | icon := "file" 96 | if i.IsDir() { 97 | icon = "folder" 98 | } 99 | f.files.Items = append(f.files.Items, ListItem{Text: name, Icon: icon}) 100 | } 101 | } 102 | sort.Slice(f.files.Items, func(i, j int) bool { 103 | if (f.files.Items[i].Icon == "folder") != (f.files.Items[j].Icon == "folder") { 104 | return f.files.Items[i].Icon == "folder" 105 | } 106 | return f.files.Items[i].Text < f.files.Items[j].Text 107 | }) 108 | if len(f.files.Items) > 0 { 109 | f.nameField.Text = (f.files.Items[0].Text) 110 | } 111 | f.path = p 112 | } 113 | 114 | func (f *FileChooser) enter() { 115 | sel := f.files.Items[f.files.Selected].Text 116 | if strings.HasSuffix(sel, string(filepath.Separator)) { 117 | f.set(sel) 118 | } else { 119 | f.set(filepath.Join(f.path, sel)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /toolkit/file_others.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package toolkit 4 | 5 | import ( 6 | "path/filepath" 7 | 8 | "github.com/jfreymuth/ui" 9 | ) 10 | 11 | func (f *FileChooser) back(*ui.State) { 12 | f.set(filepath.Dir(f.path)) 13 | } 14 | -------------------------------------------------------------------------------- /toolkit/file_windows.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "syscall" 7 | 8 | "github.com/jfreymuth/ui" 9 | ) 10 | 11 | func (f *FileChooser) back(*ui.State) { 12 | dir := filepath.Dir(f.path) 13 | if dir != f.path { 14 | f.set(dir) 15 | } else { 16 | kernel32, _ := syscall.LoadLibrary("kernel32.dll") 17 | getLogicalDrivesHandle, _ := syscall.GetProcAddress(kernel32, "GetLogicalDrives") 18 | 19 | if ret, _, errno := syscall.Syscall(uintptr(getLogicalDrivesHandle), 0, 0, 0, 0); errno == 0 { 20 | f.files.Clear() 21 | for i := 0; i < 26; i++ { 22 | if ret&(1< w { 47 | w = cw 48 | } 49 | if lw > f.w { 50 | f.w = lw 51 | } 52 | if lh > f.h { 53 | f.h = lh 54 | } 55 | } 56 | return w + f.w + 15, h 57 | } 58 | 59 | func (f *Form) Update(g *draw.Buffer, state *ui.State) { 60 | f.measure(g.FontLookup) 61 | w, _ := g.Size() 62 | y := 5 63 | for _, ff := range f.Fields { 64 | _, ch := ff.Content.PreferredSize(g.FontLookup) 65 | if ch < f.h { 66 | ch = f.h 67 | } 68 | ff.label.DrawRight(g, draw.XYWH(5, y, f.w, ch), ff.Label, f.Theme.Font("text"), f.Theme.Color("text")) 69 | state.UpdateChild(g, draw.XYXY(f.w+10, y, w-5, y+ch), ff.Content) 70 | y += ch + 5 71 | } 72 | } 73 | 74 | func (f *Form) measure(fonts draw.FontLookup) { 75 | f.w, f.h = 0, 0 76 | for _, ff := range f.Fields { 77 | w, h := ff.label.Size(ff.Label, f.Theme.Font("text"), fonts) 78 | if w > f.w { 79 | f.w = w 80 | } 81 | if h > f.h { 82 | f.h = h 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /toolkit/frame.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | "github.com/jfreymuth/ui/text" 7 | ) 8 | 9 | type Frame struct { 10 | Theme *Theme 11 | Content ui.Component 12 | Color draw.Color 13 | Title string 14 | Icon string 15 | title text.Text 16 | } 17 | 18 | func NewFrame(icon, title string, content ui.Component, color draw.Color) *Frame { 19 | return &Frame{Theme: DefaultTheme, Content: content, Color: color, Title: title, Icon: icon} 20 | } 21 | 22 | func (f *Frame) PreferredSize(fonts draw.FontLookup) (int, int) { 23 | w, h := f.Content.PreferredSize(fonts) 24 | tw, th := f.title.SizeIcon(f.Title, f.Theme.Font("title"), f.Icon, 5, fonts) 25 | if w < tw { 26 | w = tw 27 | } 28 | return w + 20, h + th + 30 29 | } 30 | 31 | func (f *Frame) Update(g *draw.Buffer, state *ui.State) { 32 | w, h := g.Size() 33 | _, th := f.title.Size(f.Title, f.Theme.Font("title"), g.FontLookup) 34 | g.Shadow(draw.XYXY(12, 12, w-8, h-8), draw.RGBA(0, 0, 0, .5), 10) 35 | g.Fill(draw.XYXY(10, 10, w-10, th+20), f.Color) 36 | f.title.DrawCenteredIcon(g, draw.XYXY(15, 10, w-15, th+20), f.Title, f.Theme.Font("title"), f.Theme.Color("title"), f.Icon, 5) 37 | g.Fill(draw.XYXY(10, th+20, w-10, h-10), DefaultTheme.Color("background")) 38 | state.UpdateChild(g, draw.XYXY(10, th+20, w-10, h-10), f.Content) 39 | } 40 | -------------------------------------------------------------------------------- /toolkit/label.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | "github.com/jfreymuth/ui/text" 7 | ) 8 | 9 | type Label struct { 10 | Theme *Theme 11 | Text string 12 | text text.Text 13 | } 14 | 15 | func NewLabel(text string) *Label { 16 | return &Label{Theme: DefaultTheme, Text: text} 17 | } 18 | 19 | func (l *Label) SetTheme(theme *Theme) { l.Theme = theme } 20 | 21 | func (l *Label) PreferredSize(fonts draw.FontLookup) (int, int) { 22 | return l.text.Size(l.Text, l.Theme.Font("text"), fonts) 23 | } 24 | 25 | func (l *Label) Update(g *draw.Buffer, state *ui.State) { 26 | w, h := g.Size() 27 | l.text.DrawLeft(g, draw.WH(w, h), l.Text, l.Theme.Font("text"), l.Theme.Color("text")) 28 | } 29 | -------------------------------------------------------------------------------- /toolkit/list.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/jfreymuth/ui" 8 | "github.com/jfreymuth/ui/draw" 9 | "github.com/jfreymuth/ui/text" 10 | ) 11 | 12 | type List struct { 13 | Theme *Theme 14 | Items []ListItem 15 | Selected int 16 | Changed func(*ui.State, ListItem) 17 | Action func(*ui.State, ListItem) 18 | 19 | grab bool 20 | search string 21 | searchT time.Time 22 | } 23 | 24 | type ListItem struct { 25 | Icon string 26 | Text string 27 | text text.Text 28 | } 29 | 30 | func NewList() *List { 31 | return &List{Theme: DefaultTheme} 32 | } 33 | 34 | func (l *List) SetTheme(theme *Theme) { l.Theme = theme } 35 | 36 | func (l *List) AddItem(text string) { l.AddItemIcon("", text) } 37 | func (l *List) AddItemIcon(icon, text string) { 38 | l.Items = append(l.Items, ListItem{Icon: icon, Text: text}) 39 | } 40 | 41 | func (l *List) InsertItem(i int, text string) { l.InsertItemIcon(i, "", text) } 42 | func (l *List) InsertItemIcon(i int, icon, text string) { 43 | if i < 0 { 44 | i = 0 45 | } else if i > len(l.Items) { 46 | i = len(l.Items) 47 | } 48 | l.Items = append(l.Items, ListItem{}) 49 | copy(l.Items[i+1:], l.Items[i:]) 50 | l.Items[i] = ListItem{Icon: icon, Text: text} 51 | if l.Selected >= i { 52 | l.Selected++ 53 | } 54 | } 55 | 56 | func (l *List) RemoveItem(i int) { 57 | if i < 0 || i >= len(l.Items) { 58 | return 59 | } 60 | l.Items = append(l.Items[:i], l.Items[i+1:]...) 61 | if l.Selected > i { 62 | l.Selected-- 63 | } 64 | } 65 | 66 | func (l *List) SwapItems(i, j int) { 67 | if i < 0 || i >= len(l.Items) || j < 0 || j >= len(l.Items) { 68 | return 69 | } 70 | l.Items[i], l.Items[j] = l.Items[j], l.Items[i] 71 | if l.Selected == i { 72 | l.Selected = j 73 | } else if l.Selected == j { 74 | l.Selected = i 75 | } 76 | } 77 | 78 | func (l *List) PreferredSize(fonts draw.FontLookup) (int, int) { 79 | w, h := 0, 0 80 | font := l.Theme.Font("text") 81 | for i := range l.Items { 82 | it := &l.Items[i] 83 | iw, ih := it.text.SizeIcon(it.Text, font, it.Icon, 3, fonts) 84 | if iw > w { 85 | w = iw 86 | } 87 | if ih > h { 88 | h = ih 89 | } 90 | } 91 | return w, h * len(l.Items) 92 | } 93 | 94 | func (l *List) Update(g *draw.Buffer, state *ui.State) { 95 | w, _ := g.Size() 96 | 97 | if len(l.Items) == 0 { 98 | return 99 | } 100 | _, h := l.Items[0].text.Size(l.Items[0].Text, l.Theme.Font("text"), g.FontLookup) 101 | 102 | mouse := state.MousePos() 103 | if state.MouseButtonDown(ui.MouseLeft) { 104 | if !l.grab { 105 | l.grab = true 106 | sel := mouse.Y / h 107 | if sel >= 0 && sel < len(l.Items) { 108 | if sel == l.Selected && state.ClickCount() == 2 { 109 | if l.Action != nil { 110 | l.Action(state, l.Items[sel]) 111 | state.RequestUpdate() 112 | } 113 | } else { 114 | l.change(state, sel, h) 115 | state.ClosePopups() 116 | } 117 | } 118 | } 119 | } else { 120 | l.grab = false 121 | for _, k := range state.KeyPresses() { 122 | switch k { 123 | case ui.KeyUp: 124 | if l.Selected > 0 { 125 | l.change(state, l.Selected-1, h) 126 | } 127 | case ui.KeyDown: 128 | if l.Selected < len(l.Items)-1 { 129 | l.change(state, l.Selected+1, h) 130 | } 131 | case ui.KeySpace, ui.KeyEnter: 132 | if l.Selected >= 0 && l.Selected < len(l.Items) && l.Action != nil { 133 | l.Action(state, l.Items[l.Selected]) 134 | state.RequestUpdate() 135 | } 136 | } 137 | } 138 | if text := state.TextInput(); text != "" { 139 | now := time.Now() 140 | if now.Sub(l.searchT) > time.Second { 141 | l.search = "" 142 | } 143 | l.searchT = now 144 | l.search += text 145 | for i, item := range l.Items { 146 | ls := len(l.search) 147 | if len(item.Text) >= ls && strings.EqualFold(l.search, item.Text[:ls]) { 148 | l.change(state, i, h) 149 | break 150 | } 151 | } 152 | } 153 | } 154 | 155 | hov := state.IsHovered() && !state.MouseButtonDown(ui.MouseLeft) 156 | for i := range l.Items { 157 | item := &l.Items[i] 158 | x, y := 2, i*h 159 | r := draw.XYWH(x, y, w, h) 160 | if i == l.Selected { 161 | if state.HasKeyboardFocus() { 162 | g.Fill(r, l.Theme.Color("selection")) 163 | } else { 164 | g.Fill(r, l.Theme.Color("selectionInactive")) 165 | } 166 | } else if hov && mouse.In(r) { 167 | g.Fill(r, l.Theme.Color("buttonHovered")) 168 | } 169 | item.text.DrawLeftIcon(g, draw.XYXY(x, y, w-2, y+h), item.Text, l.Theme.Font("text"), l.Theme.Color("text"), item.Icon, 3) 170 | } 171 | } 172 | 173 | func (l *List) change(state *ui.State, i, h int) { 174 | if len(l.Items) == 0 { 175 | return 176 | } 177 | l.Selected = i 178 | if l.Selected < 0 { 179 | l.Selected = 0 180 | } else if l.Selected >= len(l.Items) { 181 | l.Selected = len(l.Items) 182 | } 183 | if l.Changed != nil { 184 | l.Changed(state, l.Items[l.Selected]) 185 | state.RequestUpdate() 186 | } 187 | state.RequestVisible(draw.XYWH(0, i*h, 1, h)) 188 | } 189 | -------------------------------------------------------------------------------- /toolkit/menu.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui" 7 | "github.com/jfreymuth/ui/draw" 8 | "github.com/jfreymuth/ui/text" 9 | ) 10 | 11 | type MenuItem struct { 12 | Theme *Theme 13 | Text string 14 | Icon string 15 | text text.Text 16 | Action func(*ui.State) 17 | parent menuParent 18 | } 19 | 20 | func (m *MenuItem) SetTheme(theme *Theme) { m.Theme = theme } 21 | 22 | func (m *MenuItem) PreferredSize(fonts draw.FontLookup) (int, int) { 23 | w, h := m.text.SizeIcon(m.Text, m.Theme.Font("text"), m.Icon, 4, fonts) 24 | return w + 10, h + 6 25 | } 26 | 27 | func (m *MenuItem) Update(g *draw.Buffer, state *ui.State) { 28 | w, h := g.Size() 29 | if state.IsHovered() { 30 | state.SetKeyboardFocus(m) 31 | drag, drop := state.DraggedContent() 32 | if drag == nil && state.MouseButtonDown(ui.MouseLeft) { 33 | state.InitiateDrag(ui.MenuDrag) 34 | drag = ui.MenuDrag 35 | } 36 | if drop && drag == ui.MenuDrag { 37 | state.ClosePopups() 38 | if m.Action != nil { 39 | m.Action(state) 40 | } 41 | } 42 | } 43 | if state.HasKeyboardFocus() { 44 | m.parent.setOpen(nil) 45 | g.Fill(draw.WH(w, h), m.Theme.Color("selection")) 46 | for _, k := range state.KeyPresses() { 47 | switch k { 48 | case ui.KeyDown: 49 | state.FocusNext() 50 | case ui.KeyUp: 51 | state.FocusPrevious() 52 | case ui.KeyLeft: 53 | state.SetKeyboardFocus(m.parent) 54 | case ui.KeySpace, ui.KeyEnter: 55 | state.ClosePopups() 56 | if m.Action != nil { 57 | m.Action(state) 58 | } 59 | } 60 | } 61 | } 62 | m.text.DrawLeftIcon(g, draw.XYXY(5, 0, w-5, h), m.Text, m.Theme.Font("text"), m.Theme.Color("text"), m.Icon, 4) 63 | } 64 | 65 | type Menu struct { 66 | Theme *Theme 67 | Text string 68 | text text.Text 69 | parent menuParent 70 | items []ui.Component 71 | open *Menu 72 | popup ui.Popup 73 | } 74 | 75 | func NewPopupMenu(text string) *Menu { 76 | return &Menu{Theme: DefaultTheme, Text: text} 77 | } 78 | 79 | func (m *Menu) SetTheme(theme *Theme) { 80 | m.Theme = theme 81 | for _, i := range m.items { 82 | SetTheme(i, theme) 83 | } 84 | } 85 | 86 | func (m *Menu) AddItem(text string, action func(*ui.State)) *MenuItem { 87 | i := &MenuItem{parent: m, Action: action, Theme: m.Theme, Text: text} 88 | m.items = append(m.items, i) 89 | return i 90 | } 91 | 92 | func (m *Menu) AddItemIcon(icon, text string, action func(*ui.State)) *MenuItem { 93 | i := &MenuItem{parent: m, Action: action, Theme: m.Theme, Text: text, Icon: icon} 94 | m.items = append(m.items, i) 95 | return i 96 | } 97 | 98 | func (m *Menu) AddMenu(text string) *Menu { 99 | menu := &Menu{parent: m, Theme: m.Theme, Text: text} 100 | m.items = append(m.items, menu) 101 | return menu 102 | } 103 | 104 | func (m *Menu) OpenPopupMenu(p image.Point, state *ui.State, fonts draw.FontLookup) { 105 | c := &menuBackground{&Stack{m.items}, m.Theme} 106 | w, h := c.PreferredSize(fonts) 107 | win := state.WindowBounds() 108 | if r := draw.XYWH(p.X, p.Y, w, h); r.In(win) { 109 | state.OpenPopup(r, c) 110 | } else if r = draw.XYWH(p.X, p.Y, w, -h); r.In(win) { 111 | state.OpenPopup(r, c) 112 | } else if r = draw.XYWH(p.X, p.Y, -w, h); r.In(win) { 113 | state.OpenPopup(r, c) 114 | } else { 115 | state.OpenPopup(draw.XYWH(p.X, p.Y, -w, -h), c) 116 | } 117 | } 118 | 119 | func (m *Menu) setOpen(p *Menu) { 120 | if m.open != nil { 121 | if m.open.popup != nil { 122 | m.open.popup.Close() 123 | } 124 | m.open.setOpen(nil) 125 | } 126 | m.open = p 127 | } 128 | 129 | func (m *Menu) isMenuBar() bool { return false } 130 | 131 | func (m *Menu) PreferredSize(fonts draw.FontLookup) (int, int) { 132 | w, h := m.text.Size(m.Text, m.Theme.Font("text"), fonts) 133 | if m.parent.isMenuBar() { 134 | return w + 10, h + 6 135 | } else { 136 | return w + h + 10, h + 6 137 | } 138 | } 139 | 140 | func (m *Menu) Update(g *draw.Buffer, state *ui.State) { 141 | submenu := !m.parent.isMenuBar() 142 | w, h := g.Size() 143 | if submenu { 144 | for _, k := range state.KeyPresses() { 145 | switch k { 146 | case ui.KeyDown: 147 | state.FocusNext() 148 | case ui.KeyUp: 149 | state.FocusPrevious() 150 | case ui.KeyRight: 151 | state.SetKeyboardFocus(m.items[0]) 152 | case ui.KeyLeft: 153 | state.SetKeyboardFocus(m.parent) 154 | } 155 | } 156 | } 157 | open := state.HasPopups() && state.IsHovered() 158 | if state.IsHovered() { 159 | g.Fill(draw.WH(w, h), m.Theme.Color("buttonHovered")) 160 | if state.MouseButtonDown(ui.MouseLeft) { 161 | state.InitiateDrag(ui.MenuDrag) 162 | open = true 163 | } 164 | } 165 | if submenu && state.HasKeyboardFocus() { 166 | open = true 167 | } 168 | if open { 169 | if !isOpen(m.popup) { 170 | popup := &menuBackground{&Stack{m.items}, m.Theme} 171 | mw, mh := popup.PreferredSize(g.FontLookup) 172 | m.parent.setOpen(m) 173 | if submenu { 174 | m.popup = state.OpenPopup(draw.XYWH(w-3, -1, mw, mh), popup) 175 | } else { 176 | m.popup = state.OpenPopup(draw.XYWH(-3, h-1, mw, mh), popup) 177 | } 178 | state.SetKeyboardFocus(m) 179 | } 180 | if m.open != nil { 181 | m.setOpen(nil) 182 | state.SetKeyboardFocus(m) 183 | } 184 | } 185 | if submenu && isOpen(m.popup) { 186 | g.Fill(draw.WH(w, h), m.Theme.Color("selection")) 187 | } 188 | m.text.DrawLeft(g, draw.XYXY(5, 0, w-5, h), m.Text, m.Theme.Font("text"), m.Theme.Color("text")) 189 | if submenu { 190 | _, th := m.text.Size(m.Text, m.Theme.Font("text"), g.FontLookup) 191 | g.Icon(draw.XYXY(w-th-5, 0, w-5, h), "right", m.Theme.Color("text")) 192 | } 193 | } 194 | 195 | type MenuBar struct { 196 | Theme *Theme 197 | menus []*Menu 198 | open *Menu 199 | popup ui.Popup 200 | } 201 | 202 | func NewMenuBar() *MenuBar { 203 | return &MenuBar{Theme: DefaultTheme} 204 | } 205 | 206 | func (m *MenuBar) SetTheme(theme *Theme) { 207 | m.Theme = theme 208 | for _, m := range m.menus { 209 | m.SetTheme(theme) 210 | } 211 | } 212 | 213 | func (m *MenuBar) AddMenu(name string) *Menu { 214 | menu := &Menu{parent: m, Theme: m.Theme, Text: name} 215 | m.menus = append(m.menus, menu) 216 | return menu 217 | } 218 | 219 | func (m *MenuBar) setOpen(p *Menu) { 220 | if m.open != nil { 221 | if m.open.popup != nil { 222 | m.open.popup.Close() 223 | } 224 | m.open.setOpen(nil) 225 | } 226 | m.open = p 227 | } 228 | 229 | func (m *MenuBar) isMenuBar() bool { return true } 230 | 231 | func (m *MenuBar) PreferredSize(fonts draw.FontLookup) (int, int) { 232 | w := 0 233 | h := 0 234 | for _, m := range m.menus { 235 | mw, mh := m.PreferredSize(fonts) 236 | w += mw 237 | if mh > h { 238 | h = mh 239 | } 240 | 241 | } 242 | return w, h 243 | } 244 | 245 | func (m *MenuBar) Update(g *draw.Buffer, state *ui.State) { 246 | w, h := g.Size() 247 | g.Fill(draw.WH(w, h), m.Theme.Color("altBackground")) 248 | x := 0 249 | for _, menu := range m.menus { 250 | mw, _ := menu.PreferredSize(g.FontLookup) 251 | state.UpdateChild(g, draw.XYWH(x, 0, mw, h), menu) 252 | x += mw 253 | } 254 | if m.open != nil && isOpen(m.open.popup) && !isOpen(m.popup) { 255 | m.popup = state.OpenPopup(draw.WH(w, h), m) 256 | } 257 | if state.MouseClick(ui.MouseLeft) { 258 | state.ClosePopups() 259 | } 260 | } 261 | 262 | type menuParent interface { 263 | ui.Component 264 | setOpen(*Menu) 265 | isMenuBar() bool 266 | } 267 | 268 | type menuBackground struct { 269 | Content ui.Component 270 | theme *Theme 271 | } 272 | 273 | func (m *menuBackground) PreferredSize(fonts draw.FontLookup) (int, int) { 274 | w, h := m.Content.PreferredSize(fonts) 275 | return w + 6, h + 6 276 | } 277 | 278 | func (m *menuBackground) Update(g *draw.Buffer, state *ui.State) { 279 | w, h := g.Size() 280 | g.Shadow(draw.XYXY(3, 3, w-3, h-3), m.theme.Color("shadow"), 4) 281 | g.Fill(draw.XYXY(3, 1, w-3, h-5), m.theme.Color("background")) 282 | state.UpdateChild(g, draw.XYXY(3, 1, w-3, h-5), m.Content) 283 | } 284 | 285 | func isOpen(p ui.Popup) bool { 286 | return p != nil && !p.Closed() 287 | } 288 | -------------------------------------------------------------------------------- /toolkit/root.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui" 7 | "github.com/jfreymuth/ui/draw" 8 | ) 9 | 10 | type Root struct { 11 | Content ui.Component 12 | Dialog ui.Component 13 | Theme *Theme 14 | popups []*popup 15 | } 16 | 17 | type popup struct { 18 | ui.Component 19 | bounds image.Rectangle 20 | closed bool 21 | } 22 | 23 | func NewRoot(content ui.Component) *Root { 24 | return &Root{Content: content, Theme: DefaultTheme} 25 | } 26 | 27 | func (r *Root) SetTheme(theme *Theme) { 28 | r.Theme = theme 29 | SetTheme(r.Content, theme) 30 | SetTheme(r.Dialog, theme) 31 | } 32 | 33 | func (r *Root) OpenDialog(dialog ui.Component) { 34 | r.Dialog = dialog 35 | } 36 | 37 | func (r *Root) CloseDialog() { 38 | r.Dialog = nil 39 | } 40 | 41 | func (r *Root) OpenPopup(bounds image.Rectangle, p ui.Component) ui.Popup { 42 | popup := &popup{p, bounds, false} 43 | r.popups = append(r.popups, popup) 44 | return popup 45 | } 46 | 47 | func (r *Root) ClosePopups() { 48 | for _, p := range r.popups { 49 | p.closed = true 50 | } 51 | r.popups = nil 52 | } 53 | 54 | func (r *Root) HasPopups() bool { 55 | return r.popups != nil 56 | } 57 | 58 | func (p *popup) Close() { p.closed = true } 59 | func (p *popup) Closed() bool { return p.closed } 60 | 61 | func (r *Root) PreferredSize(fonts draw.FontLookup) (int, int) { 62 | return r.Content.PreferredSize(fonts) 63 | } 64 | 65 | func (r *Root) Update(g *draw.Buffer, state *ui.State) { 66 | state.SetRoot(r) 67 | w, h := g.Size() 68 | g.Fill(draw.WH(w, h), r.Theme.Color("background")) 69 | if r.Dialog == nil { 70 | if len(r.popups) == 0 { 71 | state.UpdateChild(g, draw.WH(w, h), r.Content) 72 | } else { 73 | state.DrawChild(g, draw.WH(w, h), r.Content) 74 | } 75 | } else { 76 | state.DrawChild(g, draw.WH(w, h), r.Content) 77 | g.Fill(draw.WH(w, h), r.Theme.Color("veil")) 78 | dw, dh := r.Dialog.PreferredSize(g.FontLookup) 79 | if dw > w*7/8 { 80 | dw = w * 7 / 8 81 | } 82 | if dh > h*7/8 { 83 | dh = h * 7 / 8 84 | } 85 | dx, dy := (w-dw)/2, (h-dh)/2 86 | if len(r.popups) == 0 { 87 | state.UpdateChild(g, draw.XYWH(dx, dy, dw, dh), r.Dialog) 88 | } else { 89 | state.DrawChild(g, draw.XYWH(dx, dy, dw, dh), r.Dialog) 90 | } 91 | } 92 | if r.popups != nil { 93 | allClosed := true 94 | for _, p := range r.popups { 95 | if !p.closed { 96 | state.UpdateChild(g, p.bounds, p.Component) 97 | allClosed = false 98 | } 99 | } 100 | if allClosed { 101 | r.popups = nil 102 | } 103 | if state.MouseButtonDown(ui.MouseLeft) || state.MouseButtonDown(ui.MouseRight) { 104 | state.ClosePopups() 105 | state.RequestRefocus() 106 | } 107 | } 108 | if drag, drop := state.DraggedContent(); drag != nil && !drop { 109 | mouse := state.MousePos() 110 | switch drag := drag.(type) { 111 | case string: 112 | g.Text(mouse, drag, r.Theme.Color("text"), r.Theme.Font("text")) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /toolkit/scroll.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui" 7 | "github.com/jfreymuth/ui/draw" 8 | ) 9 | 10 | type scrollBar struct { 11 | *ScrollView 12 | Max, Size, Value float32 13 | Vertical bool 14 | grab int 15 | } 16 | 17 | func (s *scrollBar) SetValue(v float32) { 18 | if v < 0 { 19 | s.Value = 0 20 | } else if v > s.Max { 21 | s.Value = s.Max 22 | } else { 23 | s.Value = v 24 | } 25 | } 26 | 27 | func (s *scrollBar) PreferredSize(fonts draw.FontLookup) (int, int) { 28 | if s.Vertical { 29 | return 15, 0 30 | } else { 31 | return 0, 15 32 | } 33 | } 34 | 35 | func (s *scrollBar) Update(g *draw.Buffer, state *ui.State) { 36 | w, h := g.Size() 37 | l := 0 38 | if s.Vertical { 39 | l = h 40 | } else { 41 | l = w 42 | } 43 | b := int(s.Size / (s.Max + s.Size) * float32(l)) 44 | if b < 30 { 45 | b = 30 46 | } else if b > l { 47 | b = l 48 | } 49 | o := int(s.Value / s.Max * float32(l-b)) 50 | if state.MouseButtonDown(ui.MouseLeft) { 51 | m := 0 52 | if s.Vertical { 53 | m = state.MousePos().Y 54 | } else { 55 | m = state.MousePos().X 56 | } 57 | if s.grab < 0 { 58 | if m >= o && m < o+b { 59 | s.grab = m - o 60 | } else { 61 | s.grab = b / 2 62 | } 63 | } 64 | c := float32(m-s.grab) / float32(l-b) 65 | if c < 0 { 66 | s.Value = 0 67 | } else if c > 1 { 68 | s.Value = s.Max 69 | } else { 70 | s.Value = c * s.Max 71 | } 72 | } else { 73 | s.grab = -1 74 | } 75 | o = int(s.Value / s.Max * float32(l-b)) 76 | g.Outline(draw.WH(w, h), s.Theme.Color("border")) 77 | if s.Vertical { 78 | g.Fill(draw.XYWH(1, o, w-2, b), s.Theme.Color("scrollBar")) 79 | } else { 80 | g.Fill(draw.XYWH(o, 1, b, h-2), s.Theme.Color("scrollBar")) 81 | } 82 | } 83 | 84 | type ScrollView struct { 85 | content ui.Component 86 | Theme *Theme 87 | sh, sv scrollBar 88 | viewport 89 | } 90 | 91 | func NewScrollView(content ui.Component) *ScrollView { 92 | s := &ScrollView{content: content, Theme: DefaultTheme} 93 | s.sh = scrollBar{s, 1, 0, 0, false, -1} 94 | s.sv = scrollBar{s, 1, 0, 0, true, -1} 95 | s.viewport.ScrollView = s 96 | return s 97 | } 98 | 99 | func (s *ScrollView) SetTheme(theme *Theme) { 100 | s.Theme = theme 101 | SetTheme(s.content, theme) 102 | } 103 | 104 | func (s *ScrollView) PreferredSize(fonts draw.FontLookup) (int, int) { 105 | w, h := s.content.PreferredSize(fonts) 106 | return w + 15, h 107 | } 108 | func (s *ScrollView) Update(g *draw.Buffer, state *ui.State) { 109 | w, h := g.Size() 110 | s.w, s.h = s.content.PreferredSize(g.FontLookup) 111 | showH, showV := false, false 112 | if s.w > w { 113 | showH = true 114 | h -= 15 115 | } 116 | if s.h > h { 117 | showV = true 118 | w -= 15 119 | } 120 | if !showH && s.w > w { 121 | showH = true 122 | h -= 15 123 | } 124 | vv, hv := s.sv.Value, s.sh.Value 125 | if showH { 126 | s.sh.Max = float32(s.w - w) 127 | s.sh.Size = float32(w) 128 | s.sh.SetValue(s.sh.Value) 129 | state.UpdateChild(g, draw.XYWH(0, h, w, 15), &s.sh) 130 | } 131 | if showV { 132 | s.sv.Max = float32(s.h - h) 133 | s.sv.Size = float32(h) 134 | s.sv.SetValue(s.sv.Value) 135 | state.UpdateChild(g, draw.XYWH(w, 0, 15, h), &s.sv) 136 | } 137 | state.UpdateChild(g, draw.WH(w, h), &s.viewport) 138 | if r, ok := state.GetVisibilityRequest(); ok { 139 | if r.Dx() <= w { 140 | if r.Min.X < 0 { 141 | s.sh.SetValue(s.sh.Value + float32(r.Min.X)) 142 | } else if r.Max.X > w { 143 | s.sh.SetValue(s.sh.Value + float32(r.Max.X-w)) 144 | } 145 | } 146 | if r.Dy() <= h { 147 | if r.Min.Y < 0 { 148 | if r.Max.Y < h { 149 | s.sv.SetValue(s.sv.Value + float32(r.Min.Y)) 150 | } 151 | } else if r.Max.Y > h { 152 | s.sv.SetValue(s.sv.Value + float32(r.Max.Y-h)) 153 | } 154 | } 155 | } 156 | if scroll := state.Scroll(); scroll != (image.Point{}) { 157 | if showH && (scroll.X > 0 && s.sh.Value > 0 || scroll.X < 0 && s.sh.Value < s.sh.Max) || 158 | showV && (scroll.Y > 0 && s.sv.Value > 0 || scroll.Y < 0 && s.sv.Value < s.sv.Max) { 159 | state.ConsumeScroll() 160 | } 161 | s.sh.SetValue(s.sh.Value - float32(scroll.X*45)) 162 | s.sv.SetValue(s.sv.Value - float32(scroll.Y*45)) 163 | } 164 | if s.sv.Value != vv || s.sh.Value != hv { 165 | state.RequestUpdate() 166 | } 167 | } 168 | 169 | type viewport struct { 170 | w, h int 171 | *ScrollView 172 | } 173 | 174 | func (viewport) PreferredSize(fonts draw.FontLookup) (int, int) { return 0, 0 } 175 | func (v *viewport) Update(g *draw.Buffer, s *ui.State) { 176 | x := -int(v.sh.Value) 177 | y := -int(v.sv.Value) 178 | w, h := g.Size() 179 | if w >= v.w { 180 | x, v.w = 0, w 181 | } 182 | if h >= v.h { 183 | y, v.h = 0, h 184 | } 185 | s.UpdateChild(g, draw.XYWH(x, y, v.w, v.h), v.content) 186 | } 187 | -------------------------------------------------------------------------------- /toolkit/stack.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | ) 7 | 8 | type Stack struct { 9 | Components []ui.Component 10 | } 11 | 12 | func NewStack(c ...ui.Component) *Stack { 13 | return &Stack{Components: c} 14 | } 15 | 16 | func (s *Stack) SetTheme(theme *Theme) { 17 | for _, c := range s.Components { 18 | SetTheme(c, theme) 19 | } 20 | } 21 | 22 | func (s *Stack) PreferredSize(fonts draw.FontLookup) (int, int) { 23 | w, h := 0, 0 24 | for _, c := range s.Components { 25 | cw, ch := c.PreferredSize(fonts) 26 | if cw > w { 27 | w = cw 28 | } 29 | h += ch 30 | } 31 | return w, h 32 | } 33 | 34 | func (s *Stack) Update(g *draw.Buffer, state *ui.State) { 35 | w, _ := g.Size() 36 | y := 0 37 | for _, c := range s.Components { 38 | _, ch := c.PreferredSize(g.FontLookup) 39 | state.UpdateChild(g, draw.XYWH(0, y, w, ch), c) 40 | y += ch 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /toolkit/tab.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui" 7 | "github.com/jfreymuth/ui/draw" 8 | "github.com/jfreymuth/ui/text" 9 | ) 10 | 11 | type Tab struct { 12 | Title string 13 | Content ui.Component 14 | Close func(*ui.State, int) 15 | title text.Text 16 | } 17 | 18 | type TabContainer struct { 19 | Tabs []Tab 20 | Selected int 21 | Changed func(*ui.State, int) 22 | Theme *Theme 23 | close tabCloseButton 24 | } 25 | 26 | func NewTabContainer() *TabContainer { 27 | t := &TabContainer{Theme: DefaultTheme} 28 | t.close.t = t 29 | return t 30 | } 31 | 32 | func (t *TabContainer) SetTheme(theme *Theme) { 33 | t.Theme = theme 34 | for _, t := range t.Tabs { 35 | SetTheme(t.Content, theme) 36 | } 37 | } 38 | 39 | func (t *TabContainer) AddTab(title string, content ui.Component) { 40 | t.Tabs = append(t.Tabs, Tab{Title: title, Content: content}) 41 | } 42 | 43 | func (t *TabContainer) AddClosableTab(title string, content ui.Component, close func(*ui.State, int)) { 44 | if close == nil { 45 | close = func(state *ui.State, i int) { t.CloseTab(i) } 46 | } 47 | t.Tabs = append(t.Tabs, Tab{Title: title, Content: content, Close: close}) 48 | } 49 | 50 | func (t *TabContainer) CloseTab(i int) { 51 | if i < 0 || i >= len(t.Tabs) { 52 | return 53 | } 54 | t.Tabs = append(t.Tabs[:i], t.Tabs[i+1:]...) 55 | } 56 | 57 | func (t *TabContainer) PreferredSize(fonts draw.FontLookup) (int, int) { 58 | hw, hh := 20, 0 59 | cw, ch := 0, 0 60 | for _, tab := range t.Tabs { 61 | w, h := tab.title.Size(tab.Title, t.Theme.Font("title"), fonts) 62 | hw += w + 20 63 | if tab.Close != nil { 64 | hw += h 65 | } 66 | hh = h 67 | tw, th := tab.Content.PreferredSize(fonts) 68 | if tw > cw { 69 | cw = tw 70 | } 71 | if th > ch { 72 | ch = th 73 | } 74 | } 75 | if cw+20 > hw { 76 | hw = cw + 20 77 | } 78 | return hw, hh + 20 + ch + 20 79 | } 80 | 81 | func (t *TabContainer) Update(g *draw.Buffer, state *ui.State) { 82 | if len(t.Tabs) == 0 { 83 | return 84 | } 85 | if t.Selected < 0 { 86 | t.Selected = 0 87 | } else if t.Selected >= len(t.Tabs) { 88 | t.Selected = len(t.Tabs) - 1 89 | } 90 | w, h := g.Size() 91 | hh, _ := t.Tabs[0].title.Size(t.Tabs[0].Title, t.Theme.Font("title"), g.FontLookup) 92 | contentArea := draw.XYXY(10, hh+10, w-10, h-10) 93 | x := 0 94 | mouse := state.MousePos() 95 | close := -1 96 | for i, tab := range t.Tabs { 97 | hw, _ := tab.title.Size(tab.Title, t.Theme.Font("title"), g.FontLookup) 98 | w := hw + 20 99 | if tab.Close != nil { 100 | w += hh 101 | } 102 | rect := draw.XYWH(x+10, 10, w, hh) 103 | if i != t.Selected && mouse.In(rect) && state.MouseClick(ui.MouseLeft) { 104 | t.Selected = i 105 | if t.Changed != nil { 106 | t.Changed(state, i) 107 | } 108 | state.RequestUpdate() 109 | return 110 | } 111 | if i == t.Selected { 112 | g.Shadow(rect.Add(image.Pt(2, 2)), t.Theme.Color("shadow"), 10) 113 | g.Shadow(contentArea.Add(image.Pt(2, 2)), t.Theme.Color("shadow"), 10) 114 | g.Fill(rect, t.Theme.Color("background")) 115 | if tab.Close != nil { 116 | t.close.click = false 117 | state.UpdateChild(g, draw.XYWH(x+hw+30, 10, hh, hh), &t.close) 118 | if t.close.click { 119 | close = i 120 | } 121 | } 122 | } else if i != 0 && i != t.Selected+1 { 123 | g.Fill(draw.XYWH(x+10, 10, 1, hh), t.Theme.Color("veil")) 124 | } 125 | tab.title.DrawLeft(g, draw.XYWH(x+20, 0, hw, hh+20), tab.Title, t.Theme.Font("title"), t.Theme.Color("title")) 126 | x += w 127 | } 128 | g.Fill(contentArea, t.Theme.Color("background")) 129 | state.UpdateChild(g, contentArea, t.Tabs[t.Selected].Content) 130 | if close >= 0 { 131 | t.Tabs[close].Close(state, close) 132 | state.RequestUpdate() 133 | } 134 | } 135 | 136 | type tabCloseButton struct { 137 | t *TabContainer 138 | click bool 139 | } 140 | 141 | func (*tabCloseButton) PreferredSize(draw.FontLookup) (int, int) { return 0, 0 } 142 | func (b *tabCloseButton) Update(g *draw.Buffer, state *ui.State) { 143 | color := b.t.Theme.Color("buttonText") 144 | if state.IsHovered() { 145 | color = b.t.Theme.Color("buttonFocused") 146 | } 147 | g.Icon(draw.WH(g.Size()), "close", color) 148 | if state.MouseClick(ui.MouseLeft) { 149 | b.click = true 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /toolkit/textarea.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "bufio" 5 | "image" 6 | "io" 7 | "strings" 8 | "unicode/utf8" 9 | 10 | "github.com/jfreymuth/ui" 11 | "github.com/jfreymuth/ui/draw" 12 | "github.com/jfreymuth/ui/text" 13 | ) 14 | 15 | type TextArea struct { 16 | Editable bool 17 | Theme *Theme 18 | Font, font draw.Font 19 | text []string 20 | cx int 21 | w, h, b int 22 | cursor cursor 23 | selectionStart cursor 24 | changedLine int 25 | ll, sll, slll int 26 | lastX, lastY int 27 | state byte 28 | scr bool 29 | changed bool 30 | popup Menu 31 | } 32 | 33 | func NewTextArea() *TextArea { 34 | t := &TextArea{Theme: DefaultTheme, Font: DefaultTheme.Font("inputText"), h: -1, text: []string{""}, Editable: true} 35 | t.popup = *NewPopupMenu("") 36 | t.popup.AddItem("Cut", t.Cut) 37 | t.popup.AddItem("Copy", t.Copy) 38 | t.popup.AddItem("Paste", t.Paste) 39 | return t 40 | } 41 | 42 | type cursor struct { 43 | line, col int 44 | } 45 | 46 | func (t *TextArea) SetTheme(theme *Theme) { 47 | t.Theme = theme 48 | t.popup.SetTheme(theme) 49 | } 50 | func (t *TextArea) Text() string { return strings.Join(t.text, "\n") } 51 | func (t *TextArea) SetText(text string) { 52 | t.text = strings.Split(strings.Replace(text, "\t", " ", -1), "\n") 53 | t.textReplaced() 54 | } 55 | 56 | func (t *TextArea) Lines() []string { 57 | return append(make([]string, 0, len(t.text)), t.text...) 58 | } 59 | 60 | func (t *TextArea) SetLines(l []string) { 61 | if len(l) == 0 { 62 | t.text = []string{""} 63 | } else { 64 | t.text = append(t.text[:0], l...) 65 | } 66 | t.textReplaced() 67 | } 68 | 69 | func (t *TextArea) Append(text string) { 70 | t.text = append(t.text, strings.Split(strings.Replace(text, "\t", " ", -1), "\n")...) 71 | } 72 | 73 | func (t *TextArea) SetTextFromReader(r io.Reader) error { 74 | t.text = nil 75 | return t.AppendFromReader(r) 76 | } 77 | 78 | func (t *TextArea) AppendFromReader(r io.Reader) error { 79 | sc := bufio.NewScanner(r) 80 | for sc.Scan() { 81 | t.text = append(t.text, strings.Replace(sc.Text(), "\t", " ", -1)) 82 | } 83 | t.text = append(t.text, strings.Replace(sc.Text(), "\t", " ", -1)) 84 | t.textReplaced() 85 | return sc.Err() 86 | } 87 | 88 | func (t *TextArea) WriteTextTo(w io.Writer) error { 89 | text := t.text 90 | if t.text[len(t.text)-1] == "" { 91 | text = t.text[:len(t.text)-1] 92 | } 93 | for _, l := range text { 94 | _, err := io.WriteString(w, l) 95 | if err != nil { 96 | return err 97 | } 98 | _, err = io.WriteString(w, "\n") 99 | if err != nil { 100 | return err 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | func (t *TextArea) ReplaceSelection(text string) { 107 | s1, _ := t.selection() 108 | t.insert(text) 109 | t.selectionStart = s1 110 | } 111 | 112 | func (t *TextArea) textReplaced() { 113 | t.cursor = cursor{len(t.text) - 1, len(t.text[len(t.text)-1])} 114 | t.selectionStart = t.cursor 115 | t.h = -1 116 | t.cx = -1 117 | t.changed = true 118 | } 119 | 120 | func (t *TextArea) Changed() bool { 121 | c := t.changed 122 | t.changed = false 123 | return c 124 | } 125 | 126 | func (t *TextArea) PreferredSize(fonts draw.FontLookup) (int, int) { 127 | t.measure(nil, fonts) 128 | w, h := t.w, t.h*len(t.text) 129 | if w < 200 { 130 | w = 200 131 | } 132 | if len(t.text) < 3 { 133 | h = t.h * 3 134 | } 135 | return w + 4, h + 4 136 | } 137 | 138 | func (t *TextArea) Update(g *draw.Buffer, state *ui.State) { 139 | state.DisableTabFocus() 140 | state.SetCursor(ui.CursorText) 141 | t.handleKeyEvents(state, g.FontLookup) 142 | t.hanldeMouseEvents(state, g.FontLookup) 143 | t.measure(state, g.FontLookup) 144 | w, h := g.Size() 145 | m := g.FontLookup.Metrics(t.font) 146 | 147 | g.Fill(draw.WH(w, h), t.Theme.Color("inputBackground")) 148 | if t.Editable && state.HasKeyboardFocus() { 149 | g.Outline(draw.WH(w, h), t.Theme.Color("border")) 150 | } 151 | s1, s2 := t.selection() 152 | 153 | x := 2 154 | y := 2 + s1.line*t.h 155 | cx, cy := -1, -1 156 | x += int(m.Advance(t.text[s1.line][:s1.col])) 157 | if state.HasKeyboardFocus() && s1 == t.cursor { 158 | if t.cx < 0 { 159 | t.cx = x 160 | t.scr = true 161 | } 162 | t.scroll(state, x) 163 | cx, cy = x, y 164 | } 165 | if s1.line == s2.line { 166 | adv := m.Advance(t.text[s1.line][s1.col:s2.col]) 167 | if s1.col != s2.col { 168 | if state.HasKeyboardFocus() { 169 | g.Fill(draw.XYWH(x, y, int(adv), t.h), t.Theme.Color("selection")) 170 | } else { 171 | g.Fill(draw.XYWH(x, y, int(adv), t.h), t.Theme.Color("selectionInactive")) 172 | } 173 | } 174 | x += int(adv) 175 | } else { 176 | var color draw.Color 177 | if state.HasKeyboardFocus() { 178 | color = t.Theme.Color("selection") 179 | } else { 180 | color = t.Theme.Color("selectionInactive") 181 | } 182 | g.Fill(draw.XYXY(x, y, w-2, y+t.h), color) 183 | g.Fill(draw.XYWH(2, 2+(s1.line+1)*t.h, w-4, (s2.line-s1.line-1)*t.h), color) 184 | x, y = 2, 2+s2.line*t.h 185 | adv := m.Advance(t.text[s2.line][:s2.col]) 186 | g.Fill(draw.XYWH(x, y, int(adv), t.h), color) 187 | x += int(adv) 188 | } 189 | if state.HasKeyboardFocus() && s2 == t.cursor { 190 | if t.cx < 0 { 191 | t.cx = int(x) 192 | t.scr = true 193 | } 194 | t.scroll(state, int(x)) 195 | cx, cy = x, y 196 | } 197 | for i := range t.text { 198 | g.Text(image.Pt(2, 2+i*t.h+t.b), t.text[i], t.Theme.Color("inputText"), t.font) 199 | } 200 | if t.Editable && cx >= 0 && state.Blink() { 201 | g.Fill(draw.XYWH(cx-1, cy, 2, t.h), t.Theme.Color("inputText")) 202 | } 203 | } 204 | 205 | func (t *TextArea) hanldeMouseEvents(state *ui.State, fonts draw.FontLookup) { 206 | mouse := state.MousePos() 207 | drag, drop := state.DraggedContent() 208 | if drag, ok := drag.(string); ok { 209 | t.cursor = t.getCursor(fonts, mouse) 210 | t.selectionStart = t.cursor 211 | state.SetBlink() 212 | if drop { 213 | t.insert(drag) 214 | } 215 | t.state = tfIdle 216 | return 217 | } 218 | if !state.HasMouseFocus() { 219 | t.state = tfIdle 220 | return 221 | } 222 | if state.MouseButtonDown(ui.MouseLeft) { 223 | c := t.getCursor(fonts, mouse) 224 | if t.state == tfDrag { 225 | if !t.inSelection(c) { 226 | state.InitiateDrag(t.SelectedText()) 227 | t.insert("") 228 | } 229 | } else if t.state == tfIdle && state.ClickCount() == 1 && t.inSelection(c) { 230 | t.state = tfDrag 231 | } else { 232 | if t.lastX != mouse.X || t.lastY != mouse.Y { 233 | t.cursor = c 234 | t.cx = mouse.X 235 | t.scr = true 236 | if t.state == tfIdle { 237 | t.state = tfSelect 238 | t.selectionStart = t.cursor 239 | } 240 | } else if t.state == tfIdle { 241 | switch state.ClickCount() % 3 { 242 | case 1: 243 | t.selectionStart, t.cursor = c, c 244 | case 2: 245 | t.selectionStart.line, t.cursor.line = c.line, c.line 246 | t.selectionStart.col, t.cursor.col = text.FindWord(t.text[c.line], c.col) 247 | t.state = tfSelect 248 | case 0: 249 | t.selectLine() 250 | t.state = tfSelect 251 | } 252 | } 253 | state.SetBlink() 254 | t.lastX, t.lastY = mouse.X, mouse.Y 255 | } 256 | } else { 257 | if t.state == tfDrag { 258 | t.cursor = t.getCursor(fonts, mouse) 259 | t.selectionStart = t.cursor 260 | state.SetBlink() 261 | } 262 | t.state = tfIdle 263 | } 264 | if state.MouseButtonDown(ui.MouseRight) { 265 | c := t.getCursor(fonts, mouse) 266 | if !t.inSelection(c) && c != t.cursor && c != t.selectionStart { 267 | t.cursor, t.selectionStart = c, c 268 | } 269 | t.popup.OpenPopupMenu(mouse, state, fonts) 270 | state.InitiateDrag(ui.MenuDrag) 271 | } 272 | } 273 | 274 | func (t *TextArea) handleKeyEvents(state *ui.State, fonts draw.FontLookup) { 275 | if text := state.TextInput(); text != "" { 276 | t.insert(text) 277 | state.SetBlink() 278 | } 279 | for _, k := range state.KeyPresses() { 280 | switch k { 281 | case ui.KeyLeft: 282 | t.cursor = t.prev(state) 283 | t.cx = -1 284 | case ui.KeyRight: 285 | t.cursor = t.next(state) 286 | t.cx = -1 287 | case ui.KeyUp: 288 | if t.cursor.line > 0 { 289 | t.cursor.line-- 290 | t.cursor.col = t.findPosition(fonts, t.cursor.line, t.cx) 291 | } else { 292 | t.cursor.col = 0 293 | } 294 | t.scr = true 295 | case ui.KeyDown: 296 | if t.cursor.line < len(t.text)-1 { 297 | t.cursor.line++ 298 | t.cursor.col = t.findPosition(fonts, t.cursor.line, t.cx) 299 | } else { 300 | t.cursor.col = len(t.text[t.cursor.line]) 301 | } 302 | t.scr = true 303 | case ui.KeyHome: 304 | t.cursor.col = 0 305 | t.cx = -1 306 | case ui.KeyEnd: 307 | t.cursor.col = len(t.text[t.cursor.line]) 308 | t.cx = -1 309 | case ui.KeyBackspace: 310 | if t.Editable && t.cursor == t.selectionStart { 311 | t.cursor = t.prev(state) 312 | } 313 | t.insert("") 314 | case ui.KeyDelete: 315 | if t.Editable && t.cursor == t.selectionStart { 316 | t.cursor = t.next(state) 317 | } 318 | t.insert("") 319 | case ui.KeyEnter: 320 | t.insert("\n") 321 | case ui.KeyTab: 322 | t.insert("\t") 323 | case ui.KeyMenu: 324 | t.popup.OpenPopupMenu(image.Pt(t.cx, (t.cursor.line+1)*t.h), state, fonts) 325 | continue 326 | default: 327 | continue 328 | } 329 | if !state.HasModifiers(ui.Shift) { 330 | t.selectionStart = t.cursor 331 | } 332 | state.SetBlink() 333 | } 334 | } 335 | 336 | func (t *TextArea) SelectAll(state *ui.State) { 337 | t.selectionStart = cursor{0, 0} 338 | t.cursor = cursor{len(t.text) - 1, len(t.text[len(t.text)-1])} 339 | } 340 | 341 | func (t *TextArea) Cut(state *ui.State) { 342 | state.SetClipboardString(t.SelectedText()) 343 | t.insert("") 344 | } 345 | 346 | func (t *TextArea) Copy(state *ui.State) { 347 | state.SetClipboardString(t.SelectedText()) 348 | } 349 | 350 | func (t *TextArea) Paste(state *ui.State) { 351 | t.insert(state.ClipboardString()) 352 | } 353 | 354 | func (t *TextArea) scroll(state *ui.State, x int) { 355 | if t.scr { 356 | state.RequestVisible(draw.XYWH(x+2-t.h*2, (t.cursor.line-1)*t.h+2, t.h*4, t.h*3)) 357 | t.scr = false 358 | } 359 | } 360 | 361 | func (t *TextArea) measure(state *ui.State, fonts draw.FontLookup) { 362 | if t.h < 0 || t.Font != t.font { 363 | t.font = t.Font 364 | m := fonts.Metrics(t.font) 365 | t.h = m.LineHeight() 366 | t.b = (t.h + m.Ascent() - m.Descent()) / 2 367 | t.w = 200 368 | t.slll = 0 369 | for i, l := range t.text { 370 | wf := int(m.Advance(l)) 371 | w := int(wf) 372 | if w > t.w { 373 | t.slll = t.w 374 | t.sll = t.ll 375 | t.w = w 376 | t.ll = i 377 | } else if w > t.slll { 378 | t.slll = w 379 | t.sll = i 380 | } 381 | } 382 | t.changedLine = -1 383 | if state != nil { 384 | state.RequestUpdate() 385 | } 386 | } else if t.changedLine != -1 { 387 | m := fonts.Metrics(t.font) 388 | cll := int(m.Advance(t.text[t.changedLine])) 389 | if t.changedLine == t.ll { 390 | if cll >= t.slll { 391 | t.w = cll 392 | } else { 393 | t.h = -1 394 | } 395 | } else if t.changedLine == t.sll { 396 | if cll < t.sll { 397 | t.h = -1 398 | } else if cll > t.w { 399 | t.ll, t.sll = t.sll, t.ll 400 | t.slll = t.w 401 | t.w = cll 402 | } 403 | } else { 404 | if cll > t.slll { 405 | if cll > t.w { 406 | t.slll = t.w 407 | t.sll = t.ll 408 | t.w = cll 409 | t.ll = t.changedLine 410 | } else { 411 | t.slll = cll 412 | t.sll = t.changedLine 413 | } 414 | } 415 | } 416 | t.changedLine = -1 417 | t.measure(state, fonts) 418 | } 419 | } 420 | 421 | func (t *TextArea) lineChanged(l int) { 422 | if t.changedLine == -1 { 423 | t.changedLine = l 424 | } else if t.changedLine != l { 425 | t.h = -1 426 | } 427 | } 428 | 429 | func (t *TextArea) getCursor(fonts draw.FontLookup, p image.Point) cursor { 430 | line := (p.Y - 2) / t.h 431 | if line < 0 { 432 | return cursor{0, 0} 433 | } else if line >= len(t.text) { 434 | return cursor{len(t.text) - 1, len(t.text[len(t.text)-1])} 435 | } 436 | col := t.findPosition(fonts, line, p.X) 437 | return cursor{line, col} 438 | } 439 | 440 | func (t *TextArea) findPosition(fonts draw.FontLookup, line int, x int) int { 441 | return fonts.Metrics(t.font).Index(t.text[line], float32(x-2)) 442 | } 443 | 444 | func (t *TextArea) selection() (cursor, cursor) { 445 | if t.selectionStart.line < t.cursor.line { 446 | return t.selectionStart, t.cursor 447 | } 448 | if t.selectionStart.line == t.cursor.line && t.selectionStart.col < t.cursor.col { 449 | return t.selectionStart, t.cursor 450 | } 451 | return t.cursor, t.selectionStart 452 | } 453 | 454 | func (t *TextArea) inSelection(c cursor) bool { 455 | s1, s2 := t.selection() 456 | if c.line < s1.line { 457 | return false 458 | } else if c.line == s1.line && c.col <= s1.col { 459 | return false 460 | } else if c.line > s2.line { 461 | return false 462 | } else if c.line == s2.line && c.col >= s2.col { 463 | return false 464 | } 465 | return true 466 | } 467 | 468 | func (t *TextArea) SelectedText() string { 469 | s1, s2 := t.selection() 470 | if s1.line == s2.line { 471 | return t.text[s1.line][s1.col:s2.col] 472 | } 473 | if s1.line+1 == s2.line { 474 | return t.text[s1.line][s1.col:] + "\n" + t.text[s2.line][:s2.col] 475 | } 476 | return t.text[s1.line][s1.col:] + "\n" + strings.Join(t.text[s1.line+1:s2.line], "\n") + "\n" + t.text[s2.line][:s2.col] 477 | } 478 | 479 | func (t *TextArea) next(state *ui.State) cursor { 480 | c := t.cursor 481 | line := t.text[c.line] 482 | if c.col == len(line) { 483 | if c.line == len(t.text)-1 { 484 | return c 485 | } 486 | return cursor{c.line + 1, 0} 487 | } 488 | if state.HasModifiers(ui.Control) { 489 | return cursor{c.line, text.NextWord(line, c.col)} 490 | } else { 491 | _, size := utf8.DecodeRuneInString(t.text[c.line][c.col:]) 492 | return cursor{c.line, c.col + size} 493 | } 494 | } 495 | 496 | func (t *TextArea) prev(state *ui.State) cursor { 497 | c := t.cursor 498 | line := t.text[c.line] 499 | if c.col == 0 { 500 | if c.line == 0 { 501 | return c 502 | } 503 | return cursor{c.line - 1, len(t.text[c.line-1])} 504 | } 505 | if state.HasModifiers(ui.Control) { 506 | return cursor{c.line, text.PreviousWord(line, c.col)} 507 | } else { 508 | _, size := utf8.DecodeLastRuneInString(t.text[c.line][:c.col]) 509 | return cursor{c.line, c.col - size} 510 | } 511 | } 512 | 513 | func (t *TextArea) selectLine() { 514 | t.selectionStart.col = 0 515 | if t.cursor.line == len(t.text)-1 { 516 | t.cursor.col = len(t.text[t.cursor.line]) 517 | } else { 518 | t.cursor.col = 0 519 | t.cursor.line++ 520 | } 521 | } 522 | 523 | func (t *TextArea) insert(s string) { 524 | if !t.Editable { 525 | return 526 | } 527 | s = strings.Replace(s, "\t", " ", -1) 528 | s1, s2 := t.selection() 529 | if s == "" && s1 == s2 { 530 | return 531 | } 532 | if strings.Contains(s, "\n") { 533 | lines := strings.Split(s, "\n") 534 | ll := len(lines) - 1 535 | t.cursor.col = len(lines[ll]) 536 | lines[ll] = lines[ll] + t.text[s2.line][s2.col:] 537 | t.text[s1.line] = t.text[s1.line][:s1.col] + lines[0] 538 | t.text = append(t.text[:s1.line+1], append(lines[1:], t.text[s2.line+1:]...)...) 539 | t.cursor.line = s1.line + len(lines) - 1 540 | t.h = -1 541 | } else { 542 | t.text[s1.line] = t.text[s1.line][:s1.col] + s + t.text[s2.line][s2.col:] 543 | t.text = append(t.text[:s1.line+1], t.text[s2.line+1:]...) 544 | t.cursor = cursor{s1.line, s1.col + len(s)} 545 | if s1.line == s2.line { 546 | t.lineChanged(s1.line) 547 | } else { 548 | t.h = -1 549 | } 550 | } 551 | t.changed = true 552 | t.selectionStart = t.cursor 553 | t.cx = -1 554 | } 555 | 556 | // Reader returns an io.Reader that will read from the contents of the text area. 557 | // Changes to the text area's content after this method is called will not affect the returned Reader. 558 | func (t *TextArea) Reader() io.Reader { 559 | return &reader{t.Lines(), 0} 560 | } 561 | 562 | type reader struct { 563 | lines []string 564 | pos int 565 | } 566 | 567 | func (r *reader) Read(p []byte) (int, error) { 568 | if len(r.lines) == 0 { 569 | return 0, io.EOF 570 | } 571 | n := copy(p, r.lines[0][r.pos:]) 572 | r.pos += n 573 | p = p[n:] 574 | if len(p) > 0 && r.pos == len(r.lines[0]) { 575 | r.lines = r.lines[1:] 576 | r.pos = 0 577 | p[0] = '\n' 578 | n++ 579 | } 580 | return n, nil 581 | } 582 | -------------------------------------------------------------------------------- /toolkit/textfield.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "unicode/utf8" 5 | 6 | "github.com/jfreymuth/ui" 7 | "github.com/jfreymuth/ui/draw" 8 | "github.com/jfreymuth/ui/text" 9 | ) 10 | 11 | type TextField struct { 12 | Editable bool 13 | Action func(*ui.State, string) 14 | MinWidth int 15 | Theme *Theme 16 | Text string 17 | text text.Text 18 | cursor int 19 | selectionStart int 20 | lastX int 21 | state byte 22 | anim float32 23 | } 24 | 25 | func NewTextField() *TextField { 26 | return &TextField{Theme: DefaultTheme, Editable: true, MinWidth: 100} 27 | } 28 | 29 | func (t *TextField) SetTheme(theme *Theme) { t.Theme = theme } 30 | 31 | func (t *TextField) SelectedText() string { 32 | s1, s2 := t.selection() 33 | return t.Text[s1:s2] 34 | } 35 | 36 | func (t *TextField) PreferredSize(fonts draw.FontLookup) (int, int) { 37 | w, h := t.text.Size(t.Text, t.Theme.Font("inputText"), fonts) 38 | if w+6 < t.MinWidth { 39 | w = t.MinWidth - 6 40 | } 41 | return w + 6, h + 6 42 | } 43 | 44 | func (t *TextField) Update(g *draw.Buffer, state *ui.State) { 45 | m := g.FontLookup.Metrics(t.Theme.Font("inputText")) 46 | state.SetCursor(ui.CursorText) 47 | t.handleKeyEvents(state) 48 | t.handleMouseEvents(state, m) 49 | w, h := g.Size() 50 | _, th := t.text.Size(t.Text, t.Theme.Font("inputText"), g.FontLookup) 51 | 52 | animate(state, &t.anim, 8, t.Editable && state.HasKeyboardFocus()) 53 | anim := int(t.anim * float32(w)) 54 | g.Fill(draw.XYXY(0, (h-th)/2-3, anim, (h+th)/2+3), t.Theme.Color("inputBackground")) 55 | line := w - 6 - anim 56 | if line > 0 { 57 | g.Fill(draw.XYWH(3, (h+th)/2, line, 1), t.Theme.Color("inputText")) 58 | } 59 | x, y := float32(3), (h-th)/2 60 | s1, s2 := t.selection() 61 | x += m.Advance(t.Text[:s1]) 62 | var cx int 63 | if s1 == t.cursor { 64 | cx = int(x) 65 | } 66 | if s1 != s2 { 67 | adv := m.Advance(t.Text[s1:s2]) 68 | if state.HasKeyboardFocus() { 69 | g.Fill(draw.XYWH(int(x), y, int(adv), th), t.Theme.Color("selection")) 70 | } else { 71 | g.Fill(draw.XYWH(int(x), y, int(adv), th), t.Theme.Color("selectionInactive")) 72 | } 73 | x += adv 74 | } 75 | if s2 == t.cursor { 76 | cx = int(x) 77 | } 78 | if state.Blink() { 79 | g.Fill(draw.XYWH(cx-1, y, 2, th), t.Theme.Color("inputText")) 80 | } 81 | t.text.DrawLeft(g, draw.XYXY(3, y, int(x), y+th), t.Text, t.Theme.Font("inputText"), t.Theme.Color("inputText")) 82 | } 83 | 84 | func (t *TextField) handleMouseEvents(state *ui.State, m draw.FontMetrics) { 85 | mx := state.MousePos().X 86 | drag, drop := state.DraggedContent() 87 | if drag, ok := drag.(string); ok { 88 | t.cursor = m.Index(t.Text, float32(mx-3)) 89 | t.selectionStart = t.cursor 90 | state.SetBlink() 91 | if drop { 92 | t.insert(drag) 93 | } 94 | t.state = tfIdle 95 | return 96 | } 97 | if !state.HasMouseFocus() { 98 | t.state = tfIdle 99 | return 100 | } 101 | if state.MouseButtonDown(ui.MouseLeft) { 102 | c := m.Index(t.Text, float32(mx-3)) 103 | if t.state == tfDrag { 104 | if !t.inSelection(c) { 105 | state.InitiateDrag(t.SelectedText()) 106 | t.insert("") 107 | } 108 | } else if t.state == tfIdle && state.ClickCount() == 1 && t.inSelection(c) { 109 | t.state = tfDrag 110 | } else { 111 | if t.lastX != mx { 112 | t.cursor = c 113 | if t.state == tfIdle { 114 | t.state = tfSelect 115 | t.selectionStart = t.cursor 116 | } 117 | } else if t.state == tfIdle { 118 | t.selectionStart = t.cursor 119 | t.state = tfSelect 120 | switch state.ClickCount() % 3 { 121 | case 1: 122 | t.selectionStart, t.cursor = c, c 123 | case 2: 124 | t.selectionStart, t.cursor = text.FindWord(t.Text, t.cursor) 125 | t.state = tfSelect 126 | case 0: 127 | t.SelectAll(state) 128 | t.state = tfSelect 129 | } 130 | } 131 | state.SetBlink() 132 | t.lastX = mx 133 | } 134 | } else { 135 | if t.state == tfDrag { 136 | t.cursor = m.Index(t.Text, float32(mx-3)) 137 | t.selectionStart = t.cursor 138 | state.SetBlink() 139 | } 140 | t.state = tfIdle 141 | } 142 | } 143 | 144 | func (t *TextField) inSelection(c int) bool { 145 | s1, s2 := t.selection() 146 | return c > s1 && c < s2 147 | } 148 | 149 | func (t *TextField) handleKeyEvents(state *ui.State) { 150 | if text := state.TextInput(); text != "" { 151 | t.insert(text) 152 | state.SetBlink() 153 | } 154 | for _, k := range state.KeyPresses() { 155 | switch k { 156 | case ui.KeyLeft: 157 | t.cursor = t.prev(state) 158 | case ui.KeyRight: 159 | t.cursor = t.next(state) 160 | case ui.KeyHome: 161 | t.cursor = 0 162 | case ui.KeyEnd: 163 | t.cursor = len(t.Text) 164 | case ui.KeyBackspace: 165 | if t.Editable && t.cursor == t.selectionStart { 166 | t.cursor = t.prev(state) 167 | } 168 | t.insert("") 169 | case ui.KeyDelete: 170 | if t.Editable && t.cursor == t.selectionStart { 171 | t.cursor = t.next(state) 172 | } 173 | t.insert("") 174 | case ui.KeyEnter: 175 | t.TriggerAction(state) 176 | continue 177 | default: 178 | continue 179 | } 180 | if !state.HasModifiers(ui.Shift) { 181 | t.selectionStart = t.cursor 182 | } 183 | state.SetBlink() 184 | } 185 | } 186 | 187 | func (t *TextField) SelectAll(state *ui.State) { 188 | t.selectionStart = 0 189 | t.cursor = len(t.Text) 190 | } 191 | 192 | func (t *TextField) Cut(state *ui.State) { 193 | state.SetClipboardString(t.SelectedText()) 194 | t.insert("") 195 | } 196 | 197 | func (t *TextField) Copy(state *ui.State) { 198 | state.SetClipboardString(t.SelectedText()) 199 | } 200 | 201 | func (t *TextField) Paste(state *ui.State) { 202 | t.insert(state.ClipboardString()) 203 | } 204 | 205 | func (t *TextField) TriggerAction(state *ui.State) { 206 | if t.Action != nil { 207 | t.Action(state, t.Text) 208 | } 209 | } 210 | 211 | func (t *TextField) selection() (int, int) { 212 | if t.selectionStart > len(t.Text) { 213 | t.selectionStart = len(t.Text) 214 | } 215 | if t.cursor > len(t.Text) { 216 | t.cursor = len(t.Text) 217 | } 218 | if t.selectionStart < t.cursor { 219 | return t.selectionStart, t.cursor 220 | } 221 | return t.cursor, t.selectionStart 222 | } 223 | 224 | func (t *TextField) next(state *ui.State) int { 225 | if t.cursor > len(t.Text) { 226 | return len(t.Text) 227 | } 228 | if state.HasModifiers(ui.Control) { 229 | return text.NextWord(t.Text, t.cursor) 230 | } else { 231 | _, size := utf8.DecodeRuneInString(t.Text[t.cursor:]) 232 | return t.cursor + size 233 | } 234 | } 235 | 236 | func (t *TextField) prev(state *ui.State) int { 237 | if t.cursor > len(t.Text) { 238 | t.cursor = len(t.Text) 239 | } 240 | if state.HasModifiers(ui.Control) { 241 | return text.PreviousWord(t.Text, t.cursor) 242 | } else { 243 | _, size := utf8.DecodeLastRuneInString(t.Text[:t.cursor]) 244 | return t.cursor - size 245 | } 246 | } 247 | 248 | func (t *TextField) insert(s string) { 249 | if !t.Editable { 250 | return 251 | } 252 | s1, s2 := t.selection() 253 | t.Text = t.Text[:s1] + s + t.Text[s2:] 254 | t.cursor = s1 + len(s) 255 | t.selectionStart = t.cursor 256 | } 257 | 258 | const ( 259 | tfIdle = iota 260 | tfSelect 261 | tfDrag 262 | ) 263 | -------------------------------------------------------------------------------- /toolkit/theme.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | ) 7 | 8 | type Theme struct { 9 | fonts map[string]draw.Font 10 | colors map[string]draw.Color 11 | parent *Theme 12 | } 13 | 14 | func SetTheme(c ui.Component, theme *Theme) { 15 | if c, ok := c.(interface{ SetTheme(*Theme) }); ok { 16 | c.SetTheme(theme) 17 | } 18 | } 19 | 20 | func (t *Theme) New() *Theme { 21 | return &Theme{parent: t} 22 | } 23 | 24 | func (t *Theme) Font(name string) draw.Font { 25 | if f, ok := t.fonts[name]; ok { 26 | return f 27 | } 28 | if t.parent != nil { 29 | return t.parent.Font(name) 30 | } 31 | return draw.Font{Name: "default", Size: 12} 32 | } 33 | 34 | func (t *Theme) Color(name string) draw.Color { 35 | if c, ok := t.colors[name]; ok { 36 | return c 37 | } 38 | if t.parent != nil { 39 | return t.parent.Color(name) 40 | } 41 | return draw.Transparent 42 | } 43 | 44 | func (t *Theme) SetFont(name string, f draw.Font) { 45 | if t.fonts == nil { 46 | t.fonts = make(map[string]draw.Font) 47 | } 48 | t.fonts[name] = f 49 | } 50 | 51 | func (t *Theme) SetColor(name string, c draw.Color) { 52 | if t.colors == nil { 53 | t.colors = make(map[string]draw.Color) 54 | } 55 | t.colors[name] = c 56 | } 57 | 58 | var DefaultTheme = LightTheme 59 | var LightTheme = &Theme{ 60 | fonts: map[string]draw.Font{ 61 | "text": {Name: "default", Size: 12}, 62 | "title": {Name: "bold", Size: 12}, 63 | "buttonText": {Name: "bold", Size: 12}, 64 | "inputText": {Name: "default", Size: 12}, 65 | }, 66 | colors: map[string]draw.Color{ 67 | "background": draw.White, 68 | "border": draw.Black, 69 | "shadow": draw.RGBA(0, 0, 0, .5), 70 | "veil": draw.RGBA(0, 0, 0, .2), 71 | "altBackground": draw.Gray(.9), 72 | "text": draw.Black, 73 | "title": draw.Black, 74 | "titleBackground": draw.RGBA(.7, .75, 1, 1), 75 | "titleBackgroundError": draw.RGBA(1, .75, .7, 1), 76 | "buttonBackground": draw.Transparent, 77 | "buttonHovered": draw.RGBA(0, 0, 0, .2), 78 | "buttonText": draw.Black, 79 | "buttonFocused": draw.Gray(.3), 80 | "inputBackground": draw.White, 81 | "inputText": draw.Black, 82 | "selection": draw.RGBA(.8, .85, 1, 1), 83 | "selectionInactive": draw.Gray(.8), 84 | "scrollBar": draw.RGBA(0, 0, 0, .3), 85 | }, 86 | } 87 | var DarkTheme = &Theme{ 88 | fonts: map[string]draw.Font{ 89 | "text": {Name: "default", Size: 12}, 90 | "title": {Name: "bold", Size: 12}, 91 | "buttonText": {Name: "bold", Size: 12}, 92 | "inputText": {Name: "default", Size: 12}, 93 | }, 94 | colors: map[string]draw.Color{ 95 | "background": draw.Gray(.3), 96 | "border": draw.Black, 97 | "shadow": draw.Black, 98 | "veil": draw.RGBA(1, 1, 1, .2), 99 | "altBackground": draw.Gray(.4), 100 | "text": draw.White, 101 | "title": draw.White, 102 | "titleBackground": draw.RGBA(.2, .25, .4, 1), 103 | "titleBackgroundError": draw.RGBA(.5, .05, 0, 1), 104 | "buttonBackground": draw.Transparent, 105 | "buttonHovered": draw.RGBA(1, 1, 1, .1), 106 | "buttonText": draw.White, 107 | "buttonFocused": draw.Gray(.8), 108 | "inputBackground": draw.Gray(.3), 109 | "inputText": draw.White, 110 | "selection": draw.RGBA(.35, .4, .6, 1), 111 | "selectionInactive": draw.Gray(.5), 112 | "scrollBar": draw.RGBA(1, 1, 1, .3), 113 | }, 114 | } 115 | -------------------------------------------------------------------------------- /toolkit/util.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | ) 7 | 8 | func animate(state *ui.State, v *float32, speed float32, on bool) { 9 | if on { 10 | *v += state.AnimationSpeed() * speed 11 | if *v >= 1 { 12 | *v = 1 13 | } else { 14 | state.RequestAnimation() 15 | } 16 | } else { 17 | *v -= state.AnimationSpeed() * speed 18 | if *v <= 0 { 19 | *v = 0 20 | } else { 21 | state.RequestAnimation() 22 | } 23 | } 24 | } 25 | 26 | type Separator struct { 27 | Theme *Theme 28 | Width, Height int 29 | } 30 | 31 | func NewSeparator(w, h int) *Separator { 32 | return &Separator{Theme: DefaultTheme, Width: w, Height: h} 33 | } 34 | 35 | func (s *Separator) PreferredSize(fonts draw.FontLookup) (int, int) { 36 | return s.Width, s.Height 37 | } 38 | 39 | func (s *Separator) Update(g *draw.Buffer, state *ui.State) { 40 | g.Fill(draw.WH(g.Size()), s.Theme.Color("veil")) 41 | } 42 | -------------------------------------------------------------------------------- /toolkit/wrap.go: -------------------------------------------------------------------------------- 1 | package toolkit 2 | 3 | import ( 4 | "github.com/jfreymuth/ui" 5 | "github.com/jfreymuth/ui/draw" 6 | ) 7 | 8 | type Padding struct { 9 | Content ui.Component 10 | Left, Right, Top, Bottom int 11 | } 12 | 13 | func NewPadding(c ui.Component, p int) *Padding { 14 | return &Padding{Content: c, Left: p, Right: p, Top: p, Bottom: p} 15 | } 16 | 17 | func (p *Padding) SetTheme(theme *Theme) { 18 | SetTheme(p.Content, theme) 19 | } 20 | 21 | func (p *Padding) PreferredSize(fonts draw.FontLookup) (int, int) { 22 | w, h := p.Content.PreferredSize(fonts) 23 | return p.Left + w + p.Right, p.Top + h + p.Bottom 24 | } 25 | 26 | func (p *Padding) Update(g *draw.Buffer, state *ui.State) { 27 | w, h := g.Size() 28 | state.UpdateChild(g, draw.XYXY(p.Left, p.Top, w-p.Right, h-p.Bottom), p.Content) 29 | } 30 | 31 | type FixedSize struct { 32 | Content ui.Component 33 | Width, Height int 34 | } 35 | 36 | func (f *FixedSize) SetTheme(theme *Theme) { 37 | SetTheme(f.Content, theme) 38 | } 39 | 40 | func (f *FixedSize) PreferredSize(fonts draw.FontLookup) (int, int) { 41 | w, h := f.Width, f.Height 42 | if w == 0 || h == 0 { 43 | cw, ch := f.Content.PreferredSize(fonts) 44 | if w == 0 { 45 | w = cw 46 | } 47 | if h == 0 { 48 | h = ch 49 | } 50 | } 51 | return w, h 52 | } 53 | 54 | func (f *FixedSize) Update(g *draw.Buffer, state *ui.State) { 55 | w, h := g.Size() 56 | state.UpdateChild(g, draw.WH(w, h), f.Content) 57 | } 58 | 59 | type Shadow struct { 60 | Content ui.Component 61 | theme *Theme 62 | } 63 | 64 | func NewShadow(c ui.Component) *Shadow { 65 | return &Shadow{c, DefaultTheme} 66 | } 67 | 68 | func (s *Shadow) Theme() *Theme { return s.theme } 69 | func (s *Shadow) SetTheme(theme *Theme) { 70 | s.theme = theme 71 | SetTheme(s.Content, theme) 72 | } 73 | 74 | func (s *Shadow) PreferredSize(fonts draw.FontLookup) (int, int) { 75 | w, h := s.Content.PreferredSize(fonts) 76 | return w + 20, h + 20 77 | } 78 | 79 | func (s *Shadow) Update(g *draw.Buffer, state *ui.State) { 80 | w, h := g.Size() 81 | g.Shadow(draw.XYXY(12, 12, w-8, h-8), s.theme.Color("shadow"), 10) 82 | g.Fill(draw.XYXY(10, 10, w-10, h-10), s.theme.Color("background")) 83 | state.UpdateChild(g, draw.XYXY(10, 10, w-10, h-10), s.Content) 84 | } 85 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/jfreymuth/ui/draw" 7 | ) 8 | 9 | type Component interface { 10 | PreferredSize(draw.FontLookup) (int, int) 11 | Update(*draw.Buffer, *State) 12 | } 13 | 14 | type Root interface { 15 | OpenDialog(Component) 16 | CloseDialog() 17 | OpenPopup(image.Rectangle, Component) Popup 18 | ClosePopups() 19 | HasPopups() bool 20 | } 21 | 22 | type Popup interface { 23 | Close() 24 | Closed() bool 25 | } 26 | 27 | type Modifier byte 28 | 29 | const ( 30 | Shift Modifier = 1 << iota 31 | Control 32 | Alt 33 | Super 34 | CapsLock 35 | NumLock 36 | ) 37 | 38 | type MouseButton byte 39 | 40 | const ( 41 | MouseLeft MouseButton = 1 << iota 42 | MouseMiddle 43 | MouseRight 44 | MouseForward 45 | MouseBack 46 | ) 47 | 48 | type Cursor byte 49 | 50 | const ( 51 | CursorNormal Cursor = iota 52 | CursorText 53 | CursorHand 54 | CursorCrosshair 55 | CursorDisabled 56 | CursorWait 57 | CursorWaitBackground 58 | CursorMove 59 | CursorResizeHorizontal 60 | CursorResizeVertical 61 | CursorResizeDiagonal 62 | CursorResizeDiagonal2 63 | ) 64 | 65 | func HandleKeyboardShortcuts(state *State) { 66 | for _, k := range state.PeekKeyPresses() { 67 | if state.HasModifiers(Control) { 68 | switch k { 69 | case KeyA: 70 | if t, ok := state.KeyboardFocus().(interface{ SelectAll(*State) }); ok { 71 | t.SelectAll(state) 72 | } 73 | case KeyX: 74 | if t, ok := state.KeyboardFocus().(interface{ Cut(*State) }); ok { 75 | t.Cut(state) 76 | } 77 | case KeyC: 78 | if t, ok := state.KeyboardFocus().(interface{ Copy(*State) }); ok { 79 | t.Copy(state) 80 | } 81 | case KeyV: 82 | if t, ok := state.KeyboardFocus().(interface{ Paste(*State) }); ok { 83 | t.Paste(state) 84 | } 85 | } 86 | } else { 87 | switch k { 88 | case KeyEscape: 89 | state.ClosePopups() 90 | } 91 | } 92 | } 93 | } 94 | 95 | type menuDrag int 96 | 97 | const MenuDrag menuDrag = 0 98 | --------------------------------------------------------------------------------