diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..acc15af --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2020 Noah Petherbridge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd06803 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# ui: User Interface Toolkit for Go + +Package ui is a user interface toolkit for Go that targets desktop +applications (SDL2, for Linux, MacOS and Windows) as well as web browsers +(WebAssembly rendering to an HTML Canvas). + +![Screenshot](docs/guitest.png) + +> _(Screenshot is from Project: Doodle's GUITest debug screen showing a_ +> _Window, several Frames, Labels, Buttons and a Checkbox widget.)_ + +It is very much a **work in progress** and it's a bit buggy. See the +[Known Issues](#known-issues) at the bottom of this document. + +This library is being developed in conjunction with my drawing-based maze +game, [Project: Doodle](https://www.kirsle.net/doodle). The rendering engine +library is at [go/render](https://git.kirsle.net/go/render) which provides +the SDL2 and Canvas back-ends. +(GitHub mirror: [kirsle/render](https://github.com/kirsle/render)) + +# Example + +```go +package main + +import ( + "fmt" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui" +) + +func main() { + mw, err := ui.NewMainWindow("Hello World") + if err != nil { + panic(err) + } + + mw.SetBackground(render.White) + + // Draw a label. + label := ui.NewLabel(ui.Label{ + Text: "Hello, world!", + Font: render.Text{ + FontFilename: "../DejaVuSans.ttf", + Size: 32, + Color: render.SkyBlue, + Shadow: render.SkyBlue.Darken(40), + }, + }) + mw.Pack(label, ui.Pack{ + Anchor: ui.N, + PadY: 12, + }) + + // Draw a button. + button := ui.NewButton("My Button", ui.NewLabel(ui.Label{ + Text: "Click me!", + Font: render.Text{ + FontFilename: "../DejaVuSans.ttf", + Size: 12, + Color: render.Red, + Padding: 4, + }, + })) + button.Handle(ui.Click, func(p render.Point) { + fmt.Println("I've been clicked!") + }) + mw.Pack(button, ui.Pack{ + Anchor: ui.N, + }) + + // Add the button to the MainWindow's Supervisor so it can be + // clicked on and interacted with. + mw.Add(button) + + mw.MainLoop() +} +``` + +# Widgets and Features + +The following widgets have been implemented or are planned for the future. + +Widgets are designed to be composable, making use of pre-existing widgets to +create more complex ones. The widgets here are ordered from simplest to +most complex. + +**Fully implemented widgets:** + +* [x] **BaseWidget**: the base class of all Widgets. + * The `Widget` interface describes the functions common to all Widgets, + such as SetBackground, Configure, MoveTo, Resize, and so on. + * BaseWidget provides sane default implementations for all the methods + required by the Widget interface. Most Widgets inherit from + the BaseWidget. +* [x] **Frame**: a layout wrapper for other widgets. + * Pack() lets you add child widgets to the Frame, aligned against one side + or another, and ability to expand widgets to take up remaining space in + their part of the Frame. +* [x] **Label**: Textual labels for your UI. + * Supports TrueType fonts, color, stroke, drop shadow, font size, etc. + * Variable binding support: TextVariable or IntVariable can point to a + string or int reference, respectively, to provide the text of the label + dynamically. +* [x] **Image**: show a PNG or Bitmap image on your UI. +* [x] **Button**: clickable buttons. + * They can wrap _any_ widget. Labels are most common but can also wrap a + Frame so you can have labels + icon images inside the button, etc. + * Mouse hover and click event handlers. +* [x] **CheckButton** and **RadioButton** + * Variants on the Button which bind to a variable and toggle its state + when clicked. Boolean variable pointers are used with CheckButton and + string pointers for RadioButton. + * CheckButtons stay pressed in when clicked (true) and pop back out when + clicked again (false). + * RadioButtons stay pressed in when the string variable matches their + value, and pop out when the string variable changes. +* [x] **Checkbox** and **Radiobox**: a Frame widget that wraps a + CheckButton and a Label to provide a more traditional UI element. + * Works the same as CheckButton and RadioButton but draws a separate + label next to a small check button. Clicking the label will toggle the + state of the checkbox. +* [x] **Window**: a Frame with a title bar Frame on top. + * Note: Window is not yet draggable or closeable. + +**Work in progress widgets:** + +* [x] **Menu**: a frame with clickable menu items. + * To be a base widget behind right-click context menus, pull-down menus + from a MenuBar, options from a SelectBox and so on. + * Powered by Frame and Button but with a nice API for composing menu + actions. + * Partially implemented so far. +* [ ] **MenuButton**: a Button that opens a Menu when clicked. +* [ ] **MenuBar**: a Frame that houses many MenuButtons, intended for the + main menu at the top of a UI window (File, Edit, Help, etc.). +* [ ] **Scrollbar**: a Frame including a trough, scroll buttons and a + draggable slider. + +**Wish list for the longer-term future:** + +* [ ] **SelectBox:** a kind of MenuButton that lets the user choose a value + from a list of possible values, bound to a string variable. +* [ ] **WindowManager**: manages Window widgets and focus support for all + interactable widgets. + * Would enable Windows to be dragged around by their title bar, overlap + other Windows, and rise on top of other Windows when clicked. + * Would enable "focus" support for Buttons, Text Boxes and other + interactable widgets. +* [ ] **TextBox:** an editable text field that the user can focus and type + a value into. + * Would depend on the WindowManager to manage focus for the widgets. + +## Supervisor for Interaction + +Some widgets that support user interaction (such as Button, CheckButton and +Checkbox) need to be added to the Supervisor which watches over them and +communicates events that they're interested in. + +```go +func SupervisorSDL2Example() { + // NOTE: using the render/sdl engine. + window := sdl.New("Hello World", 800, 600) + window.Setup() + + // One Supervisor is needed per UI. + supervisor := ui.NewSupervisor() + + // A button for our UI. + btn := ui.NewButton("Button1", ui.NewLabel(ui.Label{ + Text: "Click me!", + })) + + // Add it to the Supervisor. + supervisor.Add(btn) + + // Main loop + for { + // Check for keyboard/mouse events + ev, _ := window.Poll() + + // Ping the Supervisor Loop function with the event state, so + // it can trigger events on the widgets under its care. + supervisor.Loop(ev) + } +} +``` + +You only need one Supervisor instance per UI. Add() each interactive widget +to it, and call its Loop() method in your main loop so it can update the +state of the widgets under its care. + +The MainWindow includes its own Supervisor, see below. + +## MainWindow for Simple Applications + +The MainWindow widget may be used for "simple" UI applications where all you +want is a GUI and you don't want to manage your own SDL2 (or Canvas) engine. + +MainWindow is only to be used **one time** per application, and it sets up +its own SDL2 render context and creates the main window. It also contains a +Frame widget for the window contents and you may Pack() widgets into the +window the same as you would a Frame. + +MainWindow includes its own Supervisor: just call the `.Add(Widget)` +method to add interactive widgets to the supervisor. The MainLoop() of the +window calls Supervisor.Loop() automatically. + +# Known Issues + +The frame packing algorithm (frame_pack.go) is currently very buggy and in +need of a re-write. Some examples of issues with it: + +* Currently, when the Frame is iterating over packed widgets to decide their + location and size, it explicitly calls MoveTo() and Resize() giving them + their pixel-coordinates, relative to the Frame's own position. + * When Frames nest other Frames this becomes more of an issue. + * The Supervisor sometimes can't determine the correct position of a + button packed inside of nested frames. It currently checks the + Point() of the button (set by its parent Frame) and this doesn't + account for the grandparent frame's position. Using the + AbsolutePosition() helper function (which recursively crawls up a + widget tree) also yields incorrect results, as the position of each + Frame is _added_ to the position of the Button which throws it off even + further. + +It's on my to-do list to rewrite the algorithm from scratch and make it +more resilient. One thing I also want to do is rename the `Anchor` field +and call it `Side` to be more in line with the Tk GUI toolkit's naming +convention. ("Side: N" or "Side: SE", and let the "Anchor" name be used for +how to center the widget inside of its space ("Top", "Center", "Left", etc.) + +# License + +MIT. diff --git a/docs/guitest.png b/docs/guitest.png new file mode 100644 index 0000000..a0c816b Binary files /dev/null and b/docs/guitest.png differ diff --git a/eg/DejaVuSans.ttf b/eg/DejaVuSans.ttf new file mode 100644 index 0000000..a5f96b7 Binary files /dev/null and b/eg/DejaVuSans.ttf differ diff --git a/eg/hello-world/main.go b/eg/hello-world/main.go new file mode 100644 index 0000000..bdcfd1a --- /dev/null +++ b/eg/hello-world/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui" +) + +func main() { + mw, err := ui.NewMainWindow("Hello World") + if err != nil { + panic(err) + } + + mw.SetBackground(render.White) + + // Draw a label. + label := ui.NewLabel(ui.Label{ + Text: "Hello, world!", + Font: render.Text{ + FontFilename: "../DejaVuSans.ttf", + Size: 32, + Color: render.SkyBlue, + Shadow: render.SkyBlue.Darken(40), + }, + }) + mw.Pack(label, ui.Pack{ + Anchor: ui.N, + PadY: 12, + }) + + // Draw a button. + button := ui.NewButton("My Button", ui.NewLabel(ui.Label{ + Text: "Click me!", + Font: render.Text{ + FontFilename: "../DejaVuSans.ttf", + Size: 12, + Color: render.Red, + Padding: 4, + }, + })) + button.Handle(ui.Click, func(p render.Point) { + fmt.Println("I've been clicked!") + }) + mw.Pack(button, ui.Pack{ + Anchor: ui.N, + }) + + // Add the button to the MainWindow's Supervisor so it can be + // clicked on and interacted with. + mw.Add(button) + + mw.MainLoop() +} diff --git a/eg/layout/main.go b/eg/layout/main.go index 5bb48d7..8f99a7c 100644 --- a/eg/layout/main.go +++ b/eg/layout/main.go @@ -3,7 +3,7 @@ package main import ( "fmt" - "git.kirsle.net/apps/doodle/lib/ui/eg/layout" + "git.kirsle.net/go/ui/eg/layout" ) func main() { diff --git a/eg/main.go b/eg/main.go index 276a502..70d1349 100644 --- a/eg/main.go +++ b/eg/main.go @@ -1,8 +1,10 @@ package main import ( + "fmt" + "git.kirsle.net/go/render" - "git.kirsle.net/apps/doodle/lib/ui" + "git.kirsle.net/go/ui" ) func main() { @@ -13,7 +15,7 @@ func main() { leftFrame := ui.NewFrame("Left Frame") leftFrame.Configure(ui.Config{ - Width: 200, + Width: 160, BorderSize: 1, BorderStyle: ui.BorderRaised, Background: render.Grey, @@ -37,7 +39,37 @@ func main() { Text: "Hello world", }) leftFrame.Pack(label, ui.Pack{ - Anchor: ui.SE, + Anchor: ui.N, + PadY: 12, + }) + + // Draw some buttons in the left frame. + for i := 1; i <= 12; i++ { + i := i + + btn := ui.NewButton(fmt.Sprintf("Button-%d", i), ui.NewLabel(ui.Label{ + Text: fmt.Sprintf("Button #%d", i), + })) + btn.Handle(ui.Click, func(p render.Point) { + fmt.Printf("Button %d was clicked\n", i) + }) + + // Add the button to the MainWindow's event supervisor, so it may be + // clicked and interacted with. + mw.Add(btn) + + leftFrame.Pack(btn, ui.Pack{ + Anchor: ui.N, + FillX: true, + PadY: 2, + }) + } + + // Frame to show off check buttons. + mainFrame.Pack(radioButtonFrame(mw), ui.Pack{ + Anchor: ui.N, + FillX: true, + PadY: 8, }) err = mw.MainLoop() @@ -45,3 +77,67 @@ func main() { panic("MainLoop:" + err.Error()) } } + +// Frame that shows off radio buttons. +func radioButtonFrame(mw *ui.MainWindow) *ui.Frame { + // The string variable that will be bound to the radio buttons. + // This could also be a global variable at the package level. + radioValue := "Red" + + // Main frame. + frame := ui.NewFrame("radio button demo") + frame.Configure(ui.Config{ + Background: render.RGBA(153, 255, 153, 255), + BorderSize: 1, + BorderStyle: ui.BorderRaised, + }) + + // Top row to show the label and current radiobutton bound value. + topFrame := ui.NewFrame("radio button label frame") + frame.Pack(topFrame, ui.Pack{ + Anchor: ui.N, + FillX: true, + }) + + // Draw the labels. + { + label := ui.NewLabel(ui.Label{ + Text: "Radio buttons. Value:", + }) + topFrame.Pack(label, ui.Pack{ + Anchor: ui.W, + }) + + valueLabel := ui.NewLabel(ui.Label{ + TextVariable: &radioValue, + }) + topFrame.Pack(valueLabel, ui.Pack{ + Anchor: ui.W, + PadX: 4, + }) + } + + // The radio buttons themselves. + btnFrame := ui.NewFrame("radio button frame") + frame.Pack(btnFrame, ui.Pack{ + Anchor: ui.N, + FillX: true, + }) + { + colors := []string{"Red", "Green", "Blue", "Yellow"} + for _, color := range colors { + color := color + + btn := ui.NewRadioButton("color:"+color, &radioValue, color, ui.NewLabel(ui.Label{ + Text: color, + })) + mw.Add(btn) + btnFrame.Pack(btn, ui.Pack{ + Anchor: ui.W, + PadX: 2, + }) + } + } + + return frame +} diff --git a/main_window.go b/main_window.go index 6d3c837..46a5b3c 100644 --- a/main_window.go +++ b/main_window.go @@ -17,7 +17,7 @@ var ( // MainWindow is the parent window of a UI application. type MainWindow struct { - engine render.Engine + Engine render.Engine supervisor *Supervisor frame *Frame w int @@ -33,12 +33,12 @@ func NewMainWindow(title string) (*MainWindow, error) { supervisor: NewSupervisor(), } - mw.engine = sdl.New( + mw.Engine = sdl.New( title, mw.w, mw.h, ) - if err := mw.engine.Setup(); err != nil { + if err := mw.Engine.Setup(); err != nil { return nil, err } @@ -72,9 +72,14 @@ func (mw *MainWindow) resized() { }) } +// SetBackground changes the window's frame's background color. +func (mw *MainWindow) SetBackground(color render.Color) { + mw.frame.SetBackground(color) +} + // Present the window. func (mw *MainWindow) Present() { - mw.supervisor.Present(mw.engine) + mw.supervisor.Present(mw.Engine) } // MainLoop starts the main event loop and blocks until there's an error. @@ -89,19 +94,19 @@ func (mw *MainWindow) MainLoop() error { // Loop does one loop of the UI. func (mw *MainWindow) Loop() error { - mw.engine.Clear(render.White) + mw.Engine.Clear(render.White) // Record how long this loop took. start := time.Now() // Poll for events. - ev, err := mw.engine.Poll() + ev, err := mw.Engine.Poll() if err != nil { return fmt.Errorf("event poll error: %s", err) } if ev.WindowResized { - w, h := mw.engine.WindowSize() + w, h := mw.Engine.WindowSize() if w != mw.w || h != mw.h { mw.w = w mw.h = h @@ -109,11 +114,12 @@ func (mw *MainWindow) Loop() error { } } - mw.frame.Compute(mw.engine) + mw.frame.Compute(mw.Engine) // Render the child widgets. - mw.supervisor.Present(mw.engine) - mw.engine.Present() + mw.supervisor.Loop(ev) + mw.supervisor.Present(mw.Engine) + mw.Engine.Present() // Delay to maintain target frames per second. var delay uint32 @@ -122,7 +128,7 @@ func (mw *MainWindow) Loop() error { if targetFPS-int(elapsed) > 0 { delay = uint32(targetFPS - int(elapsed)) } - mw.engine.Delay(delay) + mw.Engine.Delay(delay) return nil }