diff --git a/README.md b/README.md index b68c384..8fa0973 100644 --- a/README.md +++ b/README.md @@ -138,25 +138,21 @@ most complex. (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. +* [x] **MenuButton**: a button that opens a modal pop-up menu on click. +* [x] **MenuBar**: a specialized Frame that groups a bunch of MenuButtons and + provides a simple API to add menus and items to it. +* [x] **Menu**: a frame full of clickable links and separators. Usually used as + a modal pop-up by the MenuButton and MenuBar. **Work in progress widgets:** -* [x] **Menu**: a frame with clickable menu items. - * To be a base widget behind right-click context menus, pull-down menus - from a MenuBar, options from a SelectBox and so on. - * Powered by Frame and Button but with a nice API for composing menu - actions. - * Partially implemented so far. -* [ ] **MenuButton**: a Button that opens a Menu when clicked. -* [ ] **MenuBar**: a Frame that houses many MenuButtons, intended for the - main menu at the top of a UI window (File, Edit, Help, etc.). * [ ] **Scrollbar**: a Frame including a trough, scroll buttons and a draggable slider. +* [ ] **SelectBox:** a kind of MenuButton that lets the user choose a value + from a list of possible values, bound to a string variable. **Wish list for the longer-term future:** -* [ ] **SelectBox:** a kind of MenuButton that lets the user choose a value - from a list of possible values, bound to a string variable. * [ ] **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. diff --git a/debug.go b/debug.go index 4ce10d0..f25d42b 100644 --- a/debug.go +++ b/debug.go @@ -5,6 +5,14 @@ import ( "strings" ) +// PrintWidgetTree prints a widget tree to console. +func PrintWidgetTree(root Widget) { + fmt.Printf("--- Widget Tree of %s ---\n", root) + for _, row := range WidgetTree(root) { + fmt.Println(row) + } +} + // WidgetTree returns a string representing the tree of widgets starting // at a given widget. func WidgetTree(root Widget) []string { diff --git a/docs/menus-1.png b/docs/menus-1.png new file mode 100644 index 0000000..3f3973f Binary files /dev/null and b/docs/menus-1.png differ diff --git a/docs/menus-2.png b/docs/menus-2.png new file mode 100644 index 0000000..5f9c7e0 Binary files /dev/null and b/docs/menus-2.png differ diff --git a/eg/README.md b/eg/README.md new file mode 100644 index 0000000..5ab58e6 --- /dev/null +++ b/eg/README.md @@ -0,0 +1,10 @@ +# Examples for go/ui + +* [Hello, World!](hello-world/): a basic UI demo. +* [Frame Place()](frame-place/): demonstrates using the Place() layout management + option for Frame widgets.] +* [Window Manager](windows/): demonstrates the Window widget and window + management features of the Supervisor. +* [Tooltip](tooltip/): demonstrates the Tooltip widget on a variety of buttons + scattered around the window. +* [Menus](menus/): demonstrates various Menu Buttons and a Menu Bar. diff --git a/eg/menus/Makefile b/eg/menus/Makefile new file mode 100644 index 0000000..b5b7bbb --- /dev/null +++ b/eg/menus/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/menus/README.md b/eg/menus/README.md new file mode 100644 index 0000000..d80001f --- /dev/null +++ b/eg/menus/README.md @@ -0,0 +1,19 @@ +# Menu Example + +This example shows off the Menu, MenuButton, and MenuBar widgets. + +* MenuButton is your basic button that pops up a Menu when clicked. +* MenuBar is a specialized Frame that attaches to the top of the parent + (usually the window) and provides a simple API to add menus and items. +* Menu is the underlying "pop-up and select an item" widget. + +## Running It + +From your terminal, just type `go run main.go` or `make run` from this +example's directory. + +## Screenshots + +![Screenshot 1](../../docs/menus-1.png) + +![Screenshot 2](../../docs/menus-2.png) diff --git a/eg/menus/main.go b/eg/menus/main.go new file mode 100644 index 0000000..254634f --- /dev/null +++ b/eg/menus/main.go @@ -0,0 +1,208 @@ +package main + +import ( + "fmt" + "os" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/render/event" + "git.kirsle.net/go/render/sdl" + "git.kirsle.net/go/ui" +) + +// Program globals. +var ( + // Size of the MainWindow. + Width = 640 + Height = 480 + + BGColor = render.White +) + +func init() { + sdl.DefaultFontFilename = "../DejaVuSans.ttf" +} + +func main() { + mw, err := ui.NewMainWindow("Menu Demo", Width, Height) + if err != nil { + panic(err) + } + + setupMainMenu(mw) + + // Menu button in middle of window. + { + btn := ui.NewMenuButton("MenuBtn", ui.NewLabel(ui.Label{ + Text: "Click me!", + })) + btn.Supervise(mw.Supervisor()) + mw.Place(btn, ui.Place{ + Center: true, + Middle: true, + }) + + for _, label := range []string{ + "MenuButtons open menus", + "when clicked. MenuBar is ", + "a Frame of MenuButtons", + "attached to the top of the", + "window.", + "", + "They all provide a nice API", + "to insert menus and items.", + } { + label := label + if label == "" { + btn.AddSeparator() + continue + } + btn.AddItem(label, func() { + fmt.Printf("Button '%s' clicked!\n", label) + }) + } + + } + + // Menu button on the bottom right of screen. + { + btn := ui.NewMenuButton("BrBtn", ui.NewLabel(ui.Label{ + Text: "Fruits", + })) + btn.Supervise(mw.Supervisor()) + mw.Place(btn, ui.Place{ + Right: 20, + Bottom: 20, + }) + + btn.AddItem("Apples", func() {}) + btn.AddItem("Oranges", func() {}) + btn.AddItem("Bananas", func() {}) + btn.AddItem("Pears", func() {}) + } + + // Menu button on the bottom left of screen. + { + btn := ui.NewMenuButton("BlBtn", ui.NewLabel(ui.Label{ + Text: "Set Window Color", + })) + btn.Supervise(mw.Supervisor()) + mw.Place(btn, ui.Place{ + Left: 20, + Bottom: 20, + }) + + setBg := func(color render.Color) func() { + return func() { + BGColor = color + } + } + + // Really fancy buttons. + var colors = []struct { + label string + hex string + color render.Color + }{ + {"Black", "#000", render.Black}, + {"Red", "#F00", render.Red}, + {"Yellow", "#FF0", render.Yellow}, + {"Green", "#0F0", render.Green}, + {"Cyan", "#0FF", render.Cyan}, + {"Blue", "#00F", render.Blue}, + {"Magenta", "#F0F", render.Magenta}, + {"White", "#FFF", render.White}, + } + for _, opt := range colors { + item := btn.AddItemAccel(opt.label, opt.hex, setBg(opt.color)) + item.SetBackground(opt.color.Lighten(128)) + } + + // btn.AddItemAccel("Black", "#000", setBg(render.White)) + // btn.AddItemAccel("Red", "#F00", setBg(render.Red)) + // btn.AddItemAccel("Yellow", "#FF0", setBg(render.Yellow)) + // btn.AddItemAccel("Green", "#0F0", setBg(render.Green)) + // btn.AddItemAccel("Cyan", "#0FF", setBg(render.Cyan)) + // btn.AddItemAccel("Blue", "#00F", setBg(render.Blue)) + // btn.AddItemAccel("Magenta", "#F0F", setBg(render.Magenta)) + // btn.AddItemAccel("White", "#FFF", setBg(render.White)) + } + + // The "Long Menu" on the middle left side + { + btn := ui.NewMenuButton("BlBtn", ui.NewLabel(ui.Label{ + Text: "Tall Growing Menu", + })) + btn.Supervise(mw.Supervisor()) + mw.Place(btn, ui.Place{ + Left: 20, + Middle: true, + }) + + var id int + btn.AddItem("Add New Option", func() { + id++ + id := id + btn.AddItem(fmt.Sprintf("Menu Item #%d", id), func() { + fmt.Printf("Chosen menu item %d\n", id) + }) + }) + + btn.AddSeparator() + } + + mw.OnLoop(func(e *event.State) { + mw.SetBackground(BGColor) + if e.Up { + fmt.Println("Supervised widgets:") + for widg := range mw.Supervisor().Widgets() { + fmt.Printf("%+v\n", widg) + } + } + if e.Escape { + os.Exit(0) + } + }) + + mw.MainLoop() +} + +func setupMainMenu(mw *ui.MainWindow) { + bar := ui.NewMenuBar("Main Menu") + + fileMenu := bar.AddMenu("File") + fileMenu.AddItemAccel("New", "Ctrl-N", func() { + fmt.Println("Chose File->New") + }) + fileMenu.AddItemAccel("Open", "Ctrl-O", func() { + fmt.Println("Chose File->Open") + }) + fileMenu.AddSeparator() + fileMenu.AddItemAccel("Exit", "Alt-F4", func() { + fmt.Println("Chose File->Exit") + os.Exit(0) + }) + + editMenu := bar.AddMenu("Edit") + editMenu.AddItemAccel("Undo", "Ctrl-Z", func() {}) + editMenu.AddItemAccel("Redo", "Shift-Ctrl-Z", func() {}) + editMenu.AddSeparator() + editMenu.AddItemAccel("Cut", "Ctrl-X", func() {}) + editMenu.AddItemAccel("Copy", "Ctrl-C", func() {}) + editMenu.AddItemAccel("Paste", "Ctrl-V", func() {}) + editMenu.AddSeparator() + editMenu.AddItem("Settings...", func() {}) + + viewMenu := bar.AddMenu("View") + viewMenu.AddItemAccel("Toggle Full Screen", "F11", func() {}) + + helpMenu := bar.AddMenu("Help") + helpMenu.AddItemAccel("Contents", "F1", func() {}) + helpMenu.AddItem("About", func() {}) + + bar.Supervise(mw.Supervisor()) + bar.Compute(mw.Engine) + mw.Pack(bar, bar.PackTop()) + + fmt.Printf("Setup MenuBar: %s\n", bar.Size()) +} diff --git a/eg/wasm-common/wasm_exec.js b/eg/wasm-common/wasm_exec.js index a54bb9a..bb66cf2 100644 --- a/eg/wasm-common/wasm_exec.js +++ b/eg/wasm-common/wasm_exec.js @@ -30,6 +30,12 @@ global.fs = require("fs"); } + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + if (!global.fs) { let outputBuf = ""; global.fs = { @@ -45,27 +51,53 @@ }, write(fd, buf, offset, length, position, callback) { if (offset !== 0 || length !== buf.length || position !== null) { - throw new Error("not implemented"); + callback(enosys()); + return; } 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); - }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, }; } + if (!global.process) { + global.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + if (!global.crypto) { const nodeCrypto = require("crypto"); global.crypto = { @@ -113,24 +145,19 @@ 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); + this.mem.setUint32(addr + 0, v, true); + this.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); + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); return low + high * 4294967296; } const loadValue = (addr) => { - const f = mem().getFloat64(addr, true); + const f = this.mem.getFloat64(addr, true); if (f === 0) { return undefined; } @@ -138,7 +165,7 @@ return f; } - const id = mem().getUint32(addr, true); + const id = this.mem.getUint32(addr, true); return this._values[id]; } @@ -147,57 +174,62 @@ if (typeof v === "number") { if (isNaN(v)) { - mem().setUint32(addr + 4, nanHead, true); - mem().setUint32(addr, 0, true); + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); return; } if (v === 0) { - mem().setUint32(addr + 4, nanHead, true); - mem().setUint32(addr, 1, true); + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 1, true); return; } - mem().setFloat64(addr, v, true); + this.mem.setFloat64(addr, v, true); return; } switch (v) { case undefined: - mem().setFloat64(addr, 0, true); + this.mem.setFloat64(addr, 0, true); return; case null: - mem().setUint32(addr + 4, nanHead, true); - mem().setUint32(addr, 2, true); + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 2, true); return; case true: - mem().setUint32(addr + 4, nanHead, true); - mem().setUint32(addr, 3, true); + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 3, true); return; case false: - mem().setUint32(addr + 4, nanHead, true); - mem().setUint32(addr, 4, true); + this.mem.setUint32(addr + 4, nanHead, true); + this.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 id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); } - let typeFlag = 0; + this._goRefCounts[id]++; + let typeFlag = 1; switch (typeof v) { case "string": - typeFlag = 1; - break; - case "symbol": typeFlag = 2; break; - case "function": + case "symbol": typeFlag = 3; break; + case "function": + typeFlag = 4; + break; } - mem().setUint32(addr + 4, nanHead | typeFlag, true); - mem().setUint32(addr, ref, true); + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); } const loadSlice = (addr) => { @@ -232,11 +264,13 @@ // func wasmExit(code int32) "runtime.wasmExit": (sp) => { - const code = mem().getInt32(sp + 8, true); + const code = this.mem.getInt32(sp + 8, true); this.exited = true; delete this._inst; delete this._values; - delete this._refs; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; this.exit(code); }, @@ -244,20 +278,25 @@ "runtime.wasmWrite": (sp) => { const fd = getInt64(sp + 8); const p = getInt64(sp + 16); - const n = mem().getInt32(sp + 24, true); + const n = this.mem.getInt32(sp + 24, true); fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); }, - // func nanotime() int64 - "runtime.nanotime": (sp) => { + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); }, - // func walltime() (sec int64, nsec int32) - "runtime.walltime": (sp) => { + // func walltime1() (sec int64, nsec int32) + "runtime.walltime1": (sp) => { const msec = (new Date).getTime(); setInt64(sp + 8, msec / 1000); - mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); }, // func scheduleTimeoutEvent(delay int64) int32 @@ -276,12 +315,12 @@ }, getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early )); - mem().setInt32(sp + 16, id, true); + this.mem.setInt32(sp + 16, id, true); }, // func clearTimeoutEvent(id int32) "runtime.clearTimeoutEvent": (sp) => { - const id = mem().getInt32(sp + 8, true); + const id = this.mem.getInt32(sp + 8, true); clearTimeout(this._scheduledTimeouts.get(id)); this._scheduledTimeouts.delete(id); }, @@ -291,6 +330,18 @@ crypto.getRandomValues(loadSlice(sp + 8)); }, + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + // func stringVal(value string) ref "syscall/js.stringVal": (sp) => { storeValue(sp + 24, loadString(sp + 8)); @@ -308,6 +359,11 @@ Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); }, + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + // func valueIndex(v ref, i int) ref "syscall/js.valueIndex": (sp) => { storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); @@ -327,10 +383,10 @@ const result = Reflect.apply(m, v, args); sp = this._inst.exports.getsp(); // see comment above storeValue(sp + 56, result); - mem().setUint8(sp + 64, 1); + this.mem.setUint8(sp + 64, 1); } catch (err) { storeValue(sp + 56, err); - mem().setUint8(sp + 64, 0); + this.mem.setUint8(sp + 64, 0); } }, @@ -342,10 +398,10 @@ const result = Reflect.apply(v, undefined, args); sp = this._inst.exports.getsp(); // see comment above storeValue(sp + 40, result); - mem().setUint8(sp + 48, 1); + this.mem.setUint8(sp + 48, 1); } catch (err) { storeValue(sp + 40, err); - mem().setUint8(sp + 48, 0); + this.mem.setUint8(sp + 48, 0); } }, @@ -357,10 +413,10 @@ const result = Reflect.construct(v, args); sp = this._inst.exports.getsp(); // see comment above storeValue(sp + 40, result); - mem().setUint8(sp + 48, 1); + this.mem.setUint8(sp + 48, 1); } catch (err) { storeValue(sp + 40, err); - mem().setUint8(sp + 48, 0); + this.mem.setUint8(sp + 48, 0); } }, @@ -384,7 +440,7 @@ // func valueInstanceOf(v ref, t ref) bool "syscall/js.valueInstanceOf": (sp) => { - mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); + this.mem.setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); }, // func copyBytesToGo(dst []byte, src ref) (int, bool) @@ -392,13 +448,13 @@ const dst = loadSlice(sp + 8); const src = loadValue(sp + 32); if (!(src instanceof Uint8Array)) { - mem().setUint8(sp + 48, 0); + this.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); + this.mem.setUint8(sp + 48, 1); }, // func copyBytesToJS(dst ref, src []byte) (int, bool) @@ -406,13 +462,13 @@ const dst = loadValue(sp + 8); const src = loadSlice(sp + 16); if (!(dst instanceof Uint8Array)) { - mem().setUint8(sp + 48, 0); + this.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); + this.mem.setUint8(sp + 48, 1); }, "debug": (value) => { @@ -424,7 +480,8 @@ async run(instance) { this._inst = instance; - this._values = [ // TODO: garbage collection + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id NaN, 0, null, @@ -433,10 +490,10 @@ global, this, ]; - this._refs = new Map(); - this.exited = false; - - const mem = new DataView(this._inst.exports.mem.buffer) + this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map(); // mapping from JS values to reference ids + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. let offset = 4096; @@ -444,7 +501,7 @@ const strPtr = (str) => { const ptr = offset; const bytes = encoder.encode(str + "\0"); - new Uint8Array(mem.buffer, offset, bytes.length).set(bytes); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); offset += bytes.length; if (offset % 8 !== 0) { offset += 8 - (offset % 8); @@ -458,17 +515,18 @@ this.argv.forEach((arg) => { argvPtrs.push(strPtr(arg)); }); + argvPtrs.push(0); const keys = Object.keys(this.env).sort(); - argvPtrs.push(keys.length); keys.forEach((key) => { argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); }); + argvPtrs.push(0); const argv = offset; argvPtrs.forEach((ptr) => { - mem.setUint32(offset, ptr, true); - mem.setUint32(offset + 4, 0, true); + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); offset += 8; }); diff --git a/eg/windows/main.go b/eg/windows/main.go index 73da02b..856cf50 100644 --- a/eg/windows/main.go +++ b/eg/windows/main.go @@ -17,7 +17,7 @@ var ( Height = 768 // Cascade offset for creating multiple windows. - Cascade = render.NewPoint(10, 10) + Cascade = render.NewPoint(10, 32) CascadeStep = render.NewPoint(24, 24) CascadeLoops = 1 @@ -45,6 +45,20 @@ func main() { panic(err) } + // Menu bar. + menu := ui.NewMenuBar("Main Menu") + file := menu.AddMenu("Options") + file.AddItem("New window", func() { + addWindow(mw) + }) + file.AddItem("Close all windows", func() { + OpenWindows -= mw.Supervisor().CloseAllWindows() + }) + + menu.Supervise(mw.Supervisor()) + menu.Compute(mw.Engine) + mw.Pack(menu, menu.PackTop()) + // Add some windows to play with. addWindow(mw) addWindow(mw) @@ -95,7 +109,7 @@ func addWindow(mw *ui.MainWindow) { Cascade.Add(CascadeStep) if Cascade.Y > Height-240-64 { CascadeLoops++ - Cascade.Y = 24 + Cascade.Y = 32 Cascade.X = 24 * CascadeLoops } diff --git a/eg/windows/main_wasm.go b/eg/windows/main_wasm.go index e0aa1b0..f866519 100644 --- a/eg/windows/main_wasm.go +++ b/eg/windows/main_wasm.go @@ -24,7 +24,7 @@ var ( Height = 768 // Cascade offset for creating multiple windows. - Cascade = render.NewPoint(10, 10) + Cascade = render.NewPoint(10, 32) CascadeStep = render.NewPoint(24, 24) CascadeLoops = 1 @@ -73,6 +73,20 @@ func main() { height-lbl.Size().H-20, )) + // Menu bar. + menu := ui.NewMenuBar("Main Menu") + file := menu.AddMenu("Options") + file.AddItem("New window", func() { + addWindow(mw, frame, supervisor) + }) + file.AddItem("Close all windows", func() { + OpenWindows -= supervisor.CloseAllWindows() + }) + + menu.Supervise(supervisor) + menu.Compute(mw) + frame.Pack(menu, menu.PackTop()) + // Add some windows to play with. addWindow(mw, frame, supervisor) addWindow(mw, frame, supervisor) @@ -85,6 +99,7 @@ func main() { panic(err) } + frame.Present(mw, frame.Point()) lbl.Present(mw, lbl.Point()) supervisor.Loop(ev) supervisor.Present(mw) diff --git a/functions.go b/functions.go index 7ae0d5f..91f340e 100644 --- a/functions.go +++ b/functions.go @@ -41,6 +41,19 @@ func AbsoluteRect(w Widget) render.Rect { } } +// HasParent returns whether the target widget is a descendant of the parent. +// This scans the parents of the widget recursively until it finds a match. +func HasParent(w Widget, parent Widget) bool { + next, ok := w.Parent() + for ok { + if next == parent { + return true + } + next, ok = next.Parent() + } + return false +} + // widgetInFocusedWindow returns whether a widget (like a Button) is a // descendant of a Window that is being Window Managed by Supervisor, and // said window is in a Focused state. diff --git a/menu.go b/menu.go index 9aed60c..863607c 100644 --- a/menu.go +++ b/menu.go @@ -4,39 +4,78 @@ import ( "fmt" "git.kirsle.net/go/render" + "git.kirsle.net/go/ui/theme" ) -// Menu is a rectangle that holds menu items. +// MenuWidth sets the width of all popup menus. TODO, widths should be automatic. +var MenuWidth = 180 + +// Menu is a frame that holds menu items. It is the type Menu struct { BaseWidget Name string - body *Frame + supervisor *Supervisor + body *Frame + items []*MenuItem } // NewMenu creates a new Menu. It is hidden by default. Usually you'll // use it with a MenuButton or in a right-click handler. func NewMenu(name string) *Menu { w := &Menu{ - Name: name, - body: NewFrame(name + ":Body"), + Name: name, + body: NewFrame(name + ":Body"), + items: []*MenuItem{}, } w.body.Configure(Config{ - Width: 150, - BorderSize: 12, + Width: MenuWidth, + Height: 100, + BorderSize: 0, BorderStyle: BorderRaised, - Background: render.Grey, + Background: theme.ButtonBackgroundColor, }) + w.body.SetParent(w) w.IDFunc(func() string { return fmt.Sprintf("Menu<%s>", w.Name) }) return w } +// Children returns the child frame of the menu. +func (w *Menu) Children() []Widget { + return []Widget{ + w.body, + } +} + +// Supervise the Menu. This will add all current and future MenuItem widgets +// to the supervisor. +func (w *Menu) Supervise(s *Supervisor) { + w.supervisor = s + for _, item := range w.items { + w.supervisor.Add(item) + } +} + // Compute the menu func (w *Menu) Compute(e render.Engine) { w.body.Compute(e) + // TODO: ideally the Frame Pack Compute would fix the size of the body + // for the height to match the height of the menu items... but for now + // manually set the height. + var maxWidth int + var height int + for _, child := range w.body.Children() { + size := child.Size() + if size.W > maxWidth { + maxWidth = size.W + } + height += child.Size().H + } + w.body.Resize(render.NewRect(maxWidth, height)) + // Call the BaseWidget Compute in case we have subscribers. w.BaseWidget.Compute(e) } @@ -51,19 +90,69 @@ func (w *Menu) Present(e render.Engine, p render.Point) { // AddItem quickly adds an item to a menu. func (w *Menu) AddItem(label string, command func()) *MenuItem { - menu := NewMenuItem(label, command) + menu := NewMenuItem(label, "", command) + + // Add a Click handler that closes the menu when a selection is made. + menu.Handle(Click, w.menuClickHandler) + w.Pack(menu) return menu } +// AddItemAccel quickly adds an item to a menu with a shortcut key label. +func (w *Menu) AddItemAccel(label string, accelerator string, command func()) *MenuItem { + menu := NewMenuItem(label, accelerator, command) + + // Add a Click handler that closes the menu when a selection is made. + menu.Handle(Click, w.menuClickHandler) + + w.Pack(menu) + return menu +} + +// Click handler for all menu items, to also close the menu behind them. +func (w *Menu) menuClickHandler(ed EventData) error { + if w.supervisor != nil { + w.supervisor.PopModal(w) + } + return nil +} + +// AddSeparator adds a separator bar to the menu to delineate items. +func (w *Menu) AddSeparator() *MenuItem { + sep := NewMenuSeparator() + w.Pack(sep) + return sep +} + // Pack a menu item onto the menu. func (w *Menu) Pack(item *MenuItem) { + w.items = append(w.items, item) w.body.Pack(item, Pack{ - Side: NE, - // Expand: true, - // Padding: 8, + Side: N, FillX: true, }) + if w.supervisor != nil { + w.supervisor.Add(item) + } +} + +// Size returns the size of the menu's body. +func (w *Menu) Size() render.Rect { + return w.body.Size() +} + +// Rect returns the rect of the menu's body. +func (w *Menu) Rect() render.Rect { + // TODO: the height reports wrong (0), manually add up the MenuItem sizes. + // This manifests in Supervisor.runWidgetEvents when checking if the cursor + // clicked outside the rect of the active menu modal. + rect := w.body.Rect() + rect.H = 0 + for _, child := range w.body.Children() { + rect.H += child.Size().H + } + return rect } // MenuItem is an item in a Menu. @@ -72,28 +161,71 @@ type MenuItem struct { Label string Accelerator string Command func() + separator bool button *Button + + // store of most recent bg color set on a menu item + cacheBg render.Color + cacheFg render.Color } // NewMenuItem creates a new menu item. -func NewMenuItem(label string, command func()) *MenuItem { +func NewMenuItem(label, accelerator string, command func()) *MenuItem { w := &MenuItem{ - Label: label, - Command: command, + Label: label, + Accelerator: accelerator, + Command: command, } w.IDFunc(func() string { return fmt.Sprintf("MenuItem<%s>", w.Label) }) font := DefaultFont - font.Color = render.White + font.Color = render.Black font.PadX = 12 - w.Button.child = NewLabel(Label{ - Text: label, - Font: font, + font.PadY = 2 + + // The button child will be a Frame so we can have a left-aligned label + // and a right-aligned accelerator. + frame := NewFrame(label + ":Frame") + frame.Configure(Config{ + Width: MenuWidth, }) + { + // Left of frame: menu item label + lbl := NewLabel(Label{ + Text: label, + Font: font, + }) + frame.Pack(lbl, Pack{ + Side: W, + }) + + // On the right: accelerator shortcut key + if accelerator != "" { + accel := NewLabel(Label{ + Text: accelerator, + Font: font, + }) + frame.Pack(accel, Pack{ + Side: E, + }) + } + } + + w.Button.child = frame w.Button.Configure(Config{ - Background: render.Blue, + BorderSize: 0, + Background: theme.ButtonBackgroundColor, + }) + + w.Button.Handle(MouseOver, func(ed EventData) error { + w.setHoverStyle(true) + return nil + }) + w.Button.Handle(MouseOut, func(ed EventData) error { + w.setHoverStyle(false) + return nil }) w.Button.Handle(Click, func(ed EventData) error { @@ -104,3 +236,53 @@ func NewMenuItem(label string, command func()) *MenuItem { // Assign the button return w } + +// NewMenuSeparator creates a separator menu item. +func NewMenuSeparator() *MenuItem { + w := &MenuItem{ + separator: true, + } + w.IDFunc(func() string { + return "MenuItem" + }) + w.Button.child = NewFrame("Menu Separator") + w.Button.Configure(Config{ + Width: MenuWidth, + Height: 2, + BorderSize: 1, + BorderStyle: BorderSunken, + BorderColor: render.Grey, + }) + return w +} + +// Set the hover styling (text/bg color) +func (w *MenuItem) setHoverStyle(hovering bool) { + // Note: this only works if the MenuItem is using the standard + // Frame and Labels layout created by AddItem(). If not, this function + // does nothing. + + // BG color. + if hovering { + w.cacheBg = w.Background() + w.SetBackground(render.SkyBlue) + } else { + w.SetBackground(w.cacheBg) + } + + frame, ok := w.Button.child.(*Frame) + if !ok { + return + } + + for _, widget := range frame.Children() { + if label, ok := widget.(*Label); ok { + if hovering { + w.cacheFg = label.Font.Color + label.Font.Color = render.White + } else { + label.Font.Color = w.cacheFg + } + } + } +} diff --git a/menu_bar.go b/menu_bar.go new file mode 100644 index 0000000..7ba2baa --- /dev/null +++ b/menu_bar.go @@ -0,0 +1,80 @@ +package ui + +import ( + "fmt" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui/theme" +) + +// MenuFont is the default font settings for MenuBar buttons. +var MenuFont = render.Text{ + Size: 12, + Color: render.Black, + PadX: 4, + PadY: 2, +} + +// MenuBar is a frame that holds several MenuButtons, such as for the main +// menu at the top of a window. +type MenuBar struct { + Frame + name string + + supervisor *Supervisor + buttons []*MenuButton +} + +// NewMenuBar creates a new menu bar frame. +func NewMenuBar(name string) *MenuBar { + w := &MenuBar{ + name: name, + buttons: []*MenuButton{}, + } + w.SetBackground(theme.ButtonBackgroundColor) + w.Frame.Setup() + w.IDFunc(func() string { + return fmt.Sprintf("MenuBar<%s>", w.name) + }) + return w +} + +// Supervise the menu bar, making its child menu buttons work correctly. +func (w *MenuBar) Supervise(s *Supervisor) { + w.supervisor = s + + // Supervise the existing buttons. + for _, btn := range w.buttons { + s.Add(btn) + btn.Supervise(s) + } +} + +// AddMenu adds a new menu button to the bar. Returns the MenuButton +// object so that you can add items to it. +func (w *MenuBar) AddMenu(label string) *MenuButton { + btn := NewMenuButton(label, NewLabel(Label{ + Text: label, + Font: MenuFont, + })) + w.buttons = append(w.buttons, btn) + + // Pack and supervise it. + w.Pack(btn, Pack{ + Side: W, + }) + if w.supervisor != nil { + w.supervisor.Add(btn) + btn.Supervise(w.supervisor) + } + return btn +} + +// PackTop returns the default Frame Pack settings to place the menu +// at the top of the parent widget. +func (w *MenuBar) PackTop() Pack { + return Pack{ + Side: N, + FillX: true, + } +} diff --git a/menu_button.go b/menu_button.go new file mode 100644 index 0000000..e242572 --- /dev/null +++ b/menu_button.go @@ -0,0 +1,195 @@ +package ui + +import ( + "fmt" + + "git.kirsle.net/go/render" + "git.kirsle.net/go/ui/theme" +) + +// MenuButton is a button that opens a menu when clicked. +// +// After creating a MenuButton, call AddItem() to add options and callback +// functions to fill out the menu. When the MenuButton is clicked, its menu +// will be drawn and take modal priority in the Supervisor. +type MenuButton struct { + Button + + name string + supervisor *Supervisor + menu *Menu +} + +// NewMenuButton creates a new MenuButton (labels recommended). +// +// If the child is a Label, this function will set some sensible padding on +// its font if the Label does not already have non-zero padding set. +func NewMenuButton(name string, child Widget) *MenuButton { + w := &MenuButton{ + name: name, + } + w.Button.child = child + + // If it's a Label (most common), set sensible default padding. + if label, ok := child.(*Label); ok { + if label.Font.Padding == 0 && label.Font.PadX == 0 && label.Font.PadY == 0 { + label.Font.PadX = 8 + label.Font.PadY = 4 + } + } + + w.IDFunc(func() string { + return fmt.Sprintf("MenuButton<%s>", name) + }) + + w.setup() + return w +} + +// Supervise the MenuButton. This is necessary for the pop-up menu to work +// when the button is clicked. +func (w *MenuButton) Supervise(s *Supervisor) { + w.initMenu() + w.supervisor = s + w.menu.Supervise(s) +} + +// AddItem adds a new option to the MenuButton's menu. +func (w *MenuButton) AddItem(label string, f func()) { + w.initMenu() + w.menu.AddItem(label, f) +} + +// AddItemAccel adds a new menu option with hotkey text. +func (w *MenuButton) AddItemAccel(label string, accelerator string, f func()) *MenuItem { + w.initMenu() + return w.menu.AddItemAccel(label, accelerator, f) +} + +// AddSeparator adds a separator to the menu. +func (w *MenuButton) AddSeparator() { + w.initMenu() + w.menu.AddSeparator() +} + +// Compute to re-evaluate the button state (in the case of radio buttons where +// a different button will affect the state of this one when clicked). +func (w *MenuButton) Compute(e render.Engine) { + if w.menu != nil { + w.menu.Compute(e) + w.positionMenu(e) + } +} + +// positionMenu sets the position where the pop-up menu will appear when +// the button is clicked. Usually, the menu appears below and to the right of +// the button. But if the menu will hit a window boundary, its position will +// be adjusted to fit the window while trying not to overlap its own button. +func (w *MenuButton) positionMenu(e render.Engine) { + var ( + // Position and size of the MenuButton button. + buttonPoint = w.Point() + buttonSize = w.Size() + + // Size of the actual desktop window. + Width, Height = e.WindowSize() + ) + + // Ideal location: below and to the right of the button. + w.menu.MoveTo(render.Point{ + X: buttonPoint.X, + Y: buttonPoint.Y + buttonSize.H + w.BoxThickness(2), + }) + + var ( + // Size of the menu. + menuPoint = w.menu.Point() + menuSize = w.menu.Rect() + margin = 8 // keep away from directly touching window edges + topMargin = 32 // keep room for standard Menu Bar + ) + + // Will we clip out the bottom of the window? + if menuPoint.Y+menuSize.H+margin > Height { + // Put us above the button instead, with the bottom of the + // menu touching the top of the button. + menuPoint = render.Point{ + X: buttonPoint.X, + Y: buttonPoint.Y - menuSize.H - w.BoxThickness(2), + } + + // If this would put us over the TOP edge of the window now, + // cap the movement so the top of the menu is visible. We can't + // avoid overlapping the button with the menu so might as well + // start now. + if menuPoint.Y < topMargin { + menuPoint.Y = topMargin + } + + w.menu.MoveTo(menuPoint) + } + + // Will we clip out the right of the window? + if menuPoint.X+menuSize.W > Width { + // Move us in from the right side of the window. + var delta = Width - menuSize.W - margin + w.menu.MoveTo(render.Point{ + X: delta, + Y: menuPoint.Y, + }) + } + _ = Width +} + +// setup the common things between checkboxes and radioboxes. +func (w *MenuButton) setup() { + w.Configure(Config{ + BorderSize: 1, + BorderStyle: BorderSolid, + Background: theme.ButtonBackgroundColor, + }) + + w.Handle(MouseOver, func(ed EventData) error { + w.hovering = true + w.SetBorderStyle(BorderRaised) + return nil + }) + w.Handle(MouseOut, func(ed EventData) error { + w.hovering = false + w.SetBorderStyle(BorderSolid) + return nil + }) + + w.Handle(MouseDown, func(ed EventData) error { + w.clicked = true + w.SetBorderStyle(BorderSunken) + return nil + }) + w.Handle(MouseUp, func(ed EventData) error { + w.clicked = false + return nil + }) + + w.Handle(Click, func(ed EventData) error { + // Are we properly configured? + if w.supervisor != nil && w.menu != nil { + w.menu.Show() + w.supervisor.PushModal(w.menu) + } + return nil + }) +} + +// initialize the Menu widget. +func (w *MenuButton) initMenu() { + if w.menu == nil { + w.menu = NewMenu(w.name + ":Menu") + w.menu.Hide() + + // Handle closing the menu when clicked outside. + w.menu.Handle(CloseModal, func(ed EventData) error { + ed.Supervisor.PopModal(w.menu) + return nil + }) + } +} diff --git a/menu_test.go b/menu_test.go new file mode 100644 index 0000000..16b304e --- /dev/null +++ b/menu_test.go @@ -0,0 +1,83 @@ +package ui_test + +import ( + "git.kirsle.net/go/ui" +) + +// Example of using the menu widgets. +func ExampleMenu() { + mw, err := ui.NewMainWindow("Menu Bar Example", 800, 600) + if err != nil { + panic(err) + } + + // Create a main menu for your window. + menu := ui.NewMenuBar("Main Menu") + + // File menu. Some items with accelerators, some without. + // NOTE: key bindings are up to you, the accelerators are + // purely decorative. + file := menu.AddMenu("File") + file.AddItemAccel("New", "Ctrl-N", func() {}) + file.AddItemAccel("Open", "Ctrl-O", func() {}) + file.AddItemAccel("Save", "Ctrl-S", func() {}) + file.AddItem("Save as...", func() {}) + file.AddSeparator() + file.AddItem("Close window", func() {}) + file.AddItemAccel("Exit", "Alt-F4", func() {}) + + // Help menu. + help := menu.AddMenu("Help") + help.AddItemAccel("Contents", "F1", func() {}) + help.AddItem("About", func() {}) + + // Give the menu bar your Supervisor so it can wire all + // events up and make the menus work. + menu.Supervise(mw.Supervisor()) + + // Compute and pack the menu bar against the top of + // the main window (or other parent container) + menu.Compute(mw.Engine) + mw.Pack(menu, menu.PackTop()) // Side: N, FillX: true + + // Each loop you must then: + // - Call Supervisor.Loop() as normal to handle events. + // - Call Supervisor.Present() to draw the modal popup menus. + // MainLoop() of the MainWindow does this for you. + mw.MainLoop() +} + +// Example of using the MenuButton. +func ExampleMenuButton() { + mw, err := ui.NewMainWindow("Menu Button", 800, 600) + if err != nil { + panic(err) + } + + // Create a MenuButton much as you would a normal Button. + btn := ui.NewMenuButton("Button1", ui.NewLabel(ui.Label{ + Text: "File", + })) + mw.Place(btn, ui.Place{ // place it in the center + Center: true, + Middle: true, + }) + + // Add menu items to it. + btn.AddItemAccel("New", "Ctrl-N", func() {}) + btn.AddItemAccel("Open", "Ctrl-O", func() {}) + btn.AddItemAccel("Save", "Ctrl-S", func() {}) + btn.AddItem("Save as...", func() {}) + btn.AddSeparator() + btn.AddItem("Close window", func() {}) + btn.AddItemAccel("Exit", "Alt-F4", func() {}) + + // Add the button to Supervisor for events to work. + btn.Supervise(mw.Supervisor()) + + // Each loop you must then: + // - Call Supervisor.Loop() as normal to handle events. + // - Call Supervisor.Present() to draw the modal popup menus. + // MainLoop() of the MainWindow does this for you. + mw.MainLoop() +} diff --git a/supervisor.go b/supervisor.go index 00b8d31..151e122 100644 --- a/supervisor.go +++ b/supervisor.go @@ -32,6 +32,7 @@ const ( CloseWindow MaximizeWindow MinimizeWindow + CloseModal // Lifecycle event handlers. Compute // fired whenever the widget runs Compute @@ -45,6 +46,9 @@ type EventData struct { // Engine is the render engine on Compute and Present events. Engine render.Engine + + // Supervisor is the reference to the supervisor who sent the event. + Supervisor *Supervisor } // Supervisor keeps track of widgets of interest to notify them about @@ -58,6 +62,9 @@ type Supervisor struct { clicked map[int]bool // map of widgets being clicked dd *DragDrop + // Stack of modal widgets that have event priority. + modals []Widget + // List of window focus history for Window Manager. winFocus *FocusedWindow winTop *FocusedWindow // pointer to top-most window @@ -76,6 +83,7 @@ func NewSupervisor() *Supervisor { widgets: map[int]WidgetSlot{}, hovering: map[int]interface{}{}, clicked: map[int]bool{}, + modals: []Widget{}, dd: NewDragDrop(), } } @@ -169,14 +177,18 @@ func (s *Supervisor) Loop(ev *event.State) error { // 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) - handled, err := s.runWidgetEvents(XY, ev, hovering, outside, true) - if err == ErrStopPropagation || handled { - // A widget in the active window has accepted an event. Do not pass - // the event also to lower widgets. - return err + // Only run if there is no active modal (modals have top priority) + if len(s.modals) == 0 { + handled, err := s.runWidgetEvents(XY, ev, hovering, outside, true) + if err == ErrStopPropagation || handled { + // A widget in the active window has accepted an event. Do not pass + // the event also to lower widgets. + return err + } } // Run events for the other widgets not in a managed window. + // (Modal event priority is handled in runWidgetEvents) s.runWidgetEvents(XY, ev, hovering, outside, false) return nil @@ -233,13 +245,21 @@ func (s *Supervisor) Hovering(cursor render.Point) (hovering, outside []WidgetSl // 0: widgets NOT part of a managed window. On this pass, if a widget IS // a part of a window, it gets no events triggered. // 1: widgets are part of the active focused window. -func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, outside []WidgetSlot, toFocusedWindow bool) (bool, error) { +func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, + hovering, outside []WidgetSlot, toFocusedWindow bool) (bool, error) { // Do we run any events? var ( stopPropagation bool ranEvents bool ) + // Do we have active modals? Modal widgets have top event priority given + // only to the top-most modal. + var modal Widget + if len(s.modals) > 0 { + modal = s.modals[len(s.modals)-1] + } + // 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" @@ -273,7 +293,8 @@ func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, // 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 { + // Does not apply when a modal widget is active. + if cursorInsideFocusedWindow && modal == nil { break } @@ -287,6 +308,14 @@ func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, continue } + // If we have a modal active, validate this widget is a child of + // the modal widget. + if modal != nil { + if !HasParent(w, modal) { + continue + } + } + // Check if the widget is part of a Window managed by Supervisor. isManaged, isFocused := widgetInFocusedWindow(w) @@ -344,6 +373,14 @@ func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, w = child.widget ) + // If we have a modal active, validate this widget is a child of + // the modal widget. + if modal != nil { + if !HasParent(w, modal) { + continue + } + } + // Cursor is not intersecting the widget. if _, ok := s.hovering[id]; ok { handle(w.Event(MouseOut, EventData{ @@ -360,6 +397,16 @@ func (s *Supervisor) runWidgetEvents(XY render.Point, ev *event.State, hovering, } } + // If a modal is active and a click was registered outside the modal's + // bounding box, send the CloseModal event. + if modal != nil && !XY.Inside(AbsoluteRect(modal)) { + if ev.Button1 { + modal.Event(CloseModal, EventData{ + Supervisor: s, + }) + } + } + // If a stopPropagation was called, return it up the stack. if stopPropagation { return ranEvents, ErrStopPropagation @@ -397,11 +444,28 @@ func (s *Supervisor) Present(e render.Engine) { // Render the window manager windows from bottom to top. s.presentWindows(e) + + // Render the modals from bottom to top. + if len(s.modals) > 0 { + for _, modal := range s.modals { + modal.Present(e, modal.Point()) + } + } } -// Add a widget to be supervised. +// Add a widget to be supervised. Has no effect if the widget is already +// under the supervisor's care. func (s *Supervisor) Add(w Widget) { s.lock.Lock() + + // Check it's not already there. + for _, child := range s.widgets { + if child.widget == w { + return + } + } + + // Add it. s.widgets[s.serial] = WidgetSlot{ id: s.serial, widget: w, @@ -409,3 +473,40 @@ func (s *Supervisor) Add(w Widget) { s.serial++ s.lock.Unlock() } + +// PushModal sets the widget to be a "modal" for the Supervisor. +// +// Modal widgets have top-most event priority: mouse and click events go ONLY +// to the modal and its descendants. Modals work as a stack: the most recently +// pushed widget is the active modal, and popping the modal will make the +// next most-recent widget be the active modal. +// +// If a Click event registers OUTSIDE the bounds of the modal widget, the +// widget receives a CloseModal event. +// +// Returns the length of the modal stack. +func (s *Supervisor) PushModal(w Widget) int { + s.modals = append(s.modals, w) + return len(s.modals) +} + +// PopModal attempts to pop the modal from the stack, but only if the modal +// is at the top of the stack. +// +// A widget may safely attempt to PopModal itself on a CloseModal event to +// close themselves when the user clicks outside their box. If there were a +// newer modal on the stack, this PopModal action would do nothing. +func (s *Supervisor) PopModal(w Widget) bool { + // only can pop if the topmost widget is the one being asked for + if len(s.modals) > 0 && s.modals[len(s.modals)-1] == w { + modal := s.modals[len(s.modals)-1] + modal.Hide() + + // pop it off + s.modals = s.modals[:len(s.modals)-1] + + return true + } + + return false +} diff --git a/widget.go b/widget.go index dedac93..d68bad8 100644 --- a/widget.go +++ b/widget.go @@ -309,8 +309,13 @@ func (w *BaseWidget) Hidden() bool { return true } - if parent, ok := w.Parent(); ok { - return parent.Hidden() + // Return if any parents are hidden. + parent, ok := w.Parent() + for ok { + if parent.Hidden() { + return true + } + parent, ok = parent.Parent() } return false diff --git a/window.go b/window.go index 8d33b67..6ece7a8 100644 --- a/window.go +++ b/window.go @@ -296,6 +296,12 @@ func (w *Window) SetMaximized(v bool) { } } +// Close the window, hiding it from display and calling its CloseWindow handler. +func (w *Window) Close() { + w.Hide() + w.Event(CloseWindow, EventData{}) +} + // Children returns the window's child widgets. func (w *Window) Children() []Widget { return []Widget{ diff --git a/window_manager.go b/window_manager.go index f9e7822..14df10b 100644 --- a/window_manager.go +++ b/window_manager.go @@ -164,6 +164,21 @@ func (s *Supervisor) IsPointInWindow(point render.Point) bool { return false } +// CloseAllWindows closes all open windows being managed by supervisor. +// Returns the number of windows closed. +func (s *Supervisor) CloseAllWindows() int { + var ( + node = s.winFocus + i = 0 + ) + for node != nil { + i++ + node.window.Hide() + node = node.next + } + return i +} + // presentWindows draws the windows from bottom to top. func (s *Supervisor) presentWindows(e render.Engine) { item := s.winBottom