From 36db1605337be63241708260ce55a4d639c1972a Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 8 Apr 2020 17:29:04 -0700 Subject: [PATCH] Window Manager Buttons and Bugfixes * Fix Supervisor event issues wrt. the window manager feature: if a focused window exists and Supervisor is running events for the "other" widgets not in managed windows, and the mouse cursor is over the rectangle of THE focused window, no widget under the cursor receives active (hover, click) events. Prevents being able to click "through" the window and interact with widgets and other windows below. * Adds Close, Maximize and Minimize buttons to windows. Maximize is still buggy and Minimize is implementation-defined behavior with no default event handler configured. * eg/windows has an example of the Window Manager for SDL2 and WebAssembly targets. --- .gitignore | 2 + README.md | 80 +++++- eg/wasm-common/index.html | 46 ++++ eg/wasm-common/serve.sh | 29 ++ eg/wasm-common/server.go | 22 ++ eg/wasm-common/wasm_exec.js | 533 ++++++++++++++++++++++++++++++++++++ eg/windows/Makefile | 11 + eg/windows/main.go | 73 +++-- eg/windows/main_wasm.go | 154 +++++++++++ functions.go | 30 +- main_window.go | 1 - supervisor.go | 38 ++- widget.go | 5 +- window.go | 154 +++++++++-- window_manager.go | 32 ++- window_test.go | 51 ++++ 16 files changed, 1174 insertions(+), 87 deletions(-) create mode 100644 .gitignore create mode 100644 eg/wasm-common/index.html create mode 100755 eg/wasm-common/serve.sh create mode 100644 eg/wasm-common/server.go create mode 100644 eg/wasm-common/wasm_exec.js create mode 100644 eg/windows/Makefile create mode 100644 eg/windows/main_wasm.go create mode 100644 window_test.go 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() +}