Browse Source

Merge pull request 'Menus and Menu Bars' (#2) from menus into master

themes
Noah Petherbridge 1 year ago
parent
commit
53c0fed7be
  1. 18
      README.md
  2. 8
      debug.go
  3. BIN
      docs/menus-1.png
  4. BIN
      docs/menus-2.png
  5. 10
      eg/README.md
  6. 11
      eg/menus/Makefile
  7. 19
      eg/menus/README.md
  8. 208
      eg/menus/main.go
  9. 214
      eg/wasm-common/wasm_exec.js
  10. 18
      eg/windows/main.go
  11. 17
      eg/windows/main_wasm.go
  12. 13
      functions.go
  13. 220
      menu.go
  14. 80
      menu_bar.go
  15. 195
      menu_button.go
  16. 83
      menu_test.go
  17. 117
      supervisor.go
  18. 9
      widget.go
  19. 6
      window.go
  20. 15
      window_manager.go

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

8
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 {

BIN
docs/menus-1.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/menus-2.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

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

11
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

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

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

214
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;
typeFlag = 2;
break;
case "symbol":
typeFlag = 2;
typeFlag = 3;
break;
case "function":
typeFlag = 3;
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;
});

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

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

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

220
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<separator>"
})
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
}
}
}
}

80
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,
}
}

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

83
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())