diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f44c78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +wasm-tmp +*.wasm diff --git a/README.md b/README.md index 06b544b..b68c384 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ most complex. 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. + * Can be managed by Supervisor to give Window Manager controls to it + (drag it by its title bar, Close button, window focus, multiple overlapping + windows, and so on). * [x] **Tooltip**: a mouse hover label attached to a widget. **Work in progress widgets:** @@ -155,12 +157,6 @@ most complex. * [ ] **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. @@ -206,6 +202,76 @@ state of the widgets under its care. The MainWindow includes its own Supervisor, see below. +## Window Manager + +The ui.Window widget provides a simple frame with a title bar. But, you can +use the Supervisor to provide Window Manager controls to your windows! + +The key steps to convert a static Window widget into one that can be dragged +around by its title bar are: + +1. Call `window.Supervise(ui.Supervisor)` and give it your Supervisor. It will + register itself to be managed by the Supervisor. +2. In your main loop, call `Supervisor.Loop()` as you normally would. It + handles sending mouse and keyboard events to all managed widgets, including + the children of the managed windows. +3. In the "draw" part of your main loop, call `Supervisor.Present()` as the + final step. Supervisor will draw the managed windows on top of everything + else, with the current focused window on top of the others. Note: managed + windows are the _only_ widgets drawn by Supervisor; other widgets should be + drawn by their parent widgets in their respective Present() methods. + +You can also customize the colors and title bar controls of the managed windows. + +Example: + +```go +func example() { + engine, _ := sdl.New("Example", 800, 600) + supervisor := ui.NewSupervisor() + + win := ui.NewWindow("My Window") + + // Customize the colors of the window. Here are the defaults: + win.ActiveTitleBackground = render.Blue + win.ActiveTitleForeground = render.White + win.InactiveTitleBackground = render.DarkGrey + win.InactiveTitleForeground = render.Grey + + // Customize the window buttons by ORing the options. + // NOTE: Maximize behavior is still a work in progress, the window doesn't + // redraw itself at the new size correctly yet. + // NOTE: Minimize button has no default behavior but does trigger a + // MinimizeWindow event that you can handle yourself. + win.SetButtons(ui.CloseButton | ui.MaximizeButton | ui.MinimizeButton) + + // Add widgets to your window. + label := ui.NewLabel(ui.Label{ + Text: "Hello world!", + }) + win.Pack(label, ui.Pack{ + Side: ui.W, + }) + + // Compute the window and its children. + win.Compute(engine) + + // This is the key step: give the window to the Supervisor. + win.Supervise(supervisor) + + // And in your main loop: + // NOTE: MainWindow.MainLoop() does this for you automatically. + for { + ev, _ = engine.Poll() // poll render engine for mouse/keyboard events + supervisor.Loop(ev) + supervisor.Present(engine) + } +} +``` + +See the eg/windows/ example in the git repository for a full example, including +SDL2 and WebAssembly versions. + ## MainWindow for Simple Applications The MainWindow widget may be used for "simple" UI applications where all you diff --git a/eg/wasm-common/index.html b/eg/wasm-common/index.html new file mode 100644 index 0000000..b5376c8 --- /dev/null +++ b/eg/wasm-common/index.html @@ -0,0 +1,46 @@ + + + + go/ui wasm demo + + + + + + + + + + + diff --git a/eg/wasm-common/serve.sh b/eg/wasm-common/serve.sh new file mode 100755 index 0000000..0e603c1 --- /dev/null +++ b/eg/wasm-common/serve.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Common build script for WASM examples. +# Working directory should be inside a wasm example folder. +# This script creates a "./wasm-tmp" folder, copies the wasm-common files +# into it, copies your .wasm binary in, and runs server.go pointed to that +# directory. +# +# Read: do not run this script yourself. Run it via a `make wasm-serve` command +# in one of the other example directories. +# +# This probably works best on Linux-like systems only. + +if [[ ! -f "../wasm-common/serve.sh" ]]; then + echo Run this script via "make wasm-serve" from a ui/eg example folder. + exit 1 +fi + +if [[ -d "./wasm-tmp" ]]; then + echo Cleaning ./wasm-tmp folder. + rm -rf ./wasm-tmp +fi + +mkdir ./wasm-tmp +cp ../wasm-common/{wasm_exec.js,index.html} ./wasm-tmp/ +cp ../DejaVuSans.ttf ./wasm-tmp/ +cp *.wasm ./wasm-tmp/app.wasm +cd wasm-tmp/ +go run ../../wasm-common/server.go diff --git a/eg/wasm-common/server.go b/eg/wasm-common/server.go new file mode 100644 index 0000000..e8125c6 --- /dev/null +++ b/eg/wasm-common/server.go @@ -0,0 +1,22 @@ +// +build disabled + +package main + +import ( + "fmt" + "log" + "net/http" +) + +func main() { + const wasm = "/app.wasm" + + http.Handle("/", http.FileServer(http.Dir("."))) + http.HandleFunc(wasm, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/wasm") + http.ServeFile(w, r, "."+wasm) + }) + + fmt.Println("Listening at http://localhost:8080/") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/eg/wasm-common/wasm_exec.js b/eg/wasm-common/wasm_exec.js new file mode 100644 index 0000000..a54bb9a --- /dev/null +++ b/eg/wasm-common/wasm_exec.js @@ -0,0 +1,533 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +(() => { + // Map multiple JavaScript environments to a single common API, + // preferring web standards over Node.js API. + // + // Environments considered: + // - Browsers + // - Node.js + // - Electron + // - Parcel + + if (typeof global !== "undefined") { + // global already exists + } else if (typeof window !== "undefined") { + window.global = window; + } else if (typeof self !== "undefined") { + self.global = self; + } else { + throw new Error("cannot export Go (neither global, window nor self is defined)"); + } + + if (!global.require && typeof require !== "undefined") { + global.require = require; + } + + if (!global.fs && global.require) { + global.fs = require("fs"); + } + + if (!global.fs) { + let outputBuf = ""; + global.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substr(0, nl)); + outputBuf = outputBuf.substr(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + throw new Error("not implemented"); + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + open(path, flags, mode, callback) { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + callback(err); + }, + read(fd, buffer, offset, length, position, callback) { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + callback(err); + }, + fsync(fd, callback) { + callback(null); + }, + }; + } + + if (!global.crypto) { + const nodeCrypto = require("crypto"); + global.crypto = { + getRandomValues(b) { + nodeCrypto.randomFillSync(b); + }, + }; + } + + if (!global.performance) { + global.performance = { + now() { + const [sec, nsec] = process.hrtime(); + return sec * 1000 + nsec / 1000000; + }, + }; + } + + if (!global.TextEncoder) { + global.TextEncoder = require("util").TextEncoder; + } + + if (!global.TextDecoder) { + global.TextDecoder = require("util").TextDecoder; + } + + // End of polyfills for common API. + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + global.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const mem = () => { + // The buffer may change when requesting more memory. + return new DataView(this._inst.exports.mem.buffer); + } + + const setInt64 = (addr, v) => { + mem().setUint32(addr + 0, v, true); + mem().setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const getInt64 = (addr) => { + const low = mem().getUint32(addr + 0, true); + const high = mem().getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = mem().getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = mem().getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number") { + if (isNaN(v)) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 0, true); + return; + } + if (v === 0) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 1, true); + return; + } + mem().setFloat64(addr, v, true); + return; + } + + switch (v) { + case undefined: + mem().setFloat64(addr, 0, true); + return; + case null: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 2, true); + return; + case true: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 3, true); + return; + case false: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 4, true); + return; + } + + let ref = this._refs.get(v); + if (ref === undefined) { + ref = this._values.length; + this._values.push(v); + this._refs.set(v, ref); + } + let typeFlag = 0; + switch (typeof v) { + case "string": + typeFlag = 1; + break; + case "symbol": + typeFlag = 2; + break; + case "function": + typeFlag = 3; + break; + } + mem().setUint32(addr + 4, nanHead | typeFlag, true); + mem().setUint32(addr, ref, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + go: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + const code = mem().getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._refs; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = mem().getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func nanotime() int64 + "runtime.nanotime": (sp) => { + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early + )); + mem().setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + const id = mem().getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 56, result); + mem().setUint8(sp + 64, 1); + } catch (err) { + storeValue(sp + 56, err); + mem().setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array)) { + mem().setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + mem().setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array)) { + mem().setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + mem().setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + this._inst = instance; + this._values = [ // TODO: garbage collection + NaN, + 0, + null, + true, + false, + global, + this, + ]; + this._refs = new Map(); + this.exited = false; + + const mem = new DataView(this._inst.exports.mem.buffer) + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + + const keys = Object.keys(this.env).sort(); + argvPtrs.push(keys.length); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + + const argv = offset; + argvPtrs.forEach((ptr) => { + mem.setUint32(offset, ptr, true); + mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } + + if ( + global.require && + global.require.main === module && + global.process && + global.process.versions && + !global.process.versions.electron + ) { + if (process.argv.length < 3) { + console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); + process.exit(1); + } + + const go = new Go(); + go.argv = process.argv.slice(2); + go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env); + go.exit = process.exit; + WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { + process.on("exit", (code) => { // Node.js exits if no event handler is pending + if (code === 0 && !go.exited) { + // deadlock, make Go print error and stack traces + go._pendingEvent = { id: 0 }; + go._resume(); + } + }); + return go.run(result.instance); + }).catch((err) => { + console.error(err); + process.exit(1); + }); + } +})(); diff --git a/eg/windows/Makefile b/eg/windows/Makefile new file mode 100644 index 0000000..b5b7bbb --- /dev/null +++ b/eg/windows/Makefile @@ -0,0 +1,11 @@ +.PHONY: run +run: + go run main.go + +.PHONY: wasm +wasm: + GOOS=js GOARCH=wasm go build -v -o windows.wasm main_wasm.go + +.PHONY: wasm-serve +wasm-serve: wasm + ../wasm-common/serve.sh diff --git a/eg/windows/main.go b/eg/windows/main.go index f083897..73da02b 100644 --- a/eg/windows/main.go +++ b/eg/windows/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "git.kirsle.net/go/render" @@ -16,8 +17,22 @@ var ( Height = 768 // Cascade offset for creating multiple windows. - Cascade = render.NewPoint(10, 10) - CascadeStep = render.NewPoint(24, 24) + Cascade = render.NewPoint(10, 10) + CascadeStep = render.NewPoint(24, 24) + CascadeLoops = 1 + + // Colors for each window created. + WindowColors = []render.Color{ + render.Blue, + render.Red, + render.DarkYellow, + render.DarkGreen, + render.DarkCyan, + render.DarkBlue, + render.DarkRed, + } + WindowID int + OpenWindows int ) func init() { @@ -31,8 +46,8 @@ func main() { } // Add some windows to play with. - addWindow(mw, "First window") - addWindow(mw, "Second window") + addWindow(mw) + addWindow(mw) mw.SetBackground(render.White) @@ -46,44 +61,50 @@ func main() { } // Add a new child window. -func addWindow(mw *ui.MainWindow, title string) { +func addWindow(mw *ui.MainWindow) { + var ( + color = WindowColors[WindowID%len(WindowColors)] + title = fmt.Sprintf("Window %d", WindowID+1) + ) + WindowID++ + win1 := ui.NewWindow(title) + win1.SetButtons(ui.CloseButton) + win1.ActiveTitleBackground = color + win1.InactiveTitleBackground = color.Darken(60) + win1.InactiveTitleForeground = render.Grey win1.Configure(ui.Config{ - Width: 640, - Height: 480, + Width: 320, + Height: 240, }) win1.Compute(mw.Engine) win1.Supervise(mw.Supervisor()) - // Attach it to the MainWindow with no placement management, i.e. - // instead of Pack() or Place(). Since draggable windows set their own - // position, a position manager would only interfere and "snap" the - // window back into place as soon as you drop the title bar! - // mw.Attach(win1) + // Re-open a window when the last one is closed. + OpenWindows++ + win1.Handle(ui.CloseWindow, func(ed ui.EventData) error { + OpenWindows-- + if OpenWindows <= 0 { + addWindow(mw) + } + return nil + }) // Default placement via cascade. win1.MoveTo(Cascade) Cascade.Add(CascadeStep) - - // Add a button to the window. - // btn := ui.NewButton("Button1", ui.NewLabel(ui.Label{ - // Text: "Click me!", - // })) - // btn.Handle(ui.Click, func(ed ui.EventData) { - // fmt.Printf("Window '%s' button clicked!\n", title) - // }) - // mw.Add(btn) - // win1.Place(btn, ui.Place{ - // Top: 10, - // Left: 10, - // }) + if Cascade.Y > Height-240-64 { + CascadeLoops++ + Cascade.Y = 24 + Cascade.X = 24 * CascadeLoops + } // Add a window duplicator button. btn2 := ui.NewButton(title+":Button2", ui.NewLabel(ui.Label{ Text: "New Window", })) btn2.Handle(ui.Click, func(ed ui.EventData) error { - addWindow(mw, "New Window") + addWindow(mw) return nil }) mw.Add(btn2) diff --git a/eg/windows/main_wasm.go b/eg/windows/main_wasm.go new file mode 100644 index 0000000..e0aa1b0 --- /dev/null +++ b/eg/windows/main_wasm.go @@ -0,0 +1,154 @@ +// +build js,wasm + +// WebAssembly version of the window manager demo. +// To build: make wasm +// To test: make wasm-serve + +package main + +import ( + "fmt" + "time" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/render/canvas" + "git.kirsle.net/go/ui" +) + +// Program globals. +var ( + ThrottleFPS = 1000 / 60 + + // Size of the MainWindow. + Width = 1024 + Height = 768 + + // Cascade offset for creating multiple windows. + Cascade = render.NewPoint(10, 10) + CascadeStep = render.NewPoint(24, 24) + CascadeLoops = 1 + + // Colors for each window created. + WindowColors = []render.Color{ + render.Blue, + render.Red, + render.DarkYellow, + render.DarkGreen, + render.DarkCyan, + render.DarkBlue, + render.DarkRed, + } + WindowID int + OpenWindows int +) + +func main() { + mw, err := canvas.New("canvas") + if err != nil { + panic(err) + } + + // Bind DOM event handlers. + mw.AddEventListeners() + + supervisor := ui.NewSupervisor() + + frame := ui.NewFrame("Main Frame") + frame.Resize(render.NewRect(mw.WindowSize())) + frame.Compute(mw) + + _, height := mw.WindowSize() + lbl := ui.NewLabel(ui.Label{ + Text: "Window Manager Demo", + Font: render.Text{ + FontFilename: "DejaVuSans.ttf", + Size: 32, + Color: render.SkyBlue, + Shadow: render.SkyBlue.Darken(60), + }, + }) + lbl.Compute(mw) + lbl.MoveTo(render.NewPoint( + 20, + height-lbl.Size().H-20, + )) + + // Add some windows to play with. + addWindow(mw, frame, supervisor) + addWindow(mw, frame, supervisor) + + for { + mw.Clear(render.RGBA(255, 255, 200, 255)) + start := time.Now() + ev, err := mw.Poll() + if err != nil { + panic(err) + } + + lbl.Present(mw, lbl.Point()) + supervisor.Loop(ev) + supervisor.Present(mw) + + var delay uint32 + elapsed := time.Now().Sub(start) + tmp := elapsed / time.Millisecond + if ThrottleFPS-int(tmp) > 0 { + delay = uint32(ThrottleFPS - int(tmp)) + } + mw.Delay(delay) + } +} + +// Add a new child window. +func addWindow(engine render.Engine, parent *ui.Frame, sup *ui.Supervisor) { + var ( + color = WindowColors[WindowID%len(WindowColors)] + title = fmt.Sprintf("Window %d", WindowID+1) + ) + WindowID++ + + win1 := ui.NewWindow(title) + win1.SetButtons(ui.CloseButton) + win1.ActiveTitleBackground = color + win1.InactiveTitleBackground = color.Darken(60) + win1.InactiveTitleForeground = render.Grey + win1.Configure(ui.Config{ + Width: 320, + Height: 240, + }) + win1.Compute(engine) + win1.Supervise(sup) + + // Re-open a window when the last one is closed. + OpenWindows++ + win1.Handle(ui.CloseWindow, func(ed ui.EventData) error { + OpenWindows-- + if OpenWindows <= 0 { + addWindow(engine, parent, sup) + } + return nil + }) + + // Default placement via cascade. + win1.MoveTo(Cascade) + Cascade.Add(CascadeStep) + if Cascade.Y > Height-240-64 { + CascadeLoops++ + Cascade.Y = 24 + Cascade.X = 24 * CascadeLoops + } + + // Add a window duplicator button. + btn2 := ui.NewButton(title+":Button2", ui.NewLabel(ui.Label{ + Text: "New Window", + })) + btn2.Handle(ui.Click, func(ed ui.EventData) error { + addWindow(engine, parent, sup) + return nil + }) + sup.Add(btn2) + win1.Place(btn2, ui.Place{ + Top: 10, + Right: 10, + }) +} diff --git a/functions.go b/functions.go index 9613881..7ae0d5f 100644 --- a/functions.go +++ b/functions.go @@ -25,6 +25,8 @@ func AbsolutePosition(w Widget) render.Point { } // AbsoluteRect returns a Rect() offset with the absolute position. +// X and Y are the AbsolutePosition of the widget. +// W and H are the widget's width and height. (X,Y not added to them) func AbsoluteRect(w Widget) render.Rect { var ( P = AbsolutePosition(w) @@ -33,7 +35,7 @@ func AbsoluteRect(w Widget) render.Rect { return render.Rect{ X: P.X, Y: P.Y, - W: R.W + P.X, + W: R.W, H: R.H, // TODO: the Canvas in EditMode lets you draw pixels // below the status bar if we do `+ R.Y` here. } @@ -53,7 +55,7 @@ func widgetInFocusedWindow(w Widget) (isManaged, isFocused bool) { for { // Is the node a Window? if window, ok := node.(*Window); ok { - return true, window.Focused() + return window.managed, window.Focused() } node, _ = node.Parent() @@ -62,27 +64,3 @@ func widgetInFocusedWindow(w Widget) (isManaged, isFocused bool) { } } } - -// WidgetInManagedWindow returns true if the widget is owned by a ui.Window -// which is being Window Managed by the Supervisor. -// -// Returns true if any parent widget is a Window with managed=true. This -// boolean is set when you call .Supervise() on the window to be managed by -// Supervisor. -func WidgetInManagedWindow(w Widget) bool { - var node = w - - for { - // Is the node a Window? - if window, ok := node.(*Window); ok { - if window.managed { - return true - } - } - - node, _ = node.Parent() - if node == nil { - return false // reached the root - } - } -} diff --git a/main_window.go b/main_window.go index eed5ce5..ee9566a 100644 --- a/main_window.go +++ b/main_window.go @@ -164,7 +164,6 @@ func (mw *MainWindow) MainLoop() error { // Loop does one loop of the UI. func (mw *MainWindow) Loop() error { - fmt.Printf("------ MAIN LOOP\n") mw.Engine.Clear(render.White) // Record how long this loop took. diff --git a/supervisor.go b/supervisor.go index fcdc525..b00690a 100644 --- a/supervisor.go +++ b/supervisor.go @@ -28,6 +28,11 @@ const ( DragMove // mouse movements sent to a widget being dragged. Drop // a "drop site" widget under the cursor when a drag is done + // Window Manager events. + CloseWindow + MaximizeWindow + MinimizeWindow + // Lifecycle event handlers. Compute // fired whenever the widget runs Compute Present // fired whenever the widget runs Present @@ -149,6 +154,18 @@ func (s *Supervisor) Loop(ev *event.State) error { return ErrStopPropagation } + // Check if the top focused window has been closed and auto-focus the next. + if s.winFocus != nil && s.winFocus.window.Hidden() { + next := s.winFocus.next + for next != nil { + if !next.window.Hidden() { + s.FocusWindow(next.window) + break + } + next = next.next + } + } + // Run events in managed windows first, from top to bottom. // Widgets in unmanaged windows will be handled next. // err := s.runWindowEvents(XY, ev, hovering, outside) @@ -223,6 +240,18 @@ func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, ranEvents bool ) + // If we're running this method in "Phase 2" (to widgets NOT in the focused + // window), only send mouse events to widgets if the cursor is NOT inside + // the bounding box of the active focused window. Prevents clicking "thru" + // the window and activating widgets/other windows behind it. + var cursorInsideFocusedWindow bool + if !toFocusedWindow && s.winFocus != nil { + // Get the bounding box of the focused window. + if XY.Inside(AbsoluteRect(s.winFocus.window)) { + cursorInsideFocusedWindow = true + } + } + // Handler for an Event response errors. handle := func(err error) { // Did any event handler run? @@ -241,6 +270,13 @@ func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, break } + // If the cursor is inside the box of the focused window, don't trigger + // active (hovering) mouse events. MouseOut type events, below, can still + // trigger. + if cursorInsideFocusedWindow { + break + } + var ( id = child.id w = child.widget @@ -270,7 +306,7 @@ func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, } // It is a window, but can only be the non-focused window. - if window.focused { + if isWindow && window.focused { continue } } diff --git a/widget.go b/widget.go index 4fb560d..dedac93 100644 --- a/widget.go +++ b/widget.go @@ -494,7 +494,10 @@ func (w *BaseWidget) Present(e render.Engine, p render.Point) { func (w *BaseWidget) Event(event Event, e EventData) error { if handlers, ok := w.handlers[event]; ok { for _, fn := range handlers { - return fn(e) + res := fn(e) + if res == ErrStopPropagation { + return res + } } } return ErrNoEventHandler diff --git a/window.go b/window.go index 5d6495a..8d33b67 100644 --- a/window.go +++ b/window.go @@ -9,8 +9,7 @@ import ( // Window is a frame with a title bar. type Window struct { BaseWidget - Title string - Active bool + Title string // Title bar colors. Sensible defaults are chosen in NewWindow but you // may customize after the fact. @@ -20,17 +19,25 @@ type Window struct { InactiveTitleForeground render.Color // Private widgets. - body *Frame - titleBar *Frame - titleLabel *Label - content *Frame + body *Frame + titleBar *Frame + titleLabel *Label + titleButtons []*Button + content *Frame + + // Configured title bar buttons. + buttonsEnabled int // Window manager controls. dragging bool startDragAt render.Point // cursor position when drag began dragOrigPoint render.Point // original position of window at drag start focused bool - managed bool // window is managed by Supervisor + managed bool // window is managed by Supervisor + maximized bool // toggled by MaximizeButton + origPoint render.Point // placement before a maximize + origSize render.Rect // size before a maximize + engine render.Engine // hang onto the render engine, for Maximize support. } // NewWindow creates a new window. @@ -42,8 +49,8 @@ func NewWindow(title string) *Window { // Default title bar colors. ActiveTitleBackground: render.Blue, ActiveTitleForeground: render.White, - InactiveTitleBackground: render.Grey, - InactiveTitleForeground: render.Black, + InactiveTitleBackground: render.DarkGrey, + InactiveTitleForeground: render.Grey, } w.IDFunc(func() string { return fmt.Sprintf("Window<%s %+v>", @@ -85,11 +92,12 @@ func NewWindow(title string) *Window { // setupTitlebar creates the title bar frame of the window. func (w *Window) setupTitleBar() (*Frame, *Label) { - frame := NewFrame("Titlebar for Windows: " + w.Title) + frame := NewFrame("Titlebar for Window: " + w.Title) frame.Configure(Config{ Background: w.ActiveTitleBackground, }) + // Title label. label := NewLabel(Label{ TextVariable: &w.Title, Font: render.Text{ @@ -103,9 +111,91 @@ func (w *Window) setupTitleBar() (*Frame, *Label) { Side: W, }) + // Window buttons. + var buttons = []struct { + If bool + Label string + Event Event + }{ + { + Label: "×", + Event: CloseWindow, + }, + { + Label: "+", + Event: MaximizeWindow, + }, + { + Label: "_", + Event: MinimizeWindow, + }, + } + w.titleButtons = make([]*Button, len(buttons)) + for i, cfg := range buttons { + cfg := cfg + btn := NewButton( + fmt.Sprintf("Title Button %d for Window: %s", i, w.Title), + NewLabel(Label{ + Text: cfg.Label, + Font: render.Text{ + // Color: w.ActiveTitleForeground, + Size: 8, + Padding: 2, + }, + }), + ) + btn.SetBorderSize(0) + btn.Handle(Click, func(ed EventData) error { + w.Event(cfg.Event, ed) + return ErrStopPropagation // TODO: doesn't work :( + }) + btn.Hide() + w.titleButtons[i] = btn + + frame.Pack(btn, Pack{ + Side: E, + }) + } + return frame, label } +// SetButtons sets the title bar buttons to show in the window. +// +// The value should be the OR of CloseButton, MaximizeButton and MinimizeButton +// that you want to be enabled. +// +// Window buttons only work if the window is managed by Supervisor and you have +// called the Supervise() method of the window. +func (w *Window) SetButtons(buttons int) { + // Show/hide each button based on the value given. + var toggle = []struct { + Value int + Index int + }{ + { + Value: CloseButton, + Index: 0, + }, + { + Value: MaximizeButton, + Index: 1, + }, + { + Value: MinimizeButton, + Index: 2, + }, + } + + for _, item := range toggle { + if buttons&item.Value == item.Value { + w.titleButtons[item.Index].Show() + } else { + w.titleButtons[item.Index].Hide() + } + } +} + // Supervise enables the window to be dragged around by its title bar by // adding its relevant event hooks to your Supervisor. func (w *Window) Supervise(s *Supervisor) { @@ -113,7 +203,6 @@ func (w *Window) Supervise(s *Supervisor) { w.titleBar.Handle(MouseDown, func(ed EventData) error { w.startDragAt = ed.Point w.dragOrigPoint = w.Point() - fmt.Printf("Clicked at %s window at %s!\n", ed.Point, w.dragOrigPoint) s.DragStartWidget(w) return nil @@ -122,10 +211,6 @@ func (w *Window) Supervise(s *Supervisor) { // Clicking anywhere in the window focuses the window. w.Handle(MouseDown, func(ed EventData) error { s.FocusWindow(w) - fmt.Printf("%s handles click event\n", w) - return nil - }) - w.Handle(Click, func(ed EventData) error { return nil }) @@ -134,7 +219,6 @@ func (w *Window) Supervise(s *Supervisor) { // Get the delta of movement from where we began. delta := w.startDragAt.Compare(ed.Point) if delta != render.Origin { - fmt.Printf(" Dragged to: %s Delta: %s\n", ed.Point, delta) moveTo := w.dragOrigPoint moveTo.Add(delta) w.MoveTo(moveTo) @@ -142,8 +226,21 @@ func (w *Window) Supervise(s *Supervisor) { return nil }) + // Window button handlers. + w.Handle(CloseWindow, func(ed EventData) error { + w.Hide() + return nil + }) + w.Handle(MaximizeWindow, func(ed EventData) error { + w.SetMaximized(!w.maximized) + return nil + }) + // Add the title bar to the supervisor. s.Add(w.titleBar) + for _, btn := range w.titleButtons { + s.Add(btn) + } s.Add(w) // Add the window to the focus list of the supervisor. @@ -175,6 +272,30 @@ func (w *Window) SetFocus(v bool) { w.titleLabel.Font.Stroke = bg.Darken(40) } +// Maximized returns whether the window is maximized. +func (w *Window) Maximized() bool { + return w.maximized +} + +// SetMaximized sets the state of the maximized window. +// Must have called Compute() once before so the window can hang on to the +// render.Engine, to calculate the size of the parent window. +func (w *Window) SetMaximized(v bool) { + w.maximized = v + + if v && w.engine != nil { + w.origPoint = w.Point() + w.origSize = w.Size() + w.MoveTo(render.Origin) + w.Resize(render.NewRect(w.engine.WindowSize())) + w.Compute(w.engine) + } else if w.engine != nil { + w.MoveTo(w.origPoint) + w.Resize(w.origSize) + w.Compute(w.engine) + } +} + // Children returns the window's child widgets. func (w *Window) Children() []Widget { return []Widget{ @@ -216,6 +337,7 @@ func (w *Window) ConfigureTitle(C Config) { // Compute the window. func (w *Window) Compute(e render.Engine) { + w.engine = e // hang onto it in case of maximize w.body.Compute(e) // Call the BaseWidget Compute in case we have subscribers. diff --git a/window_manager.go b/window_manager.go index 644ed51..2bbeab5 100644 --- a/window_manager.go +++ b/window_manager.go @@ -12,6 +12,19 @@ window_manager.go holds data types and Supervisor methods related to the management of ui.Window widgets. */ +// Window button options. OR these together in a call to Window.SetButtons(). +const ( + CloseButton = 0x01 + + // NOTICE: MaximizeButton behavior is currently buggy, window doesn't + // redraw itself at the new size properly. + MaximizeButton = 0x02 + + // Minimize button has no default behavior attached; you can bind it with + // window.Handle(MinimizeWindow) to set your own event handler. + MinimizeButton = 0x04 +) + // FocusedWindow is a doubly-linked list of recently focused Windows, with // the current and most-recently focused on top. TODO make not exported. type FocusedWindow struct { @@ -69,15 +82,6 @@ func (s *Supervisor) addWindow(win *Window) { } } -// presentWindows draws the windows from bottom to top. -func (s *Supervisor) presentWindows(e render.Engine) { - item := s.winBottom - for item != nil { - item.window.Present(e, item.window.Point()) - item = item.prev - } -} - // FocusWindow brings the given window to the top of the supervisor's focus. // // The window must have previously been added to the supervisor's Window Manager @@ -146,3 +150,13 @@ func (s *Supervisor) FocusWindow(win *Window) error { return nil } + +// presentWindows draws the windows from bottom to top. +func (s *Supervisor) presentWindows(e render.Engine) { + item := s.winBottom + for item != nil { + item.window.Compute(e) + item.window.Present(e, item.window.Point()) + item = item.prev + } +} diff --git a/window_test.go b/window_test.go new file mode 100644 index 0000000..01b7c6e --- /dev/null +++ b/window_test.go @@ -0,0 +1,51 @@ +package ui_test + +import ( + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui" +) + +// Example of using the Supervisor Window Manager. +func ExampleWindow() { + mw, err := ui.NewMainWindow("Window Manager Example", 800, 600) + if err != nil { + panic(err) + } + + // Create a window as normal. + window := ui.NewWindow("Hello world!") + window.Configure(ui.Config{ + Width: 320, + Height: 240, + }) + + // Configure its title bar colors (optional; these are the defaults) + window.ActiveTitleBackground = render.Blue + window.ActiveTitleForeground = render.White + window.InactiveTitleBackground = render.DarkGrey + window.InactiveTitleForeground = render.Grey + + // Configure its window buttons (optional); default has no window buttons. + // Window buttons are only functional in managed windows. + window.SetButtons(ui.CloseButton | ui.MaximizeButton | ui.MinimizeButton) + + // Add some widgets to the window. + btn := ui.NewButton("My Button", ui.NewLabel(ui.Label{ + Text: "Hello world!", + })) + window.Place(btn, ui.Place{ + Center: true, + Middle: true, + }) + + // To enable the window manager controls, the key step is to give it + // the Supervisor so it can be managed: + window.Compute(mw.Engine) + window.Supervise(mw.Supervisor()) + + // Each loop you must then: + // - Call Supervisor.Loop() as normal to handle events. + // - Call Supervisor.Present() to draw the managed windows. + // MainLoop() of the MainWindow does this for you. + mw.MainLoop() +}