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.
This commit is contained in:
Noah 2020-04-08 17:29:04 -07:00
parent 7d9ba79cd2
commit 36db160533
16 changed files with 1174 additions and 87 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
wasm-tmp
*.wasm

View File

@ -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

46
eg/wasm-common/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<title>go/ui wasm demo</title>
<style>
#canvas {
position: fixed;
width: 100%;
height: 100%;
top: 0;
right: 0;
left: 0;
bottom: 0;
}
@font-face {
font-family: "DejaVuSans";
src: url("DejaVuSans.ttf") format("truetype");
}
</style>
</head>
<body>
<script src="wasm_exec.js"></script>
<script>
if (!WebAssembly.instantiateStreaming) { // polyfill
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
(function() {
const go = new Go();
WebAssembly.instantiateStreaming(fetch("app.wasm"), go.importObject).then(result => {
console.clear();
go.run(result.instance);
WebAssembly.instantiate(result.module, go.importObject); // reset instance
})
})();
</script>
<canvas id="canvas"></canvas>
</body>
</html>

29
eg/wasm-common/serve.sh Executable file
View File

@ -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

22
eg/wasm-common/server.go Normal file
View File

@ -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))
}

533
eg/wasm-common/wasm_exec.js Normal file
View File

@ -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);
});
}
})();

11
eg/windows/Makefile Normal file
View File

@ -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

View File

@ -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)

154
eg/windows/main_wasm.go Normal file
View File

@ -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,
})
}

View File

@ -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
}
}
}

View File

@ -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.

View File

@ -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
}
}

View File

@ -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

154
window.go
View File

@ -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.

View File

@ -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
}
}

51
window_test.go Normal file
View File

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