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()
+}