From 5127fbac13578d3142dc819982b0c16e582d699e3fc4385a17bdddff9fc1f960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Linus=20Bj=C3=B6rnstam?= Date: Sun, 25 Oct 2026 21:21:09 +0100 Subject: [PATCH] Windows besides the main one, and dialogs of the program's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-app's #:windows gives the windows a model has. The app opens a window when it is first in the list, patches its view and title while it stays, and closes it when it is gone. A click on its close button sends its on-close message, and the model closes it. A #:modal window is a dialog over the main window, so a dialog of the program's own is a window whose state is in the model. Windows wait for the main window to be shown, because a dialog cannot belong to a hidden one, and the program ends when the main window closes. Key shortcuts and commands such as focus reach the other windows too, and #:screenshot saves a picture of each one beside the main window's. 🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-4-8) Co-Authored-By: eca-agent --- Readme.org | 59 ++++++++-- docs/contacts-edit.png | Bin 0 -> 19203 bytes docs/contacts.png | Bin 0 -> 17413 bytes examples/contacts.bjo | 108 ++++++++++++++++++ src/app.bjo | 250 +++++++++++++++++++++++++++++++++++------ src/view.bjo | 66 ++++++++++- tests/windows.bjo | 145 ++++++++++++++++++++++++ 7 files changed, 583 insertions(+), 45 deletions(-) create mode 100644 docs/contacts-edit.png create mode 100644 docs/contacts.png create mode 100644 examples/contacts.bjo create mode 100644 tests/windows.bjo diff --git a/Readme.org b/Readme.org index 63e5a90..dc65b56 100644 --- a/Readme.org +++ b/Readme.org @@ -405,6 +405,40 @@ there. (keyed "draft" (text-input (.draft m) Draft)) #+END_SRC +* Windows and dialogs of your own + +[[file:docs/contacts.png]] [[file:docs/contacts-edit.png]] + +A program can have windows besides the main one. ~run-app~'s ~#:windows~ +gives the windows a model has, as ~view~ gives the main window's view, and +the app opens, changes and closes them to match. + +#+BEGIN_SRC scheme +(: windows (-> Model (List (WindowView Msg)))) +(defun (windows m) + (match (.draft m) + ((Some d) (list (window "edit" "Edit contact" Cancel (draft-view d) #:modal #t))) + (None '()))) + +(run-app init gui-update view #:windows windows) +#+END_SRC + +- ~(window key title on-close content #:width #:height #:modal)~ is a window, + known by its key. It is opened when it is first in the list, its view and + title are changed as the main window's view is while it stays, and it is + closed when it is gone. +- A click on its close button does not close it: it sends ~on-close~, and the + window closes when the next model has it no more. +- With no ~#:width~ or ~#:height~ it is as big as its content. +- A ~#:modal~ window is a dialog: the main window takes no input until it is + closed. So a dialog of one's own is a window whose state, such as a draft + being edited, is in the model, and whose view is a view like any other. +- ~window-map~ maps a window's messages, as ~view-map~ does a view's. +- The program ends when the main window is closed, and the others with it. + +[[file:examples/contacts.bjo][examples/contacts.bjo]] edits a contact in a +modal dialog, and shows the chosen one in a second window. + * Dialogs: (bjogui dialogs) A dialog is a command, and its answer comes back as a message. @@ -439,16 +473,18 @@ answered. * Running: (bjogui app) - ~(run-app init gui-update view #:subscriptions #:keys #:theme #:styles - #:on-close #:title #:width #:height)~ opens a window and runs until it is - closed. + #:windows #:on-close #:title #:width #:height)~ opens a window and runs + until it is closed. - ~#:screenshot path~ makes ~run-app~ save a PNG of the window once it is - shown, at twice its size, and close it. It is the environment variable - ~BJOGUI_SCREENSHOT~ unless the program says otherwise, so any program can - be pictured with no change: ~BJOGUI_SCREENSHOT=docs/todo.png bjo run - examples/todo.bjo~ made the picture above. -- ~(start-app init gui-update view on-root #:subscriptions)~ makes the controls - with no window, and gives ~on-root~ the control to show. Its fibers belong - to the scope it is started in. + shown, at twice its size, and close it. Each other window that is open is + saved beside it, with its key after the name: ~shot-edit.png~. It is the + environment variable ~BJOGUI_SCREENSHOT~ unless the program says + otherwise, so any program can be pictured with no change: + ~BJOGUI_SCREENSHOT=docs/todo.png bjo run examples/todo.bjo~ made the + picture above. +- ~(start-app init gui-update view on-root #:subscriptions #:windows)~ makes + the controls with no window, and gives ~on-root~ the control to show. Its + fibers belong to the scope it is started in. - ~app-dispatch!~ gives a message on the UI thread, and ~app-send!~ from any thread. - ~app-run-pending!~ runs what fibers have posted, where no loop of @@ -459,6 +495,8 @@ answered. does. - ~app-model~ reads the model, and ~app-control-at~ finds a control by its path of child indices. + - ~app-windows~ gives the keys of the other windows that are open, and + ~app-window-control~ finds a control in one of them. ~tests/app.bjo~ and ~tests/effects.bjo~ use these to drive an app through its real controls. @@ -485,18 +523,19 @@ bjo run tests/canvas.bjo # prints "bjogui canvas ok" bjo run tests/components.bjo # prints "bjogui components ok" bjo run tests/native.bjo # prints "bjogui native ok" bjo run tests/commands.bjo # prints "bjogui commands ok" +bjo run tests/windows.bjo # prints "bjogui windows ok" bjo run examples/counter.bjo bjo run examples/stopwatch.bjo bjo run examples/todo.bjo bjo run examples/styles.bjo bjo run examples/sketch.bjo bjo run examples/mixer.bjo +bjo run examples/contacts.bjo #+END_SRC * Not yet - A data grid, and lists that make controls only for the items on screen. -- Dialogs of one's own, with a view and a state. - The macOS menu bar. - Pointer-over and pressed colours for kinds other than buttons. - A component asks for no size of its own: it is given one, or fills the diff --git a/docs/contacts-edit.png b/docs/contacts-edit.png new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..7e2373f246c3b24f49799bf740029451680b9724954abcaee8d1f229a5f9832f GIT binary patch literal 19203 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 H^tJy1s!eq4 literal 0 HcmV?d00001 diff --git a/docs/contacts.png b/docs/contacts.png new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..c3fe76c99ae85bd4505b8464d8ad4421c852adacb19daec71ee9df7d83b4593f GIT binary patch literal 17413 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 Msg Model (Tuple Model (List (Event Msg))))) +(defun (gui-update msg m) + (defun (edit-draft f) + (match (.draft m) + ((Some d) (record-set m (draft (Some (record-set d (contact (f (.contact d)))))))) + (None m))) + (match msg + ((Edit i) + (next (record-set m (draft (Some (Draft (index i) (contact (vec-ref (.contacts m) i)))))) + (focus "draft-name"))) + ((DraftName s) (next (edit-draft (fun (c) (record-set c (name s)))))) + ((DraftAddress s) (next (edit-draft (fun (c) (record-set c (address s)))))) + (Save + (next (match (.draft m) + ((Some d) (record-set m (contacts (vec-set (.contacts m) (.index d) (.contact d))) + (draft None))) + (None m)))) + (Cancel (next (record-set m (draft None)))) + ((Choose i) (next (record-set m (chosen i)))) + (ToggleDetails (next (record-set m (details (not (.details m)))))))) + +(: view (-> Model (View Msg))) +(defun (view m) + (column #:spacing 8.0 #:margin 16.0 + (list-box (vec-map (fun (c) (text (.name c) #:margin 4.0)) (.contacts m)) (.chosen m) Choose + #:height 150.0) + (row #:spacing 8.0 + (button "Edit" (Edit (.chosen m)) #:enabled (>= (.chosen m) 0) #:width 80.0) + (button (if (.details m) "Hide details" "Details") ToggleDetails)))) + +(: (-> Draft (View Msg))) +(defun (draft-view d) + (def c (.contact d)) + (grid #:columns "Auto,250" #:rows "Auto,Auto,Auto" #:spacing 8.0 #:margin 16.0 + (at 0 0 (text "Name")) + (at 0 1 (keyed "draft-name" (text-input (.name c) DraftName))) + (at 1 0 (text "Address")) + (at 1 1 (text-input (.address c) DraftAddress)) + (at 2 1 (row #:spacing 8.0 + (button "Save" Save #:width 80.0) + (button "Cancel" Cancel #:width 80.0))))) + +(: windows (-> Model (List (WindowView Msg)))) +(defun (windows m) + (def dialog + (match (.draft m) + ((Some d) (list (window "edit" #"Edit ${(.name (vec-ref (.contacts m) (.index d)))}" Cancel + (draft-view d) #:modal #t))) + (None '()))) + (def details + (if (and (.details m) (>= (.chosen m) 0)) + (let ((c (vec-ref (.contacts m) (.chosen m)))) + (list (window "details" "Details" ToggleDetails + (column #:spacing 4.0 #:margin 16.0 + (text (.name c) #:size 18.0) + (text (.address c))) + #:width 260.0 #:height 110.0))) + '())) + (list-append dialog details)) + +;; Escape and Enter are the dialog's while it is open. +(: keys (-> Model (List (Key Msg)))) +(defun (keys m) + (match (.draft m) + ((Some _) (list (key "Escape" Cancel) (key "Enter" Save))) + (None '()))) + +(defun (main) + (run-app (Model (contacts [(Contact (name "Ada Lovelace") (address "12 St James's Square, London")) + (Contact (name "Alan Turing") (address "Hollymeade, Wilmslow")) + (Contact (name "Grace Hopper") (address "Arlington, Virginia"))]) + (draft None) (chosen 0) (details #f)) + gui-update view + #:windows windows #:keys keys + #:title "Contacts" #:width 360.0 #:height 260.0)) diff --git a/src/app.bjo b/src/app.bjo index 2a96bde..785985a 100644 --- a/src/app.bjo +++ b/src/app.bjo @@ -33,7 +33,7 @@ focus scroll-to-end scroll-to-start scroll-into-view copy-text paste-text ;; For tests and for hosts of their own: an app with no window. start-app app-dispatch! app-send! app-stop! app-run-pending! app-press! app-input! - app-model app-control app-control-at start-headless!) + app-model app-control app-control-at app-windows app-window-control start-headless!) (import (std prelude)) (import (std interop)) @@ -176,7 +176,12 @@ (IClipboard (: Avalonia.Input.Platform.IClipboard)) (Stream (: System.IO.Stream)) (MemoryStream (: System.IO.MemoryStream (-> (Array byte) MemoryStream))) - (StreamBitmap (: Avalonia.Media.Imaging.Bitmap (-> Stream StreamBitmap)))) + (StreamBitmap (: Avalonia.Media.Imaging.Bitmap (-> Stream StreamBitmap))) + ;; Windows besides the main one. + (ShutdownMode (: Avalonia.Controls.ShutdownMode)) + (CloseReason (: Avalonia.Controls.WindowCloseReason)) + (SizeToContent (: Avalonia.Controls.SizeToContent)) + (StartupLocation (: Avalonia.Controls.WindowStartupLocation))) (import/extern ;; Properties, as the objects `SetValue` takes. @@ -430,7 +435,18 @@ (clipboard-text! (: Avalonia.Input.Platform.IClipboard.SetTextAsync (-> IClipboard string Task))) (scroll-end! (: Avalonia.Controls.ScrollViewer.ScrollToEnd (-> ScrollViewer void))) (scroll-home! (: Avalonia.Controls.ScrollViewer.ScrollToHome (-> ScrollViewer void))) - (bring-into-view! (: Avalonia.Controls.ControlExtensions.BringIntoView (-> Control void)))) + (bring-into-view! (: Avalonia.Controls.ControlExtensions.BringIntoView (-> Control void))) + + ;; Windows besides the main one. + (shutdown-mode! (: Avalonia.Controls.ApplicationLifetimes.ClassicDesktopStyleApplicationLifetime.ShutdownMode + (-> Lifetime ShutdownMode void) #:set)) + (close-reason (: Avalonia.Controls.WindowClosingEventArgs.CloseReason (-> ClosingArgs CloseReason) #:get)) + (show-window! (: Avalonia.Controls.Window.Show (-> Window void))) + (show-dialog! (: Avalonia.Controls.Window.ShowDialog (-> Window Window Task))) + (close-window! (: Avalonia.Controls.Window.Close (-> Window void))) + (size-to-content-property (: Avalonia.Controls.Window.SizeToContentProperty #:get)) + (startup-location-property (: Avalonia.Controls.Window.WindowStartupLocationProperty #:get)) + (visual-visible? (: Avalonia.Visual.IsVisible (-> Visual bool) #:get))) ;; --------------------------------------------------------------------------- ;; Properties @@ -912,6 +928,13 @@ ;; The app ;; --------------------------------------------------------------------------- +;; A window besides the main one, while it is open: its key, the Avalonia +;; window, the live view it shows, and the WindowView it was last given. +(type (: (Opened %m) (Record (: win-key string) + (: frame Window) + (: win-root (Live %m) #:mutable) + (: win-spec (WindowView %m) #:mutable)))) + ;; A running program. ;; ;; `patching` is true while the controls are being changed. A control that @@ -935,7 +958,10 @@ (: stopped bool #:mutable) (: home Scope) (: subs (List (Tuple string Scope)) #:mutable) - (: on-root (-> Control void))))) + (: on-root (-> Control void)) + (: windows-of (-> %model (List (WindowView %m)))) + (: opened (List (Opened %m)) #:mutable) + (: owner (Option Window) #:mutable)))) (: (-> (App %model %m) %model)) (defun (app-model app) (.model app)) @@ -1023,7 +1049,10 @@ (:finish #t (Some msg)) => None))) (def found - (match (match (.root app) ((Some live) (in-view (.view live))) (None None)) + (match (loop (:for live (live-roots app)) + (:when-let (Some msg) (in-view (.view live))) + (:finish #t (Some msg)) + => None) ((Some msg) (Some msg)) (None (loop (:for k ((.keys app) (.model app))) (:when (same? (key-gesture k))) @@ -1047,6 +1076,8 @@ (: (-> (App %model %m) void)) (defun (app-stop! app) (record-set! app (stopped #t)) + (loop (:for o (.opened app)) (:do (close-window! (.frame o)))) + (record-set! app (opened '())) (loop (:for (Tuple _ scope) (.subs app)) (:do (stop-scope! scope))) (record-set! app (subs '()))) @@ -1132,6 +1163,7 @@ (unless (eq? now live) (record-set! app (root (Some now))) ((.on-root app) (.control now))))) + (sync-windows! app) (record-set! app (patching #f))) ;; Gives the message that `pick` finds in the control's events to `gui-update`. @@ -1537,18 +1569,27 @@ (: (-> %model (-> %m %model (Tuple %model (List (Event %m)))) (-> %model (View %m)) (-> Control void) (#:subscriptions (-> %model (List (Sub %m)))) (#:keys (-> %model (List (Key %m)))) (#:theme (-> %model string)) + (#:windows (-> %model (List (WindowView %m)))) (App %model %m))) (defun (start-app init gui-update view on-root #:subscriptions (fun (m) '()) - #:keys (fun (m) '()) #:theme (fun (m) "")) + #:keys (fun (m) '()) #:theme (fun (m) "") #:windows (fun (m) '())) + (def app (make-app init gui-update view on-root subscriptions keys theme windows)) + (step! app '()) + app) + +;; An app that has not shown its view yet. +(: (-> %model (-> %m %model (Tuple %model (List (Event %m)))) (-> %model (View %m)) + (-> Control void) (-> %model (List (Sub %m))) (-> %model (List (Key %m))) + (-> %model string) (-> %model (List (WindowView %m))) + (App %model %m))) +(defun (make-app init gui-update view on-root subscriptions keys theme windows) (def app (App (model init) (gui-update gui-update) (view view) (subscriptions subscriptions) (keys keys) (theme theme) (shown-theme "") (root None) (patching #f) (stopped #f) - (home (current-scope)) (subs '()) (on-root on-root))) + (home (current-scope)) (subs '()) (on-root on-root) + (windows-of windows) (opened '()) (owner None))) (mutablevec-add! running (Running (find (fun (key) (keyed-control app key))) - (roots (fun () (match (.root app) - ((Some live) (list (.control live))) - (None '())))))) - (step! app '()) + (roots (fun () (list-map (fun (l) (.control l)) (live-roots app)))))) app) ;; --------------------------------------------------------------------------- @@ -1666,21 +1707,32 @@ (list at-rest (state ":pointerover" (.hover rule)) (state ":pressed" (.pressed rule))) (list at-rest))))) +;; A picture of `v`, `width` by `height`, saved to `path`. Twice the pixels, +;; so that it is sharp on a screen with a scale of 2. +(: (-> Visual double double string void)) +(defun (save-picture! v width height path) + (def bitmap (RenderBitmap. (PixelSize. (cast System.Int32 (* 2.0 width)) + (cast System.Int32 (* 2.0 height))) + (Vector. 192.0 192.0))) + (render-into! bitmap v) + (save-png! (cast Bitmap bitmap) path (clr-default))) + ;; Saves a picture of the window to `path` once it has been shown and laid -;; out, and closes it. Twice the pixels of the window, so that it is sharp on -;; a screen with a scale of 2. -(: (-> Lifetime Window double double string void)) -(defun (screenshot-later! lifetime w width height path) +;; out, and of each other window that is open beside it, with its key after +;; the name: "shot.png" and "shot-edit.png". Then closes them. +(: (-> Lifetime Window double double string (-> (List (Tuple string Window))) void)) +(defun (screenshot-later! lifetime w width height path others) (def timer (Timer.)) (interval! timer (seconds->span 1.0)) (ignore (add-event-handler! timer "Tick" (: (fun (sender e) (timer-stop! timer) - (def bitmap (RenderBitmap. (PixelSize. (cast System.Int32 (* 2.0 width)) - (cast System.Int32 (* 2.0 height))) - (Vector. 192.0 192.0))) - (render-into! bitmap (cast Visual w)) - (save-png! (cast Bitmap bitmap) path (clr-default)) + (save-picture! (cast Visual w) width height path) + (def stem (if (string-ends-with? path ".png") (string-drop-right path 4) path)) + (loop (:for (Tuple key other) (others)) + (:do (def b (bounds-of (cast Visual other))) + (save-picture! (cast Visual other) (rect-width b) (rect-height b) + #"${stem}-${key}.png"))) (shutdown! lifetime 0)) (-> Object EventArgs void)))) (timer-start! timer)) @@ -1701,10 +1753,11 @@ (#:subscriptions (-> %model (List (Sub %m)))) (#:keys (-> %model (List (Key %m)))) (#:on-close (Option %m)) (#:theme (-> %model string)) (#:styles (List StyleRule)) + (#:windows (-> %model (List (WindowView %m)))) (#:title string) (#:width double) (#:height double) (#:screenshot string) void)) (defun (run-app init gui-update view #:subscriptions (fun (m) '()) #:keys (fun (m) '()) - #:on-close None #:theme (fun (m) "") #:styles '() + #:on-close None #:theme (fun (m) "") #:styles '() #:windows (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 @@ -1712,6 +1765,9 @@ (def lifetime (Lifetime.)) (ignore (setup-with-lifetime (use-platform-detect (configure (fun () (Application.)))) lifetime)) + ;; The program ends when the main window is closed, and not only when the + ;; last one is: the others are the model's. + (shutdown-mode! lifetime ShutdownMode.OnMainWindowClose) (.Add (app-styles current-app) (FluentTheme. (clr-default))) (loop (:for rule styles) (:do (loop (:for s (avalonia-styles rule)) @@ -1723,9 +1779,20 @@ (ignore (set-value (cast AvaloniaObject w) (cast AvaloniaProperty height-property) (cast Object height) Priority.LocalValue)) (with-cancel (cancel) - (def app (start-app init gui-update view - (fun (c) (window-content! (cast ContentControl w) (cast Object c))) - #:subscriptions subscriptions #:keys keys #:theme theme)) + ;; The other windows belong to the main one, so it is the app's before + ;; the first view, which may have some. + (def app (make-app init gui-update view + (fun (c) (window-content! (cast ContentControl w) (cast Object c))) + subscriptions keys theme windows)) + (record-set! app (owner (Some w))) + (step! app '()) + ;; The windows the first models had wait for the main window. + (ignore (add-event-handler! w "Opened" + (: (fun (sender e) + (record-set! app (patching #t)) + (sync-windows! app) + (record-set! app (patching #f))) + (-> Object Object void)))) ;; With `#:on-close`, closing the window is a message. The program closes ;; it with `quit`, which closes it by program, and that is not asked. (match on-close @@ -1737,15 +1804,11 @@ (app-dispatch! app msg))) (-> Object Object void))))) (None unit)) - ;; 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)))) + (listen-keys! app w) (main-window! lifetime w) (unless (string-empty? screenshot) - (screenshot-later! lifetime w width height screenshot)) + (screenshot-later! lifetime w width height screenshot + (fun () (list-map (fun (o) (Tuple (.win-key o) (.frame o))) (.opened app))))) (ignore (start-lifetime lifetime (: (make-array 0) (Array ClrString)))) (app-stop! app) (cancel (Requested "the window is closed")))) @@ -2013,9 +2076,10 @@ (:when-let (Some found) (walk c)) (:finish #t (Some found)) => None))) - (match (.root app) - ((Some live) (walk live)) - (None None))) + (loop (:for live (live-roots app)) + (:when-let (Some found) (walk live)) + (:finish #t (Some found)) + => None)) ;; A command that runs `act` on the UI thread and sends no message. It runs ;; after the controls are laid out, so a control the same update made is @@ -2110,3 +2174,121 @@ (guard (fun () (wrap (promise-join (bjo (read-clipboard))) (fun (r) (on-text (match r ((Ok t) t) ((Err _) None)))))))) + +;; --------------------------------------------------------------------------- +;; Windows besides the main one +;; --------------------------------------------------------------------------- + +;; The live views at the roots: the main window's, and each other window's. +(: (-> (App %model %m) (List (Live %m)))) +(defun (live-roots app) + (def others (list-map (fun (o) (.win-root o)) (.opened app))) + (match (.root app) + ((Some live) (cons live others)) + (None others))) + +;; The keys of the windows besides the main one that are open, in order. +(: (-> (App %model %m) (List string))) +(defun (app-windows app) + (list-map (fun (o) (.win-key o)) (.opened app))) + +;; The control at `path` in the window with `key`: a child index for each +;; level below its root. +(: (-> (App %model %m) string (List int) Control)) +(defun (app-window-control app key path) + (: (-> (Live %m) (List int) Control)) + (defun (walk live path) + (match path + (() (.control live)) + ((List i rest ...) (walk (mutablevec-ref (.children live) i) rest)))) + (match (loop (:for o (.opened app)) + (:when (= (.win-key o) key)) + (:finish #t (Some o)) + => None) + ((Some o) (walk (.win-root o) path)) + (None (raise (cast Exception (InvalidOperation. #"no window is open with the key ${key}")))))) + +;; Keys that no control took, such as a shortcut pressed in a text input, +;; are the app's: its menu items' and its `#:keys`. +(: (-> (App %model %m) Window void)) +(defun (listen-keys! app w) + (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))))) + +(: (-> Window AvaloniaProperty Object void)) +(defun (window-put! w property value) + (ignore (set-value (cast AvaloniaObject w) property value Priority.LocalValue))) + +;; Opens a window for `spec`. A click on its close button sends the spec's +;; `on-close`, posted so that the window is not closed by the update while +;; it is still closing; the window closes when the model has it no more. +(: (-> (App %model %m) (WindowView %m) (Opened %m))) +(defun (open-window! app spec) + (def w (Window.)) + (window-title! w (window-title spec)) + (if (and (> (window-width spec) 0.0) (> (window-height spec) 0.0)) + (begin + (window-put! w (cast AvaloniaProperty width-property) (cast Object (window-width spec))) + (window-put! w (cast AvaloniaProperty height-property) (cast Object (window-height spec)))) + (window-put! w (cast AvaloniaProperty size-to-content-property) (cast Object SizeToContent.WidthAndHeight))) + (def live (mount app (window-content spec))) + (window-content! (cast ContentControl w) (cast Object (.control live))) + (def opened (Opened (win-key (window-key spec)) (frame w) (win-root live) (win-spec spec))) + (ignore (add-event-handler! w "Closing" + (: (fun (sender e) + (def args (cast ClosingArgs e)) + (when (and (not (programmatic? args)) + (= (close-reason args) CloseReason.WindowClosing) + (not (.stopped app))) + (cancel-close! (cast CancelArgs e) #t) + (post! app (window-on-close (.win-spec opened))))) + (-> Object Object void)))) + (listen-keys! app w) + (match (.owner app) + ((Some owner) + (window-put! w (cast AvaloniaProperty startup-location-property) (cast Object StartupLocation.CenterOwner)) + (if (window-modal? spec) + (ignore (show-dialog! w owner)) + (show-window! w))) + (None (show-window! w))) + opened) + +;; Opens the windows the model has that are not open, changes those that +;; stay, and closes those that are gone. Not before the main window is +;; shown: a window belongs to it, and a dialog cannot belong to a window +;; that is not shown. `run-app` calls this again when it is. +(: (-> (App %model %m) void)) +(defun (sync-windows! app) + (unless (match (.owner app) ((Some w) (not (visual-visible? (cast Visual w)))) (None #f)) + (sync-shown-windows! app))) + +(: (-> (App %model %m) void)) +(defun (sync-shown-windows! app) + (def wanted ((.windows-of app) (.model app))) + (defun (wanted? key) (any? (fun (spec) (= (window-key spec) key)) wanted)) + (loop (:for o (.opened app)) + (:when (not (wanted? (.win-key o)))) + (:do (close-window! (.frame o)))) + (def next + (loop (:for spec wanted) + (:acc out + (listing + (match (loop (:for o (.opened app)) + (:when (= (.win-key o) (window-key spec))) + (:finish #t (Some o)) + => None) + ((Some o) + (def now (patch! app (.win-root o) (window-content spec))) + (unless (eq? now (.win-root o)) + (record-set! o (win-root now)) + (window-content! (cast ContentControl (.frame o)) (cast Object (.control now)))) + (unless (= (window-title spec) (window-title (.win-spec o))) + (window-title! (.frame o) (window-title spec))) + (record-set! o (win-spec spec)) + o) + (None (open-window! app spec))))) + => out)) + (record-set! app (opened next))) diff --git a/src/view.bjo b/src/view.bjo index c7a26b5..7b05aa4 100644 --- a/src/view.bjo +++ b/src/view.bjo @@ -34,7 +34,11 @@ at-point on-press on-move on-release ;; Views of another message type. - view-map + view-map window-map + + ;; Windows besides the main one. + WindowView window window-key window-title window-on-close window-content + window-width window-height window-modal? ;; Components. Component Input Pointer Custom component drawn @@ -807,3 +811,63 @@ (: hosted-view (-> (Hosted %m) double double double (View %m))) (defun (hosted-view h width height margin) (leaf NativeKind (with-layout [] width height margin 0.0) [(OnHosted h)])) + +;; --------------------------------------------------------------------------- +;; Windows +;; --------------------------------------------------------------------------- + +;; A window besides the main one, and what it shows. `#:windows` of +;; `run-app` gives the windows a model has, as it gives `view` the main +;; window's view. +(type (: (WindowView %m) (Record (: key string) + (: title string) + (: on-close %m) + (: content (View %m)) + (: width double) + (: height double) + (: modal bool)))) + +;; A window, known by `key`, with `title`, showing `content`. It is opened +;; when it is first in the list of windows, changed as a view is while it +;; stays, and closed when it is gone. +;; +;; The user's click on its close button does not close it: it sends +;; `on-close`, and the window closes when the next model has it no more. +;; +;; With no `#:width` or `#:height`, it is as big as its content. A +;; `#:modal` window is a dialog: the main window takes no input until it is +;; closed. +(: window (-> string string %m (View %m) (#:width double) (#:height double) (#:modal bool) + (WindowView %m))) +(defun (window key title on-close content #:width 0.0 #:height 0.0 #:modal #f) + (WindowView (key key) (title title) (on-close on-close) (content content) + (width width) (height height) (modal modal))) + +;; The window, sending `(f msg)` for each message it would send, as +;; `view-map` does for a view. +(: window-map (-> (-> %a %b) (WindowView %a) (WindowView %b))) +(defun (window-map f w) + (WindowView (key (.key w)) (title (.title w)) (on-close (f (.on-close w))) + (content (view-map f (.content w))) + (width (.width w)) (height (.height w)) (modal (.modal w)))) + +(: window-key (-> (WindowView %m) string)) +(defun (window-key w) (.key w)) + +(: window-title (-> (WindowView %m) string)) +(defun (window-title w) (.title w)) + +(: window-on-close (-> (WindowView %m) %m)) +(defun (window-on-close w) (.on-close w)) + +(: window-content (-> (WindowView %m) (View %m))) +(defun (window-content w) (.content w)) + +(: window-width (-> (WindowView %m) double)) +(defun (window-width w) (.width w)) + +(: window-height (-> (WindowView %m) double)) +(defun (window-height w) (.height w)) + +(: window-modal? (-> (WindowView %m) bool)) +(defun (window-modal? w) (.modal w)) diff --git a/tests/windows.bjo b/tests/windows.bjo new file mode 100644 index 0000000..1b92031 --- /dev/null +++ b/tests/windows.bjo @@ -0,0 +1,145 @@ +;; 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/windows.bjo — windows besides the main one. +;; +;; bjo run tests/windows.bjo +;; +;; Avalonia runs with no screen, so the windows are real but not seen. A +;; click on a window's close button is made as the platform makes it. + +(import (std prelude)) +(import (std simpletest)) +(import (std interop)) +(import (bjogui view)) +(import (bjogui app)) + +(import/class + (Object (: System.Object)) + (Control (: Avalonia.Controls.Control)) + (Window (: Avalonia.Controls.Window)) + (TopLevel (: Avalonia.Controls.TopLevel)) + (Visual (: Avalonia.Visual)) + (TextBox (: Avalonia.Controls.TextBox)) + (TextBlock (: Avalonia.Controls.TextBlock)) + (InputElement (: Avalonia.Input.InputElement)) + (Interactive (: Avalonia.Interactivity.Interactive)) + (RoutedEvent (: Avalonia.Interactivity.RoutedEvent)) + (RoutedEventArgs (: Avalonia.Interactivity.RoutedEventArgs (-> RoutedEvent RoutedEventArgs))) + (CloseReason (: Avalonia.Controls.WindowCloseReason)) + (ClrType (: System.Type)) + (PropertyInfo (: System.Reflection.PropertyInfo))) + +(import/extern + (top-level-of (: Avalonia.Controls.TopLevel.GetTopLevel (-> Visual TopLevel))) + (title-of (: Avalonia.Controls.Window.Title (-> Window string) #:get)) + (visible? (: Avalonia.Visual.IsVisible (-> Visual bool) #:get)) + (box-text! (: Avalonia.Controls.TextBox.Text (-> TextBox string void) #:set)) + (block-text (: Avalonia.Controls.TextBlock.Text (-> TextBlock string) #:get)) + (focused? (: Avalonia.Input.InputElement.IsFocused (-> InputElement bool) #:get)) + (click-event (: Avalonia.Controls.Button.ClickEvent #:get)) + (raise-event (: Avalonia.Interactivity.Interactive.RaiseEvent (-> Interactive RoutedEventArgs void))) + ;; What a click on the close button calls: the window's platform's Closing. + ;; Avalonia hides it from C#, so it is found by reflection. + (type-named (: System.Type.GetType (-> string ClrType))) + (property-of (: System.Type.GetProperty (-> ClrType string PropertyInfo))) + (value-of (: System.Reflection.PropertyInfo.GetValue (-> PropertyInfo Object Object))) + (invoke (: System.Delegate.DynamicInvoke (-> Delegate (Array Object) Object))) + (sleep-ms (: System.Threading.Thread.Sleep (-> int void) #:blocking))) + +(type (: Msg (Union OpenSettings CloseSettings OpenNotes CloseNotes (: Name string) Save FocusName))) +(type (: Model (Record (: settings bool) (: notes bool) (: name string) (: saved (List string))))) + +(: (-> Msg Model (Tuple Model (List (Event Msg))))) +(defun (gui-update msg m) + (match msg + (OpenSettings (next (record-set m (settings #t)))) + (CloseSettings (next (record-set m (settings #f)))) + (OpenNotes (next (record-set m (notes #t)))) + (CloseNotes (next (record-set m (notes #f)))) + ((Name s) (next (record-set m (name s)))) + (Save (next (record-set m (saved (cons (.name m) (.saved m)))))) + (FocusName (next m (focus "name"))))) + +(: (-> Model (View Msg))) +(defun (view m) + (text #"name: ${(.name m)}")) + +(: (-> Model (List (WindowView Msg)))) +(defun (windows m) + (def settings + (window "settings" "Settings" CloseSettings + (column (keyed "name" (text-input (.name m) Name)) ; 0 + (button "Save" Save)) ; 1 + #:modal #t)) + (def notes + (window "notes" #"Notes: ${(.name m)}" CloseNotes (text (.name m)) #:width 200.0 #:height 100.0)) + (list-append (if (.settings m) (list settings) '()) + (if (.notes m) (list notes) '()))) + +(defun (main) + (start-headless!) + (def app (start-app (Model (settings #f) (notes #f) (name "") (saved '())) + gui-update view (fun (c) unit) #:windows windows)) + (defun (in-window key path) (app-window-control app key path)) + (defun (frame key) (cast Window (top-level-of (cast Visual (in-window key '()))))) + (defun (click c) (raise-event (cast Interactive c) (RoutedEventArgs. (cast RoutedEvent click-event)))) + (: (-> (-> bool) bool)) + (defun (wait-for done?) + (let go ((tries 0)) + (app-run-pending! app) + (cond ((done?) #t) + ((> tries 200) #f) + (else (sleep-ms 10) (go (+ tries 1)))))) + + (expect "no windows at first" (app-windows app) '()) + + ;; --- opening, and the window's own view --------------------------------------- + (app-dispatch! app OpenSettings) + (expect "a window is opened" (app-windows app) '("settings")) + (def settings (frame "settings")) + (expect "with its title" (title-of settings) "Settings") + (expect "and shown" (visible? (cast Visual settings)) #t) + (box-text! (cast TextBox (in-window "settings" '(0))) "Ada") + (expect "its controls send messages" (.name (app-model app)) "Ada") + (expect "which the main view shows" (block-text (cast TextBlock (app-control app))) "name: Ada") + (click (in-window "settings" '(1))) + (expect "a button in it" (.saved (app-model app)) '("Ada")) + + ;; --- two windows, and changes --------------------------------------------------- + (app-dispatch! app OpenNotes) + (expect "a second window" (app-windows app) '("settings" "notes")) + (expect "the first is the same window" (eq? (cast Object (frame "settings")) (cast Object settings)) #t) + (expect "a title from the model" (title-of (frame "notes")) "Notes: Ada") + (app-dispatch! app (Name "Bob")) + (expect "changes with it" (title-of (frame "notes")) "Notes: Bob") + (expect "and so does its view" (block-text (cast TextBlock (in-window "notes" '()))) "Bob") + + ;; --- a command finds a control in a window --------------------------------------- + (app-dispatch! app FocusName) + (expect "focus in a window" (wait-for (fun () (focused? (cast InputElement (in-window "settings" '(0)))))) #t) + + ;; --- closing ---------------------------------------------------------------------- + (app-dispatch! app CloseSettings) + (expect "a window the model has no more is closed" (app-windows app) '("notes")) + (expect "and not shown" (visible? (cast Visual settings)) #f) + + (def notes (frame "notes")) + (defun (impl-of (: o Object) type-name property) + (value-of (property-of (type-named type-name) property) o)) + (def platform (impl-of (cast Object notes) "Avalonia.Controls.TopLevel, Avalonia.Controls" "PlatformImpl")) + (def closing (impl-of platform "Avalonia.Platform.IWindowImpl, Avalonia.Controls" "Closing")) + (ignore (invoke (cast Delegate closing) (: (make-array 1 #:fill (cast Object CloseReason.WindowClosing)) (Array Object)))) + (expect "a click on the close button does not close it" (visible? (cast Visual notes)) #t) + (expect "it sends on-close" (wait-for (fun () (not (.notes (app-model app))))) #t) + (expect "and the model closes it" (app-windows app) '()) + (expect "so it is gone" (visible? (cast Visual notes)) #f) + + ;; --- stopping closes them --------------------------------------------------------- + (app-dispatch! app OpenNotes) + (def again (frame "notes")) + (app-stop! app) + (expect "a stopped app closes its windows" (visible? (cast Visual again)) #f) + + (println "bjogui windows ok"))