diff --git a/src/app.bjo b/src/app.bjo index d7eb4a6..18f052a 100644 --- a/src/app.bjo +++ b/src/app.bjo @@ -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))) - (unless (eq? now was) - (mutablevec-set! kids 0 now) - (set-single! kind (.control live) (.control now)))) + (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 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)) diff --git a/src/view.bjo b/src/view.bjo index d1fc949..51d1e67 100644 --- a/src/view.bjo +++ b/src/view.bjo @@ -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)) diff --git a/tests/menus.bjo b/tests/menus.bjo new file mode 100644 index 0000000..6115d32 --- /dev/null +++ b/tests/menus.bjo @@ -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"))