Menus, context menus and keyboard shortcuts

A menu bar, submenus and menu items are views. A menu item's items are an
ItemsControl's, so they are patched as a list's. A context menu wraps a
view: the view and its menu are two places of the wrapper.

Keys are matched in the window's KeyDown, after the focused control had its
chance: an enabled menu item in the view whose shortcut it is, or else the
app's #:keys. Avalonia's own key bindings take an ICommand, which a Bjolang
program cannot make. app-press! presses keys for a test.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-4-8)

Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
Linus Björnstam 2026-10-25 17:55:19 +01:00
parent 1e46202eef
commit be288ac03e
3 changed files with 300 additions and 28 deletions

View file

@ -30,7 +30,7 @@
;; Subscriptions.
every subscription
;; For tests and for hosts of their own: an app with no window.
start-app app-dispatch! app-send! app-stop! app-run-pending!
start-app app-dispatch! app-send! app-stop! app-run-pending! app-press!
app-model app-control app-control-at)
(import (std prelude))
@ -80,6 +80,14 @@
(ItemsControl (: Avalonia.Controls.ItemsControl))
(ItemCollection (: Avalonia.Controls.ItemCollection))
(IList (: System.Collections.IList))
(Menu (: Avalonia.Controls.Menu (-> Menu)))
(MenuItem (: Avalonia.Controls.MenuItem (-> MenuItem)))
(Separator (: Avalonia.Controls.Separator (-> Separator)))
(ContextMenu (: Avalonia.Controls.ContextMenu (-> ContextMenu)))
(KeyGesture (: Avalonia.Input.KeyGesture (-> KeyCode KeyModifiers KeyGesture)))
(KeyCode (: Avalonia.Input.Key))
(KeyModifiers (: Avalonia.Input.KeyModifiers))
(KeyEventArgs (: Avalonia.Input.KeyEventArgs))
(ICollection (: System.Collections.ICollection))
(Timer (: Avalonia.Threading.DispatcherTimer (-> Timer)))
(TimeSpan (: System.TimeSpan))
@ -163,6 +171,15 @@
(decorator-padding (: Avalonia.Controls.Decorator.PaddingProperty #:get))
(decorator-child (: Avalonia.Controls.Decorator.ChildProperty #:get))
(parse-brush (: Avalonia.Media.Brush.Parse (-> string IBrush)))
(menu-header-property
(: Avalonia.Controls.Primitives.HeaderedSelectingItemsControl.HeaderProperty #:get))
(input-gesture-property (: Avalonia.Controls.MenuItem.InputGestureProperty #:get))
(context-menu-property (: Avalonia.Controls.Control.ContextMenuProperty #:get))
(parse-gesture (: Avalonia.Input.KeyGesture.Parse (-> string KeyGesture)))
(gesture=? (: Avalonia.Input.KeyGesture.Equals (-> KeyGesture KeyGesture bool)))
(key-of (: Avalonia.Input.KeyEventArgs.Key (-> KeyEventArgs KeyCode) #:get))
(modifiers-of (: Avalonia.Input.KeyEventArgs.KeyModifiers (-> KeyEventArgs KeyModifiers) #:get))
(handled! (: Avalonia.Interactivity.RoutedEventArgs.Handled (-> RoutedEventArgs bool void) #:set))
;; A grid's sizes are not Avalonia properties, so they are set as .NET
;; properties are.
@ -177,6 +194,8 @@
;; Events.
(click-event (: Avalonia.Controls.Button.ClickEvent #:get))
(menu-click-event (: Avalonia.Controls.MenuItem.ClickEvent #:get))
(key-down-event (: Avalonia.Input.InputElement.KeyDownEvent #:get))
;; TextChanging and not TextChanged: TextChanged is posted to the
;; dispatcher and comes later, after the patch that set the text is over.
(text-changing-event (: Avalonia.Controls.TextBox.TextChangingEvent #:get))
@ -271,6 +290,7 @@
(RowKind #f)
(GridKind #f)
(BorderKind #f)
(ContextKind #f)
(_ #t)))
;; A kind whose children are the items of an Avalonia ItemsControl.
@ -279,15 +299,20 @@
(match kind
(ListBoxKind #t)
(TabsKind #t)
(MenuBarKind #t)
(MenuItemKind #t)
(ContextMenuKind #t)
(_ #f)))
;; A kind with one child, which is its content.
;; A kind whose children each have a place of their own: the content of a
;; scroll, a tab or a border, and a context menu's view and its menu.
(: (-> Kind bool))
(defun (single? kind)
(match kind
(ScrollKind #t)
(TabKind #t)
(BorderKind #t)
(ContextKind #t)
(_ #f)))
;; A kind that chooses one of its items or options.
@ -326,6 +351,7 @@
(ButtonKind (Some (cast AvaloniaProperty content-property)))
(CheckBoxKind (Some (cast AvaloniaProperty content-property)))
(TabKind (Some (cast AvaloniaProperty header-property)))
(MenuItemKind (Some (cast AvaloniaProperty menu-header-property)))
(_ None)))
((Value _) (when-kind (text-box? kind) (cast AvaloniaProperty text-box-text)))
((Placeholder _) (when-kind (text-box? kind) (cast AvaloniaProperty watermark-property)))
@ -367,6 +393,7 @@
((BorderColour _) (when-kind (= kind BorderKind) (cast AvaloniaProperty border-brush-property)))
((BorderWidth _) (when-kind (= kind BorderKind) (cast AvaloniaProperty border-thickness-property)))
((Radius _) (when-kind (= kind BorderKind) (cast AvaloniaProperty corner-radius-property)))
((Shortcut _) (when-kind (= kind MenuItemKind) (cast AvaloniaProperty input-gesture-property)))
;; Set by `set-prop!` itself.
((Source _) None)
((Columns _) None)
@ -395,8 +422,16 @@
((Background s) (brush s))
((Foreground s) (brush s))
((BorderColour s) (brush s))
((Shortcut s) (match (gesture s) ((Some g) (Some (cast Object g))) (None None)))
(_ (Some (plain p)))))
;; Keys, as Avalonia reads them. None for keys it cannot read.
(: (-> string (Option KeyGesture)))
(defun (gesture s)
(match (try (parse-gesture s) #:catch (System.Exception))
((Ok g) (Some g))
((Err _) None)))
(: (-> Prop Object))
(defun (plain p)
(defun (text s) (cast Object (->clr-string s)))
@ -431,7 +466,8 @@
((Foreground s) (text s))
((BorderColour s) (text s))
((BorderWidth d) (cast Object (Thickness. d)))
((Radius d) (cast Object (CornerRadius. d)))))
((Radius d) (cast Object (CornerRadius. d)))
((Shortcut s) (text s))))
;; Which prop it is, without its value. Two props with one tag set the same
;; property.
@ -445,7 +481,7 @@
((Items _) 16) ((Selected _) 17) ((Source _) 18) ((Columns _) 19)
((Rows _) 20) ((Row _) 21) ((Column _) 22) ((RowSpan _) 23) ((ColumnSpan _) 24)
((Background _) 25) ((Foreground _) 26) ((BorderColour _) 27) ((BorderWidth _) 28)
((Radius _) 29)))
((Radius _) 29) ((Shortcut _) 30)))
(: (-> Control AvaloniaProperty Object void))
(defun (put! control property value)
@ -567,6 +603,7 @@
(: gui-update (-> %m %model (Tuple %model (List (Event %m)))))
(: view (-> %model (View %m)))
(: subscriptions (-> %model (List (Sub %m))))
(: keys (-> %model (List (Key %m))))
(: root (Option (Live %m)) #:mutable)
(: patching bool #:mutable)
(: stopped bool #:mutable)
@ -627,6 +664,45 @@
(defun (app-run-pending! app)
(ui-run-jobs ui-dispatcher (clr-default)))
;; Sends the message of the keys `g`: of an enabled menu item in the view
;; with those keys as its shortcut, or else of the app's `#:keys`. True when
;; there was one.
(: (-> (App %model %m) KeyGesture bool))
(defun (press! app g)
(defun (same? s) (match (gesture s) ((Some h) (gesture=? g h)) (None #f)))
(: (-> (View %m) (Option %m)))
(defun (in-view v)
(def props (view-props v))
(if (and (= (view-kind v) MenuItemKind)
(not (vec-contains props (Enabled #f)))
(any? (fun (p) (match p ((Shortcut s) (same? s)) (_ #f))) props))
(loop (:for on (view-events v))
(:when-let (Click msg) on)
(:finish #t (Some msg))
=> None)
(loop (:for c (view-children v))
(:when-let (Some msg) (in-view c))
(:finish #t (Some msg))
=> None)))
(def found
(match (match (.root app) ((Some live) (in-view (.view live))) (None None))
((Some msg) (Some msg))
(None (loop (:for k ((.keys app) (.model app)))
(:when (same? (key-gesture k)))
(:finish #t (Some (key-message k)))
=> None))))
(match found
((Some msg) (app-dispatch! app msg) #t)
(None #f)))
;; Presses the keys `keys`, such as "Ctrl+S", as `press!` does when they are
;; pressed in the window. True when they sent a message. For tests.
(: (-> (App %model %m) string bool))
(defun (app-press! app keys)
(match (gesture keys)
((Some g) (press! app g))
(None #f)))
;; Stops the app: its subscriptions are cancelled, and later messages are
;; dropped. A command that is still running is cancelled when the scope the
;; app was started in ends.
@ -733,6 +809,9 @@
(when (eq? (event-source e) (cast Object control))
(fire! app live (pick))))))
(match kind
(MenuItemKind
(on-event (cast RoutedEvent menu-click-event)
(fun () (fun (on) (match on ((Click msg) (Some msg)) (_ None))))))
(ButtonKind
(on-event (cast RoutedEvent click-event)
(fun () (fun (on) (match on ((Click msg) (Some msg)) (_ None))))))
@ -796,7 +875,12 @@
(ListBoxKind (cast Control (ListBox.)))
(TabsKind (cast Control (TabControl.)))
(TabKind (cast Control (TabItem.)))
(BorderKind (cast Control (Border.)))))
(BorderKind (cast Control (Border.)))
(MenuBarKind (cast Control (Menu.)))
(MenuItemKind (cast Control (MenuItem.)))
(SeparatorKind (cast Control (Separator.)))
(ContextKind (cast Control (Border.)))
(ContextMenuKind (cast Control (ContextMenu.)))))
;; Where the controls of a panel's children, or of a list's items, are.
(: (-> Kind Control IList))
@ -805,18 +889,21 @@
(cast IList (items-of (cast ItemsControl parent)))
(cast IList (panel-children (cast Panel parent)))))
;; Makes `child` the one child of a scroll, a tab or a border.
(: (-> Kind Control Control void))
(defun (set-single! kind parent child)
(if (= kind BorderKind)
(put! parent (cast AvaloniaProperty decorator-child) (cast Object child))
(content! (cast ContentControl parent) (cast Object child))))
;; Puts `child` in its place: the content of a scroll, a tab or a border, and
;; for a context menu, its view at 0 and the menu at 1.
(: (-> Kind Control int Control void))
(defun (set-slot! kind parent i child)
(cond ((and (= kind ContextKind) (= i 1))
(put! parent (cast AvaloniaProperty context-menu-property) (cast Object child)))
((or (= kind BorderKind) (= kind ContextKind))
(put! parent (cast AvaloniaProperty decorator-child) (cast Object child)))
(else (content! (cast ContentControl parent) (cast Object child)))))
;; Puts a child's control in its parent's, as the last one.
(: (-> Kind Control Control void))
(defun (adopt! kind parent child)
;; Puts a child's control in its parent's, as child `i`, the last one so far.
(: (-> Kind Control int Control void))
(defun (adopt! kind parent i child)
(if (single? kind)
(set-single! kind parent child)
(set-slot! kind parent i child)
(ignore (list-add! (child-list kind parent) (cast Object child)))))
;; A list or tabs can choose an item only once the items are there, so the
@ -843,7 +930,7 @@
(loop (:for child (view-children v))
(:do (def c (mount app child))
(mutablevec-add! (.children live) c)
(adopt! kind control (.control c))))
(adopt! kind control (- (mutablevec-length (.children live)) 1) (.control c))))
(sync-selection! control kind (view-props v))
live)
@ -864,15 +951,16 @@
(sync-selection! (.control live) kind (view-props next))
live)))
;; The one child of a scroll, a tab or a border.
;; The children of a kind whose children each have a place of their own.
(: (-> (App %model %m) (Live %m) Kind (Vec (View %m)) void))
(defun (patch-single! app live kind views)
(def kids (.children live))
(def was (mutablevec-ref kids 0))
(def now (patch! app was (vec-ref views 0)))
(loop (:for (Tuple i v) (enumerate views))
(:do (def was (mutablevec-ref kids i))
(def now (patch! app was v))
(unless (eq? now was)
(mutablevec-set! kids 0 now)
(set-single! kind (.control live) (.control now))))
(mutablevec-set! kids i now)
(set-slot! kind (.control live) i (.control now))))))
;; Which child each view is: its key, or its place among the children that
;; have none. The two kinds cannot be mistaken for each other.
@ -940,10 +1028,12 @@
;; Its fibers belong to the scope it is started in.
(: (-> %model (-> %m %model (Tuple %model (List (Event %m)))) (-> %model (View %m))
(-> Control void) (#:subscriptions (-> %model (List (Sub %m))))
(#:keys (-> %model (List (Key %m))))
(App %model %m)))
(defun (start-app init gui-update view on-root #:subscriptions (fun (m) '()))
(defun (start-app init gui-update view on-root #:subscriptions (fun (m) '())
#:keys (fun (m) '()))
(def app (App (model init) (gui-update gui-update) (view view)
(subscriptions subscriptions) (root None) (patching #f) (stopped #f)
(subscriptions subscriptions) (keys keys) (root None) (patching #f) (stopped #f)
(home (current-scope)) (subs '()) (on-root on-root)))
(step! app '())
app)
@ -977,9 +1067,10 @@
;; BJOGUI_SCREENSHOT=shot.png bjo run examples/todo.bjo
(: (-> %model (-> %m %model (Tuple %model (List (Event %m)))) (-> %model (View %m))
(#:subscriptions (-> %model (List (Sub %m))))
(#:keys (-> %model (List (Key %m))))
(#:title string) (#:width double) (#:height double) (#:screenshot string)
void))
(defun (run-app init gui-update view #:subscriptions (fun (m) '())
(defun (run-app init gui-update view #:subscriptions (fun (m) '()) #:keys (fun (m) '())
#:title "bjogui" #:width 400.0 #:height 300.0
#:screenshot (option-ref-or (get-environment-variable "BJOGUI_SCREENSHOT") ""))
;; The Application is made inside the setup, because making one uses the
@ -997,7 +1088,13 @@
(with-cancel (cancel)
(def app (start-app init gui-update view
(fun (c) (window-content! (cast ContentControl w) (cast Object c)))
#:subscriptions subscriptions))
#:subscriptions subscriptions #:keys keys))
;; Keys that no control took, such as a shortcut pressed in a text input.
(on! (cast Control w) (cast RoutedEvent key-down-event)
(fun (sender e)
(def k (cast KeyEventArgs e))
(when (press! app (KeyGesture. (key-of k) (modifiers-of k)))
(handled! e #t))))
(main-window! lifetime w)
(unless (string-empty? screenshot)
(screenshot-later! lifetime w width height screenshot))

View file

@ -20,9 +20,13 @@
;; Constructors.
text button text-input text-area checkbox slider progress-bar dropdown image
list-box tabs tab
menu-bar submenu menu-item menu-separator
column row grid scroll border
;; Wrappers.
keyed at paint
keyed at paint with-context-menu
;; Keyboard shortcuts.
Key key key-gesture key-message
;; Reading one.
view-kind view-props view-events view-children view-key)
@ -37,6 +41,7 @@
(type (: Kind (Union TextKind ButtonKind TextInputKind TextAreaKind CheckBoxKind
SliderKind ProgressKind DropdownKind ImageKind
ListBoxKind TabsKind TabKind
MenuBarKind MenuItemKind SeparatorKind ContextKind ContextMenuKind
ColumnKind RowKind GridKind ScrollKind BorderKind)))
;; A property of a control. A kind takes only some of them; the app ignores
@ -70,7 +75,8 @@
(: Foreground string) ; the colour of the text
(: BorderColour string)
(: BorderWidth double)
(: Radius double)))) ; how round a border's corners are
(: Radius double) ; how round a border's corners are
(: Shortcut string)))) ; a menu item's keys, as "Ctrl+S"
;; An event, and the message it sends.
(type (: (On %m) (Union (: Click %m)
@ -124,6 +130,12 @@
(defun (with props make c) (if (string-empty? c) props (vec-add props (make c))))
(record-set v (props (with (with (.props v) Foreground colour) Background background))))
;; The view, with a menu that opens when it is clicked with the right button.
;; The items are made by `menu-item`, `submenu` and `menu-separator`.
(: with-context-menu (-> (View %m) #:rest (View %m) (View %m)))
(defun (with-context-menu v #:rest items)
(container ContextKind [] [v (container ContextMenuKind [] (array->vec items))]))
;; The view, in a cell of its grid. Rows and columns count from 0.
(: at (-> int int (View %m) (#:row-span int) (#:column-span int) (View %m)))
(defun (at row column v #:row-span 1 #:column-span 1)
@ -261,6 +273,33 @@
(defun (tab header content)
(View (kind TabKind) (props [(Label header)]) (events []) (children [content]) (key None)))
;; A bar of menus, made by `submenu`, for the top of the window.
(: (-> #:rest (View %m) (View %m)))
(defun (menu-bar #:rest menus)
(container MenuBarKind [] (array->vec menus)))
;; A menu of items, in a menu bar or in another menu.
(: (-> string #:rest (View %m) (View %m)))
(defun (submenu label #:rest items)
(View (kind MenuItemKind) (props [(Label label)]) (events []) (children (array->vec items))
(key None)))
;; An item that sends `msg` when it is chosen. `#:shortcut`, such as
;; "Ctrl+S", is shown beside it, and the keys send `msg` too while the item
;; is in the view and enabled.
(: (-> string %m (#:shortcut string) (#:enabled bool) (View %m)))
(defun (menu-item label msg #:shortcut "" #:enabled #t)
(leaf MenuItemKind
(if (string-empty? shortcut)
[(Label label) (Enabled enabled)]
[(Label label) (Enabled enabled) (Shortcut shortcut)])
[(Click msg)]))
;; A line between items of a menu.
(: (-> (View %m)))
(defun (menu-separator)
(leaf SeparatorKind [] []))
;; A checkbox that is `checked?`. A click sends `(on-toggle checked)`, with
;; the new state.
(: (-> string bool (-> bool %m) (#:enabled bool) (#:margin double) (View %m)))
@ -321,3 +360,21 @@
(: (-> (View %m) (#:width double) (#:height double) (#:margin double) (View %m)))
(defun (scroll child #:width 0.0 #:height 0.0 #:margin 0.0)
(container ScrollKind (with-layout [] width height margin 0.0) [child]))
;; ---------------------------------------------------------------------------
;; Keyboard shortcuts
;; ---------------------------------------------------------------------------
;; Keys, such as "Ctrl+N" or "F5", and the message they send.
(type (: (Key %m) (Record (: gesture string) (: msg %m))))
;; `msg` is sent when `gesture` is pressed in the window, and no control that
;; has the keyboard takes the keys itself.
(: key (-> string %m (Key %m)))
(defun (key gesture msg) (Key (gesture gesture) (msg msg)))
(: key-gesture (-> (Key %m) string))
(defun (key-gesture k) (.gesture k))
(: key-message (-> (Key %m) %m))
(defun (key-message k) (.msg k))

118
tests/menus.bjo Normal file
View file

@ -0,0 +1,118 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;; bjogui/tests/menus.bjo — menus, context menus and keyboard shortcuts.
;;
;; bjo run tests/menus.bjo
;;
;; No window is opened. A click is raised on a menu item as Avalonia raises
;; it, and keys are pressed with `app-press!`, which is what the window's
;; handler calls.
(import (std prelude))
(import (std simpletest))
(import (bjogui view))
(import (bjogui app))
(import/class
(Object (: System.Object))
(Control (: Avalonia.Controls.Control))
(Interactive (: Avalonia.Interactivity.Interactive))
(RoutedEvent (: Avalonia.Interactivity.RoutedEvent))
(RoutedEventArgs (: Avalonia.Interactivity.RoutedEventArgs (-> RoutedEvent RoutedEventArgs)))
(ItemsControl (: Avalonia.Controls.ItemsControl))
(MenuItem (: Avalonia.Controls.MenuItem))
(Headered (: Avalonia.Controls.Primitives.HeaderedSelectingItemsControl))
(KeyGesture (: Avalonia.Input.KeyGesture))
(ContextMenu (: Avalonia.Controls.ContextMenu)))
(import/extern
(menu-click-event (: Avalonia.Controls.MenuItem.ClickEvent #:get))
(raise-event (: Avalonia.Interactivity.Interactive.RaiseEvent (-> Interactive RoutedEventArgs void)))
(item-count (: Avalonia.Controls.ItemsControl.ItemCount (-> ItemsControl int) #:get))
(header (: Avalonia.Controls.Primitives.HeaderedSelectingItemsControl.Header
(-> Headered Object) #:get))
(input-gesture (: Avalonia.Controls.MenuItem.InputGesture (-> MenuItem KeyGesture) #:get))
(context-menu (: Avalonia.Controls.Control.ContextMenu (-> Control ContextMenu) #:get)))
;; ---------------------------------------------------------------------------
;; The program under test
;; ---------------------------------------------------------------------------
(type (: Msg (Union New (: Open string) Quit Copy Refresh AllowQuit)))
(type (: Model (Record (: log (List string)) (: can-quit bool))))
(: (-> Msg Model (Tuple Model (List (Event Msg)))))
(defun (gui-update msg m)
(defun (log s) (record-set m (log (cons s (.log m)))))
(next (match msg
(New (log "new"))
((Open f) (log (string-append "open " f)))
(Quit (log "quit"))
(Copy (log "copy"))
(Refresh (log "refresh"))
(AllowQuit (record-set m (can-quit #t))))))
(: (-> Model (View Msg)))
(defun (view m)
(column
(menu-bar ; 0
(submenu "File" ; 0 0
(menu-item "New" New #:shortcut "Ctrl+N") ; 0 0 0
(menu-separator) ; 0 0 1
(submenu "Recent" ; 0 0 2
(menu-item "notes.txt" (Open "notes.txt"))) ; 0 0 2 0
(menu-item "Quit" Quit #:shortcut "Ctrl+Q" #:enabled (.can-quit m))))
(with-context-menu (text "right click me") ; 1
(menu-item "Copy" Copy #:shortcut "Ctrl+C"))))
(: (-> Model (List (Key Msg))))
(defun (keys m) (list (key "F5" Refresh)))
;; ---------------------------------------------------------------------------
;; The test
;; ---------------------------------------------------------------------------
(defun (main)
(def app (start-app (Model (log '()) (can-quit #f)) gui-update view (fun (c) unit)
#:keys keys))
(defun (ctl path) (app-control-at app path))
(defun (click path)
(raise-event (cast Interactive (ctl path))
(RoutedEventArgs. (cast RoutedEvent menu-click-event))))
(defun (history) (.log (app-model app)))
;; --- the menu bar -------------------------------------------------------------
(expect "one menu" (item-count (cast ItemsControl (ctl '(0)))) 1)
(expect "its items, with the separator" (item-count (cast ItemsControl (ctl '(0 0)))) 4)
(expect "a header" (.ToString (header (cast Headered (ctl '(0 0 0))))) "New")
(expect "a shortcut is shown" (.ToString (cast Object (input-gesture (cast MenuItem (ctl '(0 0 0))))))
"Ctrl+N")
(click '(0 0 0))
(expect "an item chosen is a message" (history) '("new"))
;; The click bubbles through "Recent" and "File"; only the item answers.
(click '(0 0 2 0))
(expect "an item in a submenu, once" (history) '("open notes.txt" "new"))
;; --- the context menu -------------------------------------------------------
(def cm (context-menu (ctl '(1))))
(expect "the context menu is the view's" (eq? (cast Object cm) (cast Object (ctl '(1 1)))) #t)
(expect "its items" (item-count (cast ItemsControl cm)) 1)
(click '(1 1 0))
(expect "a context menu item" (first (history)) (Some "copy"))
;; --- keys -----------------------------------------------------------------------
(expect "a menu item's shortcut" (app-press! app "Ctrl+N") #t)
(expect "sends its message" (first (history)) (Some "new"))
(expect "a context menu's shortcut too" (app-press! app "Ctrl+C") #t)
(expect "a disabled item's shortcut does nothing" (app-press! app "Ctrl+Q") #f)
(app-dispatch! app AllowQuit)
(expect "until it is enabled" (app-press! app "Ctrl+Q") #t)
(expect "the app's own keys" (app-press! app "F5") #t)
(expect "and their message" (first (history)) (Some "refresh"))
(expect "keys bound to nothing" (app-press! app "Ctrl+X") #f)
(expect "keys that cannot be read" (app-press! app "Ctrl+Nope") #f)
(println "bjogui menus ok"))