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:
parent
1e46202eef
commit
be288ac03e
3 changed files with 300 additions and 28 deletions
149
src/app.bjo
149
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))
|
||||
|
|
|
|||
61
src/view.bjo
61
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))
|
||||
|
|
|
|||
118
tests/menus.bjo
Normal file
118
tests/menus.bjo
Normal 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"))
|
||||
Loading…
Add table
Add a link
Reference in a new issue