From 07cefb64991d32a4eb8b7eaf70279405e168581d Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 4 Jun 2020 00:50:06 -0700 Subject: [PATCH] Menus and Menu Bars * New and completed widgets: Menu, MenuButton and MenuBar. * MenuButton is a kind of Button that opens a popup Menu when clicked. * MenuBar is a container of buttons designed to be attached to the top of an application window ("File, Edit, View, Help") * Supervisor manages the popup menus with its new concept of a Modal Widget. Modal widgets take exclusive event priority for all mouse and key events. The pop-up menu is a modal window, which means you must click an option inside the menu OR clicking outside the menu will close it and eat your click event (widgets outside the modal don't receive events, but the modal itself gets an event that you've done this). --- README.md | 18 ++- debug.go | 8 ++ docs/menus-1.png | Bin 0 -> 18435 bytes docs/menus-2.png | Bin 0 -> 20409 bytes eg/README.md | 10 ++ eg/menus/Makefile | 11 ++ eg/menus/README.md | 19 ++++ eg/menus/main.go | 208 ++++++++++++++++++++++++++++++++++ eg/wasm-common/wasm_exec.js | 216 ++++++++++++++++++++++------------- eg/windows/main.go | 18 ++- eg/windows/main_wasm.go | 17 ++- functions.go | 13 +++ menu.go | 220 ++++++++++++++++++++++++++++++++---- menu_bar.go | 80 +++++++++++++ menu_button.go | 195 ++++++++++++++++++++++++++++++++ menu_test.go | 83 ++++++++++++++ supervisor.go | 117 +++++++++++++++++-- widget.go | 9 +- window.go | 6 + window_manager.go | 15 +++ 20 files changed, 1141 insertions(+), 122 deletions(-) create mode 100644 docs/menus-1.png create mode 100644 docs/menus-2.png create mode 100644 eg/README.md create mode 100644 eg/menus/Makefile create mode 100644 eg/menus/README.md create mode 100644 eg/menus/main.go create mode 100644 menu_bar.go create mode 100644 menu_button.go create mode 100644 menu_test.go 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 0000000000000000000000000000000000000000..3f3973fcd78b018cec3746d03d63291d9e04e214 GIT binary patch literal 18435 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 literal 0 HcmV?d00001 diff --git a/docs/menus-2.png b/docs/menus-2.png new file mode 100644 index 0000000000000000000000000000000000000000..5f9c7e09287449371fd024bd249cef5c7d51fcf4 GIT binary patch literal 20409 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") + }) + 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