Windows besides the main one, and dialogs of the program's own

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 <git@eca.dev>
This commit is contained in:
Linus Björnstam 2026-10-25 21:21:09 +01:00
parent 708256d0b3
commit 5127fbac13
7 changed files with 583 additions and 45 deletions

145
tests/windows.bjo Normal file
View file

@ -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"))