From 9a909d9ef8ebff84875957d248cc9ce46fa4a505 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Tue, 27 Oct 2026 00:32:50 +0100 Subject: [PATCH 01/47] Prototype: save and open a view as a BeamletOpticsProjects folder save_project(gui, dir), open_project(dir), Ctrl+S / Ctrl+O and the tools Save / Open with native folder dialogs; system.jl via BeamletOpticsProjects, view.toml for camera, views, clip planes, table and beam looks. Co-Authored-By: Claude Opus 5.5 --- Project.toml | 7 + docs/Project.toml | 3 + skills/beamletopticsgui/API.md | 1 + src/BeamletOpticsGUI.jl | 8 +- src/LiveCustom.jl | 4 +- src/LiveIcons.jl | 4 + src/LiveProject.jl | 600 +++++++++++++++++++++++++++++++++ src/LiveView.jl | 7 + test/Project.toml | 3 + test/TestLiveHelp.jl | 2 +- test/TestLiveIcons.jl | 4 +- test/TestLiveProject.jl | 312 +++++++++++++++++ test/runtests.jl | 1 + 13 files changed, 951 insertions(+), 5 deletions(-) create mode 100644 src/LiveProject.jl create mode 100644 test/TestLiveProject.jl diff --git a/Project.toml b/Project.toml index f614676..21acacc 100644 --- a/Project.toml +++ b/Project.toml @@ -9,11 +9,14 @@ projects = ["test", "docs"] [deps] AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c" BeamletOptics = "c387492b-ffec-4e51-9a46-bd230226031c" +BeamletOpticsProjects = "cd81f88b-d4c6-44ce-ae16-166b70b38414" GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" +NativeFileDialog = "e1fe445b-aa65-4df4-81c1-2041507f0fd4" PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" [weakdeps] GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" @@ -23,14 +26,18 @@ BeamletOpticsGUIGLMakieExt = "GLMakie" [sources] BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} +BeamletOpticsProjects = {path = "../BeamletOpticsProjects"} [compat] AbstractTrees = "0.4" BeamletOptics = "0.14" +BeamletOpticsProjects = "0.1" GLMakie = "0.13" GeometryBasics = "0.5" InteractiveUtils = "1" LinearAlgebra = "1" Makie = "0.24" +NativeFileDialog = "0.2" PrecompileTools = "1" +TOML = "1" julia = "1.12" diff --git a/docs/Project.toml b/docs/Project.toml index a3349a8..149bbc4 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,14 +1,17 @@ [deps] BeamletOptics = "c387492b-ffec-4e51-9a46-bd230226031c" BeamletOpticsGUI = "d62ba671-5e61-47cc-8a7f-19d21d414e61" +BeamletOpticsProjects = "cd81f88b-d4c6-44ce-ae16-166b70b38414" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" [sources] BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} +BeamletOpticsProjects = {path = "../../BeamletOpticsProjects"} [compat] BeamletOptics = "0.14" +BeamletOpticsProjects = "0.1" Documenter = "1.11" GLMakie = "0.13" diff --git a/skills/beamletopticsgui/API.md b/skills/beamletopticsgui/API.md index 2e716e9..df01252 100644 --- a/skills/beamletopticsgui/API.md +++ b/skills/beamletopticsgui/API.md @@ -23,6 +23,7 @@ Look up the docstring of any name before use, e.g. | Category | Names | |----------|-------| | Window | `live_view`, `open_system`, `export_changes`, `export_script`, `retrace!` | +| Projects (prototype) | `save_project`, `open_project` | | Scripting | `select!`, `spectator!`, `wait_solve` (plus methods of the BeamletOptics verbs, see "Scripting a window") | | Components and systems at runtime | `add_component!`, `remove_component!`, `add_system!`, `remove_system!`, `CatalogEntry`, `CatalogParam`, `CatalogGlass`, `component_catalog`, `catalog_glasses` | | Interactive helpers | `kinematic_controls!`, `view_cube!` | diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 1d4f0fd..95b39ff 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -21,7 +21,12 @@ export live_view, kinematic_controls!, view_cube!, export_changes, export_script add_panel!, add_controls!, add_tool!, retrace!, add_component!, remove_component!, open_system, add_system!, remove_system!, CatalogEntry, CatalogParam, CatalogGlass, component_catalog, catalog_glasses, select!, - spectator!, wait_solve + spectator!, wait_solve, save_project, open_project + +import BeamletOpticsProjects +import BeamletOpticsProjects: save_project +import NativeFileDialog +import TOML import Makie using Makie: Figure, Axis3, LScene, mesh!, lines!, linesegments!, RGBf, RGBAf, scatter!, text!, @@ -103,6 +108,7 @@ include("LiveCatalogEntries.jl") include("LiveCatalog.jl") include("LiveCatalogWindow.jl") include("LiveCopy.jl") +include("LiveProject.jl") include("LiveDebug.jl") include("LiveDebugHUD.jl") include("LiveDebugSpheres.jl") diff --git a/src/LiveCustom.jl b/src/LiveCustom.jl index 2ad5d9c..f861409 100644 --- a/src/LiveCustom.jl +++ b/src/LiveCustom.jl @@ -272,8 +272,8 @@ const _LIVE_VIEW_KEYS = ( Keyboard.page_down => "the key steps (Page Up/Down)", Keyboard.left_shift => "Shift (large key steps, Shift+c)", Keyboard.right_shift => "Shift (large key steps, Shift+c)", - Keyboard.left_control => "Ctrl (undo, redo, copy, paste)", - Keyboard.right_control => "Ctrl (undo, redo, copy, paste)", + Keyboard.left_control => "Ctrl (undo, redo, copy, paste, save, open)", + Keyboard.right_control => "Ctrl (undo, redo, copy, paste, save, open)", Keyboard.left_super => "Cmd (undo, redo)", Keyboard.right_super => "Cmd (undo, redo)", Keyboard.minus => "the step size (-)", diff --git a/src/LiveIcons.jl b/src/LiveIcons.jl index f2b0c23..7780795 100644 --- a/src/LiveIcons.jl +++ b/src/LiveIcons.jl @@ -165,6 +165,10 @@ const _ICON_SVG = Dict{Symbol, String}( :trash => "M160-760L360-760L360-840L600-840L600-760L800-760L800-680L160-680ZM220-640L740-640L740-120L220-120ZM300-200L660-200L660-560L300-560ZM400-520L440-520L440-240L400-240ZM520-520L560-520L560-240L520-240Z", # own design: the brackets of code, for the export of the setup as a script :script => "M360-700L140-480L360-260L416-316L252-480L416-644ZM600-700L820-480L600-260L544-316L708-480L544-644Z", + # save: saving the view as a project folder + :save => "M840-680v480q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160Zm-80 34L646-760H200v560h560v-446ZM480-240q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM240-560h360v-160H240v160Zm-40-86v446-560 114Z", + # folder_open: opening a project folder + :open => "M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640H447l-80-80H160v480l96-320h684L837-217q-8 26-29.5 41.5T760-160H160Zm84-80h516l72-240H316l-72 240Zm0 0 72-240-72 240Zm-84-400v-80 80Z", # own design: an optical table, a plate with holes :table => "M120-760H840V-200H120ZM200-680V-280H760V-680ZM320-520q-16.5 0-28.25-11.75T280-560q0-16.5 11.75-28.25T320-600q16.5 0 28.25 11.75T360-560q0 16.5-11.75 28.25T320-520ZM480-520q-16.5 0-28.25-11.75T440-560q0-16.5 11.75-28.25T480-600q16.5 0 28.25 11.75T520-560q0 16.5-11.75 28.25T480-520ZM640-520q-16.5 0-28.25-11.75T600-560q0-16.5 11.75-28.25T640-600q16.5 0 28.25 11.75T680-560q0 16.5-11.75 28.25T640-520ZM320-360q-16.5 0-28.25-11.75T280-400q0-16.5 11.75-28.25T320-440q16.5 0 28.25 11.75T360-400q0 16.5-11.75 28.25T320-360ZM480-360q-16.5 0-28.25-11.75T440-400q0-16.5 11.75-28.25T480-440q16.5 0 28.25 11.75T520-400q0 16.5-11.75 28.25T480-360ZM640-360q-16.5 0-28.25-11.75T600-400q0-16.5 11.75-28.25T640-440q16.5 0 28.25 11.75T680-400q0 16.5-11.75 28.25T640-360Z", # sources of the catalog, own designs as above: the light goes from the left to the right diff --git a/src/LiveProject.jl b/src/LiveProject.jl new file mode 100644 index 0000000..47f7cf1 --- /dev/null +++ b/src/LiveProject.jl @@ -0,0 +1,600 @@ +#= +Projects: saving a view to a project folder and opening one (see BeamletOpticsProjects). The setup +is `system.jl` of the folder, written and read by BeamletOpticsProjects; how the view looks (camera, +saved views, clip planes, table, the colors of the beams, ...) is `view.toml`, which the GUI writes +and reads itself. +=# + +const BMOP = BeamletOpticsProjects + +# The file of the folder of a project that holds how the view looks +const _VIEW_TOML = "view.toml" + +# The keys of saving and opening, in the help of the controls, see `_help_sections` +const _PROJECT_HELP = _HelpSection["Edit" => [ + _HelpEntry([_ctrl_cap(), "S"], "save the setup as a project folder"; combo = true), + _HelpEntry([_ctrl_cap(), "O"], "open a project folder"; combo = true)]] + +""" +The dialog that picks a folder, `path -> folder`, which returns `""` (or `nothing`) if it was +cancelled: the native dialog of NativeFileDialog, replaced by the tests. +""" +const _PICK_FOLDER = Ref{Any}(path -> NativeFileDialog.pick_folder(path)) + +# The task of the last dialog of `Ctrl+S` or `Ctrl+O`, which runs after the key callback returned +const _DIALOG_TASK = Ref{Any}(nothing) + +#= +Names +=# + +"""Whether `s` can be the name of an entry or a system of a project: a free variable name.""" +_is_project_name(s::AbstractString) = _is_variable_name(s) && !_is_taken_name(s) && + !Base.isexported(BMOP, Symbol(s)) + +"""`s` with every character that is not allowed in a name replaced by `_`, e.g. `Mirror_1`.""" +function _sanitized_name(s::AbstractString) + t = replace(strip(s), r"[^\p{L}\p{N}_]+" => "_") + t = strip(t, '_') + isempty(t) && return "obj" + return isletter(first(t)) ? String(t) : "obj_" * t +end + +""" + _project_names(gui, items) -> IdDict{Any, Symbol} + +The names of the objects, sources and systems `items` of the `gui` in a project: the label (see +`_label`) if it is a free variable name (see `_is_project_name`) that no item before took, else the +label with the characters of no name replaced by `_` (see `_sanitized_name`), with `_2`, `_3`, … +appended while it is taken. +""" +function _project_names(gui::LiveView, items) + names = IdDict{Any, Symbol}() + used = Set{String}() + for x in items + label = _label(gui, x) + base = _is_project_name(label) ? label : _sanitized_name(label) + name, k = base, 1 + while name in used || !_is_project_name(name) + k += 1 + name = "$(base)_$k" + end + push!(used, name) + names[x] = Symbol(name) + end + return names +end + +#= +Collecting a project from a view +=# + +_entry_kind(::_Source) = :source +_entry_kind(@nospecialize(x)) = :component + +_static_system(::BMO.StaticSystem) = true +_static_system(@nospecialize(sys)) = false + +""" + _project_pose(x, origin) -> (pos, rot) + +The `pos` and `rot` of the entry of the object or source `x` with its `origin`: those of the entry +of the project that it was opened from (the `project` of its origin, see `_project_origin`) while +it is in the pose that it was loaded in, such that the text of an unmoved entry stays as it was; +otherwise its position and the rotation `Δ = R * R0'` since its pose `R0` as constructed, `nothing` +for the identity within `1e-13`. +""" +function _project_pose(@nospecialize(x), origin) + P, R = _pose(x) + rec = get(origin, :project, nothing) + if !isnothing(rec) && P == rec.pose[1] && maximum(abs, R - rec.pose[2]) <= 1e-12 + return rec.pos, rec.rot + end + Δ = Matrix{Float64}(R * origin.pose0[2]') + rot = maximum(abs, Δ - I) <= 1e-13 ? nothing : Δ + return (Float64(P[1]), Float64(P[2]), Float64(P[3])), rot +end + +""" +The `meta` of the entry of an object with the `origin`: that of the entry it was opened from, and +`:catalog => (name, strings)` of a component or source of the catalog, which the page "Edit" needs. +""" +function _project_meta(origin) + rec = get(origin, :project, nothing) + meta = isnothing(rec) ? Pair{Symbol, Any}[] : Pair{Symbol, Any}[m for m in rec.meta] + if haskey(origin, :entry) && haskey(origin, :strings) + cat = :catalog => (String(origin.entry.name), String[origin.strings...]) + i = findfirst(m -> first(m) === :catalog, meta) + isnothing(i) ? push!(meta, cat) : (meta[i] = cat) + end + return meta +end + +""" + _project_parts(gui, dir) -> (; project, names, skipped) + +The `project` of the `gui` in the folder `dir` (see `_collect_project`), the `names` of its +objects, sources and systems and the labels of the objects and sources that were `skipped`, since +their constructor is not known. +""" +function _project_parts(gui::LiveView, dir::AbstractString) + comp = gui.components + # A system without members and sources that has no name, e.g. the one of an empty view + systems = Any[sys for sys in _systems(gui) + if !(isempty(sys.objects) && isempty(_sources_of(gui, sys)) && !haskey(gui.labels, sys))] + # Without the objects of the `extras` kwarg, which have no system at the start + start = comp.start_systems + free = Any[obj for obj in rendered(gui.extras).objects if !isempty(get(start, obj, Any[nothing]))] + objects = unique(objectid, Any[(obj for sys in systems for obj in sys.objects)..., free...]) + sources = unique(objectid, Any[(src for sys in systems for src in _sources_of(gui, sys))..., + (src for src in _sources(gui) if isempty(_snapshot(gui, src).systems))...]) + skipped = String[] + saved = Any[] + for x in Any[objects..., sources...] + if isnothing(get(comp.origin, x, nothing)) + push!(skipped, _label(gui, x)) + else + push!(saved, x) + end + end + names = _project_names(gui, Any[saved..., systems...]) + entries = BMOP.Entry[] + for x in saved + origin = comp.origin[x] + pos, rot = _project_pose(x, origin) + P0, R0 = origin.pose0 + push!(entries, BMOP.Entry(_entry_kind(x), names[x], Meta.parse(origin.code), x; pos, rot, + pose0 = (Vector{Float64}(P0), Matrix{Float64}(R0)), meta = _project_meta(origin))) + end + is_saved = Base.IdSet{Any}(saved) + system_entries = BMOP.SystemEntry[BMOP.SystemEntry(names[sys], + Symbol[names[obj] for obj in sys.objects if obj in is_saved], + Symbol[names[src] for src in _sources_of(gui, sys) if src in is_saved]; + static = _static_system(sys), object = sys) for sys in systems] + project = BMOP.Project(dir, BMOP.project_name(dir); entries, systems = system_entries) + return (; project, names, skipped) +end + +""" + _collect_project(gui, dir) -> Project + +The setup of the `gui` as a project in the folder `dir`: its systems (`System` or `StaticSystem`) +with their members and sources, the objects and sources without a system (not those of the +`extras` kwarg), each object once, also one of several systems. Each is an entry with its +constructor call and its pose, see `_project_pose`; one whose constructor is not known (see the +`origin` of `_ComponentState`) is left out. The inputs of the page "Edit" that are not applied are +ignored. +""" +_collect_project(gui::LiveView, dir::AbstractString) = _project_parts(gui, dir).project + +"""The packages whose versions a new project pins: BeamletOptics, the projects, the GUI, GLMakie.""" +function _project_packages() + mods = Module[BMO, BMOP, @__MODULE__] + glmakie = Base.PkgId(Base.UUID("e9467ef8-e4e7-5192-8a1a-b1aee30e663a"), "GLMakie") + m = get(Base.loaded_modules, glmakie, nothing) + isnothing(m) || push!(mods, m) + return mods +end + +# The handle of a system has the name of the system, see `_rename_system!` +function _name_handle!(gui::LiveView, sys::BMO.AbstractSystem, name::String) + h = _system_handle(gui, sys) + isnothing(h) || (gui.objects.names[h] = name) + return nothing +end +_name_handle!(::LiveView, @nospecialize(x), ::String) = nothing + +""" +Writes the `names` of a saved project into the labels of the `gui`, such that the next save gives +the same names. +""" +function _apply_project_names!(gui::LiveView, names) + changed = false + for (x, name) in names + s = String(name) + get(gui.labels, x, nothing) == s && continue + changed = true + gui.labels[x] = gui.objects.names[x] = s + _name_handle!(gui, x, s) + end + changed || return nothing + _refresh_names!(gui) + _refresh_catalog!(gui) + _refresh_links!(gui) + return nothing +end + +#= +view.toml +=# + +# The color of a beam in view.toml: "wavelength" or "#rrggbb"; `nothing` for any other +function _toml_color(color) + _by_wavelength(color) && return "wavelength" + c = _single_color(color) + return isnothing(c) ? nothing : _color_hex(c) +end +_toml_color_value(s::AbstractString) = s == "wavelength" ? :wavelength : RGBf(Makie.to_color(s)) + +""" + _view_dict(gui, names) -> Dict + +What `view.toml` holds of the `gui`: the `camera`, the saved `views`, the `clip_planes` and +`clip_beams`, the `table`, `auto_trace`, `orthographic`, `show_sources`, `snap`, per source (by its +name in `names`) the `color`, `alpha` and `linewidth` of its beam, and the sources that are off. +""" +function _view_dict(gui::LiveView, names) + eye, lookat, up = _current_view(gui) + d = Dict{String, Any}( + "camera" => Dict{String, Any}("eye" => eye, "lookat" => lookat, "up" => up), + "views" => [Dict{String, Any}("name" => name, "eye" => v[1], "lookat" => v[2], "up" => v[3]) + for (name, v) in gui.camera.views], + "clip_planes" => [Dict{String, Any}("pos" => Vector{Float64}(collect(p.pos)), + "normal" => Vector{Float64}(_normal(p))) for p in gui.clip.planes], + "clip_beams" => gui.clip.beams, + "auto_trace" => gui.trace.auto[], + "orthographic" => gui.widgets.orthographic_toggle.active[], + "show_sources" => gui.widgets.sources_toggle.active[], + "snap" => String(gui.controls.snap[])) + table = _table_kwarg(gui.components.table) + table === false || (d["table"] = Dict{String, Any}(String(k) => v for (k, v) in pairs(table) if !isnothing(v))) + beams = Dict{String, Any}() + off = String[] + for src in _sources(gui) + name = get(names, src, nothing) + isnothing(name) && continue + kw = _snapshot(gui, src).kwargs + b = Dict{String, Any}() + if haskey(kw, :color) + c = _toml_color(kw.color) + isnothing(c) || (b["color"] = c) + end + haskey(kw, :alpha) && (b["alpha"] = Float64(kw.alpha)) + haskey(kw, :linewidth) && (b["linewidth"] = Float64(kw.linewidth)) + isempty(b) || (beams[String(name)] = b) + src in gui.beams.off && push!(off, String(name)) + end + d["beams"] = beams + d["beams_off"] = off + return d +end + +"""Writes `view.toml` of the `gui` into the folder of the `project`, with the `names` of its sources.""" +function _write_view_toml(gui::LiveView, project, names) + path = BMOP.project_file(project, _VIEW_TOML) + mkpath(dirname(path)) + text = sprint(io -> TOML.print(io, _view_dict(gui, names); sorted = true)) + write(path, text) + return path +end + +"""`view.toml` of the `project`, an empty `Dict` without one.""" +function _read_view(project) + path = BMOP.project_file(project, _VIEW_TOML) + return isfile(path) ? TOML.parsefile(path) : Dict{String, Any}() +end + +_view_tuple(d) = (Vector{Float64}(d["eye"]), Vector{Float64}(d["lookat"]), Vector{Float64}(d["up"])) + +""" + _view_kwargs(view, byname, paired) -> (kwargs, beam_kwargs, off) + +The kwargs of `live_view` from the `view` (of `view.toml`), with the objects of the project by name +in `byname`; `paired` are the sources of the pairs of the view. Also the `beam_kwargs` per source and +the sources that are `off`, of which those without a system are set after `live_view`. +""" +function _view_kwargs(view::AbstractDict, byname, paired) + kw = Dict{Symbol, Any}() + haskey(view, "views") && (kw[:views] = [String(v["name"]) => _view_tuple(v) for v in view["views"]]) + haskey(view, "clip_planes") && + (kw[:clip_planes] = [Vector{Float64}(c["pos"]) => Vector{Float64}(c["normal"]) for c in view["clip_planes"]]) + for key in ("clip_beams", "auto_trace", "orthographic", "show_sources") + haskey(view, key) && (kw[Symbol(key)] = Bool(view[key])) + end + haskey(view, "snap") && (kw[:snap] = Symbol(view["snap"])) + haskey(view, "table") && (kw[:table] = (; (Symbol(k) => v for (k, v) in view["table"])...)) + beam_kwargs = IdDict{Any, Any}() + for (name, b) in get(view, "beams", Dict{String, Any}()) + src = get(byname, Symbol(name), nothing) + isnothing(src) && continue + nt = (;) + haskey(b, "color") && (nt = merge(nt, (; color = _toml_color_value(b["color"])))) + haskey(b, "alpha") && (nt = merge(nt, (; alpha = Float64(b["alpha"])))) + haskey(b, "linewidth") && (nt = merge(nt, (; linewidth = Float64(b["linewidth"])))) + beam_kwargs[src] = nt + end + off = Any[byname[Symbol(n)] for n in get(view, "beams_off", String[]) if haskey(byname, Symbol(n))] + kw[:beam_kwargs] = IdDict{Any, Any}(src => nt for (src, nt) in beam_kwargs if src in paired) + kw[:beams_off] = Tuple(src for src in off if src in paired) + return (; kwargs = (; kw...), beam_kwargs, off) +end + +#= +Opening a project +=# + +# A literal of `meta` as a value: tuples and vectors of an expression become values +_literal(x) = x +_literal(ex::Expr) = ex.head === :tuple ? Tuple(_literal.(ex.args)) : + ex.head === :vect ? Any[_literal.(ex.args)...] : ex + +# The code of an entry as a string on one line +_code_string(@nospecialize(code)) = string(BMOP.normalize_code(code)) + +""" + _project_catalog(gui, entry) -> Union{Nothing, NamedTuple} + +The entry of the catalog of the `gui` and the `strings` of its form that the `:catalog` of the +`meta` of the project `entry` names, if they build the same code as the entry has; `nothing` +otherwise, e.g. for an entry that an agent changed: then it can not be edited on the page "Edit". +""" +function _project_catalog(gui::LiveView, e) + i = findfirst(m -> first(m) === :catalog, e.meta) + isnothing(i) && return nothing + v = _literal(last(e.meta[i])) + (v isa Union{Tuple, AbstractVector} && length(v) == 2) || return nothing + name, strings = v + strings = _literal(strings) + (name isa AbstractString && strings isa Union{Tuple, AbstractVector} && + all(s -> s isa AbstractString, strings)) || return nothing + j = findfirst(c -> c.name == name, gui.components.catalog) + isnothing(j) && return nothing + entry = gui.components.catalog[j] + built = try + _catalog_component(entry, String[strings...]) + catch err + err isa InterruptException && rethrow() + return nothing + end + same = try + Meta.parse(built.origin.code) == Meta.parse(_code_string(e.code)) + catch + false + end + return same ? (; entry, strings = String[strings...]) : nothing +end + +""" + _project_origin(gui, entry) -> NamedTuple + +The `origin` (see `_ComponentState`) of the object of the project `entry`: its `code` and `pose0`, +and `project`, the `name`, `pos`, `rot` and `meta` of the entry and the `pose` that the object was +loaded in, from which `_project_pose` tells whether it moved; of a component of the catalog also +its `entry` and `strings` (see `_project_catalog`). +""" +function _project_origin(gui::LiveView, e) + P0, R0 = e.pose0 + origin = (; code = _code_string(e.code), pose0 = (Point3{Float64}(P0), Matrix{Float64}(R0)), + project = (; e.name, e.pos, e.rot, meta = Pair{Symbol, Any}[e.meta...], pose = _pose(e.object))) + cat = _project_catalog(gui, e) + return isnothing(cat) ? origin : merge(origin, cat) +end + +# Adds an object or source of the project without a system to the `gui` +function _add_loose!(gui::LiveView, src::_Source, label, origin, beam_kwargs) + add_component!(gui, src; system = :none, select = false, label, origin, + beam_kwargs = get(beam_kwargs, src, (;))) + return nothing +end +function _add_loose!(gui::LiveView, @nospecialize(obj), label, origin, beam_kwargs) + add_component!(gui, obj; system = :none, select = false, label, origin) + return nothing +end + +""" + _open_from(project; kwargs...) -> LiveView + +The live view of a loaded `project` (see `BeamletOpticsProjects.load_project`), without showing it: +its systems with their sources, a `System()` for a project without systems, the objects and +sources without a system, the names of the project as labels, the origins of the objects (their +code, with which they are saved again and, from the catalog, edited and copied), and `view.toml`. +The `kwargs` of `live_view` win over those of `view.toml`. +""" +function _open_from(project; kwargs...) + @nospecialize + byname = Dict{Symbol, Any}(e.name => e.object for e in project.entries) + in_system = Set{Symbol}() + args = Any[] + for s in project.systems + union!(in_system, s.members, s.sources) + sources = Any[byname[n] for n in s.sources] + isempty(sources) ? push!(args, s.object) : append!(args, Any[s.object => src for src in sources]) + end + isempty(args) && push!(args, BMO.System()) + paired = Base.IdSet{Any}(a.second for a in args if a isa Pair) + labels = IdDict{Any, String}() + foreach(e -> labels[e.object] = String(e.name), project.entries) + foreach(s -> labels[s.object] = String(s.name), project.systems) + view = _read_view(project) + vk = _view_kwargs(view, byname, paired) + gui = live_view(args...; labels, vk.kwargs..., kwargs...) + origins = IdDict{Any, Any}(e.object => _project_origin(gui, e) for e in project.entries) + _unrecorded(gui) do + for e in project.entries + e.name in in_system && continue + _add_loose!(gui, e.object, String(e.name), origins[e.object], vk.beam_kwargs) + end + for src in vk.off + src in paired || _set_beam_on!(gui, src, false) + end + end + for (obj, origin) in origins + gui.components.origin[obj] = origin + end + empty!(gui.controls.undo_stack) + empty!(gui.controls.redo_stack) + if haskey(view, "camera") + set_view(gui.ax, _view_tuple(view["camera"])...) + end + gui.project = project.path + gui.status.text[] = "project $(project.name) opened from $(project.path)" + return gui +end + +""" + open_project(dir; display = true, kwargs...) -> LiveView + +Opens the project in the folder `dir` (see `BeamletOpticsProjects.load_project`) in a live view +and shows it if `display` is `true`: its systems with their sources, the objects and sources +without a system, the names of the project as labels and how the view looked when it was saved +(`view.toml`: camera, saved views, clip planes, table, the colors of the beams, ...). The `kwargs` +are those of [`live_view`](@ref) and win over `view.toml`. `Ctrl+S` saves it there again, see +[`save_project`](@ref). The components and sources from the catalog can be edited and copied. +`Ctrl+O` or the tool "Open" of a window opens a project chosen in a dialog and closes the window. +""" +function open_project(dir::AbstractString; display::Bool = true, kwargs...) + project = BMOP.load_project(dir) + gui = _open_from(project; kwargs...) + if display + _multi_light_backend() ? Base.display(gui; title = project.name) : Base.display(gui) + end + return gui +end + +#= +Saving +=# + +""" + save_project(gui::LiveView, dir = gui.project) -> String + +Saves the setup of the live view `gui` as the project in the folder `dir` and returns the path of +its `system.jl`: each system with its members and sources, the objects and sources without a +system, each with its constructor call and its pose, see `BeamletOpticsProjects`. A folder that is +not a project yet becomes one, with the versions of BeamletOptics, BeamletOpticsProjects, +BeamletOpticsGUI and GLMakie. How the view looks is written to `view.toml`. An object whose +constructor is not known, e.g. one that the script gave to `live_view`, is not saved: a warning and +the status line name it. The names of the project become the labels of the view, and `dir` its +project, which `Ctrl+S` saves to. +""" +function save_project(gui::LiveView, dir::AbstractString) + dir = abspath(dir) + parts = _project_parts(gui, dir) + path = save_project(parts.project; packages = _project_packages()) + _write_view_toml(gui, parts.project, parts.names) + _apply_project_names!(gui, parts.names) + gui.project = dir + msg = "project saved to $dir" + if !isempty(parts.skipped) + list = join(parts.skipped, ", ") + @warn "save_project: $(length(parts.skipped)) objects without a recipe were not saved: $list" + msg *= "; $(length(parts.skipped)) objects without a recipe were not saved: $list" + end + gui.status.text[] = msg + return path +end + +function save_project(gui::LiveView) + isnothing(gui.project) && + throw(ArgumentError("the live view has no project folder yet, use save_project(gui, dir)")) + return save_project(gui, gui.project) +end + +#= +Keys, tools and dialogs +=# + +# The first line of the message of the error `e` +_message_line(e) = first(split(sprint(showerror, e), '\n')) + +"""Saves the `gui` to `dir`; an error is logged and shown in the status line.""" +function _save_to!(gui::LiveView, dir::AbstractString) + try + save_project(gui, dir) + catch e + e isa InterruptException && rethrow() + @error "save_project failed" exception = (e, catch_backtrace()) + gui.status.text[] = "project not saved: $(_message_line(e))" + end + return nothing +end + +""" +The folder that the dialog picks, starting at the project of the `gui`; `nothing` if it was +cancelled or failed, the reason of a failure in the status line. +""" +function _pick_folder(gui::LiveView) + dir = try + _PICK_FOLDER[](something(gui.project, "")) + catch e + e isa InterruptException && rethrow() + gui.status.text[] = "no folder dialog: $(_message_line(e)), use save_project/open_project" + return nothing + end + (isnothing(dir) || isempty(dir)) && return nothing + return String(dir) +end + +# Runs `f` after the key callback returned, e.g. a dialog, which blocks +_run_later(f) = (_DIALOG_TASK[] = @async f()) + +""" +`Ctrl+S` and the tool "Save" of the `gui`: saves to its project, or, without one, to the folder +that the dialog picks (a cancelled dialog does nothing). +""" +function _save_key!(gui::LiveView) + if isnothing(gui.project) + _run_later() do + dir = _pick_folder(gui) + isnothing(dir) || _save_to!(gui, dir) + return nothing + end + else + _save_to!(gui, gui.project) + end + return nothing +end + +""" +`Ctrl+O` and the tool "Open" of the `gui`: opens the project that the dialog picks in a new window +with the layout and the theme of the `gui`, then closes the `gui` and its window. A cancelled dialog +does nothing, an error shows its message in the status line and keeps the `gui`. +""" +function _open_key!(gui::LiveView) + _run_later() do + dir = _pick_folder(gui) + isnothing(dir) && return nothing + try + open_project(dir; layout = _layout_name(gui.layout), theme = _theme_name(gui.layout.theme)) + catch e + e isa InterruptException && rethrow() + @error "open_project failed" exception = (e, catch_backtrace()) + gui.status.text[] = "project not opened: $(_message_line(e))" + return nothing + end + # Never inside the event callback of its own screen: this task runs after it returned + screen = Makie.getscreen(gui.fig.scene) + close(gui) + isnothing(screen) || close(screen) + return nothing + end + return nothing +end + +""" +Connects `Ctrl+S` and `Ctrl+O` (`Cmd` on macOS) of the `gui` and the tools "Save" and "Open", see +`_save_key!` and `_open_key!`, like `_connect_copy!`: by the letters of the keys in the keyboard +layout, before the camera, which takes `s` and `o`; left to a focused text box, nothing in the +spectator mode. +""" +function _connect_project!(gui::LiveView) + scene = gui.ax.scene + ctrl = gui.controls + push!(ctrl.listeners, on(events(scene).keyboardbutton; priority = 210) do event + (event.action == Keyboard.press && _modifier_held(scene, _CTRL_OR_CMD_KEYS)) || + return Consume(false) + key = _layout_key(event.key) + key in (Keyboard.s, Keyboard.o) || return Consume(false) + ctrl.ignore_keys() && return Consume(false) + ctrl.spectator[] && return Consume(true) + key == Keyboard.s ? _save_key!(gui) : _open_key!(gui) + return Consume(true) + end) + append!(ctrl.help_extra, _PROJECT_HELP) + _update_help!(ctrl) + add_tool!(_save_key!, gui, "Save"; icon = :save, + tooltip = "Save the setup as a project folder ($(_ctrl_cap())+S)") + add_tool!(_open_key!, gui, "Open"; icon = :open, + tooltip = "Open a project folder ($(_ctrl_cap())+O)") + return nothing +end diff --git a/src/LiveView.jl b/src/LiveView.jl index 5208a3e..30561c8 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -596,6 +596,8 @@ they are rendered, selectable and movable, but not traced, see `_live_render_ext `custom`, see `LiveCustom.jl`. `background_card` is the kwarg of [`live_view`](@ref): the object (or `gui -> object`) whose card a click on the empty background shows, see `_show_background!`. `links` holds the views that show systems of this one in other windows, see `_ViewLinks`. +`project` is the folder of the project that the view was opened from or saved to (`nothing` +before), which `Ctrl+S` saves to, see [`save_project`](@ref) and [`open_project`](@ref). The type parameter `L` is the type of the `layout`, see `AbstractLiveLayout`: `CompactView` and `AppView` are the `LiveView`s of `live_view(...; layout = :compact)` and `layout = :app`. @@ -629,6 +631,8 @@ Base.@kwdef mutable struct LiveView{L <: AbstractLiveLayout} spectator::_SpectatorState = _SpectatorState() background_card::Any = nothing links::_ViewLinks = _ViewLinks() + # the folder of the project that `Ctrl+S` saves to, see `save_project` + project::Union{Nothing, String} = nothing widgets::_LayoutWidgets # state of the layout, e.g. the slots of the app layout, see `AbstractLiveLayout` layout::L @@ -1660,6 +1664,9 @@ function live_view( _connect_placement!(gui) # Copying and pasting of the components of the catalog, see `_copy_selected!` _connect_copy!(gui) + # Saving and opening projects with `Ctrl+S` and `Ctrl+O` and the tools "Save" and "Open", see + # `save_project` and `open_project` + _connect_project!(gui) # What linked views share beside their objects, see `open_system` _connect_links!(gui) # Aiming a source with the mouse, see `_start_aim!` diff --git a/test/Project.toml b/test/Project.toml index 2fcc48f..a3dd5ec 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -2,6 +2,7 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" BeamletOptics = "c387492b-ffec-4e51-9a46-bd230226031c" BeamletOpticsGUI = "d62ba671-5e61-47cc-8a7f-19d21d414e61" +BeamletOpticsProjects = "cd81f88b-d4c6-44ce-ae16-166b70b38414" GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -11,10 +12,12 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [sources] BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} +BeamletOpticsProjects = {path = "../../BeamletOpticsProjects"} [compat] Aqua = "0.8" BeamletOptics = "0.14" +BeamletOpticsProjects = "0.1" GLMakie = "0.13" GeometryBasics = "0.5" LinearAlgebra = "1" diff --git a/test/TestLiveHelp.jl b/test/TestLiveHelp.jl index 0005748..e35ffc9 100644 --- a/test/TestLiveHelp.jl +++ b/test/TestLiveHelp.jl @@ -104,7 +104,7 @@ const GUI = BeamletOpticsGUI # the card ctrl = GUI._ctrl_cap() @test Set(_caps(sections)) == Set(["Esc", "Enter", "G", "M", "↑", "↓", "←", "→", "PgUp", "PgDn", "+", - "−", "Shift", "Bksp", ctrl, "Z", "Y", "V", "1", "P", "Del", "C", "T", "Ins", "Tab"]) + "−", "Shift", "Bksp", ctrl, "Z", "Y", "V", "1", "P", "Del", "C", "T", "Ins", "Tab", "S", "O"]) # no key is listed twice; Esc is the key of the groups, a trace is cancelled by a button keys = [(e.keys, e.combo) for (_, entries) in sections for e in entries if !(e.keys[1] isa Pair)] @test allunique(keys) diff --git a/test/TestLiveIcons.jl b/test/TestLiveIcons.jl index 27c9ef9..010cfad 100644 --- a/test/TestLiveIcons.jl +++ b/test/TestLiveIcons.jl @@ -30,7 +30,9 @@ const GUI = BeamletOpticsGUI # the card of a system: its own window, renaming and removing :window, :pencil, :trash, # the tool that adds a system - :add_system) + :add_system, + # the tools that save and open a project + :save, :open) @test Set(keys(GUI._ICONS)) == Set(names) for name in names icon = GUI._icon(name) diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl new file mode 100644 index 0000000..3d68552 --- /dev/null +++ b/test/TestLiveProject.jl @@ -0,0 +1,312 @@ +module TestLiveProject + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using LinearAlgebra: norm, normalize +using TOML +using Test + +const BMO = BeamletOptics +const BMOP = BeamletOpticsProjects +const GUI = BeamletOpticsGUI + +@testset "Projects" begin + + # Each change is solved at once, see `TestLiveView.jl`; no card of a detector is pinned at start + const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + _live_view(args...; kwargs...) = live_view(args...; merge(const_kw, kwargs)...) + + _entry(name) = only(e for e in component_catalog() if e.name == name) + _defaults(entry) = String[GUI._catalog_string(p) for p in entry.params] + _built(name, strings = _defaults(_entry(name))) = GUI._catalog_component(_entry(name), strings) + + # The key that types the letter of `key` in the keyboard layout of this machine, see `_layout_key` + _typed(key) = first(k for k in Keyboard.Button.(Int(Keyboard.a):Int(Keyboard.z)) if GUI._layout_key(k) == key) + _key!(gui, key) = (events(gui.ax.scene).keyboardbutton[] = Makie.KeyEvent(key, Keyboard.press)) + function _ctrl!(gui, key) + state = events(gui.ax.scene).keyboardstate + push!(state, Keyboard.left_control) + _key!(gui, _typed(key)) + delete!(state, Keyboard.left_control) + return nothing + end + function _select!(gui, obj) + gui.controls.selected[] = obj + GUI._update_selection_box!(gui.controls) + return nothing + end + _in(obj, list) = any(o -> o === obj, list) + _hex(c) = GUI._color_hex(Makie.to_color(c)) + + # What `load_project` does, in memory: builds each entry from its code, poses it and builds + # the systems, see BeamletOpticsProjects + function _reload(p) + M = Module() + Core.eval(M, :(using BeamletOptics)) + entries = BMOP.Entry[] + for e in p.entries + obj = Core.eval(M, e.code) + pose0 = BMOP.pose_of(obj) + BMOP.apply_pose!(obj, e.pos, e.rot) + push!(entries, BMOP.Entry(e.kind, e.name, e.code, obj; e.pos, e.rot, pose0, e.meta)) + end + byname = Dict(e.name => e.object for e in entries) + systems = map(p.systems) do s + objs = Any[byname[n] for n in s.members] + sys = s.static ? StaticSystem(objs) : isempty(objs) ? System() : System(objs) + BMOP.SystemEntry(s.name, s.members, s.sources; s.static, object = sys) + end + return BMOP.Project(p.path, p.name; entries, systems) + end + + # A setup from the catalog: a lens in two systems, a prism mirror, an object and a source + # without a system, a source per system + function _setup(layout) + s1, s2 = System(), System() + gui = _live_view(s1, s2; layout, labels = Dict(s1 => "Transmitter", s2 => "Receiver")) + lens = _built("Thin lens", ["80", "-60", "30", "N-SF11"]) + rotate3d!(lens.obj, normalize([1.0, 2, 3]), 0.4) + translate_to3d!(lens.obj, [0.01, 0.1, -0.02]) + add_component!(gui, lens.obj; system = s1, origin = lens.origin, label = "L1", select = false) + add_component!(gui, lens.obj; system = s2, select = false) + prism = _built("Prism mirror") + zrotate3d!(prism.obj, deg2rad(90)) + translate3d!(prism.obj, [0.0, 0.2, 0.0]) + add_component!(gui, prism.obj; system = s2, origin = prism.origin, select = false) + free = _built("Round mirror") + translate3d!(free.obj, [0.1, 0.0, 0.0]) + add_component!(gui, free.obj; system = :none, origin = free.origin, label = "spare", select = false) + b1 = _built("Beam", ["532"]) + add_component!(gui, b1.obj; system = s1, origin = b1.origin, label = "B1", select = false) + b2 = _built("Beam") + zrotate3d!(b2.obj, deg2rad(30)) + translate3d!(b2.obj, [0.0, -0.05, 0.01]) + add_component!(gui, b2.obj; system = s2, origin = b2.origin, label = "laser 2", select = false, + beam_kwargs = (; color = :orange, linewidth = 3)) + b3 = _built("Beam", ["1064"]) + add_component!(gui, b3.obj; system = :none, origin = b3.origin, label = "B3", select = false) + return (; gui, s1, s2, lens = lens.obj, prism = prism.obj, free = free.obj, b1 = b1.obj, + b2 = b2.obj, b3 = b3.obj) + end + + _byname(gui) = Dict(Symbol(l) => x for (x, l) in gui.labels) + + @testset "collect and open, $layout" for layout in (:compact, :app) + GUI._CLIPBOARD[] = nothing + dir = mktempdir() + t = _setup(layout) + gui = t.gui + # how the view looks + GUI._set_beam_opacity!(gui, t.b2, 0.5) + GUI._set_beam_on!(gui, t.b1, false) + GUI._add_clip_plane!(gui, [0.0, 0.05, 0.0], [0.0, 1.0, 0.0]; select = false) + GUI._set_clip_beams!(gui, true) + push!(gui.camera.views, "side" => ([0.3, 0.0, 0.0], [0.0, 0.1, 0.0], [0.0, 0.0, 1.0])) + gui.widgets.orthographic_toggle.active[] = true + gui.widgets.sources_toggle.active[] = false + GUI._set_snap!(gui.controls, :pose) + set_view(gui.ax, [0.4, -0.3, 0.2], [0.0, 0.1, 0.0], [0.0, 0.0, 1.0]) + + parts = GUI._project_parts(gui, dir) + p = parts.project + names = parts.names + @test isempty(parts.skipped) + @test p.path == abspath(dir) && Base.isidentifier(p.name) + @test [e.name for e in p.entries][[1, 3, 4, 5, 6]] == [:L1, :spare, :B1, :laser_2, :B3] + @test [e.kind for e in p.entries] == [:component, :component, :component, :source, :source, :source] + @test [s.name for s in p.systems] == [:Transmitter, :Receiver] + @test p.systems[1].members == [names[t.lens]] && p.systems[1].sources == [:B1] + @test p.systems[2].members == [names[t.lens], names[t.prism]] && p.systems[2].sources == [:laser_2] + @test all(!s.static for s in p.systems) + # names are free variable names + @test all(e -> Base.isidentifier(String(e.name)), p.entries) + e_lens = p.entries[1] + @test e_lens.meta == [:catalog => ("Thin lens", ["80", "-60", "30", "N-SF11"])] + @test e_lens.pos == Tuple(Float64.(position(t.lens))) + @test e_lens.rot isa Matrix{Float64} + # an unrotated source: no `rot` + @test isnothing(p.entries[4].rot) + GUI._write_view_toml(gui, p, names) + view = TOML.parsefile(joinpath(dir, "view.toml")) + @test view["beams"]["laser_2"]["color"] == _hex(:orange) + @test view["beams"]["laser_2"]["alpha"] == 0.5 && view["beams"]["laser_2"]["linewidth"] == 3.0 + @test view["beams_off"] == ["B1"] && view["snap"] == "pose" + + # open: a project as `load_project` returns it + p2 = _reload(p) + gui2 = GUI._open_from(p2; layout) + @test gui2.project == abspath(dir) + by = _byname(gui2) + @test Set([e.name for e in p.entries]) ∪ Set([:Transmitter, :Receiver]) ⊆ Set(keys(by)) + old = Dict(names[x] => x for x in keys(names)) + for e in p2.entries + a, b = old[e.name], e.object + @test typeof(a) === typeof(b) && by[e.name] === b + @test position(b) == position(a) + @test maximum(abs, GUI._pose(b)[2] - GUI._pose(a)[2]) < 1e-12 + end + S1, S2 = by[:Transmitter], by[:Receiver] + @test GUI._systems(gui2) == [S1, S2] + lens2 = by[names[t.lens]] + @test _in(lens2, S1.objects) && _in(lens2, S2.objects) && length(S2.objects) == 2 + @test GUI._sources_of(gui2, S1) == [by[:B1]] && GUI._sources_of(gui2, S2) == [by[:laser_2]] + @test by[:B3] in gui2.beams.unassigned + @test _in(by[:spare], GUI.rendered(gui2.extras).objects) + @test isempty(gui2.controls.undo_stack) && isempty(gui2.controls.redo_stack) + # view.toml + @test _hex(gui2.beams.kwargs[by[:laser_2]].color) == _hex(:orange) + @test GUI._beam_opacity(gui2, by[:laser_2]) == 0.5 + @test GUI._beam_linewidth(gui2, by[:laser_2]) == 3.0 + @test !GUI._beam_on(gui2, by[:B1]) + @test length(gui2.clip.planes) == 1 && collect(gui2.clip.planes[1].pos) == [0.0, 0.05, 0.0] + @test gui2.clip.beams + @test gui2.camera.views == gui.camera.views + @test gui2.widgets.orthographic_toggle.active[] && !gui2.widgets.sources_toggle.active[] + @test gui2.controls.snap[] === :pose + for (a, b) in zip(GUI._current_view(gui2), GUI._current_view(gui)) + @test maximum(abs, a - b) < 1e-6 + end + + # unmoved objects keep the `pos` and `rot` of their entry bit for bit + p3 = GUI._collect_project(gui2, dir) + for (e2, e3) in zip(p2.entries, p3.entries) + @test e2.name === e3.name && isequal(e2.pos, e3.pos) && isequal(e2.rot, e3.rot) + @test e2.meta == e3.meta && e2.code == e3.code + end + # a moved one gets its new pose + translate3d!(by[:spare], [0.0, 0.0, 0.01]) + p4 = GUI._collect_project(gui2, dir) + e = only(e for e in p4.entries if e.name === :spare) + @test e.pos == Tuple(Float64.(position(by[:spare]))) + + # a loaded catalog lens can be edited and copied + @test GUI._editable(gui2, lens2) + _select!(gui2, lens2) + _ctrl!(gui2, Keyboard.c) + @test !isnothing(GUI._CLIPBOARD[]) && GUI._CLIPBOARD[].strings == ["80", "-60", "30", "N-SF11"] + GUI._set_edit_string!(gui2, lens2, 1, "120") + GUI._apply_edit!(gui2, lens2; read = false) + new = only(o for o in S1.objects) + @test new !== lens2 && _in(new, S2.objects) && GUI._editable(gui2, new) + @test GUI._label(gui2, new) == String(names[t.lens]) + # ... and is saved with its new code + p5 = GUI._collect_project(gui2, dir) + e = only(e for e in p5.entries if e.name === names[t.lens]) + @test last(only(e.meta))[2][1] == "120" + @test position(new) ≈ position(lens2) atol = 1e-12 + close(gui2) + close(gui) + end + + @testset "catalog entry that does not match" begin + dir = mktempdir() + t = _setup(:compact) + p = GUI._collect_project(t.gui, dir) + close(t.gui) + # an agent changed the code, but not the inputs of the catalog: not editable + e = p.entries[1] + code = :(ThinLens(0.1, -0.06, 0.003, 1.5)) + p.entries[1] = BMOP.Entry(e.kind, e.name, code, e.object; e.pos, e.rot, e.pose0, e.meta) + p2 = _reload(p) + gui = GUI._open_from(p2) + lens = p2.entries[1].object + @test !GUI._editable(gui, lens) + @test gui.components.origin[lens].code == string(code) + # it is saved as it is, with its meta + p3 = GUI._collect_project(gui, dir) + @test p3.entries[1].meta == e.meta && p3.entries[1].code == code + close(gui) + end + + @testset "objects without a recipe" begin + dir = mktempdir() + m = RoundPlanoMirror(25e-3, 5e-3) + translate3d!(m, [0, 0.2, 0]) + sys = System([m]) + beam = Beam([0.0, 0, 0], [0.0, 1, 0], 632.8e-9) + gui = _live_view(sys => beam; labels = Dict(m => "M0")) + c = _built("Thin lens") + add_component!(gui, c.obj; origin = c.origin, label = "L1", system = sys) + path = @test_logs (:warn, r"2 objects without a recipe were not saved: M0, Beam") match_mode = :any save_project(gui, dir) + @test isfile(path) && isfile(joinpath(dir, "view.toml")) + @test occursin("project saved to $(abspath(dir))", gui.status.text[]) + @test occursin("2 objects without a recipe were not saved: M0, Beam", gui.status.text[]) + @test gui.project == abspath(dir) + p = load_project(dir) + @test [e.name for e in p.entries] == [:L1] + @test only(p.systems).members == [:L1] && isempty(only(p.systems).sources) + close(gui) + end + + @testset "keys and dialogs" begin + picked = Ref{Any}("") + calls = Ref(0) + GUI._PICK_FOLDER[] = path -> (calls[] += 1; picked[]) + try + dir = mktempdir() + t = _setup(:compact) + gui = t.gui + # without a project: the dialog; cancelled, nothing happens + _ctrl!(gui, Keyboard.s) + wait(GUI._DIALOG_TASK[]) + @test calls[] == 1 && isnothing(gui.project) && !isfile(joinpath(dir, "system.jl")) + # a folder: saved there + picked[] = dir + _ctrl!(gui, Keyboard.s) + wait(GUI._DIALOG_TASK[]) + @test calls[] == 2 && gui.project == abspath(dir) + @test isfile(joinpath(dir, "system.jl")) && isfile(joinpath(dir, "Project.toml")) + @test gui.status.text[] == "project saved to $(abspath(dir))" + # with a project: saved without the dialog + rm(joinpath(dir, "system.jl")) + gui.status.text[] = "" + _ctrl!(gui, Keyboard.s) + @test calls[] == 2 && isfile(joinpath(dir, "system.jl")) + @test gui.status.text[] == "project saved to $(abspath(dir))" + # the names became the labels + @test Base.isidentifier(gui.labels[t.prism]) && gui.labels[t.b2] == "laser_2" + # open: cancelled, and a folder that is no project + picked[] = "" + _ctrl!(gui, Keyboard.o) + wait(GUI._DIALOG_TASK[]) + @test calls[] == 3 && gui.status.text[] == "project saved to $(abspath(dir))" + picked[] = mktempdir() + @test_logs (:error,) match_mode = :any begin + _ctrl!(gui, Keyboard.o) + wait(GUI._DIALOG_TASK[]) + end + @test startswith(gui.status.text[], "project not opened") + # the spectator mode ignores the keys + gui.controls.spectator[] = true + _ctrl!(gui, Keyboard.s) + @test calls[] == 4 + gui.controls.spectator[] = false + close(gui) + finally + GUI._PICK_FOLDER[] = path -> GUI.NativeFileDialog.pick_folder(path) + end + end + + @testset "end-to-end" begin + dir = mktempdir() + t = _setup(:app) + gui = t.gui + GUI._set_beam_opacity!(gui, t.b2, 0.5) + push!(gui.camera.views, "side" => ([0.3, 0.0, 0.0], [0.0, 0.1, 0.0], [0.0, 0.0, 1.0])) + set_view(gui.ax, [0.4, -0.3, 0.2], [0.0, 0.1, 0.0], [0.0, 0.0, 1.0]) + save_project(gui, dir) + close(gui) + code = read(joinpath(dir, "system.jl"), String) + view = read(joinpath(dir, "view.toml"), String) + gui2 = open_project(dir; display = false, const_kw...) + @test gui2.project == abspath(dir) + save_project(gui2) + @test read(joinpath(dir, "system.jl"), String) == code + @test read(joinpath(dir, "view.toml"), String) == view + @test count(x -> GUI._editable(gui2, x), collect(keys(gui2.components.origin))) == 6 + close(gui2) + end +end + +end diff --git a/test/runtests.jl b/test/runtests.jl index 5ac53f8..f4c693f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -38,6 +38,7 @@ include(joinpath(@__DIR__, "TestLiveBeamColor.jl")) include(joinpath(@__DIR__, "TestLiveHistory.jl")) include(joinpath(@__DIR__, "TestLiveEdit.jl")) include(joinpath(@__DIR__, "TestLiveCopy.jl")) +include(joinpath(@__DIR__, "TestLiveProject.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) From d8c843a2900878c182595f85a8e105d94fd4a63b Mon Sep 17 00:00:00 2001 From: Hugo Uittenbosch Date: Tue, 27 Oct 2026 11:18:20 +0100 Subject: [PATCH 02/47] Projects prototype: save as with a name, open by the project file .bmo Ctrl+S saves to the project or asks like Save as; Ctrl+Shift+S and the tool Save as ask for a place and a name and create the folder; Ctrl+O picks a .bmo file. Co-Authored-By: Claude Opus 5.5 --- src/LiveIcons.jl | 2 + src/LiveProject.jl | 137 +++++++++++++++++++++++++++------------- test/TestLiveIcons.jl | 2 +- test/TestLiveProject.jl | 98 +++++++++++++++++++++------- 4 files changed, 172 insertions(+), 67 deletions(-) diff --git a/src/LiveIcons.jl b/src/LiveIcons.jl index 7780795..bb30b6f 100644 --- a/src/LiveIcons.jl +++ b/src/LiveIcons.jl @@ -167,6 +167,8 @@ const _ICON_SVG = Dict{Symbol, String}( :script => "M360-700L140-480L360-260L416-316L252-480L416-644ZM600-700L820-480L600-260L544-316L708-480L544-644Z", # save: saving the view as a project folder :save => "M840-680v480q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160Zm-80 34L646-760H200v560h560v-446ZM480-240q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM240-560h360v-160H240v160Zm-40-86v446-560 114Z", + # save_as: saving a project as a new folder with a name + :save_as => "M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h480l160 160v212q-19-8-39.5-10.5t-40.5.5v-169L647-760H200v560h240v80H200Zm0-640v560-560ZM520-40v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q8 9 12.5 20t4.5 22q0 11-4 22.5T863-260L643-40H520Zm300-263-37-37 37 37ZM580-100h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19ZM240-560h360v-160H240v160Zm240 320h4l116-115v-5q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Z", # folder_open: opening a project folder :open => "M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640H447l-80-80H160v480l96-320h684L837-217q-8 26-29.5 41.5T760-160H160Zm84-80h516l72-240H316l-72 240Zm0 0 72-240-72 240Zm-84-400v-80 80Z", # own design: an optical table, a plate with holes diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 47f7cf1..fe68a77 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -12,14 +12,24 @@ const _VIEW_TOML = "view.toml" # The keys of saving and opening, in the help of the controls, see `_help_sections` const _PROJECT_HELP = _HelpSection["Edit" => [ - _HelpEntry([_ctrl_cap(), "S"], "save the setup as a project folder"; combo = true), - _HelpEntry([_ctrl_cap(), "O"], "open a project folder"; combo = true)]] + _HelpEntry([_ctrl_cap(), "S"], "save the project"; combo = true), + _HelpEntry([_ctrl_cap(), "Shift", "S"], "save as a new project with a name"; combo = true), + _HelpEntry([_ctrl_cap(), "O"], "open a project (.bmo)"; combo = true)]] + +# The filter of the dialogs: the project files, see `BeamletOpticsProjects.project_marker` +const _BMO_FILTER = "bmo" """ -The dialog that picks a folder, `path -> folder`, which returns `""` (or `nothing`) if it was -cancelled: the native dialog of NativeFileDialog, replaced by the tests. +The dialog of "Save as", `folder -> path`: the user goes to a folder and types a name; `""` (or +`nothing`) if it was cancelled. The native dialog of NativeFileDialog, replaced by the tests. """ -const _PICK_FOLDER = Ref{Any}(path -> NativeFileDialog.pick_folder(path)) +const _SAVE_DIALOG = Ref{Any}(dir -> NativeFileDialog.save_file(dir; filterlist = _BMO_FILTER)) + +""" +The dialog of "Open", `folder -> path` of a project file `*.bmo`; `""` (or `nothing`) if it was +cancelled. The native dialog of NativeFileDialog, replaced by the tests. +""" +const _OPEN_DIALOG = Ref{Any}(dir -> NativeFileDialog.pick_file(dir; filterlist = _BMO_FILTER)) # The task of the last dialog of `Ctrl+S` or `Ctrl+O`, which runs after the key callback returned const _DIALOG_TASK = Ref{Any}(nothing) @@ -432,9 +442,10 @@ function _open_from(project; kwargs...) end """ - open_project(dir; display = true, kwargs...) -> LiveView + open_project(path; display = true, kwargs...) -> LiveView -Opens the project in the folder `dir` (see `BeamletOpticsProjects.load_project`) in a live view +Opens the project in the folder `path`, or of its project file `path` (`.bmo`), see +`BeamletOpticsProjects.load_project`, in a live view and shows it if `display` is `true`: its systems with their sources, the objects and sources without a system, the names of the project as labels and how the view looked when it was saved (`view.toml`: camera, saved views, clip planes, table, the colors of the beams, ...). The `kwargs` @@ -442,11 +453,12 @@ are those of [`live_view`](@ref) and win over `view.toml`. `Ctrl+S` saves it the [`save_project`](@ref). The components and sources from the catalog can be edited and copied. `Ctrl+O` or the tool "Open" of a window opens a project chosen in a dialog and closes the window. """ -function open_project(dir::AbstractString; display::Bool = true, kwargs...) - project = BMOP.load_project(dir) +function open_project(path::AbstractString; display::Bool = true, kwargs...) + project = BMOP.load_project(path) gui = _open_from(project; kwargs...) if display - _multi_light_backend() ? Base.display(gui; title = project.name) : Base.display(gui) + title = basename(project.path) + _multi_light_backend() ? Base.display(gui; title) : Base.display(gui) end return gui end @@ -465,7 +477,7 @@ not a project yet becomes one, with the versions of BeamletOptics, BeamletOptics BeamletOpticsGUI and GLMakie. How the view looks is written to `view.toml`. An object whose constructor is not known, e.g. one that the script gave to `live_view`, is not saved: a warning and the status line name it. The names of the project become the labels of the view, and `dir` its -project, which `Ctrl+S` saves to. +project, which `Ctrl+S` saves to. `Ctrl+Shift+S` asks for a place and a name, see `_save_as_key!`. """ function save_project(gui::LiveView, dir::AbstractString) dir = abspath(dir) @@ -510,52 +522,85 @@ function _save_to!(gui::LiveView, dir::AbstractString) end """ -The folder that the dialog picks, starting at the project of the `gui`; `nothing` if it was -cancelled or failed, the reason of a failure in the status line. +The path that the `dialog` (`_SAVE_DIALOG` or `_OPEN_DIALOG`) returns, starting in the folder that +holds the project of the `gui`; `nothing` if it was cancelled or failed, the reason of a failure in +the status line. """ -function _pick_folder(gui::LiveView) - dir = try - _PICK_FOLDER[](something(gui.project, "")) +function _ask_path(gui::LiveView, dialog::Ref) + start = isnothing(gui.project) ? "" : dirname(gui.project) + path = try + dialog[](start) catch e e isa InterruptException && rethrow() - gui.status.text[] = "no folder dialog: $(_message_line(e)), use save_project/open_project" + gui.status.text[] = "no file dialog: $(_message_line(e)), use save_project/open_project" return nothing end - (isnothing(dir) || isempty(dir)) && return nothing - return String(dir) + (isnothing(path) || isempty(path)) && return nothing + return String(path) +end + +""" + _save_target(path) -> (; dir, message) + +The folder that "Save as" saves to, from the `path` of its dialog, a folder and a typed name +(`.bmo` or not): the folder of the project whose project file the user picked (the dialog asked +whether to overwrite it), else a new or empty folder with the name next to it. A `dir` of `nothing` +and the reason in `message` for any other path, e.g. a folder of that name that exists already. +""" +function _save_target(path::AbstractString) + path = abspath(path) + file = basename(path) + name = endswith(lowercase(file), BMOP.EXTENSION) ? file[1:(end - length(BMOP.EXTENSION))] : file + parent = dirname(path) + isempty(strip(name)) && return (; dir = nothing, message = "project not saved: no name given") + isfile(joinpath(parent, name * BMOP.EXTENSION)) && isfile(joinpath(parent, "Project.toml")) && + return (; dir = parent, message = "") + dir = joinpath(parent, name) + (!ispath(dir) || (isdir(dir) && isempty(readdir(dir)))) && return (; dir, message = "") + return (; dir = nothing, + message = "project not saved: \"$dir\" exists already; to overwrite a project, pick its .bmo file") end # Runs `f` after the key callback returned, e.g. a dialog, which blocks _run_later(f) = (_DIALOG_TASK[] = @async f()) """ -`Ctrl+S` and the tool "Save" of the `gui`: saves to its project, or, without one, to the folder -that the dialog picks (a cancelled dialog does nothing). +`Ctrl+Shift+S` and the tool "Save as" of the `gui`: the dialog asks for a folder and a name, and the +setup is saved as the project in the folder of that name there, see `_save_target`, which becomes +the project of the `gui`. A cancelled dialog does nothing. """ -function _save_key!(gui::LiveView) - if isnothing(gui.project) - _run_later() do - dir = _pick_folder(gui) - isnothing(dir) || _save_to!(gui, dir) - return nothing - end - else - _save_to!(gui, gui.project) +function _save_as_key!(gui::LiveView) + _run_later() do + path = _ask_path(gui, _SAVE_DIALOG) + isnothing(path) && return nothing + target = _save_target(path) + isnothing(target.dir) ? (gui.status.text[] = target.message) : _save_to!(gui, target.dir) + return nothing end return nothing end """ -`Ctrl+O` and the tool "Open" of the `gui`: opens the project that the dialog picks in a new window -with the layout and the theme of the `gui`, then closes the `gui` and its window. A cancelled dialog -does nothing, an error shows its message in the status line and keeps the `gui`. +`Ctrl+S` and the tool "Save" of the `gui`: saves to its project, or, without one, like "Save as" +(see `_save_as_key!`). +""" +function _save_key!(gui::LiveView) + isnothing(gui.project) ? _save_as_key!(gui) : _save_to!(gui, gui.project) + return nothing +end + +""" +`Ctrl+O` and the tool "Open" of the `gui`: opens the project of the project file (`*.bmo`) that the +dialog picks in a new window with the layout and the theme of the `gui`, then closes the `gui` and +its window. A cancelled dialog does nothing, an error shows its message in the status line and +keeps the `gui`. """ function _open_key!(gui::LiveView) _run_later() do - dir = _pick_folder(gui) - isnothing(dir) && return nothing + path = _ask_path(gui, _OPEN_DIALOG) + isnothing(path) && return nothing try - open_project(dir; layout = _layout_name(gui.layout), theme = _theme_name(gui.layout.theme)) + open_project(path; layout = _layout_name(gui.layout), theme = _theme_name(gui.layout.theme)) catch e e isa InterruptException && rethrow() @error "open_project failed" exception = (e, catch_backtrace()) @@ -572,10 +617,10 @@ function _open_key!(gui::LiveView) end """ -Connects `Ctrl+S` and `Ctrl+O` (`Cmd` on macOS) of the `gui` and the tools "Save" and "Open", see -`_save_key!` and `_open_key!`, like `_connect_copy!`: by the letters of the keys in the keyboard -layout, before the camera, which takes `s` and `o`; left to a focused text box, nothing in the -spectator mode. +Connects `Ctrl+S`, `Ctrl+Shift+S` and `Ctrl+O` (`Cmd` on macOS) of the `gui` and the tools "Save", +"Save as" and "Open", see `_save_key!`, `_save_as_key!` and `_open_key!`, like `_connect_copy!`: by +the letters of the keys in the keyboard layout, before the camera, which takes `s` and `o`; left to +a focused text box, nothing in the spectator mode. """ function _connect_project!(gui::LiveView) scene = gui.ax.scene @@ -587,14 +632,20 @@ function _connect_project!(gui::LiveView) key in (Keyboard.s, Keyboard.o) || return Consume(false) ctrl.ignore_keys() && return Consume(false) ctrl.spectator[] && return Consume(true) - key == Keyboard.s ? _save_key!(gui) : _open_key!(gui) + if key == Keyboard.o + _open_key!(gui) + else + _shift_pressed(scene) ? _save_as_key!(gui) : _save_key!(gui) + end return Consume(true) end) append!(ctrl.help_extra, _PROJECT_HELP) _update_help!(ctrl) add_tool!(_save_key!, gui, "Save"; icon = :save, - tooltip = "Save the setup as a project folder ($(_ctrl_cap())+S)") + tooltip = "Save the project ($(_ctrl_cap())+S)") + add_tool!(_save_as_key!, gui, "Save as"; icon = :save_as, + tooltip = "Save as a new project with a name ($(_ctrl_cap())+Shift+S)") add_tool!(_open_key!, gui, "Open"; icon = :open, - tooltip = "Open a project folder ($(_ctrl_cap())+O)") + tooltip = "Open a project, its .bmo file ($(_ctrl_cap())+O)") return nothing end diff --git a/test/TestLiveIcons.jl b/test/TestLiveIcons.jl index 010cfad..6dfab9f 100644 --- a/test/TestLiveIcons.jl +++ b/test/TestLiveIcons.jl @@ -32,7 +32,7 @@ const GUI = BeamletOpticsGUI # the tool that adds a system :add_system, # the tools that save and open a project - :save, :open) + :save, :save_as, :open) @test Set(keys(GUI._ICONS)) == Set(names) for name in names icon = GUI._icon(name) diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl index 3d68552..6424a8d 100644 --- a/test/TestLiveProject.jl +++ b/test/TestLiveProject.jl @@ -240,54 +240,106 @@ const GUI = BeamletOpticsGUI end @testset "keys and dialogs" begin - picked = Ref{Any}("") + saved = Ref{Any}("") + opened = Ref{Any}("") calls = Ref(0) - GUI._PICK_FOLDER[] = path -> (calls[] += 1; picked[]) + GUI._SAVE_DIALOG[] = dir -> (calls[] += 1; saved[]) + GUI._OPEN_DIALOG[] = dir -> (calls[] += 1; opened[]) + _wait() = wait(GUI._DIALOG_TASK[]) + function _ctrl_shift!(gui, key) + state = events(gui.ax.scene).keyboardstate + push!(state, Keyboard.left_shift) + _ctrl!(gui, key) + delete!(state, Keyboard.left_shift) + return nothing + end try - dir = mktempdir() + parent = mktempdir() t = _setup(:compact) gui = t.gui - # without a project: the dialog; cancelled, nothing happens + # without a project: the dialog of "Save as"; cancelled, nothing happens _ctrl!(gui, Keyboard.s) - wait(GUI._DIALOG_TASK[]) - @test calls[] == 1 && isnothing(gui.project) && !isfile(joinpath(dir, "system.jl")) - # a folder: saved there - picked[] = dir + _wait() + @test calls[] == 1 && isnothing(gui.project) && isempty(readdir(parent)) + # a place and a name: a new folder of that name with its project file + saved[] = joinpath(parent, "Laser bench") _ctrl!(gui, Keyboard.s) - wait(GUI._DIALOG_TASK[]) - @test calls[] == 2 && gui.project == abspath(dir) - @test isfile(joinpath(dir, "system.jl")) && isfile(joinpath(dir, "Project.toml")) - @test gui.status.text[] == "project saved to $(abspath(dir))" + _wait() + dir = joinpath(parent, "Laser bench") + @test calls[] == 2 && gui.project == dir + @test isfile(joinpath(dir, "Laser bench.bmo")) && isfile(joinpath(dir, "system.jl")) + @test load_project(joinpath(dir, "Laser bench.bmo")).name == "Laser_bench" + @test gui.status.text[] == "project saved to $dir" # with a project: saved without the dialog rm(joinpath(dir, "system.jl")) - gui.status.text[] = "" _ctrl!(gui, Keyboard.s) @test calls[] == 2 && isfile(joinpath(dir, "system.jl")) - @test gui.status.text[] == "project saved to $(abspath(dir))" # the names became the labels @test Base.isidentifier(gui.labels[t.prism]) && gui.labels[t.b2] == "laser_2" - # open: cancelled, and a folder that is no project - picked[] = "" + # "Save as" always asks; the name of an existing folder is refused + saved[] = joinpath(parent, "Laser bench.bmo") + _ctrl_shift!(gui, Keyboard.s) + _wait() + @test calls[] == 3 && gui.project == dir + @test startswith(gui.status.text[], "project not saved: \"$dir\" exists already") + # picking the project file of a project overwrites it + rm(joinpath(dir, "system.jl")) + saved[] = joinpath(dir, "Laser bench.bmo") + _ctrl_shift!(gui, Keyboard.s) + _wait() + @test calls[] == 4 && gui.project == dir && isfile(joinpath(dir, "system.jl")) + @test !isdir(joinpath(dir, "Laser bench")) + # another name: the window belongs to the new project, Ctrl+S saves there + saved[] = joinpath(parent, "copy.bmo") + _ctrl_shift!(gui, Keyboard.s) + _wait() + copy = joinpath(parent, "copy") + @test calls[] == 5 && gui.project == copy && isfile(joinpath(copy, "copy.bmo")) + rm(joinpath(copy, "system.jl")) + _ctrl!(gui, Keyboard.s) + @test calls[] == 5 && isfile(joinpath(copy, "system.jl")) + # the tool "Save as" does the same as the keys + saved[] = "" + GUI._save_as_key!(gui) + _wait() + @test calls[] == 6 && gui.project == copy + # open: cancelled, and a file that is no project + opened[] = "" + gui.status.text[] = "before" _ctrl!(gui, Keyboard.o) - wait(GUI._DIALOG_TASK[]) - @test calls[] == 3 && gui.status.text[] == "project saved to $(abspath(dir))" - picked[] = mktempdir() + _wait() + @test calls[] == 7 && gui.status.text[] == "before" + opened[] = joinpath(mktempdir(), "nothing.bmo") @test_logs (:error,) match_mode = :any begin _ctrl!(gui, Keyboard.o) - wait(GUI._DIALOG_TASK[]) + _wait() end @test startswith(gui.status.text[], "project not opened") # the spectator mode ignores the keys gui.controls.spectator[] = true _ctrl!(gui, Keyboard.s) - @test calls[] == 4 + _ctrl_shift!(gui, Keyboard.s) + @test calls[] == 8 gui.controls.spectator[] = false close(gui) finally - GUI._PICK_FOLDER[] = path -> GUI.NativeFileDialog.pick_folder(path) + GUI._SAVE_DIALOG[] = dir -> GUI.NativeFileDialog.save_file(dir; filterlist = GUI._BMO_FILTER) + GUI._OPEN_DIALOG[] = dir -> GUI.NativeFileDialog.pick_file(dir; filterlist = GUI._BMO_FILTER) end end + @testset "target of save as" begin + parent = mktempdir() + @test GUI._save_target(joinpath(parent, "a")).dir == joinpath(parent, "a") + @test GUI._save_target(joinpath(parent, "a.bmo")).dir == joinpath(parent, "a") + @test GUI._save_target(joinpath(parent, "A.BMO")).dir == joinpath(parent, "A") + mkdir(joinpath(parent, "empty")) + @test GUI._save_target(joinpath(parent, "empty")).dir == joinpath(parent, "empty") + write(joinpath(parent, "file"), "x") + @test isnothing(GUI._save_target(joinpath(parent, "file")).dir) + @test isnothing(GUI._save_target(joinpath(parent, ".bmo")).dir) + end + @testset "end-to-end" begin dir = mktempdir() t = _setup(:app) @@ -299,7 +351,7 @@ const GUI = BeamletOpticsGUI close(gui) code = read(joinpath(dir, "system.jl"), String) view = read(joinpath(dir, "view.toml"), String) - gui2 = open_project(dir; display = false, const_kw...) + gui2 = open_project(project_marker(dir); display = false, const_kw...) @test gui2.project == abspath(dir) save_project(gui2) @test read(joinpath(dir, "system.jl"), String) == code From ea9576cfa698ccb1e06bb1aa5d4791fac37f9a09 Mon Sep 17 00:00:00 2001 From: Hugo Uittenbosch Date: Tue, 27 Oct 2026 12:12:10 +0100 Subject: [PATCH 03/47] Projects prototype: GUI and agent on the same project A watcher reloads the project when its files change and the setup in the window did not; otherwise, and on Ctrl+S after an outside change, a card offers Load from disk, Overwrite and Save as. Ctrl+R reloads. Co-Authored-By: Claude Opus 5.5 --- src/BeamletOpticsGUI.jl | 1 + src/LiveCustom.jl | 4 +- src/LiveProject.jl | 46 +++++--- src/LiveProjectSync.jl | 235 ++++++++++++++++++++++++++++++++++++++++ src/LiveView.jl | 23 +++- test/TestLiveHelp.jl | 2 +- test/TestLiveProject.jl | 77 +++++++++++++ 7 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 src/LiveProjectSync.jl diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 95b39ff..4a8de9d 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -109,6 +109,7 @@ include("LiveCatalog.jl") include("LiveCatalogWindow.jl") include("LiveCopy.jl") include("LiveProject.jl") +include("LiveProjectSync.jl") include("LiveDebug.jl") include("LiveDebugHUD.jl") include("LiveDebugSpheres.jl") diff --git a/src/LiveCustom.jl b/src/LiveCustom.jl index f861409..e58082e 100644 --- a/src/LiveCustom.jl +++ b/src/LiveCustom.jl @@ -272,8 +272,8 @@ const _LIVE_VIEW_KEYS = ( Keyboard.page_down => "the key steps (Page Up/Down)", Keyboard.left_shift => "Shift (large key steps, Shift+c)", Keyboard.right_shift => "Shift (large key steps, Shift+c)", - Keyboard.left_control => "Ctrl (undo, redo, copy, paste, save, open)", - Keyboard.right_control => "Ctrl (undo, redo, copy, paste, save, open)", + Keyboard.left_control => "Ctrl (undo, redo, copy, paste, save, open, reload)", + Keyboard.right_control => "Ctrl (undo, redo, copy, paste, save, open, reload)", Keyboard.left_super => "Cmd (undo, redo)", Keyboard.right_super => "Cmd (undo, redo)", Keyboard.minus => "the step size (-)", diff --git a/src/LiveProject.jl b/src/LiveProject.jl index fe68a77..90dcf05 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -14,7 +14,8 @@ const _VIEW_TOML = "view.toml" const _PROJECT_HELP = _HelpSection["Edit" => [ _HelpEntry([_ctrl_cap(), "S"], "save the project"; combo = true), _HelpEntry([_ctrl_cap(), "Shift", "S"], "save as a new project with a name"; combo = true), - _HelpEntry([_ctrl_cap(), "O"], "open a project (.bmo)"; combo = true)]] + _HelpEntry([_ctrl_cap(), "O"], "open a project (.bmo)"; combo = true), + _HelpEntry([_ctrl_cap(), "R"], "reload the project from its files"; combo = true)]] # The filter of the dialogs: the project files, see `BeamletOpticsProjects.project_marker` const _BMO_FILTER = "bmo" @@ -400,7 +401,7 @@ sources without a system, the names of the project as labels, the origins of the code, with which they are saved again and, from the catalog, edited and copied), and `view.toml`. The `kwargs` of `live_view` win over those of `view.toml`. """ -function _open_from(project; kwargs...) +function _open_from(project; view = _read_view(project), kwargs...) @nospecialize byname = Dict{Symbol, Any}(e.name => e.object for e in project.entries) in_system = Set{Symbol}() @@ -415,7 +416,6 @@ function _open_from(project; kwargs...) labels = IdDict{Any, String}() foreach(e -> labels[e.object] = String(e.name), project.entries) foreach(s -> labels[s.object] = String(s.name), project.systems) - view = _read_view(project) vk = _view_kwargs(view, byname, paired) gui = live_view(args...; labels, vk.kwargs..., kwargs...) origins = IdDict{Any, Any}(e.object => _project_origin(gui, e) for e in project.entries) @@ -437,6 +437,9 @@ function _open_from(project; kwargs...) set_view(gui.ax, _view_tuple(view["camera"])...) end gui.project = project.path + # What `Ctrl+S` and the watcher compare with, see `_ProjectSync` + gui.sync.stamp = project.stamp + gui.sync.code = BMOP.system_code(_collect_project(gui, project.path)) gui.status.text[] = "project $(project.name) opened from $(project.path)" return gui end @@ -459,6 +462,7 @@ function open_project(path::AbstractString; display::Bool = true, kwargs...) if display title = basename(project.path) _multi_light_backend() ? Base.display(gui; title) : Base.display(gui) + _start_watching!(gui) end return gui end @@ -478,14 +482,21 @@ BeamletOpticsGUI and GLMakie. How the view looks is written to `view.toml`. An o constructor is not known, e.g. one that the script gave to `live_view`, is not saved: a warning and the status line name it. The names of the project become the labels of the view, and `dir` its project, which `Ctrl+S` saves to. `Ctrl+Shift+S` asks for a place and a name, see `_save_as_key!`. + +Throws `BeamletOpticsProjects.ProjectChanged` and saves nothing if the files of the project of the +view changed since it opened or saved it, e.g. by an agent, unless `force` is `true`; `Ctrl+S` shows +a card that offers to load them, to overwrite them or to save as, see `_ProjectConflict`. """ -function save_project(gui::LiveView, dir::AbstractString) +function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) dir = abspath(dir) parts = _project_parts(gui, dir) - path = save_project(parts.project; packages = _project_packages()) + # The files as the view knows them: only of its own project + dir == gui.project && (parts.project.stamp = gui.sync.stamp) + path = save_project(parts.project; packages = _project_packages(), force) _write_view_toml(gui, parts.project, parts.names) _apply_project_names!(gui, parts.names) gui.project = dir + _saved!(gui, parts.project) msg = "project saved to $dir" if !isempty(parts.skipped) list = join(parts.skipped, ", ") @@ -496,10 +507,10 @@ function save_project(gui::LiveView, dir::AbstractString) return path end -function save_project(gui::LiveView) +function save_project(gui::LiveView; force::Bool = false) isnothing(gui.project) && throw(ArgumentError("the live view has no project folder yet, use save_project(gui, dir)")) - return save_project(gui, gui.project) + return save_project(gui, gui.project; force) end #= @@ -509,12 +520,16 @@ Keys, tools and dialogs # The first line of the message of the error `e` _message_line(e) = first(split(sprint(showerror, e), '\n')) -"""Saves the `gui` to `dir`; an error is logged and shown in the status line.""" -function _save_to!(gui::LiveView, dir::AbstractString) +""" +Saves the `gui` to `dir`; if the files changed meanwhile, the card of the conflict instead (see +`_ProjectConflict`), any other error is logged and shown in the status line. +""" +function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) try - save_project(gui, dir) + save_project(gui, dir; force) catch e e isa InterruptException && rethrow() + e isa BMOP.ProjectChanged && return _show_conflict!(gui) @error "save_project failed" exception = (e, catch_backtrace()) gui.status.text[] = "project not saved: $(_message_line(e))" end @@ -600,17 +615,14 @@ function _open_key!(gui::LiveView) path = _ask_path(gui, _OPEN_DIALOG) isnothing(path) && return nothing try - open_project(path; layout = _layout_name(gui.layout), theme = _theme_name(gui.layout.theme)) + open_project(path; _window_kwargs(gui)...) catch e e isa InterruptException && rethrow() @error "open_project failed" exception = (e, catch_backtrace()) gui.status.text[] = "project not opened: $(_message_line(e))" return nothing end - # Never inside the event callback of its own screen: this task runs after it returned - screen = Makie.getscreen(gui.fig.scene) - close(gui) - isnothing(screen) || close(screen) + _close_window!(gui) return nothing end return nothing @@ -629,11 +641,13 @@ function _connect_project!(gui::LiveView) (event.action == Keyboard.press && _modifier_held(scene, _CTRL_OR_CMD_KEYS)) || return Consume(false) key = _layout_key(event.key) - key in (Keyboard.s, Keyboard.o) || return Consume(false) ctrl.ignore_keys() && return Consume(false) ctrl.spectator[] && return Consume(true) + key in (Keyboard.s, Keyboard.o, Keyboard.r) || return Consume(false) if key == Keyboard.o _open_key!(gui) + elseif key == Keyboard.r + _reload_key!(gui) else _shift_pressed(scene) ? _save_as_key!(gui) : _save_key!(gui) end diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl new file mode 100644 index 0000000..39e67dc --- /dev/null +++ b/src/LiveProjectSync.jl @@ -0,0 +1,235 @@ +#= +Working on a project while an agent changes its files: a watcher of the files of the project of a +shown view, which reloads the project when only the files changed and shows the card of the +conflict when the setup in the view changed too; `Ctrl+S` never overwrites a change of the files +silently (`BeamletOpticsProjects.ProjectChanged`) +=# + +# How often the watcher looks at the files [s] +const _WATCH_INTERVAL = 1.0 + +"""Whether the `gui` is shown in a window that is open.""" +function _shown(gui::LiveView) + screen = Makie.getscreen(gui.fig.scene) + isnothing(screen) && return false + return applicable(isopen, screen) ? isopen(screen) : true +end + +"""Records the files of the `project` that the `gui` just saved, see `_ProjectSync`.""" +function _saved!(gui::LiveView, project) + s = gui.sync + s.stamp = project.stamp + s.code = BMOP.system_code(project) + s.pending = s.handled = nothing + _start_watching!(gui) + return nothing +end + +""" +Whether the setup of the `gui` differs from that of the files of its project when it opened or +saved them; only the setup (`system.jl`), not the camera or the colors. +""" +function _setup_changed(gui::LiveView) + (isnothing(gui.project) || isnothing(gui.sync.code)) && return false + return BMOP.system_code(_collect_project(gui, gui.project)) != gui.sync.code +end + +""" +Starts the watcher of the files of the project of the `gui` if it is shown, which calls +`_check_files!` every `_WATCH_INTERVAL` seconds until the window is closed. A view that is not +shown (e.g. in the tests) is not watched; `_check_files!` can be called instead. +""" +function _start_watching!(gui::LiveView) + s = gui.sync + (isnothing(s.timer) && !isnothing(gui.project) && _shown(gui)) || return nothing + last_error = Ref("") + s.timer = Timer(_WATCH_INTERVAL; interval = _WATCH_INTERVAL) do _ + _shown(gui) || return _stop_watching!(gui) + try + _check_files!(gui) + catch e + e isa InterruptException && rethrow() + last_error[] = _log_once(e, last_error[], "watching the project") + end + end + return nothing +end + +"""Stops the watcher of the `gui`, see `_start_watching!`.""" +function _stop_watching!(gui::LiveView) + t = gui.sync.timer + gui.sync.timer = nothing + isnothing(t) || close(t) + return nothing +end + +""" + _check_files!(gui; kwargs...) -> (; status, gui) + +One look of the watcher at the files of the project of the `gui`, with the `status` `:none` if they are +as the `gui` +knows them; `:pending` when they changed, to look again at the next call, since an agent may still +be writing; when they changed and stayed so, `:reloaded` (or `:failed`, e.g. a syntax error) if the +setup in the `gui` did not change (see `_reload!`), otherwise `:conflict` with the card of the +conflict (see `_ProjectConflict`). It reacts once to each state of the files. `gui` is the view +afterwards, the new one after a reload, which gets the `kwargs` of `live_view`. +""" +function _check_files!(gui::LiveView; kwargs...) + s = gui.sync + (isnothing(gui.project) || isnothing(s.stamp)) && return (; status = :none, gui) + disk = try + BMOP.project_stamp(gui.project) + catch e + e isa InterruptException && rethrow() + return (; status = :none, gui) + end + if disk == s.stamp + s.pending = nothing + return (; status = :none, gui) + end + # Reacted to this state already, e.g. the card of the conflict was shown + disk == s.handled && return (; status = :none, gui) + if disk != s.pending + s.pending = disk + return (; status = :pending, gui) + end + s.pending = nothing + s.handled = disk + if _setup_changed(gui) + _show_conflict!(gui) + return (; status = :conflict, gui) + end + new = _reload!(gui; kwargs...) + return isnothing(new) ? (; status = :failed, gui) : (; status = :reloaded, gui = new) +end + +"""The layout, theme and size of the window of the `gui`, for a window that replaces it.""" +function _window_kwargs(gui::LiveView) + w = Makie.widths(gui.fig.scene.viewport[]) + return (; layout = _layout_name(gui.layout), theme = _theme_name(gui.layout.theme), + size = (round(Int, w[1]), round(Int, w[2]))) +end + +"""Closes the `gui` and its window, never inside an event callback of that window.""" +function _close_window!(gui::LiveView) + screen = Makie.getscreen(gui.fig.scene) + close(gui) + isnothing(screen) || close(screen) + return nothing +end + +"""The message of an error of loading a project: the file and line of a `LoadError`.""" +function _load_message(e) + e isa LoadError || return _message_line(e) + msg = _message_line(e.error) + # The macros of BeamletOpticsProjects name the line themselves + occursin(r"\.jl:\d+", msg) && return msg + return "$(basename(e.file)):$(e.line): $msg" +end + +""" + _reload!(gui; display = _shown(gui), kwargs...) -> Union{Nothing, LiveView} + +Loads the project of the `gui` from its files again and shows it in a new window that replaces the +window of the `gui` (if `display`), with the layout, theme and size of that window and how it +looked: camera, saved views, clip planes, colors of the beams (not `view.toml`). The own code in +`src/` is loaded again too. The changes of the setup in the `gui` that were not saved are lost. +Returns the new view, or `nothing` and the reason in the status line of the `gui` if the project +could not be loaded, e.g. while an agent is still writing it; the `gui` stays then. The `kwargs` are +those of `live_view`. +""" +function _reload!(gui::LiveView; display::Bool = _shown(gui), kwargs...) + project = try + BMOP.load_project(gui.project) + catch e + e isa InterruptException && rethrow() + gui.status.text[] = "project not reloaded: $(_load_message(e))" + return nothing + end + view = _view_dict(gui, _project_parts(gui, gui.project).names) + window = display ? _window_kwargs(gui) : (; layout = _layout_name(gui.layout)) + new = try + _open_from(project; view, window..., kwargs...) + catch e + e isa InterruptException && rethrow() + @error "reloading the project failed" exception = (e, catch_backtrace()) + gui.status.text[] = "project not reloaded: $(_message_line(e))" + return nothing + end + new.status.text[] = "project reloaded from $(project.path)" + if display + title = basename(project.path) + _multi_light_backend() ? Base.display(new; title) : Base.display(new) + _start_watching!(new) + _close_window!(gui) + else + close(gui) + end + return new +end + +"""`Ctrl+R` of the `gui`: loads its project from the files again, see `_reload!`.""" +function _reload_key!(gui::LiveView) + if isnothing(gui.project) + gui.status.text[] = "no project to reload: open or save one first" + return nothing + end + _run_later(() -> (_reload!(gui); nothing)) + return nothing +end + +#= +The card of the conflict +=# + +""" + _ProjectConflict + +The files of the project of the view changed outside of it (e.g. by an agent) and its setup changed +too: an info card at `point` with what happened and three buttons, see `_resolve_conflict!`. +Shown by `Ctrl+S` (see `_save_to!`) and by the watcher (see `_check_files!`). +""" +struct _ProjectConflict <: _InfoItem + point::Point3f +end + +_points(x::_ProjectConflict) = [x.point] +_title(::_ProjectConflict) = "Project changed on disk" +_tree_kind(::_ProjectConflict) = :warning +_keep_plots!(::LiveView, ::_ProjectConflict) = nothing +_keepable(::_ProjectConflict) = false +card_rows(::_ProjectConflict) = ( + _text_row("files", :conflict_files, (_, _) -> "changed outside, e.g. by an agent"; width = 48), + _text_row("here", :conflict_here, (_, _) -> "changes that are not saved"; width = 48), + CardRow( + CardWidget(Button; name = :conflict_load, label = "Load from disk", + on = (gui, _, _) -> _resolve_conflict!(gui, :load)), + CardWidget(Button; name = :conflict_overwrite, label = "Overwrite", + on = (gui, _, _) -> _resolve_conflict!(gui, :overwrite)), + CardWidget(Button; name = :conflict_save_as, label = "Save as…", + on = (gui, _, _) -> _resolve_conflict!(gui, :save_as)))) + +"""Shows the card of the conflict of the `gui`, see `_ProjectConflict`.""" +function _show_conflict!(gui::LiveView) + _show_info!(gui, _ProjectConflict(_error_anchor(gui))) + gui.status.text[] = "the project changed on disk and here: choose on the card" + return nothing +end + +""" +Resolves the conflict of the `gui` (see `_ProjectConflict`) as the user chose, `how`: `:load` the +files (the changes here are lost, see `_reload!`), `:overwrite` them with the setup here, or +`:save_as` a new project (see `_save_as_key!`), which leaves the files as they are. +""" +function _resolve_conflict!(gui::LiveView, how::Symbol) + _release_info!(gui, _ProjectConflict) + if how === :load + # Replaces the window: after the callback of its button returned + _run_later(() -> (_reload!(gui); nothing)) + elseif how === :overwrite + _save_to!(gui, gui.project; force = true) + else + _save_as_key!(gui) + end + return nothing +end diff --git a/src/LiveView.jl b/src/LiveView.jl index 30561c8..f649b0f 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -574,6 +574,23 @@ end _ViewLinks() = _ViewLinks(Any[], false) +""" + _ProjectSync + +What the view knows of the files of its project, which an agent may change while it is open, see +`LiveProjectSync.jl`: the `stamp` of the files (`BeamletOpticsProjects.project_stamp`) and the text +of `system.jl` of its setup (`code`) when it was opened or saved; the stamp on disk that the +watcher saw once and waits to see again (`pending`), the last one it reacted to (`handled`) and the +`timer` of the watcher. +""" +Base.@kwdef mutable struct _ProjectSync + stamp::Union{Nothing, String} = nothing + code::Union{Nothing, String} = nothing + pending::Union{Nothing, String} = nothing + handled::Union{Nothing, String} = nothing + timer::Union{Nothing, Timer} = nothing +end + """ LiveView @@ -597,7 +614,8 @@ they are rendered, selectable and movable, but not traced, see `_live_render_ext (or `gui -> object`) whose card a click on the empty background shows, see `_show_background!`. `links` holds the views that show systems of this one in other windows, see `_ViewLinks`. `project` is the folder of the project that the view was opened from or saved to (`nothing` -before), which `Ctrl+S` saves to, see [`save_project`](@ref) and [`open_project`](@ref). +before), which `Ctrl+S` saves to, see [`save_project`](@ref) and [`open_project`](@ref); `sync` +what the view knows of its files, which an agent may change meanwhile, see `_ProjectSync`. The type parameter `L` is the type of the `layout`, see `AbstractLiveLayout`: `CompactView` and `AppView` are the `LiveView`s of `live_view(...; layout = :compact)` and `layout = :app`. @@ -633,6 +651,8 @@ Base.@kwdef mutable struct LiveView{L <: AbstractLiveLayout} links::_ViewLinks = _ViewLinks() # the folder of the project that `Ctrl+S` saves to, see `save_project` project::Union{Nothing, String} = nothing + # the files of the project when it was opened or saved, see `_ProjectSync` + sync::_ProjectSync = _ProjectSync() widgets::_LayoutWidgets # state of the layout, e.g. the slots of the app layout, see `AbstractLiveLayout` layout::L @@ -682,6 +702,7 @@ end function Base.close(gui::LiveView) # The linked views no longer follow it, and it stops only its own solve _unlink!(gui) + _stop_watching!(gui) _cancel_solve!(gui) _close_debug!(gui) _end_placement!(gui) diff --git a/test/TestLiveHelp.jl b/test/TestLiveHelp.jl index e35ffc9..ff01234 100644 --- a/test/TestLiveHelp.jl +++ b/test/TestLiveHelp.jl @@ -104,7 +104,7 @@ const GUI = BeamletOpticsGUI # the card ctrl = GUI._ctrl_cap() @test Set(_caps(sections)) == Set(["Esc", "Enter", "G", "M", "↑", "↓", "←", "→", "PgUp", "PgDn", "+", - "−", "Shift", "Bksp", ctrl, "Z", "Y", "V", "1", "P", "Del", "C", "T", "Ins", "Tab", "S", "O"]) + "−", "Shift", "Bksp", ctrl, "Z", "Y", "V", "1", "P", "Del", "C", "T", "Ins", "Tab", "S", "O", "R"]) # no key is listed twice; Esc is the key of the groups, a trace is cancelled by a button keys = [(e.keys, e.combo) for (_, entries) in sections for e in entries if !(e.keys[1] isa Pair)] @test allunique(keys) diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl index 6424a8d..045d2ca 100644 --- a/test/TestLiveProject.jl +++ b/test/TestLiveProject.jl @@ -340,6 +340,83 @@ const GUI = BeamletOpticsGUI @test isnothing(GUI._save_target(joinpath(parent, ".bmo")).dir) end + @testset "files changed outside" begin + dir = mktempdir() + t = _setup(:compact) + gui = t.gui + save_project(gui, dir) + system = joinpath(dir, "system.jl") + check!(g) = GUI._check_files!(g; const_kw...) + # an agent appends a line (a function of its own code), the GUI saves in the meantime + agent_line = "@component L9 = extra_lens(0.2) pos = (0.0, 0.3, 0.0)\n" + name = BeamletOpticsProjects.project_name(dir) + own = joinpath(dir, "src", name * ".jl") + @test check!(gui).status === :none + save_project(gui) + @test check!(gui).status === :none + # only the files changed: reloaded, after the second look, with the view state of the window + set_view(gui.ax, [0.4, -0.3, 0.2], [0.0, 0.1, 0.0], [0.0, 0.0, 1.0]) + cam = GUI._current_view(gui) + GUI._set_beam_opacity!(gui, t.b2, 0.25) + write(own, "module $name\nusing BeamletOptics\nexport extra_lens\n" * + "extra_lens(f) = SphericalLens(f, -f, 0.004, 0.0254, 1.5)\nend\n") + write(system, read(system, String) * agent_line) + r = check!(gui) + @test r.status === :pending && r.gui === gui + r = check!(gui) + @test r.status === :reloaded && r.gui !== gui + gui2 = r.gui + @test gui2.project == dir && haskey(_byname(gui2), :L9) + @test all(abs.(reduce(vcat, GUI._current_view(gui2)) .- reduce(vcat, cam)) .< 1e-6) + b2 = _byname(gui2)[:laser_2] + @test GUI._snapshot(gui2, b2).kwargs.alpha == 0.25 + @test check!(gui2).status === :none + # the setup changed here and there: the card, once; Ctrl+S does not overwrite + translate3d!(gui2, _byname(gui2)[:spare], [0.0, 0.0, 0.01]) + agent_text = read(system, String) * "@component L10 = extra_lens(0.3)\n" + write(system, agent_text) + @test check!(gui2).status === :pending + @test check!(gui2).status === :conflict + @test GUI._info_card(gui2).obj isa GUI._ProjectConflict + @test occursin("choose on the card", gui2.status.text[]) + @test check!(gui2).status === :none && check!(gui2).status === :none + GUI._release_info!(gui2, GUI._ProjectConflict) + GUI._save_to!(gui2, dir) + @test read(system, String) == agent_text + @test GUI._info_card(gui2).obj isa GUI._ProjectConflict + @test_throws BeamletOpticsProjects.ProjectChanged save_project(gui2) + # "Overwrite": the setup of the window + GUI._resolve_conflict!(gui2, :overwrite) + @test isnothing(GUI._info_card(gui2)) || !(GUI._info_card(gui2).obj isa GUI._ProjectConflict) + @test !occursin("L10", read(system, String)) && check!(gui2).status === :none + # a syntax error while the agent writes: the window stays, the line is named; then fixed + good = read(system, String) + write(system, good * "@component L11 = extra_lens(\n") + @test check!(gui2).status === :pending + r = check!(gui2) + @test r.status === :failed && r.gui === gui2 + @test occursin(r"project not reloaded: .*system\.jl:\d+", gui2.status.text[]) + write(system, good * "@component L11 = extra_lens(0.4)\n") + @test check!(gui2).status === :pending + r = check!(gui2) + @test r.status === :reloaded && haskey(_byname(r.gui), :L11) + gui3 = r.gui + # "Load from disk" through _reload!: the change here is lost + translate3d!(gui3, _byname(gui3)[:spare], [0.0, 0.0, 0.01]) + moved = BeamletOptics.position(_byname(gui3)[:spare]) + gui4 = GUI._reload!(gui3; display = false, const_kw...) + @test BeamletOptics.position(_byname(gui4)[:spare]) == moved .- [0.0, 0.0, 0.01] + @test !GUI._setup_changed(gui4) + # Ctrl+R without a project, and close stops the watcher + gui5 = _live_view(System()) + GUI._reload_key!(gui5) + @test startswith(gui5.status.text[], "no project to reload") + close(gui5) + gui4.sync.timer = Timer(100) + close(gui4) + @test isnothing(gui4.sync.timer) + end + @testset "end-to-end" begin dir = mktempdir() t = _setup(:app) From eb987853d57deafe7a3d001cb361f6550ccd1ebd Mon Sep 17 00:00:00 2001 From: Hugo Uittenbosch Date: Tue, 27 Oct 2026 16:18:15 +0100 Subject: [PATCH 04/47] Projects prototype: changes of the files appear live in the open window Objects have a readable id, a label and a machine id. The watcher merges the files into the open window per object: moved objects glide, new ones appear, changed ones are rebuilt, removed ones go; only objects changed on both sides are a conflict, shown on a card. Ctrl+R loads the files in place. Save keeps the line order; save as copies src/ and sets the window title. Co-Authored-By: Claude Opus 5.5 --- .../BeamletOpticsGUIGLMakieExt.jl | 1 + ext/BeamletOpticsGUIGLMakieExt/title.jl | 12 + src/LiveEdit.jl | 2 + src/LiveProject.jl | 239 +++++-- src/LiveProjectSync.jl | 658 ++++++++++++++---- src/LiveView.jl | 26 +- test/TestLiveProject.jl | 258 ++++--- test/TestLiveProjectSync.jl | 201 ++++++ test/runtests.jl | 1 + 9 files changed, 1113 insertions(+), 285 deletions(-) create mode 100644 ext/BeamletOpticsGUIGLMakieExt/title.jl create mode 100644 test/TestLiveProjectSync.jl diff --git a/ext/BeamletOpticsGUIGLMakieExt/BeamletOpticsGUIGLMakieExt.jl b/ext/BeamletOpticsGUIGLMakieExt/BeamletOpticsGUIGLMakieExt.jl index 3da9de3..0fc421e 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/BeamletOpticsGUIGLMakieExt.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/BeamletOpticsGUIGLMakieExt.jl @@ -15,6 +15,7 @@ const GUI = BeamletOpticsGUI const BMO = BeamletOptics include("session.jl") +include("title.jl") @setup_workload begin # A step that fails only loses its part of the precompilation; `test/TestPrecompileSession.jl` diff --git a/ext/BeamletOpticsGUIGLMakieExt/title.jl b/ext/BeamletOpticsGUIGLMakieExt/title.jl new file mode 100644 index 0000000..19cca46 --- /dev/null +++ b/ext/BeamletOpticsGUIGLMakieExt/title.jl @@ -0,0 +1,12 @@ +#= +The title of the window of a live view, see `_set_title!` of the package: e.g. the name of the folder +of its project after "Save as" +=# + +function GUI._set_screen_title!(screen::GLMakie.Screen, title::String) + window = screen.glscreen + # A window of GLFW that is open; an embedded screen has another window type + (window isa GLMakie.GLFW.Window && isopen(screen)) || return nothing + GLMakie.GLFW.SetWindowTitle(window, title) + return nothing +end diff --git a/src/LiveEdit.jl b/src/LiveEdit.jl index 6102d05..8da2577 100644 --- a/src/LiveEdit.jl +++ b/src/LiveEdit.jl @@ -171,6 +171,8 @@ function _replace!(gui::LiveView, old, new, origin) swap!(out, out_state, in, in_state) = begin out_state[] = _remove_unrecorded!(gui, out) delete!(comp.edits, out) + # the same entry of the project, see `_move_id!` + _move_id!(gui, out, in) _restore!(gui, in, in_state[]) _show_edit_page!(gui, in) return nothing diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 90dcf05..93cc950 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -43,37 +43,85 @@ Names _is_project_name(s::AbstractString) = _is_variable_name(s) && !_is_taken_name(s) && !Base.isexported(BMOP, Symbol(s)) -"""`s` with every character that is not allowed in a name replaced by `_`, e.g. `Mirror_1`.""" -function _sanitized_name(s::AbstractString) - t = replace(strip(s), r"[^\p{L}\p{N}_]+" => "_") - t = strip(t, '_') - isempty(t) && return "obj" - return isletter(first(t)) ? String(t) : "obj_" * t +""" + _project_ids(gui, items) -> IdDict{Any, Tuple{Symbol, String}} + +The readable id and the machine id of the objects, sources and systems `items` of the `gui` in a +project: those it has in `gui.sync.ids` (see `_ProjectSync`), else a new id of the prefix of its +type (see `BeamletOpticsProjects.id_prefix` and `next_id`) and a new uid. Every id in +`gui.sync.ids` is reserved first, also of an item later in `items` or of an object that is not in +the view anymore, such that a new one never takes it: after `L5` the next lens is `L6`. An id or a +uid that an item before took already is given anew, an empty uid too. +""" +function _project_ids(gui::LiveView, items) + known = gui.sync.ids + out = IdDict{Any, Tuple{Symbol, String}}() + # The ids that are taken, as systems of a project for `next_id` + taken = BMOP.Project(pwd(), "taken") + reserve!(id) = (push!(taken.systems, BMOP.SystemEntry(id, Symbol[])); id) + foreach(k -> reserve!(first(k)), values(known)) + used = Set{Symbol}() + uids = Set{String}() + for x in items + k = get(known, x, nothing) + (isnothing(k) || first(k) in used) && continue + id, uid = k + (isempty(uid) || uid in uids) && (uid = BMOP.new_uid()) + push!(used, id) + push!(uids, uid) + out[x] = (id, uid) + end + for x in items + haskey(out, x) && continue + prefix = BMOP.id_prefix(x) + id = BMOP.next_id(taken, prefix) + while !_is_project_name(String(id)) + reserve!(id) + id = BMOP.next_id(taken, prefix) + end + reserve!(id) + k = get(known, x, nothing) + uid = isnothing(k) || isempty(last(k)) || last(k) in uids ? BMOP.new_uid() : last(k) + push!(uids, uid) + out[x] = (id, uid) + end + return out +end + +""" +The `label` of an entry of `x` with the readable `id`: the label of `x` in the `gui` (see +`_ProjectSync`) if it has one and it is not the id, else `nothing`. The automatic names of the tree +(e.g. "Lens 2") are no labels. +""" +function _project_label(gui::LiveView, @nospecialize(x), id::Symbol) + label = get(gui.labels, x, nothing) + return (isnothing(label) || label == String(id)) ? nothing : String(label) end """ - _project_names(gui, items) -> IdDict{Any, Symbol} + _file_order(base, entries, systems) -> (entries, systems) -The names of the objects, sources and systems `items` of the `gui` in a project: the label (see -`_label`) if it is a free variable name (see `_is_project_name`) that no item before took, else the -label with the characters of no name replaced by `_` (see `_sanitized_name`), with `_2`, `_3`, … -appended while it is taken. +The `entries` and `systems` of a project in the order of the project `base` (the state that the view +and the files last agreed on, see `_ProjectSync`): each by the place of the entry or system of the +same uid in `base`, else of the same readable id; the others after those, in their order. `system_code` +writes the components before the sources, so a new one is written at the end of its kind. Without +`base`, as they are. """ -function _project_names(gui::LiveView, items) - names = IdDict{Any, Symbol}() - used = Set{String}() - for x in items - label = _label(gui, x) - base = _is_project_name(label) ? label : _sanitized_name(label) - name, k = base, 1 - while name in used || !_is_project_name(name) - k += 1 - name = "$(base)_$k" +function _file_order(base, entries, systems) + isnothing(base) && return entries, systems + function order(xs, old) + by_uid = Dict{String, Int}() + by_name = Dict{Symbol, Int}() + for (i, o) in enumerate(old) + isnothing(o.uid) || get!(by_uid, o.uid, i) + get!(by_name, o.name, i) end - push!(used, name) - names[x] = Symbol(name) + # The uid of a line of an agent is given in the view, the file has none yet + place(x) = !isnothing(x.uid) && haskey(by_uid, x.uid) ? by_uid[x.uid] : + get(by_name, x.name, typemax(Int)) + return xs[sortperm([place(x) for x in xs]; alg = Base.Sort.DEFAULT_STABLE)] end - return names + return order(entries, base.entries), order(systems, base.systems) end #= @@ -122,13 +170,14 @@ function _project_meta(origin) end """ - _project_parts(gui, dir) -> (; project, names, skipped) + _project_parts(gui, dir; name = project_name(dir)) -> (; project, names, ids, skipped) -The `project` of the `gui` in the folder `dir` (see `_collect_project`), the `names` of its -objects, sources and systems and the labels of the objects and sources that were `skipped`, since -their constructor is not known. +The `project` of the `gui` in the folder `dir` with the module `name` (see `_collect_project`), the +readable ids (`names`) and the readable and machine `ids` of its objects, sources and systems (see +`_project_ids`) and the labels of the objects and sources that were `skipped`, since their +constructor is not known. """ -function _project_parts(gui::LiveView, dir::AbstractString) +function _project_parts(gui::LiveView, dir::AbstractString; name::AbstractString = BMOP.project_name(dir)) comp = gui.components # A system without members and sources that has no name, e.g. the one of an empty view systems = Any[sys for sys in _systems(gui) @@ -148,22 +197,27 @@ function _project_parts(gui::LiveView, dir::AbstractString) push!(saved, x) end end - names = _project_names(gui, Any[saved..., systems...]) + ids = _project_ids(gui, Any[saved..., systems...]) + names = IdDict{Any, Symbol}(x => first(v) for (x, v) in ids) entries = BMOP.Entry[] for x in saved origin = comp.origin[x] pos, rot = _project_pose(x, origin) P0, R0 = origin.pose0 - push!(entries, BMOP.Entry(_entry_kind(x), names[x], Meta.parse(origin.code), x; pos, rot, - pose0 = (Vector{Float64}(P0), Matrix{Float64}(R0)), meta = _project_meta(origin))) + id, uid = ids[x] + push!(entries, BMOP.Entry(_entry_kind(x), id, Meta.parse(origin.code), x; pos, rot, + pose0 = (Vector{Float64}(P0), Matrix{Float64}(R0)), meta = _project_meta(origin), + label = _project_label(gui, x, id), uid)) end is_saved = Base.IdSet{Any}(saved) system_entries = BMOP.SystemEntry[BMOP.SystemEntry(names[sys], Symbol[names[obj] for obj in sys.objects if obj in is_saved], Symbol[names[src] for src in _sources_of(gui, sys) if src in is_saved]; - static = _static_system(sys), object = sys) for sys in systems] - project = BMOP.Project(dir, BMOP.project_name(dir); entries, systems = system_entries) - return (; project, names, skipped) + static = _static_system(sys), object = sys, label = _project_label(gui, sys, names[sys]), + uid = last(ids[sys])) for sys in systems] + entries, system_entries = _file_order(gui.sync.base, entries, system_entries) + project = BMOP.Project(dir, name; entries, systems = system_entries) + return (; project, names, ids, skipped) end """ @@ -187,31 +241,35 @@ function _project_packages() return mods end -# The handle of a system has the name of the system, see `_rename_system!` -function _name_handle!(gui::LiveView, sys::BMO.AbstractSystem, name::String) - h = _system_handle(gui, sys) - isnothing(h) || (gui.objects.names[h] = name) +""" + _apply_project_names!(gui, project) + +Writes the readable ids and the machine ids of the entries and systems of the saved `project` into +`gui.sync.ids` (see `_ProjectSync`), such that the next save gives the same ones; the uids that +`BeamletOpticsProjects.save_project` gave are those of the entries in `project`. The labels of the +`gui` stay as they are. +""" +function _apply_project_names!(gui::LiveView, project) + ids = gui.sync.ids + for e in Iterators.flatten((project.entries, project.systems)) + isnothing(e.object) && continue + ids[e.object] = (e.name, something(e.uid, BMOP.new_uid())) + end return nothing end -_name_handle!(::LiveView, @nospecialize(x), ::String) = nothing """ -Writes the `names` of a saved project into the labels of the `gui`, such that the next save gives -the same names. + _move_id!(gui, old, new) + +Gives the readable id and the machine id of `old` to `new`, which takes its place in the `gui`, +e.g. on the page "Edit" (see `_replace!`): the project sees the same entry with other code. """ -function _apply_project_names!(gui::LiveView, names) - changed = false - for (x, name) in names - s = String(name) - get(gui.labels, x, nothing) == s && continue - changed = true - gui.labels[x] = gui.objects.names[x] = s - _name_handle!(gui, x, s) - end - changed || return nothing - _refresh_names!(gui) - _refresh_catalog!(gui) - _refresh_links!(gui) +function _move_id!(gui::LiveView, @nospecialize(old), @nospecialize(new)) + ids = gui.sync.ids + k = get(ids, old, nothing) + isnothing(k) && return nothing + delete!(ids, old) + ids[new] = k return nothing end @@ -397,9 +455,10 @@ end The live view of a loaded `project` (see `BeamletOpticsProjects.load_project`), without showing it: its systems with their sources, a `System()` for a project without systems, the objects and -sources without a system, the names of the project as labels, the origins of the objects (their -code, with which they are saved again and, from the catalog, edited and copied), and `view.toml`. -The `kwargs` of `live_view` win over those of `view.toml`. +sources without a system, their labels (else their readable ids), their ids and uids (see +`_ProjectSync`), the origins of the objects (their code, with which they are saved again and, from +the catalog, edited and copied), and `view.toml`. The `kwargs` of `live_view` win over those of +`view.toml`. The `project` becomes the `base` of the sync. """ function _open_from(project; view = _read_view(project), kwargs...) @nospecialize @@ -413,16 +472,23 @@ function _open_from(project; view = _read_view(project), kwargs...) end isempty(args) && push!(args, BMO.System()) paired = Base.IdSet{Any}(a.second for a in args if a isa Pair) + # The label of each, else its readable id labels = IdDict{Any, String}() - foreach(e -> labels[e.object] = String(e.name), project.entries) - foreach(s -> labels[s.object] = String(s.name), project.systems) + for e in Iterators.flatten((project.entries, project.systems)) + labels[e.object] = something(e.label, String(e.name)) + end vk = _view_kwargs(view, byname, paired) gui = live_view(args...; labels, vk.kwargs..., kwargs...) + # The readable id and the uid of each; a line of an agent without a uid gets one, which the + # next save writes (`diff_projects` pairs it with its line by the readable id meanwhile) + for e in Iterators.flatten((project.entries, project.systems)) + gui.sync.ids[e.object] = (e.name, something(e.uid, BMOP.new_uid())) + end origins = IdDict{Any, Any}(e.object => _project_origin(gui, e) for e in project.entries) _unrecorded(gui) do for e in project.entries e.name in in_system && continue - _add_loose!(gui, e.object, String(e.name), origins[e.object], vk.beam_kwargs) + _add_loose!(gui, e.object, labels[e.object], origins[e.object], vk.beam_kwargs) end for src in vk.off src in paired || _set_beam_on!(gui, src, false) @@ -438,8 +504,7 @@ function _open_from(project; view = _read_view(project), kwargs...) end gui.project = project.path # What `Ctrl+S` and the watcher compare with, see `_ProjectSync` - gui.sync.stamp = project.stamp - gui.sync.code = BMOP.system_code(_collect_project(gui, project.path)) + _saved!(gui, project) gui.status.text[] = "project $(project.name) opened from $(project.path)" return gui end @@ -450,7 +515,7 @@ end Opens the project in the folder `path`, or of its project file `path` (`.bmo`), see `BeamletOpticsProjects.load_project`, in a live view and shows it if `display` is `true`: its systems with their sources, the objects and sources -without a system, the names of the project as labels and how the view looked when it was saved +without a system, their labels (else their ids) and how the view looked when it was saved (`view.toml`: camera, saved views, clip planes, table, the colors of the beams, ...). The `kwargs` are those of [`live_view`](@ref) and win over `view.toml`. `Ctrl+S` saves it there again, see [`save_project`](@ref). The components and sources from the catalog can be edited and copied. @@ -471,6 +536,21 @@ end Saving =# +""" + _set_title!(gui, title) + +Sets the title of the window of the `gui`, e.g. to the name of the folder of its project after +"Save as". Nothing if the `gui` is not shown or its backend has no window titles: the method per +backend is `_set_screen_title!(screen, title)`, of GLMakie in its extension. +""" +function _set_title!(gui::LiveView, title::AbstractString) + _set_screen_title!(Makie.getscreen(gui.fig.scene), String(title)) + return nothing +end + +"""Sets the title of the window of the `screen` of a backend, see `_set_title!`; nothing by default.""" +_set_screen_title!(@nospecialize(screen), ::String) = nothing + """ save_project(gui::LiveView, dir = gui.project) -> String @@ -480,8 +560,16 @@ system, each with its constructor call and its pose, see `BeamletOpticsProjects` not a project yet becomes one, with the versions of BeamletOptics, BeamletOpticsProjects, BeamletOpticsGUI and GLMakie. How the view looks is written to `view.toml`. An object whose constructor is not known, e.g. one that the script gave to `live_view`, is not saved: a warning and -the status line name it. The names of the project become the labels of the view, and `dir` its -project, which `Ctrl+S` saves to. `Ctrl+Shift+S` asks for a place and a name, see `_save_as_key!`. +the status line name it. Each object, source and system keeps its readable id (e.g. `L1`) and its +machine id (uid) across saves; a new one gets the next free id of its kind and a new uid, and its +label in the view, if it has one, is saved as its `label`. The entries stay in the order of the +file, new ones at the end of their kind. `dir` becomes the project of the view, which `Ctrl+S` +saves to, and the title of its window. `Ctrl+Shift+S` asks for a place and a name, see +`_save_as_key!`. + +Saving a view that has a project to another folder ("save as") copies the own code of the project +(`src/`) there first, without overwriting a file, and keeps the name of its module, such that the +copy loads. Throws `BeamletOpticsProjects.ProjectChanged` and saves nothing if the files of the project of the view changed since it opened or saved it, e.g. by an agent, unless `force` is `true`; `Ctrl+S` shows @@ -489,15 +577,26 @@ a card that offers to load them, to overwrite them or to save as, see `_ProjectC """ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) dir = abspath(dir) - parts = _project_parts(gui, dir) + from = gui.project + save_as = !isnothing(from) && dir != from + # A new folder of "save as" gets the own code and the module of the project + name = save_as && !isfile(joinpath(dir, "Project.toml")) ? BMOP.project_name(from) : + BMOP.project_name(dir) + parts = _project_parts(gui, dir; name) # The files as the view knows them: only of its own project - dir == gui.project && (parts.project.stamp = gui.sync.stamp) + dir == from && (parts.project.stamp = gui.sync.stamp) + if save_as + # Before checking whether the folder is a project: the copy must not miss its module + BMOP.copy_code(from, dir) + end path = save_project(parts.project; packages = _project_packages(), force) _write_view_toml(gui, parts.project, parts.names) - _apply_project_names!(gui, parts.names) + # The uids that the save gave are those of `parts.project` + _apply_project_names!(gui, parts.project) gui.project = dir _saved!(gui, parts.project) - msg = "project saved to $dir" + dir == from || _set_title!(gui, basename(dir)) + msg = save_as ? "project saved as $dir" : "project saved to $dir" if !isempty(parts.skipped) list = join(parts.skipped, ", ") @warn "save_project: $(length(parts.skipped)) objects without a recipe were not saved: $list" diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 39e67dc..759aaa5 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -1,12 +1,21 @@ #= -Working on a project while an agent changes its files: a watcher of the files of the project of a -shown view, which reloads the project when only the files changed and shows the card of the -conflict when the setup in the view changed too; `Ctrl+S` never overwrites a change of the files -silently (`BeamletOpticsProjects.ProjectChanged`) +Working on a project while an agent changes its files. A watcher looks at the times of the files +of the project of a shown view; when they changed and settled, the project is loaded and compared +with the state that the view and the files last agreed on (`base`) and with the setup in the view: +what only the files changed is applied in the open window (moved objects glide, new ones appear, +changed ones are rebuilt, removed ones go), what both changed differently is a conflict, shown on +a card. The window is never replaced. =# -# How often the watcher looks at the files [s] -const _WATCH_INTERVAL = 1.0 +# How often the watcher looks at the times of the files [s] +const _WATCH_INTERVAL = 0.2 +# How long the files must stay unchanged before they are loaded [s], an agent writes in steps +const _SETTLE = 0.3 +# How long a moved object glides to its new pose [s], and the frames per second of that +const _GLIDE = 0.3 +const _GLIDE_FPS = 60 +# How long the boxes of the changed objects are shown [s] +const _FLASH = 1.0 """Whether the `gui` is shown in a window that is open.""" function _shown(gui::LiveView) @@ -15,25 +24,46 @@ function _shown(gui::LiveView) return applicable(isopen, screen) ? isopen(screen) : true end -"""Records the files of the `project` that the `gui` just saved, see `_ProjectSync`.""" +""" +The times and sizes of the files of the project in `dir` that define the setup: `system.jl` and +the `.jl` files of `src/`. Cheap enough to be looked at often, see `_check_files!`. +""" +function _file_marks(dir::AbstractString) + marks = Tuple{String, Float64, Int}[] + file = joinpath(dir, "system.jl") + isfile(file) && push!(marks, ("system.jl", mtime(file), filesize(file))) + src = joinpath(dir, "src") + if isdir(src) + for (root, _, names) in walkdir(src), n in names + endswith(n, ".jl") || continue + path = joinpath(root, n) + push!(marks, (relpath(path, dir), mtime(path), filesize(path))) + end + end + return sort!(marks) +end + +_src_marks(marks) = filter(m -> m[1] != "system.jl", marks) + +""" + _saved!(gui, project) + +Records that the `gui` and the files of the `project` agree, after it was opened, saved or the +files were merged: `project` becomes the `base` of the comparisons (see `_ProjectSync`), and the +watcher starts if the `gui` is shown. +""" function _saved!(gui::LiveView, project) s = gui.sync + s.base = _align_uids!(gui, project) s.stamp = project.stamp - s.code = BMOP.system_code(project) - s.pending = s.handled = nothing + s.marks = _file_marks(project.path) + s.src = _src_marks(s.marks) + s.changed_at = s.handled = nothing + s.failed = false _start_watching!(gui) return nothing end -""" -Whether the setup of the `gui` differs from that of the files of its project when it opened or -saved them; only the setup (`system.jl`), not the camera or the colors. -""" -function _setup_changed(gui::LiveView) - (isnothing(gui.project) || isnothing(gui.sync.code)) && return false - return BMOP.system_code(_collect_project(gui, gui.project)) != gui.sync.code -end - """ Starts the watcher of the files of the project of the `gui` if it is shown, which calls `_check_files!` every `_WATCH_INTERVAL` seconds until the window is closed. A view that is not @@ -64,172 +94,562 @@ function _stop_watching!(gui::LiveView) end """ - _check_files!(gui; kwargs...) -> (; status, gui) + _check_files!(gui; now = time()) -> Symbol -One look of the watcher at the files of the project of the `gui`, with the `status` `:none` if they are -as the `gui` -knows them; `:pending` when they changed, to look again at the next call, since an agent may still -be writing; when they changed and stayed so, `:reloaded` (or `:failed`, e.g. a syntax error) if the -setup in the `gui` did not change (see `_reload!`), otherwise `:conflict` with the card of the -conflict (see `_ProjectConflict`). It reacts once to each state of the files. `gui` is the view -afterwards, the new one after a reload, which gets the `kwargs` of `live_view`. +One look of the watcher at the files of the project of the `gui`: `:pending` while they change or +have not stayed unchanged for `_SETTLE` seconds; then, once per state of the files, `:applied`, +`:conflict` or `:failed`, see `_sync!`; `:none` otherwise, also for the files that the `gui` saved +itself. A load error shown before is cleared when the files are back to the state of the `gui`. """ -function _check_files!(gui::LiveView; kwargs...) +function _check_files!(gui::LiveView; now::Real = time()) s = gui.sync - (isnothing(gui.project) || isnothing(s.stamp)) && return (; status = :none, gui) - disk = try - BMOP.project_stamp(gui.project) - catch e - e isa InterruptException && rethrow() - return (; status = :none, gui) + dir = gui.project + (isnothing(dir) || isnothing(s.base)) && return :none + marks = _file_marks(dir) + if marks != s.marks + s.marks = marks + s.changed_at = now + return :pending end + isnothing(s.changed_at) && return :none + now - s.changed_at < _SETTLE && return :pending + s.changed_at = nothing + disk = BMOP.project_stamp(dir) if disk == s.stamp - s.pending = nothing - return (; status = :none, gui) + # Saved by the view itself, or back to the state of the view + s.failed && (s.failed = false; gui.status.text[] = "project files are as in the window") + s.handled = nothing + return :none end - # Reacted to this state already, e.g. the card of the conflict was shown - disk == s.handled && return (; status = :none, gui) - if disk != s.pending - s.pending = disk - return (; status = :pending, gui) - end - s.pending = nothing + disk == s.handled && return :none s.handled = disk - if _setup_changed(gui) - _show_conflict!(gui) - return (; status = :conflict, gui) - end - new = _reload!(gui; kwargs...) - return isnothing(new) ? (; status = :failed, gui) : (; status = :reloaded, gui = new) + return _sync!(gui) end -"""The layout, theme and size of the window of the `gui`, for a window that replaces it.""" -function _window_kwargs(gui::LiveView) - w = Makie.widths(gui.fig.scene.viewport[]) - return (; layout = _layout_name(gui.layout), theme = _theme_name(gui.layout.theme), - size = (round(Int, w[1]), round(Int, w[2]))) -end +#= +Comparing and applying +=# -"""Closes the `gui` and its window, never inside an event callback of that window.""" -function _close_window!(gui::LiveView) - screen = Makie.getscreen(gui.fig.scene) - close(gui) - isnothing(screen) || close(screen) - return nothing +# The key of an entry or system: its machine id, or its readable id for a line without one +_key(x) = isnothing(x.uid) ? "id:" * String(x.name) : x.uid +_change_key(c) = _key(something(c.old, c.new)) + +"""The entry (or system) of the `key` in `xs`, by machine id or by readable id (`\"id:L1\"`).""" +function _by_key(xs, key::String) + if startswith(key, "id:") + name = Symbol(key[4:end]) + i = findfirst(x -> x.name === name, xs) + else + i = findfirst(x -> x.uid == key, xs) + end + return isnothing(i) ? nothing : xs[i] end -"""The message of an error of loading a project: the file and line of a `LoadError`.""" +""" +The message of an error of loading a project for the status line: file, line, column and reason, +e.g. `system.jl:22:34: Expected ')' or ','`. +""" function _load_message(e) e isa LoadError || return _message_line(e) - msg = _message_line(e.error) + inner = e.error + text = sprint(showerror, inner) + if inner isa Base.Meta.ParseError + at = match(r"@ .*?([^\\/]+\.jl):(\d+):(\d+)", text) + reason = match(r"└[─┘ ]*── (.*)$"m, text) + file = isnothing(at) ? basename(e.file) : at.captures[1] + pos = isnothing(at) ? string(e.line) : string(at.captures[2], ":", at.captures[3]) + return string(file, ":", pos, ": ", isnothing(reason) ? "syntax error" : strip(reason.captures[1])) + end + msg = first(split(text, '\n')) # The macros of BeamletOpticsProjects name the line themselves occursin(r"\.jl:\d+", msg) && return msg return "$(basename(e.file)):$(e.line): $msg" end """ - _reload!(gui; display = _shown(gui), kwargs...) -> Union{Nothing, LiveView} +The readable ids of the entries of the project `p` whose code calls a function or type that the own +code of the project (`src/.jl`) exports; they are built again when that code changed. +""" +function _calls_own_code(p) + isnothing(p.mod) && return Symbol[] + own = Symbol(p.name) + Base.invokelatest(isdefined, p.mod, own) || return Symbol[] + m = Base.invokelatest(getfield, p.mod, own) + m isa Module || return Symbol[] + exported = Set{Symbol}(Base.invokelatest(names, m)) + return Symbol[e.name for e in p.entries if _uses(e.code, exported)] +end +_uses(x::Symbol, names) = x in names +_uses(x::Expr, names) = any(a -> _uses(a, names), x.args) +_uses(x, names) = false + +"""Whether the state of the `key` is the same in the projects `a` and `b`.""" +function _same_state(a, b, key::String) + ea, eb = _by_key(a.entries, key), _by_key(b.entries, key) + if !(isnothing(ea) && isnothing(eb)) + (isnothing(ea) || isnothing(eb)) && return false + return BMOP.same_entry(ea, eb) + end + sa, sb = _by_key(a.systems, key), _by_key(b.systems, key) + (isnothing(sa) && isnothing(sb)) && return true + (isnothing(sa) || isnothing(sb)) && return false + return BMOP.same_system(sa, a, sb, b) +end + +""" + _sync!(gui) -> Symbol -Loads the project of the `gui` from its files again and shows it in a new window that replaces the -window of the `gui` (if `display`), with the layout, theme and size of that window and how it -looked: camera, saved views, clip planes, colors of the beams (not `view.toml`). The own code in -`src/` is loaded again too. The changes of the setup in the `gui` that were not saved are lost. -Returns the new view, or `nothing` and the reason in the status line of the `gui` if the project -could not be loaded, e.g. while an agent is still writing it; the `gui` stays then. The `kwargs` are -those of `live_view`. +Loads the project of the `gui` from its files and merges it into the open window, see +`LiveProjectSync.jl`: the changes of the files since `base` that the window did not change too are +applied (`_adopt!`), conflicting ones are left and shown on a card (`_ProjectConflict`). The +files become the new `base`. Returns `:applied`, `:conflict` or `:failed` (the reason in the status +line, the window as it was). """ -function _reload!(gui::LiveView; display::Bool = _shown(gui), kwargs...) - project = try - BMOP.load_project(gui.project) +function _sync!(gui::LiveView) + s = gui.sync + dir = gui.project + disk = try + BMOP.load_project(dir) catch e e isa InterruptException && rethrow() + s.failed = true gui.status.text[] = "project not reloaded: $(_load_message(e))" - return nothing + return :failed end - view = _view_dict(gui, _project_parts(gui, gui.project).names) - window = display ? _window_kwargs(gui) : (; layout = _layout_name(gui.layout)) - new = try - _open_from(project; view, window..., kwargs...) - catch e - e isa InterruptException && rethrow() - @error "reloading the project failed" exception = (e, catch_backtrace()) - gui.status.text[] = "project not reloaded: $(_message_line(e))" - return nothing + s.failed = false + # The lines without uid (of an agent) get those of their objects in the window + _align_uids!(gui, disk) + rebuild = _src_marks(s.marks) == s.src ? Symbol[] : _calls_own_code(disk) + d = BMOP.diff_projects(s.base, disk; rebuild) + mine = _collect_project(gui, dir) + g = BMOP.diff_projects(s.base, mine) + theirs = Set{String}(_change_key(c) for c in Iterators.flatten((d.entries, d.systems))) + ours = Set{String}(_change_key(c) for c in Iterators.flatten((g.entries, g.systems))) + conflicts = String[k for k in theirs if k in ours && !_same_state(mine, disk, k)] + done = _adopt!(gui, mine, disk, setdiff(theirs, conflicts); rebuild = Set(rebuild)) + s.base = _align_uids!(gui, disk) + s.stamp = disk.stamp + s.src = _src_marks(s.marks) + s.conflicts = conflicts + s.theirs = disk + if isempty(conflicts) + isempty(done) || (gui.status.text[] = "agent: " * join(done, ", ")) + return :applied end - new.status.text[] = "project reloaded from $(project.path)" - if display - title = basename(project.path) - _multi_light_backend() ? Base.display(new; title) : Base.display(new) - _start_watching!(new) - _close_window!(gui) - else - close(gui) + _show_conflict!(gui, conflicts) + return :conflict +end + +""" +Gives the entries and systems of the `project` without a machine id (lines of an agent) the one +that the `gui` gave their objects (see `ids`), such that the project and the window compare equal. +""" +function _align_uids!(gui::LiveView, project) + for (i, e) in enumerate(project.entries) + isnothing(e.uid) || continue + obj = _window_object(gui, e) + isnothing(obj) && continue + project.entries[i] = BMOP.Entry(e.kind, e.name, e.code, e.object, e.pos, e.rot, e.pose0, + e.meta, e.label, gui.sync.ids[obj][2]) + end + for (i, se) in enumerate(project.systems) + isnothing(se.uid) || continue + sys = _window_object(gui, se) + isnothing(sys) && continue + project.systems[i] = BMOP.SystemEntry(se.name, se.members, se.sources, se.static, se.object, + se.label, gui.sync.ids[sys][2]) + end + return project +end + +"""The object, source or system of the `gui` of the project entry (or system) `x`.""" +function _window_object(gui::LiveView, x) + for (obj, (name, uid)) in gui.sync.ids + (isnothing(x.uid) ? name === x.name : uid == x.uid) && return obj + end + return nothing +end + +# The label that the view shows for an entry: its label, else its readable id +_shown_label(x) = something(x.label, String(x.name)) + +"""Changes the record of the project in the `origin` of `obj` (see `_project_origin`).""" +function _update_record!(gui::LiveView, @nospecialize(obj); kwargs...) + origin = get(gui.components.origin, obj, nothing) + rec = isnothing(origin) ? nothing : get(origin, :project, nothing) + isnothing(rec) && return nothing + gui.components.origin[obj] = merge(origin, (; project = merge(rec, (; kwargs...)))) + return nothing +end + +# The handle of a system has the name of the system, see `_rename_system!` +function _name_handle!(gui::LiveView, sys::BMO.AbstractSystem, name::String) + h = _system_handle(gui, sys) + isnothing(h) || (gui.objects.names[h] = name) + return nothing +end +_name_handle!(::LiveView, @nospecialize(x), ::String) = nothing + +"""Sets the label of `x` (an object, source or system of the `gui`) and its id in `ids`.""" +function _name!(gui::LiveView, @nospecialize(x), e) + uid = something(e.uid, get(gui.sync.ids, x, (e.name, BMOP.new_uid()))[2]) + gui.sync.ids[x] = (e.name, uid) + label = _shown_label(e) + if get(gui.labels, x, nothing) != label + gui.labels[x] = gui.objects.names[x] = label + _name_handle!(gui, x, label) + end + return nothing +end + +"""Adds the object or source of the entry `e` of the files to the `gui`, without a system yet.""" +function _add_entry!(gui::LiveView, e) + obj = e.object + origin = _project_origin(gui, e) + _unrecorded(gui) do + add_component!(gui, obj; system = :none, select = false, label = _shown_label(e), origin) end + _name!(gui, obj, e) + return obj +end + +"""Removes the object or source `obj` of the project from the `gui`, and its undo history.""" +function _remove_entry!(gui::LiveView, @nospecialize(obj)) + _unrecorded(() -> remove_component!(gui, obj), gui) + _drop_history!(gui.controls, Any[obj]) + delete!(gui.sync.ids, obj) + return nothing +end + +""" +Puts the object of the entry `e` of the files in the place of `old` in the `gui` (other code, or +own code that changed): in the same systems, with the pose of the files; selected if `old` was. +""" +function _swap!(gui::LiveView, @nospecialize(old), e) + new = e.object + origin = _project_origin(gui, e) + selected = gui.controls.selected[] === old + snap = _replacement_snapshot(gui, old, new, origin, _snapshot(gui, old)) + _remove_unrecorded!(gui, old) + _restore!(gui, new, snap) + _drop_history!(gui.controls, Any[old]) + delete!(gui.sync.ids, old) + _name!(gui, new, e) + selected && select!(gui, new) return new end -"""`Ctrl+R` of the `gui`: loads its project from the files again, see `_reload!`.""" +"""The rotation by `angle` about the unit vector `axis`.""" +function _axis_rotation(axis, angle) + K = [0.0 -axis[3] axis[2]; axis[3] 0.0 -axis[1]; -axis[2] axis[1] 0.0] + return Matrix{Float64}(I, 3, 3) + sin(angle) * K + (1 - cos(angle)) * K * K +end + +""" +Moves `obj` of the `gui` to the pose `(P1, R1)`: gliding over `_GLIDE` seconds if `animate`, the +position along the line, the orientation about the axis of the rotation between the two. +""" +function _glide!(gui::LiveView, @nospecialize(obj), P1, R1; animate::Bool) + ctrl = gui.controls + set!(P, R) = (_change!(() -> _set_pose!(obj, P, R), ctrl, obj); _apply_update!(ctrl, obj)) + if animate + P0, R0 = _pose(obj) + axis, angle = _rotation_axis_angle(Matrix{Float64}(R1) * Matrix{Float64}(R0)') + n = max(1, round(Int, _GLIDE * _GLIDE_FPS)) + for i in 1:(n - 1) + f = i / n + f = f * f * (3 - 2f) + R = angle > 0 ? _axis_rotation(axis, f * angle) * R0 : R0 + set!(Point3{Float64}(P0 .+ f .* (Vector{Float64}(P1) .- P0)), R) + sleep(1 / _GLIDE_FPS) + end + end + _change!(() -> _place_exactly!(obj, P1, R1), ctrl, obj) + _apply_update!(ctrl, obj) + return nothing +end + +""" +Sets the pose of `obj` to `(P, R)` with the position exactly `P`: rotates about its position, then +moves it to the origin, which is exact, and from there by `P`, like `BeamletOpticsProjects` +poses the objects of a project. +""" +function _place_exactly!(@nospecialize(obj), P, R) + axis, angle = _rotation_axis_angle(Matrix{Float64}(R) * _pose(obj)[2]') + angle > 0 && rotate3d!(obj, axis, angle) + reset_translation3d!(obj) + translate3d!(obj, Vector{Float64}(P)) + return nothing +end + +"""Shows a box around each of the `objs` of the `gui` for `_FLASH` seconds.""" +function _flash!(gui::LiveView, objs) + color = _part_box_color(gui) + boxes = AbstractPlot[] + for obj in objs + bb = _part_box(gui, obj) + isnothing(bb) && continue + push!(boxes, linesegments!(gui.ax, _bbox_wireframe(bb); color, linewidth = 2, + clip_planes = Plane3f[], inspectable = false)) + end + isempty(boxes) || Timer(_ -> foreach(b -> delete!(gui.ax, b), boxes), _FLASH) + return boxes +end + +""" +Makes the systems of the `gui` that are in `keys` (or that the entries `changed` are members of) +have the members and sources of the files `disk`. +""" +function _sync_members!(gui::LiveView, disk, keys, changed) + for se in disk.systems + member = any(n -> n in changed, se.members) || any(n -> n in changed, se.sources) + (_key(se) in keys || member) || continue + sys = _window_object(gui, se) + sys isa BMO.System || continue + objs(names) = Any[o for n in names + for o in (_window_object(gui, _by_key(disk.entries, "id:" * String(n))),) + if !isnothing(o)] + want, sources = objs(se.members), objs(se.sources) + _unrecorded(gui) do + for o in want + _has(sys.objects, o) || _add_member!(gui, o, sys) + end + for o in copy(sys.objects) + haskey(gui.sync.ids, o) && !_has(want, o) && _remove_member!(gui, o, sys) + end + for src in _sources_of(gui, sys) + haskey(gui.sync.ids, src) && !_has(sources, src) && _set_source_system!(gui, src, nothing) + end + for src in sources + _has(_sources_of(gui, sys), src) || _set_source_system!(gui, src, sys) + end + end + end + return nothing +end + +""" + _adopt!(gui, mine, disk, keys; rebuild = Set{Symbol}()) -> Vector{String} + +Makes the entries and systems of the `keys` in the open `gui` (whose state is the project `mine`, +see `_collect_project`) as in the files `disk`, without undo entries: removes, adds, rebuilds (other +code, or calling own code in `rebuild`), moves (gliding, if the `gui` is shown), renames and +relabels. The changed objects get a box for a moment. Returns what was done, e.g. `"moved M1"`. +""" +function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}()) + done = String[] + touched = Any[] + glides = Tuple{Any, Any, Any}[] + changed = Set{Symbol}() + m = BMOP.match_entries(disk.entries, mine.entries) + # In the window only: removed in the files + for e in m.added + _key(e) in keys || continue + _remove_entry!(gui, e.object) + push!(done, "removed $(_shown_label(e))") + end + ms = BMOP.match_entries(disk.systems, mine.systems) + for se in ms.removed + _key(se) in keys || continue + sys = _unrecorded(() -> add_system!(gui; label = _shown_label(se), select = false), gui) + _name!(gui, sys, se) + push!(done, "added $(_shown_label(se))") + end + # In the files only: added + for e in m.removed + _key(e) in keys || continue + push!(touched, _add_entry!(gui, e)) + push!(changed, e.name) + push!(done, "added $(_shown_label(e))") + end + for (e, mine_e) in m.pairs + _key(e) in keys || continue + obj = mine_e.object + if e.kind != mine_e.kind || e.name in rebuild || + BMOP.normalize_code(e.code) != BMOP.normalize_code(mine_e.code) + push!(touched, _swap!(gui, obj, e)) + push!(changed, e.name) + push!(done, "rebuilt $(_shown_label(e))") + continue + end + if !BMOP.same_entry(BMOP.Entry(e.kind, e.name, e.code, nothing; e.pos, e.rot, e.pose0), + BMOP.Entry(e.kind, e.name, e.code, nothing; mine_e.pos, mine_e.rot, e.pose0)) + push!(glides, (obj, _pose(e.object), e)) + push!(done, "moved $(_shown_label(e))") + end + if e.name !== mine_e.name || e.label != mine_e.label + _name!(gui, obj, e) + e.name === mine_e.name || push!(changed, e.name) + push!(done, "renamed $(_shown_label(e))") + end + isequal(Dict(e.meta), Dict(mine_e.meta)) || _update_record!(gui, obj; meta = Pair{Symbol, Any}[e.meta...]) + end + _sync_members!(gui, disk, keys, changed) + for se in ms.added + _key(se) in keys || continue + try + _unrecorded(() -> remove_system!(gui, se.object), gui) + delete!(gui.sync.ids, se.object) + push!(done, "removed $(_shown_label(se))") + catch e + e isa ArgumentError || rethrow() + end + end + for (se, mine_se) in ms.pairs + _key(se) in keys || continue + (se.name !== mine_se.name || se.label != mine_se.label) && _name!(gui, mine_se.object, se) + end + isempty(glides) && (_flash!(gui, touched); return done) + animate = _shown(gui) + finish() = begin + for (obj, (P, R), e) in glides + _glide!(gui, obj, P, R; animate) + _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) + end + _flash!(gui, Any[touched..., first.(glides)...]) + return nothing + end + animate ? (@async finish()) : finish() + return done +end + +#= +Loading from disk, the conflict and its card +=# + +""" +`Ctrl+R` of the `gui`: makes the window as in the files of its project, in place: everything that +differs is taken from the files, the changes in the window that were not saved are lost. +""" function _reload_key!(gui::LiveView) if isnothing(gui.project) gui.status.text[] = "no project to reload: open or save one first" return nothing end - _run_later(() -> (_reload!(gui); nothing)) + _load_from_disk!(gui) return nothing end -#= -The card of the conflict -=# +"""Takes all of the files of the project into the open `gui`, see `_reload_key!`.""" +function _load_from_disk!(gui::LiveView) + disk = try + BMOP.load_project(gui.project) + catch e + e isa InterruptException && rethrow() + gui.sync.failed = true + gui.status.text[] = "project not reloaded: $(_load_message(e))" + return nothing + end + _align_uids!(gui, disk) + mine = _collect_project(gui, gui.project) + keys = Set{String}(_key(x) for x in Iterators.flatten((disk.entries, disk.systems, mine.entries, + mine.systems))) + _release_info!(gui, _ProjectConflict) + done = _adopt!(gui, mine, disk, keys; rebuild = Set(_calls_own_code(disk))) + _saved!(gui, disk) + gui.status.text[] = isempty(done) ? "project reloaded, nothing changed" : "reloaded: " * join(done, ", ") + return nothing +end """ _ProjectConflict -The files of the project of the view changed outside of it (e.g. by an agent) and its setup changed -too: an info card at `point` with what happened and three buttons, see `_resolve_conflict!`. -Shown by `Ctrl+S` (see `_save_to!`) and by the watcher (see `_check_files!`). +Objects that were changed both in the window and in the files of its project (e.g. by an agent), +differently: an info card at `point` naming them (`names`), with three buttons, see +`_resolve_conflict!`. All other changes of the files are already applied, see `_sync!`. """ struct _ProjectConflict <: _InfoItem point::Point3f + names::Vector{String} end _points(x::_ProjectConflict) = [x.point] -_title(::_ProjectConflict) = "Project changed on disk" +_title(::_ProjectConflict) = "Changed here and in the files" _tree_kind(::_ProjectConflict) = :warning _keep_plots!(::LiveView, ::_ProjectConflict) = nothing _keepable(::_ProjectConflict) = false -card_rows(::_ProjectConflict) = ( - _text_row("files", :conflict_files, (_, _) -> "changed outside, e.g. by an agent"; width = 48), - _text_row("here", :conflict_here, (_, _) -> "changes that are not saved"; width = 48), +card_rows(x::_ProjectConflict) = ( + _text_row("objects", :conflict_names, (_, _) -> join(x.names, ", "); width = 48), + _text_row("files", :conflict_files, (_, _) -> "the rest of the files is applied"; width = 48), CardRow( - CardWidget(Button; name = :conflict_load, label = "Load from disk", - on = (gui, _, _) -> _resolve_conflict!(gui, :load)), - CardWidget(Button; name = :conflict_overwrite, label = "Overwrite", - on = (gui, _, _) -> _resolve_conflict!(gui, :overwrite)), + CardWidget(Button; name = :conflict_theirs, label = "Take theirs", + on = (gui, _, _) -> _resolve_conflict!(gui, :theirs)), + CardWidget(Button; name = :conflict_mine, label = "Keep mine", + on = (gui, _, _) -> _resolve_conflict!(gui, :mine)), CardWidget(Button; name = :conflict_save_as, label = "Save as…", on = (gui, _, _) -> _resolve_conflict!(gui, :save_as)))) -"""Shows the card of the conflict of the `gui`, see `_ProjectConflict`.""" +"""Shows the card of the conflict of the `gui` for the entries and systems of the `keys`.""" +function _show_conflict!(gui::LiveView, keys::Vector{String}) + names = String[] + for k in keys + x = something(_by_key(gui.sync.theirs.entries, k), _by_key(gui.sync.theirs.systems, k), + Some(nothing)) + if isnothing(x) + obj = findfirst(t -> t[2] == k, gui.sync.ids) + push!(names, isnothing(obj) ? k : _label(gui, obj)) + else + push!(names, _shown_label(x)) + end + end + _show_info!(gui, _ProjectConflict(_error_anchor(gui), names)) + gui.status.text[] = "changed here and in the files: $(join(names, ", ")); choose on the card" + return nothing +end + +""" +`Ctrl+S` found the files changed since the window last agreed with them (`ProjectChanged`): merges +them now (see `_sync!`) and saves again if that left no conflict. +""" function _show_conflict!(gui::LiveView) - _show_info!(gui, _ProjectConflict(_error_anchor(gui))) - gui.status.text[] = "the project changed on disk and here: choose on the card" + gui.sync.changed_at = nothing + _sync!(gui) === :applied || return nothing + try + save_project(gui, gui.project) + catch e + e isa InterruptException && rethrow() + gui.status.text[] = "project not saved: $(_message_line(e))" + end return nothing end """ -Resolves the conflict of the `gui` (see `_ProjectConflict`) as the user chose, `how`: `:load` the -files (the changes here are lost, see `_reload!`), `:overwrite` them with the setup here, or -`:save_as` a new project (see `_save_as_key!`), which leaves the files as they are. +Resolves the conflict of the `gui` (see `_ProjectConflict`) as the user chose, `how`: `:theirs` +takes the objects of the conflict from the files (their changes here are lost), `:mine` keeps them +as they are here, which the next save writes, `:save_as` saves the window as a new project (see +`_save_as_key!`). """ function _resolve_conflict!(gui::LiveView, how::Symbol) + s = gui.sync _release_info!(gui, _ProjectConflict) - if how === :load - # Replaces the window: after the callback of its button returned - _run_later(() -> (_reload!(gui); nothing)) - elseif how === :overwrite - _save_to!(gui, gui.project; force = true) - else + if how === :theirs && !isnothing(s.theirs) + mine = _collect_project(gui, gui.project) + done = _adopt!(gui, mine, s.theirs, Set(s.conflicts)) + gui.status.text[] = "taken from the files: " * join(done, ", ") + elseif how === :mine + gui.status.text[] = "kept as here; Ctrl+S writes it to the files" + elseif how === :save_as _save_as_key!(gui) end + empty!(s.conflicts) + return nothing +end + +#= +Windows +=# + +"""The layout, theme and size of the window of the `gui`, for a window that replaces it.""" +function _window_kwargs(gui::LiveView) + w = Makie.widths(gui.fig.scene.viewport[]) + return (; layout = _layout_name(gui.layout), theme = _theme_name(gui.layout.theme), + size = (round(Int, w[1]), round(Int, w[2]))) +end + +"""Closes the `gui` and its window, never inside an event callback of that window.""" +function _close_window!(gui::LiveView) + screen = Makie.getscreen(gui.fig.scene) + close(gui) + isnothing(screen) || close(screen) return nothing end diff --git a/src/LiveView.jl b/src/LiveView.jl index f649b0f..5900a08 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -578,16 +578,30 @@ _ViewLinks() = _ViewLinks(Any[], false) _ProjectSync What the view knows of the files of its project, which an agent may change while it is open, see -`LiveProjectSync.jl`: the `stamp` of the files (`BeamletOpticsProjects.project_stamp`) and the text -of `system.jl` of its setup (`code`) when it was opened or saved; the stamp on disk that the -watcher saw once and waits to see again (`pending`), the last one it reacted to (`handled`) and the -`timer` of the watcher. +`LiveProjectSync.jl`: +- `base`: the project (`BeamletOpticsProjects.Project`) of the state that the view and the files + last agreed on (opened, saved or merged), and `stamp`, the stamp of its files + (`BeamletOpticsProjects.project_stamp`); +- `ids`: the readable id and the machine id of each object, source and system of the view that is + in the project; +- `marks`: the times and sizes of the files at the last look of the watcher, `src` those of `src/` + when `base` was taken, `changed_at` the time the files last changed (`nothing`: settled); +- `handled`: the stamp of the files that the view last reacted to, `failed` whether that failed + (e.g. a syntax error, shown in the status line), `conflicts` the names of the objects changed + both in the view and in the files, and `theirs` the project of the files of that conflict; +- `timer`: the watcher. """ Base.@kwdef mutable struct _ProjectSync + base::Any = nothing stamp::Union{Nothing, String} = nothing - code::Union{Nothing, String} = nothing - pending::Union{Nothing, String} = nothing + ids::IdDict{Any, Tuple{Symbol, String}} = IdDict{Any, Tuple{Symbol, String}}() + marks::Vector{Tuple{String, Float64, Int}} = Tuple{String, Float64, Int}[] + src::Vector{Tuple{String, Float64, Int}} = Tuple{String, Float64, Int}[] + changed_at::Union{Nothing, Float64} = nothing handled::Union{Nothing, String} = nothing + failed::Bool = false + conflicts::Vector{String} = String[] + theirs::Any = nothing timer::Union{Nothing, Timer} = nothing end diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl index 045d2ca..c256961 100644 --- a/test/TestLiveProject.jl +++ b/test/TestLiveProject.jl @@ -49,13 +49,13 @@ const GUI = BeamletOpticsGUI obj = Core.eval(M, e.code) pose0 = BMOP.pose_of(obj) BMOP.apply_pose!(obj, e.pos, e.rot) - push!(entries, BMOP.Entry(e.kind, e.name, e.code, obj; e.pos, e.rot, pose0, e.meta)) + push!(entries, BMOP.Entry(e.kind, e.name, e.code, obj; e.pos, e.rot, pose0, e.meta, e.label, e.uid)) end byname = Dict(e.name => e.object for e in entries) systems = map(p.systems) do s objs = Any[byname[n] for n in s.members] sys = s.static ? StaticSystem(objs) : isempty(objs) ? System() : System(objs) - BMOP.SystemEntry(s.name, s.members, s.sources; s.static, object = sys) + BMOP.SystemEntry(s.name, s.members, s.sources; s.static, object = sys, s.label, s.uid) end return BMOP.Project(p.path, p.name; entries, systems) end @@ -90,7 +90,9 @@ const GUI = BeamletOpticsGUI b2 = b2.obj, b3 = b3.obj) end - _byname(gui) = Dict(Symbol(l) => x for (x, l) in gui.labels) + # The objects, sources and systems of a view by their readable ids, see `_ProjectSync` + _byid(gui) = Dict(first(v) => x for (x, v) in gui.sync.ids) + _prefix(x) = BMOP.id_prefix(x) @testset "collect and open, $layout" for layout in (:compact, :app) GUI._CLIPBOARD[] = nothing @@ -113,13 +115,21 @@ const GUI = BeamletOpticsGUI names = parts.names @test isempty(parts.skipped) @test p.path == abspath(dir) && Base.isidentifier(p.name) - @test [e.name for e in p.entries][[1, 3, 4, 5, 6]] == [:L1, :spare, :B1, :laser_2, :B3] + # readable ids by the kind of each, the labels of the view as labels (not if they are the id) + @test [e.name for e in p.entries] == + [:L1, Symbol(_prefix(t.prism), 1), Symbol("M", _prefix(t.prism) == "M" ? 2 : 1), :B1, :B2, :B3] + @test [e.label for e in p.entries] == [nothing, nothing, "spare", nothing, "laser 2", nothing] @test [e.kind for e in p.entries] == [:component, :component, :component, :source, :source, :source] - @test [s.name for s in p.systems] == [:Transmitter, :Receiver] + @test [s.name for s in p.systems] == [:S1, :S2] + @test [s.label for s in p.systems] == ["Transmitter", "Receiver"] @test p.systems[1].members == [names[t.lens]] && p.systems[1].sources == [:B1] - @test p.systems[2].members == [names[t.lens], names[t.prism]] && p.systems[2].sources == [:laser_2] + @test p.systems[2].members == [names[t.lens], names[t.prism]] && p.systems[2].sources == [:B2] @test all(!s.static for s in p.systems) - # names are free variable names + # a uid each, all different + uids = [x.uid for x in [p.entries; p.systems]] + @test all(u -> u isa String && length(u) == 36, uids) && allunique(uids) + @test parts.ids[t.lens] == (:L1, p.entries[1].uid) + # ids are free variable names @test all(e -> Base.isidentifier(String(e.name)), p.entries) e_lens = p.entries[1] @test e_lens.meta == [:catalog => ("Thin lens", ["80", "-60", "30", "N-SF11"])] @@ -129,16 +139,23 @@ const GUI = BeamletOpticsGUI @test isnothing(p.entries[4].rot) GUI._write_view_toml(gui, p, names) view = TOML.parsefile(joinpath(dir, "view.toml")) - @test view["beams"]["laser_2"]["color"] == _hex(:orange) - @test view["beams"]["laser_2"]["alpha"] == 0.5 && view["beams"]["laser_2"]["linewidth"] == 3.0 + @test view["beams"]["B2"]["color"] == _hex(:orange) + @test view["beams"]["B2"]["alpha"] == 0.5 && view["beams"]["B2"]["linewidth"] == 3.0 @test view["beams_off"] == ["B1"] && view["snap"] == "pose" # open: a project as `load_project` returns it p2 = _reload(p) gui2 = GUI._open_from(p2; layout) @test gui2.project == abspath(dir) - by = _byname(gui2) - @test Set([e.name for e in p.entries]) ∪ Set([:Transmitter, :Receiver]) ⊆ Set(keys(by)) + by = _byid(gui2) + @test Set(keys(by)) == Set([e.name for e in p.entries]) ∪ Set([:S1, :S2]) + # the ids and uids of the files; the labels of the files, else the ids + for e in [p2.entries; p2.systems] + @test gui2.sync.ids[e.object] == (e.name, e.uid) + @test gui2.labels[e.object] == something(e.label, String(e.name)) + end + @test gui2.labels[by[:S1]] == "Transmitter" && gui2.labels[by[:B2]] == "laser 2" + @test gui2.sync.base === p2 old = Dict(names[x] => x for x in keys(names)) for e in p2.entries a, b = old[e.name], e.object @@ -146,18 +163,19 @@ const GUI = BeamletOpticsGUI @test position(b) == position(a) @test maximum(abs, GUI._pose(b)[2] - GUI._pose(a)[2]) < 1e-12 end - S1, S2 = by[:Transmitter], by[:Receiver] + S1, S2 = by[:S1], by[:S2] @test GUI._systems(gui2) == [S1, S2] lens2 = by[names[t.lens]] @test _in(lens2, S1.objects) && _in(lens2, S2.objects) && length(S2.objects) == 2 - @test GUI._sources_of(gui2, S1) == [by[:B1]] && GUI._sources_of(gui2, S2) == [by[:laser_2]] + @test GUI._sources_of(gui2, S1) == [by[:B1]] && GUI._sources_of(gui2, S2) == [by[:B2]] @test by[:B3] in gui2.beams.unassigned - @test _in(by[:spare], GUI.rendered(gui2.extras).objects) + spare = by[names[t.free]] + @test _in(spare, GUI.rendered(gui2.extras).objects) @test isempty(gui2.controls.undo_stack) && isempty(gui2.controls.redo_stack) # view.toml - @test _hex(gui2.beams.kwargs[by[:laser_2]].color) == _hex(:orange) - @test GUI._beam_opacity(gui2, by[:laser_2]) == 0.5 - @test GUI._beam_linewidth(gui2, by[:laser_2]) == 3.0 + @test _hex(gui2.beams.kwargs[by[:B2]].color) == _hex(:orange) + @test GUI._beam_opacity(gui2, by[:B2]) == 0.5 + @test GUI._beam_linewidth(gui2, by[:B2]) == 3.0 @test !GUI._beam_on(gui2, by[:B1]) @test length(gui2.clip.planes) == 1 && collect(gui2.clip.planes[1].pos) == [0.0, 0.05, 0.0] @test gui2.clip.beams @@ -173,12 +191,13 @@ const GUI = BeamletOpticsGUI for (e2, e3) in zip(p2.entries, p3.entries) @test e2.name === e3.name && isequal(e2.pos, e3.pos) && isequal(e2.rot, e3.rot) @test e2.meta == e3.meta && e2.code == e3.code + @test e2.label == e3.label && e2.uid == e3.uid end # a moved one gets its new pose - translate3d!(by[:spare], [0.0, 0.0, 0.01]) + translate3d!(spare, [0.0, 0.0, 0.01]) p4 = GUI._collect_project(gui2, dir) - e = only(e for e in p4.entries if e.name === :spare) - @test e.pos == Tuple(Float64.(position(by[:spare]))) + e = only(e for e in p4.entries if e.object === spare) + @test e.pos == Tuple(Float64.(position(spare))) # a loaded catalog lens can be edited and copied @test GUI._editable(gui2, lens2) @@ -190,11 +209,20 @@ const GUI = BeamletOpticsGUI new = only(o for o in S1.objects) @test new !== lens2 && _in(new, S2.objects) && GUI._editable(gui2, new) @test GUI._label(gui2, new) == String(names[t.lens]) + # ... keeps the id and the uid of the one it replaced ... + lens_id = (names[t.lens], p2.entries[1].uid) + @test gui2.sync.ids[new] == lens_id && !haskey(gui2.sync.ids, lens2) # ... and is saved with its new code p5 = GUI._collect_project(gui2, dir) e = only(e for e in p5.entries if e.name === names[t.lens]) + @test e.object === new && e.uid == lens_id[2] @test last(only(e.meta))[2][1] == "120" @test position(new) ≈ position(lens2) atol = 1e-12 + # undo gives the id back to the old one, redo to the new one + GUI._undo!(gui2.controls) + @test gui2.sync.ids[lens2] == lens_id && !haskey(gui2.sync.ids, new) + GUI._redo!(gui2.controls) + @test gui2.sync.ids[new] == lens_id && !haskey(gui2.sync.ids, lens2) close(gui2) close(gui) end @@ -274,8 +302,9 @@ const GUI = BeamletOpticsGUI rm(joinpath(dir, "system.jl")) _ctrl!(gui, Keyboard.s) @test calls[] == 2 && isfile(joinpath(dir, "system.jl")) - # the names became the labels - @test Base.isidentifier(gui.labels[t.prism]) && gui.labels[t.b2] == "laser_2" + # the ids are kept, the labels stay as they are + @test first(gui.sync.ids[t.b2]) === :B2 && gui.labels[t.b2] == "laser 2" + @test !haskey(gui.labels, t.prism) # "Save as" always asks; the name of an existing folder is refused saved[] = joinpath(parent, "Laser bench.bmo") _ctrl_shift!(gui, Keyboard.s) @@ -295,6 +324,9 @@ const GUI = BeamletOpticsGUI _wait() copy = joinpath(parent, "copy") @test calls[] == 5 && gui.project == copy && isfile(joinpath(copy, "copy.bmo")) + @test gui.status.text[] == "project saved as $copy" + # the copy keeps the module of the project + @test load_project(copy).name == "Laser_bench" && isfile(joinpath(copy, "src", "Laser_bench.jl")) rm(joinpath(copy, "system.jl")) _ctrl!(gui, Keyboard.s) @test calls[] == 5 && isfile(joinpath(copy, "system.jl")) @@ -340,81 +372,127 @@ const GUI = BeamletOpticsGUI @test isnothing(GUI._save_target(joinpath(parent, ".bmo")).dir) end - @testset "files changed outside" begin + @testset "ids, labels and uids across saves" begin dir = mktempdir() t = _setup(:compact) gui = t.gui save_project(gui, dir) - system = joinpath(dir, "system.jl") - check!(g) = GUI._check_files!(g; const_kw...) - # an agent appends a line (a function of its own code), the GUI saves in the meantime - agent_line = "@component L9 = extra_lens(0.2) pos = (0.0, 0.3, 0.0)\n" - name = BeamletOpticsProjects.project_name(dir) - own = joinpath(dir, "src", name * ".jl") - @test check!(gui).status === :none + p = load_project(dir) + text = read(joinpath(dir, "system.jl"), String) + ids = Dict(e.name => e.uid for e in [p.entries; p.systems]) + @test all(u -> u isa String, values(ids)) + @test occursin("label = \"spare\"", text) && occursin("label = \"Transmitter\"", text) + # the uid is the last keyword of a line + @test all(l -> occursin(r"uid = \"[0-9a-f-]{36}\"$", l), filter(startswith("@"), split(text, '\n'))) + # the view knows them + known = Dict(first(v) => last(v) for (x, v) in gui.sync.ids) + @test all(known[n] == u for (n, u) in ids) + # renaming in the view: a label, the id and the uid stay + GUI._rename_object!(gui, t.lens, "Kollimator") + GUI._rename_system!(gui, t.s2, "Empfänger") save_project(gui) - @test check!(gui).status === :none - # only the files changed: reloaded, after the second look, with the view state of the window - set_view(gui.ax, [0.4, -0.3, 0.2], [0.0, 0.1, 0.0], [0.0, 0.0, 1.0]) - cam = GUI._current_view(gui) - GUI._set_beam_opacity!(gui, t.b2, 0.25) + p2 = load_project(dir) + e = only(e for e in p2.entries if e.name === :L1) + @test e.label == "Kollimator" && e.uid == ids[:L1] + s2 = only(s for s in p2.systems if s.name === :S2) + @test s2.label == "Empfänger" && s2.uid == ids[:S2] + @test Dict(e.name => e.uid for e in [p2.entries; p2.systems]) == ids + # a new object gets the next id of its kind and a new uid, which stay + c = _built("Thin lens") + add_component!(gui, c.obj; system = t.s1, origin = c.origin, select = false) + save_project(gui) + p3 = load_project(dir) + e3 = only(e for e in p3.entries if !haskey(ids, e.name)) + @test e3.name === :L2 && isnothing(e3.label) && !(e3.uid in values(ids)) + @test gui.sync.ids[c.obj] == (:L2, e3.uid) + save_project(gui) + @test only(e for e in load_project(dir).entries if e.name === :L2).uid == e3.uid + # opening shows the labels, else the ids + gui2 = open_project(dir; display = false, const_kw...) + by = _byid(gui2) + @test gui2.labels[by[:L1]] == "Kollimator" && gui2.labels[by[:L2]] == "L2" + @test gui2.labels[by[:S2]] == "Empfänger" && gui2.labels[by[:B1]] == "B1" + close(gui2) + # an id that the view knows is never given again, also of an object later in the order + gui.sync.ids[t.free] = (:L7, gui.sync.ids[t.free][2]) + c2 = _built("Thin lens") + add_component!(gui, c2.obj; system = t.s1, origin = c2.origin, select = false) + parts = GUI._project_parts(gui, dir) + @test parts.names[c2.obj] === :L8 && parts.names[t.free] === :L7 + @test parts.ids[c2.obj][2] != parts.ids[c.obj][2] + close(gui) + end + + @testset "file order and lines of an agent" begin + dir = mktempdir() + t = _setup(:compact) + save_project(t.gui, dir) + close(t.gui) + file = joinpath(dir, "system.jl") + lines = split(chomp(read(file, String)), '\n') + comps = filter(startswith("@component"), lines) + srcs = filter(startswith("@source"), lines) + systems = filter(startswith("@system"), lines) + # an agent reorders the lines and adds a lens and a mirror without uid + agent = ["@component L5 = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) pos = (0.0, 0.3, 0.0) label = \"agent lens\"", + "@component M9 = RoundPlanoMirror(0.0254, 0.005)"] + order = [lines[1]; reverse(comps); agent; reverse(srcs); reverse(systems)] + write(file, join(order, '\n') * '\n') + gui = open_project(dir; display = false, const_kw...) + by = _byid(gui) + @test gui.labels[by[:L5]] == "agent lens" && gui.labels[by[:M9]] == "M9" + # a lens added in the view: after L5, the largest id of the lenses + c = _built("Thin lens") + add_component!(gui, c.obj; system = :none, origin = c.origin, select = false) + save_project(gui) + saved = split(chomp(read(file, String)), '\n') + name(l) = match(r"^@\w+ (\w+) =", l).captures[1] + @test name.(filter(startswith("@component"), saved)) == + [name.(reverse(comps)); "L5"; "M9"; "L6"] + @test name.(filter(startswith("@source"), saved)) == name.(reverse(srcs)) + @test name.(filter(startswith("@system"), saved)) == name.(reverse(systems)) + # the lines of the agent got a uid, the others kept theirs + p = load_project(dir) + @test all(e -> !isnothing(e.uid), p.entries) + old = Dict(name(l) => match(r"uid = \"(.*)\"", l).captures[1] for l in [comps; srcs; systems]) + @test all(e -> !haskey(old, String(e.name)) || old[String(e.name)] == e.uid, p.entries) + @test gui.sync.ids[by[:L5]] == (:L5, only(e for e in p.entries if e.name === :L5).uid) + close(gui) + end + + @testset "save as" begin + dir = mktempdir() + t = _setup(:compact) + save_project(t.gui, dir) + close(t.gui) + name = BMOP.project_name(dir) + own = joinpath(dir, "src", name * ".jl") write(own, "module $name\nusing BeamletOptics\nexport extra_lens\n" * "extra_lens(f) = SphericalLens(f, -f, 0.004, 0.0254, 1.5)\nend\n") - write(system, read(system, String) * agent_line) - r = check!(gui) - @test r.status === :pending && r.gui === gui - r = check!(gui) - @test r.status === :reloaded && r.gui !== gui - gui2 = r.gui - @test gui2.project == dir && haskey(_byname(gui2), :L9) - @test all(abs.(reduce(vcat, GUI._current_view(gui2)) .- reduce(vcat, cam)) .< 1e-6) - b2 = _byname(gui2)[:laser_2] - @test GUI._snapshot(gui2, b2).kwargs.alpha == 0.25 - @test check!(gui2).status === :none - # the setup changed here and there: the card, once; Ctrl+S does not overwrite - translate3d!(gui2, _byname(gui2)[:spare], [0.0, 0.0, 0.01]) - agent_text = read(system, String) * "@component L10 = extra_lens(0.3)\n" - write(system, agent_text) - @test check!(gui2).status === :pending - @test check!(gui2).status === :conflict - @test GUI._info_card(gui2).obj isa GUI._ProjectConflict - @test occursin("choose on the card", gui2.status.text[]) - @test check!(gui2).status === :none && check!(gui2).status === :none - GUI._release_info!(gui2, GUI._ProjectConflict) - GUI._save_to!(gui2, dir) - @test read(system, String) == agent_text - @test GUI._info_card(gui2).obj isa GUI._ProjectConflict - @test_throws BeamletOpticsProjects.ProjectChanged save_project(gui2) - # "Overwrite": the setup of the window - GUI._resolve_conflict!(gui2, :overwrite) - @test isnothing(GUI._info_card(gui2)) || !(GUI._info_card(gui2).obj isa GUI._ProjectConflict) - @test !occursin("L10", read(system, String)) && check!(gui2).status === :none - # a syntax error while the agent writes: the window stays, the line is named; then fixed - good = read(system, String) - write(system, good * "@component L11 = extra_lens(\n") - @test check!(gui2).status === :pending - r = check!(gui2) - @test r.status === :failed && r.gui === gui2 - @test occursin(r"project not reloaded: .*system\.jl:\d+", gui2.status.text[]) - write(system, good * "@component L11 = extra_lens(0.4)\n") - @test check!(gui2).status === :pending - r = check!(gui2) - @test r.status === :reloaded && haskey(_byname(r.gui), :L11) - gui3 = r.gui - # "Load from disk" through _reload!: the change here is lost - translate3d!(gui3, _byname(gui3)[:spare], [0.0, 0.0, 0.01]) - moved = BeamletOptics.position(_byname(gui3)[:spare]) - gui4 = GUI._reload!(gui3; display = false, const_kw...) - @test BeamletOptics.position(_byname(gui4)[:spare]) == moved .- [0.0, 0.0, 0.01] - @test !GUI._setup_changed(gui4) - # Ctrl+R without a project, and close stops the watcher - gui5 = _live_view(System()) - GUI._reload_key!(gui5) - @test startswith(gui5.status.text[], "no project to reload") - close(gui5) - gui4.sync.timer = Timer(100) - close(gui4) - @test isnothing(gui4.sync.timer) + write(joinpath(dir, "system.jl"), read(joinpath(dir, "system.jl"), String) * + "@component L9 = extra_lens(0.2) pos = (0.0, 0.3, 0.0)\n") + gui = open_project(dir; display = false, const_kw...) + screen = GLMakie.Screen(visible = false) + display(screen, gui.fig) + title() = unsafe_string(ccall((:glfwGetWindowTitle, GLMakie.GLFW.libglfw), Cstring, + (GLMakie.GLFW.Window,), screen.glscreen)) + target = joinpath(mktempdir(), "Bench copy") + save_project(gui, target) + @test gui.project == abspath(target) + @test gui.status.text[] == "project saved as $(abspath(target))" + @test title() == "Bench copy" + # the own code is copied, the module kept: the copy loads + @test read(joinpath(target, "src", name * ".jl"), String) == read(own, String) + p = load_project(target) + @test p.name == name && any(e -> e.name === :L9, p.entries) + # Ctrl+S saves to the copy, the original stays + before = read(joinpath(dir, "system.jl"), String) + translate3d!(_byid(gui)[:L9], [0.0, 0.0, 0.01]) + save_project(gui) + @test read(joinpath(dir, "system.jl"), String) == before + @test position(load_project(target)[:L9]) ≈ [0.0, 0.3, 0.01] + close(screen) + close(gui) end @testset "end-to-end" begin diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl new file mode 100644 index 0000000..2f3d4fe --- /dev/null +++ b/test/TestLiveProjectSync.jl @@ -0,0 +1,201 @@ +module TestLiveProjectSync + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const LENS = "SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5)" +const START = """ + @component L1 = $LENS pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, D1] sources = [B1] + """ + +"""A project with `START`, opened (not shown) and saved once, so that every line has a uid.""" +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + return gui, dir +end + +system(dir) = joinpath(dir, "system.jl") +edit!(f, dir) = write(system(dir), f(read(system(dir), String))) + +"""Two looks of the watcher: the files changed, then settled.""" +function tick!(gui) + t = time() + first = GUI._check_files!(gui; now = t) + return first === :pending ? GUI._check_files!(gui; now = t + 1) : first +end + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +"""Replaces the line of `id` in `text` by `f(line)`.""" +function line!(f, text, id) + lines = split(text, "\n") + i = findfirst(l -> occursin(Regex("^@(component|source|system) $id = "), l), lines) + lines[i] = f(lines[i]) + return join(lines, "\n") +end + +@testset "Live changes of the files" begin + gui, dir = opened() + ctrl = gui.controls + undo = length(ctrl.undo_stack) + + @testset "moved, in place" begin + l1 = obj(gui, :L1) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.12, 0.01) rot = (0.0, 0.0, 30.0)"), text, "L1"), dir) + @test tick!(gui) === :applied + @test obj(gui, :L1) === l1 + @test BeamletOptics.position(l1) ≈ [0.0, 0.12, 0.01] atol = 1e-15 + @test occursin("moved L1", gui.status.text[]) + # The window writes the same line again + @test occursin("pos = (0.0, 0.12, 0.01) rot = (0.0, 0.0, 30.0)", + BeamletOpticsProjects.system_code(GUI._collect_project(gui, dir))) + end + + @testset "added into a system" begin + edit!(text -> line!(l -> replace(l, "[L1, M1, D1]" => "[L1, M1, D1, T1]"), + text * "@component T1 = $LENS pos = (0.0, 0.4, 0.0)\n", "S1"), dir) + @test tick!(gui) === :applied + t1 = obj(gui, :T1) + @test !isnothing(t1) && any(o -> o === t1, only(GUI._systems(gui)).objects) + @test BeamletOptics.position(t1) == [0.0, 0.4, 0.0] + end + + @testset "rebuilt, removed" begin + d1 = obj(gui, :D1) + uid = gui.sync.ids[d1][2] + edit!(text -> line!(l -> replace(l, "Detector(0.02)" => "Detector(0.03)"), text, "D1"), dir) + @test tick!(gui) === :applied + d1b = obj(gui, :D1) + @test d1b !== d1 && gui.sync.ids[d1b][2] == uid + @test any(o -> o === d1b, only(GUI._systems(gui)).objects) + @test BeamletOptics.position(d1b) == [0.0, 0.3, 0.0] + m1 = obj(gui, :M1) + edit!(dir) do text + text = join(filter(l -> !startswith(l, "@component M1 = "), split(text, "\n")), "\n") + return line!(l -> replace(l, "M1, " => ""), text, "S1") + end + @test tick!(gui) === :applied + @test isnothing(obj(gui, :M1)) && !any(o -> o === m1, only(GUI._systems(gui)).objects) + end + + @testset "renamed and relabeled" begin + l1 = obj(gui, :L1) + edit!(dir) do text + text = line!(l -> replace(l, "@component L1 = " => "@component L7 = ") , text, "L1") + text = line!(l -> replace(l, "uid = " => "label = \"Kollimator\" uid = "), text, "L7") + return line!(l -> replace(l, "[L1, " => "[L7, "), text, "S1") + end + @test tick!(gui) === :applied + @test obj(gui, :L7) === l1 && gui.labels[l1] == "Kollimator" + end + + @testset "own code" begin + own = joinpath(dir, "src", "Bench.jl") + write(own, "module Bench\nusing BeamletOptics\nexport flat_lens\nflat_lens(r) = SphericalLens(r, Inf, 0.004, 0.0254, 1.5)\nend\n") + edit!(text -> text * "@component Q1 = flat_lens(0.1) pos = (0.0, 0.5, 0.0)\n", dir) + @test tick!(gui) === :applied + q1 = obj(gui, :Q1) + @test !isnothing(q1) + write(own, "module Bench\nusing BeamletOptics\nexport flat_lens\nflat_lens(r) = SphericalLens(r, -r, 0.004, 0.0254, 1.5)\nend\n") + @test tick!(gui) === :applied + @test obj(gui, :Q1) !== q1 && BeamletOptics.position(obj(gui, :Q1)) == [0.0, 0.5, 0.0] + end + + @testset "merged with changes in the window" begin + t1 = obj(gui, :T1) + translate3d!(gui, t1, [0.0, 0.0, 0.02]) + mine = BeamletOptics.position(t1) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.3, 0.0)" => "pos = (0.0, 0.31, 0.0)"), text, "D1"), dir) + @test tick!(gui) === :applied + @test BeamletOptics.position(t1) == mine + @test BeamletOptics.position(obj(gui, :D1)) == [0.0, 0.31, 0.0] + # Ctrl+S writes both, without a conflict + save_project(gui) + text = read(system(dir), String) + @test occursin("pos = (0.0, 0.31, 0.0)", text) && occursin(string(mine[3]), text) + @test tick!(gui) === :none + end + + @testset "conflict" begin + d1 = obj(gui, :D1) + translate3d!(gui, d1, [0.01, 0.0, 0.0]) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.31, 0.0)" => "pos = (0.0, 0.32, 0.0)"), text, "D1"), dir) + @test tick!(gui) === :conflict + card = GUI._info_card(gui) + @test card.obj isa GUI._ProjectConflict && card.obj.names == ["D1"] + @test BeamletOptics.position(d1) == [0.01, 0.31, 0.0] + GUI._resolve_conflict!(gui, :theirs) + @test BeamletOptics.position(obj(gui, :D1)) == [0.0, 0.32, 0.0] + # Keep mine: the next save writes it + translate3d!(gui, obj(gui, :D1), [0.0, 0.0, 0.01]) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.32, 0.0)" => "pos = (0.0, 0.33, 0.0)"), text, "D1"), dir) + @test tick!(gui) === :conflict + GUI._resolve_conflict!(gui, :mine) + save_project(gui) + @test occursin("pos = (0.0, 0.32, 0.01)", read(system(dir), String)) + end + + @testset "syntax error, then fixed and back" begin + good = read(system(dir), String) + edit!(text -> text * "@component Z1 = $(LENS[1:end-1])\n", dir) + @test tick!(gui) === :failed + @test occursin(r"project not reloaded: system\.jl:\d+:\d+: ", gui.status.text[]) + write(system(dir), good) + @test tick!(gui) === :none + @test gui.status.text[] == "project files are as in the window" + end + + @testset "Ctrl+S after a change of the files that the watcher did not see yet" begin + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.32, 0.01)" => "pos = (0.0, 0.34, 0.01)"), text, "D1"), dir) + translate3d!(gui, obj(gui, :T1), [0.5, 0.0, 0.0]) + GUI._save_to!(gui, dir) + text = read(system(dir), String) + @test occursin("pos = (0.0, 0.34, 0.01)", text) && occursin("pos = (0.5, 0.4, 0.02)", text) + @test startswith(gui.status.text[], "project saved") + end + + @testset "Ctrl+R loads the files in place" begin + t1 = obj(gui, :T1) + translate3d!(gui, t1, [0.05, 0.0, 0.0]) + GUI._reload_key!(gui) + @test obj(gui, :T1) === t1 && BeamletOptics.position(t1)[1] == 0.5 + end + + @testset "no undo entries of the files" begin + n = length(ctrl.undo_stack) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.5, 0.0)" => "pos = (0.0, 0.55, 0.0)"), text, "Q1"), dir) + @test tick!(gui) === :applied + @test length(ctrl.undo_stack) == n + end + + @testset "time of a merge" begin + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.55, 0.0)" => "pos = (0.0, 0.56, 0.0)"), text, "Q1"), dir) + t = time() + GUI._check_files!(gui; now = t) + elapsed = @elapsed GUI._check_files!(gui; now = t + 1) + @info "a merge of the files took $(round(elapsed * 1000; digits = 1)) ms" + @test elapsed < 1.0 + end + close(gui) +end + +end diff --git a/test/runtests.jl b/test/runtests.jl index f4c693f..6960df9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -39,6 +39,7 @@ include(joinpath(@__DIR__, "TestLiveHistory.jl")) include(joinpath(@__DIR__, "TestLiveEdit.jl")) include(joinpath(@__DIR__, "TestLiveCopy.jl")) include(joinpath(@__DIR__, "TestLiveProject.jl")) +include(joinpath(@__DIR__, "TestLiveProjectSync.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) From 393a4de35efe1a90cf43da620569ea634ab91b0a Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Tue, 27 Oct 2026 20:38:27 +0100 Subject: [PATCH 05/47] The keys of the camera do nothing while Ctrl or Cmd is held Ctrl+S, Ctrl+O and Ctrl+R moved the camera back, out or up by the frames that S, O or R was held. Co-Authored-By: Claude Opus 5.5 --- src/LiveCamera.jl | 16 ++++++++++++++++ test/TestLiveView.jl | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/LiveCamera.jl b/src/LiveCamera.jl index ac606a2..075a493 100644 --- a/src/LiveCamera.jl +++ b/src/LiveCamera.jl @@ -524,6 +524,7 @@ Moving along the view direction does not change an orthographic view at all, so function _connect_projection!(gui::LiveView, orthographic::Bool) cam = cameracontrols(gui.ax.scene) settings, controls = cam.settings, cam.controls + _without_ctrl!(controls) perspective_depth = (settings.clipping_mode[], cam.near[], cam.far[]) walk_keys = (controls.forward_key[], controls.backward_key[], controls.zoom_in_key[], controls.zoom_out_key[]) @@ -581,6 +582,21 @@ function _connect_projection!(gui::LiveView, orthographic::Bool) return nothing end +""" +The keys that move, turn and zoom the camera (`forward_key` etc. of `Camera3D`) only act while no +`Ctrl` or `Cmd` is held: otherwise `Ctrl`+`S`, `Ctrl`+`O` and `Ctrl`+`R` of the window would also +move the camera, by the frames that `S`, `O` or `R` is held. +""" +function _without_ctrl!(controls) + ctrl = reduce(|, _CTRL_OR_CMD_KEYS) + for k in keys(controls) + (endswith(String(k), "_key") && !startswith(String(k), "fix_")) || continue + binding = Makie.to_value(controls[k]) + binding isa Bool || (controls[k][] = binding & !ctrl) + end + return nothing +end + """Returns the center and radius of the bounding sphere of the plots of the 3D view of the `gui`.""" function _scene_bounds(gui::LiveView) bb = Makie.data_limits(gui.ax.scene) diff --git a/test/TestLiveView.jl b/test/TestLiveView.jl index 74f44d8..9725ae7 100644 --- a/test/TestLiveView.jl +++ b/test/TestLiveView.jl @@ -1120,8 +1120,21 @@ _points(h) = only(render_plots(h))[1][] @test (settings.clipping_mode[], cam.near[], cam.far[]) == perspective_depth shift, scale = _walk(Keyboard.w) @test norm(shift) > 0 && scale ≈ 1 - @test cam.controls.forward_key[] == Keyboard.w - @test cam.controls.zoom_in_key[] == Keyboard.u + @test GUI._keyboard_buttons(cam.controls.forward_key[]) == (Keyboard.w,) + @test GUI._keyboard_buttons(cam.controls.zoom_in_key[]) == (Keyboard.u,) + # Ctrl+S (save), Ctrl+O and Ctrl+R do not move the camera (the keys are held, without + # their events, which would open the dialogs) + held = events(scene).keyboardstate + for key in (Keyboard.s, Keyboard.o, Keyboard.r) + eye = cam.eyeposition[] + push!(held, Keyboard.left_control, key) + Makie.on_pulse(scene, cam, 0.1) + @test cam.eyeposition[] == eye + delete!(held, Keyboard.left_control) + Makie.on_pulse(scene, cam, 0.1) + @test cam.eyeposition[] != eye + delete!(held, key) + end close(gui) m, pd = _fixture() From 2c83a9b2625b787ad7b3eeb6e480d8317602aee0 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Tue, 27 Oct 2026 20:38:27 +0100 Subject: [PATCH 06/47] Projects prototype: the agent sees what the person does in the window - Autosave: the window writes every finished action to system.jl (after 0.5 s, not during a drag, a glide or a conflict); files that do not load get .bmo/window-backup.jl; closing a watched window saves the last change - .bmo/session.md: the selection, open conflicts, load errors and the recent changes of the person, for the agent (shown with every prompt by the hook of the project) - Ctrl+S saves a version (a git commit via BeamletOpticsProjects) on a card with the summary of what changed - Merge: a conflict stays until the person decides; an agent that writes from an older read over a change of the person is a conflict (written states of the window); same-size writes are found by the stamp of the contents - Precompile step of a project session Co-Authored-By: Claude Opus 5.5 --- ext/BeamletOpticsGUIGLMakieExt/session.jl | 112 ++++++- src/BeamletOpticsGUI.jl | 1 + src/LiveProject.jl | 225 +++++++++++-- src/LiveProjectSession.jl | 231 +++++++++++++ src/LiveProjectSync.jl | 383 ++++++++++++++++++---- src/LiveView.jl | 21 +- test/TestLiveProject.jl | 146 ++++++++- test/TestLiveProjectSession.jl | 166 ++++++++++ test/TestLiveProjectSync.jl | 353 ++++++++++++++++++++ test/TestPrecompileSession.jl | 19 ++ test/runtests.jl | 1 + 11 files changed, 1574 insertions(+), 84 deletions(-) create mode 100644 src/LiveProjectSession.jl create mode 100644 test/TestLiveProjectSession.jl diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index c95b2ac..59b5fe0 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -718,11 +718,120 @@ function _catalog_session(step, entries = component_catalog()) return gui, screen end +const _PROJECT_SYSTEM = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) pos = (0.0, 0.05, 0.0) + @component M1 = RoundPlanoMirror(0.025, 0.005) pos = (0.0, 0.1, 0.0) rot = (0.0, 0.0, 45.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1] sources = [B1] + """ + +# The position of `obj` of a project in the record of the `gui`, which a merge updates after the +# glide of `obj` (see `_update_record!`) +_project_pos(gui, @nospecialize(obj)) = collect(Float64, gui.components.origin[obj].project.pos) + +""" + _project_session(step; dir = mktempdir()) -> (gui, screen) + +Opens a new project in the empty folder `dir`, with a repository of its own, in an invisible window +and works on it like a person with an agent: drags the lens, which the autosave writes to the +files; changes the files like an agent, which the window merges and glides; `Ctrl+S` with the card +of the version, closed by a typed message and `Enter`, by `Esc`, by the button "Cancel" and by the +button "Save version". The steps call the autosave and the watcher themselves, with times that do +not depend on how long a step takes: the watcher of the window never starts, and the step of the +agent waits until the mirror has glided. Returns the live view and its screen, which the caller +closes. +""" +function _project_session(step; dir = mktempdir()) + file = joinpath(dir, "system.jl") + gui = step("project: open") do + # The versions go into this repository, not into one that encloses `dir`, e.g. a + # repository of the home folder that holds the temporary folders on Windows + mkpath(dir) + close(GUI.BMOP.LibGit2.init(dir)) + GUI.BMOP.new_project(dir; name = "Bench") + write(file, _PROJECT_SYSTEM) + g = open_project(dir; display = false) + # A closed timer in place of the watcher, such that `_start_watching!` does not start it + t = Timer(3600.0) + close(t) + g.sync.timer = t + g + end + isnothing(gui) && return nothing, nothing + screen = _screen() + display(screen, gui.fig) + frame!() = _frame!(gui, screen) + settle!() = _settle!(gui, screen) + settle!() + lens = only(o for (o, (id, _)) in gui.sync.ids if id === :L1) + mirror = only(o for (o, (id, _)) in gui.sync.ids if id === :M1) + # Drags the lens and lets the autosave write it to the files + function drag_lens!() + a = _px(gui, _pos(lens)) + _click!(gui, a) + _drag!(gui, a, a .+ (14, 4)) + settle!() + t = time() + GUI._autosave!(gui; now = t) + _check(GUI._autosave!(gui; now = t + 1) === :saved, "the autosave did not save the drag") + end + function ask_version!() + _key!(gui, Keyboard.s, Keyboard.left_control) + frame!() + _check(!isnothing(GUI._version_box(gui)), "Ctrl+S did not show the card of the version") + end + button(name) = _rect_center(GUI._card_widget(GUI._version_card(gui), name)) + step("project: drag the lens, autosave") do + drag_lens!() + end + step("project: an agent moves the mirror") do + write(file, replace(read(file, String), "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.12, 0.0)")) + t = time() + GUI._check_files!(gui; now = t) + _check(GUI._check_files!(gui; now = t + 1) === :applied, "the window did not merge the file") + # the mirror glides in a task of its own, which ends with its record + target = [0.0, 0.12, 0.0] + timedwait(() -> _project_pos(gui, mirror) ≈ target, 60.0; pollint = 0.01) + _check(_project_pos(gui, mirror) ≈ target && _pos(mirror) ≈ target, "the mirror did not glide") + settle!() + end + step("project: Ctrl+S, the card of the version, Enter") do + ask_version!() + foreach(char -> (_events(gui).unicode_input[] = char), " ok") + _key!(gui, Keyboard.enter) + frame!() + _check(length(GUI.BMOP.versions(dir)) == 2, "Enter did not save a version") + end + step("project: Ctrl+S, Esc") do + drag_lens!() + ask_version!() + _key!(gui, Keyboard.escape) + frame!() + _check(isnothing(GUI._version_card(gui)), "Esc did not close the card of the version") + _check(length(GUI.BMOP.versions(dir)) == 2, "Esc saved a version") + end + step("project: Ctrl+S, Cancel") do + ask_version!() + _click!(gui, button(:version_cancel)) + frame!() + _check(isnothing(GUI._version_card(gui)), "Cancel did not close the card of the version") + _check(length(GUI.BMOP.versions(dir)) == 2, "Cancel saved a version") + end + step("project: Ctrl+S, Save version") do + ask_version!() + _click!(gui, button(:version_save)) + frame!() + _check(isnothing(GUI._version_card(gui)), "Save version did not close the card of the version") + _check(length(GUI.BMOP.versions(dir)) == 3, "Save version did not save a version") + end + return gui, screen +end + """ _precompile_sessions(step) The sessions of the workload: `_session` in both layouts, with a beam and with a Gaussian beamlet, -and `_catalog_session`. Closes their windows. +`_catalog_session` and `_project_session`. Closes their windows. """ function _precompile_sessions(step) # Without an OpenGL context, e.g. on a headless machine, no window opens: the error is thrown @@ -734,6 +843,7 @@ function _precompile_sessions(step) _close!(_session(step, layout, fixture)...) end _close!(_catalog_session(step)...) + _close!(_project_session(step)...) return nothing end diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 4a8de9d..60aaca2 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -110,6 +110,7 @@ include("LiveCatalogWindow.jl") include("LiveCopy.jl") include("LiveProject.jl") include("LiveProjectSync.jl") +include("LiveProjectSession.jl") include("LiveDebug.jl") include("LiveDebugHUD.jl") include("LiveDebugSpheres.jl") diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 93cc950..53f911e 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -12,7 +12,7 @@ const _VIEW_TOML = "view.toml" # The keys of saving and opening, in the help of the controls, see `_help_sections` const _PROJECT_HELP = _HelpSection["Edit" => [ - _HelpEntry([_ctrl_cap(), "S"], "save the project"; combo = true), + _HelpEntry([_ctrl_cap(), "S"], "save a version"; combo = true), _HelpEntry([_ctrl_cap(), "Shift", "S"], "save as a new project with a name"; combo = true), _HelpEntry([_ctrl_cap(), "O"], "open a project (.bmo)"; combo = true), _HelpEntry([_ctrl_cap(), "R"], "reload the project from its files"; combo = true)]] @@ -517,7 +517,10 @@ Opens the project in the folder `path`, or of its project file `path` (`.b and shows it if `display` is `true`: its systems with their sources, the objects and sources without a system, their labels (else their ids) and how the view looked when it was saved (`view.toml`: camera, saved views, clip planes, table, the colors of the beams, ...). The `kwargs` -are those of [`live_view`](@ref) and win over `view.toml`. `Ctrl+S` saves it there again, see +are those of [`live_view`](@ref) and win over `view.toml`. While it is shown, the window writes +every finished change of the person to the files (autosave), applies the changes that others make +to them (e.g. an agent) and notes the selection and the recent changes of the person in +`.bmo/session.md`. `Ctrl+S` saves a version of the project (a git commit with a message), see [`save_project`](@ref). The components and sources from the catalog can be edited and copied. `Ctrl+O` or the tool "Open" of a window opens a project chosen in a dialog and closes the window. """ @@ -563,17 +566,25 @@ constructor is not known, e.g. one that the script gave to `live_view`, is not s the status line name it. Each object, source and system keeps its readable id (e.g. `L1`) and its machine id (uid) across saves; a new one gets the next free id of its kind and a new uid, and its label in the view, if it has one, is saved as its `label`. The entries stay in the order of the -file, new ones at the end of their kind. `dir` becomes the project of the view, which `Ctrl+S` -saves to, and the title of its window. `Ctrl+Shift+S` asks for a place and a name, see -`_save_as_key!`. +file, new ones at the end of their kind. `dir` becomes the project of the view, which the autosave +of its window and `Ctrl+S` save to, and the title of its window. `Ctrl+Shift+S` asks for a place and +a name, see `_save_as_key!`. + +A shown window saves itself: every finished change of the person is written to the files (see +`_autosave!`) and noted in `.bmo/session.md`. `Ctrl+S` writes what is still pending and then saves a +version of the project, a git commit whose message the card of the version asks for (see +`_save_key!`). Saving a view that has a project to another folder ("save as") copies the own code of the project (`src/`) there first, without overwriting a file, and keeps the name of its module, such that the -copy loads. +copy loads. A shown window notes in `.bmo/session.md` of the old folder that no window is open on +it any more. Throws `BeamletOpticsProjects.ProjectChanged` and saves nothing if the files of the project of the -view changed since it opened or saved it, e.g. by an agent, unless `force` is `true`; `Ctrl+S` shows -a card that offers to load them, to overwrite them or to save as, see `_ProjectConflict`. +view changed since it opened or saved it, e.g. by an agent, unless `force` is `true`. The autosave +and `Ctrl+S` merge such changes into the window first and save again; objects that were changed +differently in both places get a card with "Take theirs", "Keep mine" and "Save as…", see +`_ProjectConflict`. """ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) dir = abspath(dir) @@ -593,8 +604,21 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) _write_view_toml(gui, parts.project, parts.names) # The uids that the save gave are those of `parts.project` _apply_project_names!(gui, parts.project) + # A window that writes `.bmo/session.md` (see `_connect_session!`) leaves the old folder: its + # `session.md` says that no window is open, and its journal is not of the new project + connected = save_as && !isempty(gui.sync.session) + if connected + try + _session_closed!(gui) + catch e + e isa InterruptException && rethrow() + @warn "could not write session.md of $from" exception = e + end + end + save_as && empty!(gui.sync.journal) gui.project = dir _saved!(gui, parts.project) + connected && _connect_session!(gui) dir == from || _set_title!(gui, basename(dir)) msg = save_as ? "project saved as $dir" : "project saved to $dir" if !isempty(parts.skipped) @@ -620,19 +644,37 @@ Keys, tools and dialogs _message_line(e) = first(split(sprint(showerror, e), '\n')) """ -Saves the `gui` to `dir`; if the files changed meanwhile, the card of the conflict instead (see -`_ProjectConflict`), any other error is logged and shown in the status line. +Saves the `gui` to `dir`; if the files changed meanwhile, they are merged first (see `_sync!`) and +saved again if that left no conflict, else the card of the conflict is shown (see +`_ProjectConflict`); any other error, also of the second save, is logged and shown in the status +line. Whether the files hold the window afterwards: `false` after an error, a conflict or files +that do not load. """ function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) try save_project(gui, dir; force) catch e e isa InterruptException && rethrow() - e isa BMOP.ProjectChanged && return _show_conflict!(gui) - @error "save_project failed" exception = (e, catch_backtrace()) - gui.status.text[] = "project not saved: $(_message_line(e))" + e isa BMOP.ProjectChanged || return _save_failed!(gui, e) + gui.sync.changed_at = nothing + # Without gliding: the save right after must see the poses of the files + _sync!(gui; animate = false) === :applied || return false + (isempty(gui.sync.conflicts) && !gui.sync.failed) || return false + try + save_project(gui, dir; force) + catch e2 + e2 isa InterruptException && rethrow() + return _save_failed!(gui, e2) + end end - return nothing + return true +end + +# Logs the error `e` of saving the `gui` and shows it in the status line; returns `false` +function _save_failed!(gui::LiveView, e) + @error "save_project failed" exception = (e, catch_backtrace()) + gui.status.text[] = "project not saved: $(_message_line(e))" + return false end """ @@ -695,11 +737,148 @@ function _save_as_key!(gui::LiveView) end """ -`Ctrl+S` and the tool "Save" of the `gui`: saves to its project, or, without one, like "Save as" -(see `_save_as_key!`). +`Ctrl+S` and the tool "Save" of the `gui`: saves a version of its project. What the autosave has +not written yet is written to the files first, without waiting, and noted in `.bmo/session.md` (see +`_autosave!` with `force`); then the card of the version asks for its message, filled with what +changed since the last version (see `_ask_version!`), or the status line says that nothing changed. +Nothing is saved while a conflict is open (see `_ProjectConflict`), whose card is shown, or when the +flush ends in a conflict, in files that do not load or in an error. Without a project, like "Save +as" (see `_save_as_key!`), whose new project has its first version. """ function _save_key!(gui::LiveView) - isnothing(gui.project) ? _save_as_key!(gui) : _save_to!(gui, gui.project) + isnothing(gui.project) && return _save_as_key!(gui) + _conflict_open!(gui) && return nothing + _autosave!(gui; force = true) in (:none, :saved) || return nothing + summary = try + BMOP.version_summary(gui.project) + catch e + e isa InterruptException && rethrow() + @error "version_summary failed" exception = (e, catch_backtrace()) + gui.status.text[] = "no version saved: $(_message_line(e))" + return nothing + end + isempty(summary) ? (gui.status.text[] = "no changes since the last version") : _ask_version!(gui, summary) + return nothing +end + +""" +Whether a conflict of the `gui` waits for a decision on its card (see `_ProjectConflict`), for +`_save_key!`: the card is shown again if another one replaced it, and the status line asks to +decide on it first. +""" +function _conflict_open!(gui::LiveView) + s = gui.sync + c = _info_card(gui) + shown = !isnothing(c) && c.obj isa _ProjectConflict + (shown || !isempty(s.conflicts)) || return false + if !shown && !isnothing(s.theirs) + _show_conflict!(gui, s.conflicts) + c = _info_card(gui) + end + names = (!isnothing(c) && c.obj isa _ProjectConflict) ? c.obj.names : s.conflicts + gui.status.text[] = "decide on the card first: $(join(names, ", "))" + return true +end + +""" + _VersionCard + +The card of `Ctrl+S` that saves a version of the project (see `_save_key!`): a text box with the +message of the version and the buttons "Save version" and "Cancel"; `Enter` in the box saves, `Esc` +cancels, see `_save_version!` and `_cancel_version!`. `message` is what changed since the last +version when the card was shown, which `_ask_version!` puts in the box once (the refreshes of the +cards leave the box alone, it holds what the person typed). +""" +struct _VersionCard <: _InfoItem + point::Point3f + message::String +end + +_points(x::_VersionCard) = [x.point] +_title(::_VersionCard) = "Save a version" +_tree_kind(::_VersionCard) = :save +_keep_plots!(::LiveView, ::_VersionCard) = nothing +_keepable(::_VersionCard) = false +card_rows(::_VersionCard) = ( + CardRow(CardWidget(Textbox; name = :version_message, placeholder = "message of the version", + width = 280, halign = :left, + on = (gui, _, v) -> _save_version!(gui, v))), + CardRow( + CardWidget(Button; name = :version_save, label = "Save version", + on = (gui, _, _) -> _save_version!(gui)), + CardWidget(Button; name = :version_cancel, label = "Cancel", + on = (gui, _, _) -> _cancel_version!(gui)))) + +"""The card of the version of the `gui` if it is shown (see `_VersionCard`), else `nothing`.""" +function _version_card(gui::LiveView) + c = _info_card(gui) + return (isnothing(c) || !(c.obj isa _VersionCard)) ? nothing : c +end + +"""The text box of the message on the card of the version of the `gui`, or `nothing`.""" +function _version_box(gui::LiveView) + c = _version_card(gui) + return isnothing(c) ? nothing : _card_widget(c, :version_message) +end + +""" + _ask_version!(gui, message) + +Shows the card of a version of the project of the `gui` (see `_VersionCard`) with the `message` in +its text box, which has the focus with the caret at the end, such that typing adds to it: the keys +of the window are left to it meanwhile. +""" +function _ask_version!(gui::LiveView, message::AbstractString) + _show_info!(gui, _VersionCard(_error_anchor(gui), String(message))) + box = _version_box(gui) + if !isnothing(box) + _set_box!(box, String(message)) + Makie.focus!(box) + box.editor.cursors[] = [Makie.EditCursor(length(message))] + end + gui.status.text[] = "save a version: Enter saves, Esc cancels" + return nothing +end + +""" + _save_version!(gui, message = text of the box) + +Saves a version of the project of the `gui` with the `message` (see +`BeamletOpticsProjects.save_version`) and closes the card of the version; the result or the error +(e.g. changes outside the project that are staged in its repository) in the status line. A message +that is still the summary that the card showed is computed again (`version_summary`), since the +files may have changed while the card was open; a typed message is kept. +""" +function _save_version!(gui::LiveView, message = nothing) + box = _version_box(gui) + if isnothing(message) + message = isnothing(box) ? "" : box.displayed_string[] + end + message = String(strip(String(message))) + c = _version_card(gui) + shown = isnothing(c) ? nothing : strip(c.obj.message) + isnothing(box) || Makie.defocus!(box) + _release_info!(gui, _VersionCard) + isnothing(gui.project) && return nothing + try + message == shown && (message = String(strip(BMOP.version_summary(gui.project)))) + id = BMOP.save_version(gui.project; message) + gui.status.text[] = isnothing(id) ? "no changes since the last version" : "version saved: $message" + catch e + e isa InterruptException && rethrow() + @error "save_version failed" exception = (e, catch_backtrace()) + gui.status.text[] = "version not saved: $(_message_line(e))" + end + return nothing +end + +"""Closes the card of the version of the `gui` without saving a version, e.g. by `Esc`.""" +function _cancel_version!(gui::LiveView) + box = _version_box(gui) + isnothing(box) && return nothing + Makie.defocus!(box) + _release_info!(gui, _VersionCard) + gui.status.text[] = "no version saved" return nothing end @@ -731,7 +910,7 @@ end Connects `Ctrl+S`, `Ctrl+Shift+S` and `Ctrl+O` (`Cmd` on macOS) of the `gui` and the tools "Save", "Save as" and "Open", see `_save_key!`, `_save_as_key!` and `_open_key!`, like `_connect_copy!`: by the letters of the keys in the keyboard layout, before the camera, which takes `s` and `o`; left to -a focused text box, nothing in the spectator mode. +a focused text box, nothing in the spectator mode. `Esc` closes the card of a version. """ function _connect_project!(gui::LiveView) scene = gui.ax.scene @@ -752,10 +931,18 @@ function _connect_project!(gui::LiveView) end return Consume(true) end) + # Esc closes the card of a version, also while its text box has the keyboard, before the box + # and the other keys of the window, see `_cancel_version!` + push!(ctrl.listeners, on(events(scene).keyboardbutton; priority = 210) do event + (event.action == Keyboard.press && event.key == Keyboard.escape) || return Consume(false) + isnothing(_version_card(gui)) && return Consume(false) + _cancel_version!(gui) + return Consume(true) + end) append!(ctrl.help_extra, _PROJECT_HELP) _update_help!(ctrl) add_tool!(_save_key!, gui, "Save"; icon = :save, - tooltip = "Save the project ($(_ctrl_cap())+S)") + tooltip = "Save a version ($(_ctrl_cap())+S)") add_tool!(_save_as_key!, gui, "Save as"; icon = :save_as, tooltip = "Save as a new project with a name ($(_ctrl_cap())+Shift+S)") add_tool!(_open_key!, gui, "Open"; icon = :open, diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl new file mode 100644 index 0000000..ea632b4 --- /dev/null +++ b/src/LiveProjectSession.jl @@ -0,0 +1,231 @@ +#= +The file `.bmo/session.md` of a project: what the person sees and did in the open window, for the +agents that work on the same project (see plans/projects-reverse-sync.md). The window writes it +when the selection changes and when the autosave saved changes of the person (`_journal!`). +=# + +# How many changes of the person `session.md` lists +const _JOURNAL_MAX = 20 + +_session_file(dir::AbstractString) = joinpath(dir, ".bmo", "session.md") + +# A position in m with 4 significant digits, e.g. `(0.0, 0.105, 0.0)` +_session_pos(p) = "(" * join((string(round(Float64(x); sigdigits = 4)) for x in p), ", ") * ")" + +_entry_pos(e) = isnothing(e.pos) ? Tuple(e.pose0[1]) : e.pos + +# The code of an entry shortened to 60 characters +function _session_code(code) + s = replace(string(code isa AbstractString ? code : BMOP.normalize_code(code)), r"\s+" => " ") + return length(s) > 60 ? first(s, 57) * "..." : s +end + +"""The one line of the journal for one change of an entry, see `_journal!`.""" +function _entry_line(c) + new, old = c.new, c.old + c.kind === :added && return "added $(new.name) = $(_session_code(new.code))" + c.kind === :removed && return "removed $(old.name)" + c.kind === :renamed && return "renamed $(old.name) → $(new.name)" + c.kind === :rebuilt && return "rebuilt $(new.name)" + c.kind === :relabeled && return "relabeled $(new.name) \"$(something(new.label, ""))\"" + c.kind === :meta && return "changed settings of $(new.name)" + if c.kind === :moved + p = _entry_pos(old) != _entry_pos(new) + r = maximum(abs, BMOP.rotation_matrix(old.rot) - BMOP.rotation_matrix(new.rot)) > 1e-13 + p && r && return "moved and rotated $(new.name) to $(_session_pos(_entry_pos(new)))" + p && return "moved $(new.name) to $(_session_pos(_entry_pos(new)))" + return "rotated $(new.name)" + end + return "changed $(new.name)" +end + +"""The line of the journal for one change of a system, see `_journal!`.""" +function _system_line(c, ::Nothing = nothing) + new, old = c.new, c.old + c.kind === :added && return "added system $(new.name)" + c.kind === :removed && return "removed system $(old.name)" + c.kind === :renamed && return "renamed system $(old.name) → $(new.name)" + c.kind === :relabeled && return "relabeled system $(new.name) \"$(something(new.label, ""))\"" + return "changed system $(new.name)" +end + +# `:members` as one line per added and removed member or source. `renames` maps the old id of a +# renamed entry to its new one, so that members are paired by identity like `diff_projects` does +function _member_lines(c, renames = Dict{Symbol, Symbol}()) + old, new = c.old, c.new + lines = String[] + for (a0, b) in ((old.members, new.members), (old.sources, new.sources)) + a = [get(renames, m, m) for m in a0] + foreach(m -> push!(lines, "added $m to $(new.name)"), setdiff(b, a)) + foreach(m -> push!(lines, "removed $m from $(new.name)"), setdiff(a, b)) + end + old.static == new.static || push!(lines, "set $(new.name) " * (new.static ? "static" : "not static")) + isempty(lines) && push!(lines, "changed system $(new.name)") + return lines +end + +""" + _journal!(gui, changes) + +Adds the changes of the person to the journal of the `gui` (`gui.sync.journal`, the last +`$(_JOURNAL_MAX)`) and writes `.bmo/session.md`. `changes` is the result of +`BeamletOpticsProjects.diff_projects` from the state before an autosave to the one it saved. +""" +function _journal!(gui::LiveView, changes) + lines = String[] + for c in changes.entries + push!(lines, _entry_line(c)) + end + renames = Dict{Symbol, Symbol}(c.old.name => c.new.name for c in changes.entries if c.kind === :renamed) + for c in changes.systems + if c.kind === :members + append!(lines, _member_lines(c, renames)) + else + push!(lines, _system_line(c)) + end + end + isempty(lines) && return nothing + t = time() + j = gui.sync.journal + foreach(l -> push!(j, (t, l)), lines) + length(j) > _JOURNAL_MAX && deleteat!(j, 1:(length(j) - _JOURNAL_MAX)) + _write_session!(gui) + return nothing +end + +"""The id, label, type and position of the selected object of the `gui`, or `nothing`.""" +function _session_selected(gui::LiveView) + sel = gui.controls.selected[] + isnothing(sel) && return nothing + chain = try + _chain(gui.controls, sel) + catch + Any[sel] + end + for x in chain + haskey(gui.sync.ids, x) || continue + id = gui.sync.ids[x][1] + text = String(id) + label = get(gui.labels, x, nothing) + isnothing(label) || isempty(label) || label == text || (text *= " \"$label\"") + text *= " ($(nameof(typeof(x))))" + try + text *= " at " * _session_pos(BMO.position(x)) + catch + end + return text + end + return nothing +end + +_session_clock(t) = Libc.strftime("%H:%M:%S", t) + +# The readable id of the entry or system of a conflict `key` (a uid, or "id:L1") +function _conflict_id(gui::LiveView, key::String) + s = gui.sync + for p in (s.theirs, s.base) + isnothing(p) && continue + x = something(_by_key(p.entries, key), _by_key(p.systems, key), Some(nothing)) + isnothing(x) || return String(x.name) + end + for (id, uid) in values(s.ids) + uid == key && return String(id) + end + return startswith(key, "id:") ? key[4:end] : key +end + +function _session_text(gui::LiveView) + s = gui.sync + io = IOBuffer() + println(io, _session_header(gui.project)) + println(io, "Written by the open BeamletOpticsGUI window: what the person sees and did there.") + println(io, "system.jl holds the state of the window (it saves every finished action), except while the") + println(io, "files do not load or a conflict is open: then the window keeps its state, which") + println(io, ".bmo/window-backup.jl holds.") + println(io) + sel = _session_selected(gui) + println(io, "Selected: ", isnothing(sel) ? "nothing" : sel) + isempty(s.conflicts) || println(io, "Open conflict: ", join((_conflict_id(gui, k) for k in s.conflicts), ", "), + " (window and file differ; the person decides on the card)") + s.failed && println(io, "The file does not load: ", s.load_error, + " (the window keeps its state)") + println(io) + if isempty(s.journal) + println(io, "Recent changes by the person: none") + else + println(io, "Recent changes by the person (newest first):") + for (t, l) in Iterators.reverse(s.journal) + println(io, "- ", _session_clock(t), " ", l) + end + end + return String(take!(io)) +end + +_session_header(dir) = "# Window of the project \"$(basename(abspath(dir)))\", " * + Libc.strftime("%Y-%m-%d %H:%M:%S", time()) + +# Writes `text` to `.bmo/session.md` atomically +function _put_session(dir::AbstractString, text::AbstractString) + file = _session_file(dir) + mkpath(dirname(file)) + tmp = file * ".tmp" + write(tmp, text) + mv(tmp, file; force = true) + return nothing +end + +""" + _write_session!(gui) + +Writes `.bmo/session.md` of the project of the `gui` now: selection, open conflicts, load failure +and the journal. Does nothing without a project. An error of writing never propagates; it is +logged once per distinct message. +""" +function _write_session!(gui::LiveView) + isnothing(gui.project) && return nothing + try + _put_session(gui.project, _session_text(gui)) + catch e + e isa InterruptException && rethrow() + msg = sprint(showerror, e) + if msg != _SESSION_WARNED[] + _SESSION_WARNED[] = msg + @warn "could not write session.md" exception = e + end + end + return nothing +end + +# The last message of a failed write that was logged +const _SESSION_WARNED = Ref("") + +""" + _connect_session!(gui) + +Starts writing `.bmo/session.md` whenever the selection of the `gui` changes, and writes it once. +Does nothing if it is connected already. +""" +function _connect_session!(gui::LiveView) + isnothing(gui.project) && return nothing + if isempty(gui.sync.session) + l = on(_ -> _write_session!(gui), gui.controls.selected) + push!(gui.sync.session, l) + end + _write_session!(gui) + return nothing +end + +""" + _session_closed!(gui) + +Removes the listener of the `gui` and writes `.bmo/session.md` with the note that no window is open. +""" +function _session_closed!(gui::LiveView) + for l in gui.sync.session + off(l) + end + empty!(gui.sync.session) + isnothing(gui.project) && return nothing + _put_session(gui.project, _session_header(gui.project) * "\nNo window is open on this project.\n") + return nothing +end diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 759aaa5..9c81efa 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -5,6 +5,10 @@ with the state that the view and the files last agreed on (`base`) and with the what only the files changed is applied in the open window (moved objects glide, new ones appear, changed ones are rebuilt, removed ones go), what both changed differently is a conflict, shown on a card. The window is never replaced. + +The other way, the window saves every finished action of the person (autosave, `_autosave!`), so +that `system.jl` is always what the person sees, and lists them in `.bmo/session.md` for the agent +(see `LiveProjectSession.jl`). =# # How often the watcher looks at the times of the files [s] @@ -16,6 +20,12 @@ const _GLIDE = 0.3 const _GLIDE_FPS = 60 # How long the boxes of the changed objects are shown [s] const _FLASH = 1.0 +# How long the setup in the window must stay unchanged before it is saved [s], and how long the +# autosave waits after a save that failed +const _AUTOSAVE_DELAY = 0.5 +const _AUTOSAVE_RETRY = 5.0 +# How many states of each entry that the window wrote are kept, see `_stale` +const _WRITTEN_MAX = 8 """Whether the `gui` is shown in a window that is open.""" function _shown(gui::LiveView) @@ -46,37 +56,86 @@ end _src_marks(marks) = filter(m -> m[1] != "system.jl", marks) """ - _saved!(gui, project) + _saved!(gui, project; written = true) Records that the `gui` and the files of the `project` agree, after it was opened, saved or the -files were merged: `project` becomes the `base` of the comparisons (see `_ProjectSync`), and the -watcher starts if the `gui` is shown. +files were merged: `project` becomes the `base` of the comparisons (see `_ProjectSync`), a conflict +is over, the states that the window wrote are remembered (unless `written` is `false`, e.g. for +files that `Ctrl+R` took in; see `_stale`), `.bmo/session.md` is written if the view writes it, +and the watcher starts if the `gui` is shown. """ -function _saved!(gui::LiveView, project) +function _saved!(gui::LiveView, project; written::Bool = true) s = gui.sync + before = s.base s.base = _align_uids!(gui, project) s.stamp = project.stamp s.marks = _file_marks(project.path) s.src = _src_marks(s.marks) s.changed_at = s.handled = nothing s.failed = false + s.load_error = "" + if !isempty(s.conflicts) + empty!(s.conflicts) + _release_info!(gui, _ProjectConflict) + end + written && _remember_written!(s, before) + _note_session!(gui) _start_watching!(gui) return nothing end +# Writes `.bmo/session.md` of the `gui` if it is connected (a shown window), see `_connect_session!` +_note_session!(gui::LiveView) = (isempty(gui.sync.session) || _write_session!(gui); nothing) + +_same_written(a, b) = (isnothing(a) || isnothing(b)) ? a === b : BMOP.same_entry(a, b) + +function _push_written!(w::Vector{Any}, x) + (isempty(w) || !_same_written(last(w), x)) && push!(w, x) + length(w) > _WRITTEN_MAX && popfirst!(w) + return nothing +end + +""" +Adds the states of the entries that the window wrote by a save from the state `before` (the `base` +before the save, `nothing` when it was opened: then every entry) to `written` of `s`: each one that +the save changed, after the state it overwrote (e.g. merged from an agent), and `nothing` after the +last state of an entry that the save removed. +""" +function _remember_written!(s::_ProjectSync, before) + for e in s.base.entries + isnothing(e.uid) && continue + old = isnothing(before) ? nothing : _by_key(before.entries, e.uid) + !isnothing(old) && BMOP.same_entry(old, e) && continue + w = get!(() -> Any[], s.written, e.uid) + isnothing(old) || _push_written!(w, old) + _push_written!(w, e) + end + isnothing(before) && return nothing + for old in before.entries + (isnothing(old.uid) || !isnothing(_by_key(s.base.entries, old.uid))) && continue + w = get!(() -> Any[], s.written, old.uid) + _push_written!(w, old) + _push_written!(w, nothing) + end + return nothing +end + """ Starts the watcher of the files of the project of the `gui` if it is shown, which calls -`_check_files!` every `_WATCH_INTERVAL` seconds until the window is closed. A view that is not -shown (e.g. in the tests) is not watched; `_check_files!` can be called instead. +`_check_files!` and `_autosave!` every `_WATCH_INTERVAL` seconds until the window is closed, and +the file `.bmo/session.md` (see `_connect_session!`). A view that is not shown (e.g. in the tests +or of a script) is not watched and writes no `session.md`; the functions can be called instead. """ function _start_watching!(gui::LiveView) s = gui.sync - (isnothing(s.timer) && !isnothing(gui.project) && _shown(gui)) || return nothing + (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing + _connect_session!(gui) last_error = Ref("") s.timer = Timer(_WATCH_INTERVAL; interval = _WATCH_INTERVAL) do _ _shown(gui) || return _stop_watching!(gui) try _check_files!(gui) + _autosave!(gui) catch e e isa InterruptException && rethrow() last_error[] = _log_once(e, last_error[], "watching the project") @@ -85,21 +144,167 @@ function _start_watching!(gui::LiveView) return nothing end -"""Stops the watcher of the `gui`, see `_start_watching!`.""" +""" +Stops the watcher of the `gui` (see `_start_watching!`) when it is closed: if it was watched, saves +what the person changed last, without waiting (`_autosave!` with `force`), and notes in +`.bmo/session.md` that no window is open. A view that was not watched saves nothing. No watcher +starts again afterwards, also not by that save. +""" function _stop_watching!(gui::LiveView) - t = gui.sync.timer - gui.sync.timer = nothing - isnothing(t) || close(t) + s = gui.sync + s.closed = true + t = s.timer + s.timer = nothing + if !isnothing(t) + close(t) + try + _autosave!(gui; force = true) + catch e + e isa InterruptException && rethrow() + @error "saving the project at closing failed" exception = (e, catch_backtrace()) + end + end + isempty(s.session) && return nothing + try + _session_closed!(gui) + catch e + e isa InterruptException && rethrow() + @error "writing .bmo/session.md at closing failed" exception = (e, catch_backtrace()) + end return nothing end +#= +Autosave +=# + +# The text of `system.jl` of the project `p` without the machine ids: the view gives one to a new +# object only when it saves, a new one at every look before +_uidless_code(p) = replace(BMOP.system_code(p), r" uid = \"[^\"]*\"" => "") + +""" + _autosave!(gui; now = time(), force = false) -> Symbol + +One look of the autosave at the setup of the `gui`, after `_check_files!`: once the setup differs +from the files (`base`) and has not changed for `_AUTOSAVE_DELAY` seconds, it is saved (`:saved`) +and the changes of the person are added to the journal of `.bmo/session.md` (`_journal!`); files +changed meanwhile are merged first. `:pending` while it waits, `:none` if the setup is as in the +files. `:paused` while the mouse drags or places an object (also in a linked view, see +`open_system`), an object glides to a pose of the files, the files changed and are not merged yet, +or a conflict is open: a change of the person then shows its card again if it was closed without +a decision. While the files do not load (e.g. an agent is half through an edit) or a conflict is +open, the setup is written to `.bmo/window-backup.jl` instead (`:paused`) and saved once they load +again. `:failed` if the save failed, the reason in the status line; tried again after +`_AUTOSAVE_RETRY` seconds. `force` saves without waiting and while the files changed or objects +glide (when the window closes or `Ctrl+S` saves a version); a conflict then writes the backup. +""" +function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) + s = gui.sync + dir = gui.project + (isnothing(dir) || isnothing(s.base)) && return :none + if force + _finish_glides!(gui) + else + (_busy(gui) || !isnothing(s.changed_at) || !isempty(s.gliding)) && return :paused + end + parts = _project_parts(gui, dir) + text = _uidless_code(parts.project) + if text == _uidless_code(s.base) + s.text, s.edited_at = text, nothing + return :none + end + if text != s.text + s.text, s.edited_at = text, now + # A change of the person while a conflict is open whose card was closed: ask again + !force && !isempty(s.conflicts) && !_conflict_shown(gui) && _show_conflict!(gui, s.conflicts) + force || return :pending + end + !force && !isempty(s.conflicts) && return :paused + # Written to the backup already, the files still do not load + isnothing(s.edited_at) && s.failed && !force && return :paused + !force && !isnothing(s.edited_at) && now - s.edited_at < _AUTOSAVE_DELAY && return :pending + s.edited_at = nothing + if s.failed || !isempty(s.conflicts) + _write_backup!(gui, parts.project) + return :paused + end + return _autosave_write!(gui, parts, now) +end + +"""Whether the mouse drags or places an object in the `gui` or in a view linked to it.""" +_busy(gui::LiveView) = any(v -> v.controls.dragging || _placing(v), Any[gui, gui.links.views...]) + +"""Whether the card of the conflict of the `gui` is shown, see `_ProjectConflict`.""" +function _conflict_shown(gui::LiveView) + c = _info_card(gui) + return !isnothing(c) && c.obj isa _ProjectConflict +end + +""" +Saves the setup (`parts`, see `_project_parts`) of the `gui` for `_autosave!`. Files that changed +since the last look are merged first, without gliding, such that the save sees the poses of the +files; a conflict or files that do not load then write the backup. A new list of objects without a +recipe stays in the status line and is warned once; otherwise the status line stays as it was. +""" +function _autosave_write!(gui::LiveView, parts, now::Real) + s = gui.sync + dir = gui.project + if BMOP.project_stamp(dir) != s.stamp + s.changed_at = nothing + if _sync!(gui; animate = false) !== :applied || !isempty(s.conflicts) + _write_backup!(gui, _collect_project(gui, dir)) + return :paused + end + parts = _project_parts(gui, dir) + _uidless_code(parts.project) == _uidless_code(s.base) && return :none + end + changes = BMOP.diff_projects(s.base, parts.project) + status = gui.status.text[] + saved = Base.CoreLogging.with_logger(Base.CoreLogging.SimpleLogger(stderr, Base.CoreLogging.Error)) do + _save_to!(gui, dir) + end + if !saved + # A conflict, files that do not load (both found by a merge of the save), or an error in + # the status line + if !isempty(s.conflicts) || s.failed + _write_backup!(gui, _collect_project(gui, dir)) + return :paused + end + s.edited_at = now + _AUTOSAVE_RETRY + return :failed + end + _journal!(gui, changes) + if !isempty(parts.skipped) && parts.skipped != s.skipped + @warn "save_project: $(length(parts.skipped)) objects without a recipe are not saved: " * + join(parts.skipped, ", ") + elseif startswith(gui.status.text[], "project saved to") + gui.status.text[] = status + end + s.skipped = parts.skipped + return :saved +end + +"""Writes the setup `mine` of the `gui` to `.bmo/window-backup.jl`, while the files do not load.""" +function _write_backup!(gui::LiveView, mine) + file = joinpath(gui.project, ".bmo", "window-backup.jl") + mkpath(dirname(file)) + write(file, BMOP.system_code(mine)) + note = "the window is kept in .bmo/window-backup.jl" + st = gui.status.text[] + occursin(note, st) || (gui.status.text[] = isempty(st) ? note : "$st; $note") + _note_session!(gui) + return file +end + """ _check_files!(gui; now = time()) -> Symbol One look of the watcher at the files of the project of the `gui`: `:pending` while they change or have not stayed unchanged for `_SETTLE` seconds; then, once per state of the files, `:applied`, `:conflict` or `:failed`, see `_sync!`; `:none` otherwise, also for the files that the `gui` saved -itself. A load error shown before is cleared when the files are back to the state of the `gui`. +itself. The files changed if their times or sizes (`_file_marks`) or else the stamp of their +contents (`project_stamp`) changed. A load error shown before is cleared when the files are back to +the state of the `gui`. """ function _check_files!(gui::LiveView; now::Real = time()) s = gui.sync @@ -111,13 +316,25 @@ function _check_files!(gui::LiveView; now::Real = time()) s.changed_at = now return :pending end - isnothing(s.changed_at) && return :none + if isnothing(s.changed_at) + # A write of the same size within the resolution of the file times (e.g. right after a save + # of the view) leaves the marks as they were: the stamp of the contents tells + stamp = BMOP.project_stamp(dir) + (stamp == s.stamp || stamp == s.handled) && return :none + s.changed_at = now + return :pending + end now - s.changed_at < _SETTLE && return :pending s.changed_at = nothing disk = BMOP.project_stamp(dir) if disk == s.stamp # Saved by the view itself, or back to the state of the view - s.failed && (s.failed = false; gui.status.text[] = "project files are as in the window") + if s.failed + s.failed = false + s.load_error = "" + gui.status.text[] = "project files are as in the window" + _note_session!(gui) + end s.handled = nothing return :none end @@ -197,15 +414,40 @@ function _same_state(a, b, key::String) end """ - _sync!(gui) -> Symbol + _stale(s, key, disk) -> Bool + +Whether the files `disk` bring the entry of the machine id `key` back to an older state of the +window (see `written` of `_ProjectSync`), while the last state that the window and the files +agreed on is one the window wrote: most likely an agent that writes from an older read, over a +change of the person. Also an entry that the window removed last and that comes back as it was. +Not a change of an agent that sets back its own change (the window did not write the state it +sets back from), and not an entry that the agent removed and brings back. Entries without a +machine id are not checked. +""" +function _stale(s::_ProjectSync, key::String, disk) + startswith(key, "id:") && return false + w = get(s.written, key, nothing) + (isnothing(w) || length(w) < 2) && return false + de, be = _by_key(disk.entries, key), _by_key(s.base.entries, key) + isnothing(de) && return false + older(x) = !isnothing(x) && BMOP.same_entry(x, de) + isnothing(be) && return isnothing(last(w)) && any(older, w) + _same_written(be, last(w)) || return false + return !BMOP.same_entry(de, be) && any(older, @view w[1:(end - 1)]) +end + +""" + _sync!(gui; animate = _shown(gui)) -> Symbol Loads the project of the `gui` from its files and merges it into the open window, see `LiveProjectSync.jl`: the changes of the files since `base` that the window did not change too are -applied (`_adopt!`), conflicting ones are left and shown on a card (`_ProjectConflict`). The -files become the new `base`. Returns `:applied`, `:conflict` or `:failed` (the reason in the status -line, the window as it was). +applied (`_adopt!`, moved objects glide if `animate`), conflicting ones are left and shown on a +card (`_ProjectConflict`), also changes back to an older state of the window (see `_stale`) and +the conflicts still open from before, unless the files now agree with the window there. The files +become the new `base`. Returns `:applied`, `:conflict` or `:failed` (the reason in the status line +and in `load_error`, the window as it was). """ -function _sync!(gui::LiveView) +function _sync!(gui::LiveView; animate::Bool = _shown(gui)) s = gui.sync dir = gui.project disk = try @@ -213,10 +455,15 @@ function _sync!(gui::LiveView) catch e e isa InterruptException && rethrow() s.failed = true - gui.status.text[] = "project not reloaded: $(_load_message(e))" + s.load_error = _load_message(e) + gui.status.text[] = "project not reloaded: $(s.load_error)" + _note_session!(gui) return :failed end s.failed = false + s.load_error = "" + # The window compares in the poses of the files of the last merge + _finish_glides!(gui) # The lines without uid (of an agent) get those of their objects in the window _align_uids!(gui, disk) rebuild = _src_marks(s.marks) == s.src ? Symbol[] : _calls_own_code(disk) @@ -225,18 +472,25 @@ function _sync!(gui::LiveView) g = BMOP.diff_projects(s.base, mine) theirs = Set{String}(_change_key(c) for c in Iterators.flatten((d.entries, d.systems))) ours = Set{String}(_change_key(c) for c in Iterators.flatten((g.entries, g.systems))) - conflicts = String[k for k in theirs if k in ours && !_same_state(mine, disk, k)] - done = _adopt!(gui, mine, disk, setdiff(theirs, conflicts); rebuild = Set(rebuild)) + conflicts = String[k for k in theirs + if k in ours ? !_same_state(mine, disk, k) : _stale(s, k, disk)] + # A conflict of before stays until the person decides, or the files agree with the window + still = String[k for k in s.conflicts if !(k in conflicts) && !_same_state(mine, disk, k)] + conflicts = vcat(still, conflicts) + done = _adopt!(gui, mine, disk, setdiff(theirs, conflicts); rebuild = Set(rebuild), animate) s.base = _align_uids!(gui, disk) s.stamp = disk.stamp s.src = _src_marks(s.marks) s.conflicts = conflicts s.theirs = disk if isempty(conflicts) + _release_info!(gui, _ProjectConflict) isempty(done) || (gui.status.text[] = "agent: " * join(done, ", ")) + _note_session!(gui) return :applied end _show_conflict!(gui, conflicts) + _note_session!(gui) return :conflict end @@ -346,10 +600,15 @@ function _axis_rotation(axis, angle) end """ -Moves `obj` of the `gui` to the pose `(P1, R1)`: gliding over `_GLIDE` seconds if `animate`, the -position along the line, the orientation about the axis of the rotation between the two. +Moves `obj` of the `gui`, which is in `gliding` of its `_ProjectSync`, to the pose `(P, R)` of its +entry there: gliding over `_GLIDE` seconds if `animate`, the position along the line, the +orientation about the axis of the rotation between the two; then lands it (`_land!`). Stops if it +was landed meanwhile, see `_finish_glides!`. """ -function _glide!(gui::LiveView, @nospecialize(obj), P1, R1; animate::Bool) +function _glide!(gui::LiveView, @nospecialize(obj); animate::Bool) + gliding = gui.sync.gliding + haskey(gliding, obj) || return nothing + P1, R1, _ = gliding[obj] ctrl = gui.controls set!(P, R) = (_change!(() -> _set_pose!(obj, P, R), ctrl, obj); _apply_update!(ctrl, obj)) if animate @@ -357,6 +616,7 @@ function _glide!(gui::LiveView, @nospecialize(obj), P1, R1; animate::Bool) axis, angle = _rotation_axis_angle(Matrix{Float64}(R1) * Matrix{Float64}(R0)') n = max(1, round(Int, _GLIDE * _GLIDE_FPS)) for i in 1:(n - 1) + haskey(gliding, obj) || return nothing f = i / n f = f * f * (3 - 2f) R = angle > 0 ? _axis_rotation(axis, f * angle) * R0 : R0 @@ -364,11 +624,30 @@ function _glide!(gui::LiveView, @nospecialize(obj), P1, R1; animate::Bool) sleep(1 / _GLIDE_FPS) end end - _change!(() -> _place_exactly!(obj, P1, R1), ctrl, obj) + _land!(gui, obj) + return nothing +end + +""" +Puts `obj` of `gliding` of the `gui` exactly into the pose of its entry of the files and records +that pose, see `_glide!`. +""" +function _land!(gui::LiveView, @nospecialize(obj)) + haskey(gui.sync.gliding, obj) || return nothing + P, R, e = pop!(gui.sync.gliding, obj) + ctrl = gui.controls + _change!(() -> _place_exactly!(obj, P, R), ctrl, obj) _apply_update!(ctrl, obj) + _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) return nothing end +""" +Lands every object of the `gui` that glides to a pose of the files at once, e.g. before a save, +which must see the poses of the files; their glides stop. +""" +_finish_glides!(gui::LiveView) = foreach(obj -> _land!(gui, obj), collect(keys(gui.sync.gliding))) + """ Sets the pose of `obj` to `(P, R)` with the position exactly `P`: rotates about its position, then moves it to the origin, which is exact, and from there by `P`, like `BeamletOpticsProjects` @@ -429,14 +708,14 @@ function _sync_members!(gui::LiveView, disk, keys, changed) end """ - _adopt!(gui, mine, disk, keys; rebuild = Set{Symbol}()) -> Vector{String} + _adopt!(gui, mine, disk, keys; rebuild = Set{Symbol}(), animate = _shown(gui)) -> Vector{String} Makes the entries and systems of the `keys` in the open `gui` (whose state is the project `mine`, see `_collect_project`) as in the files `disk`, without undo entries: removes, adds, rebuilds (other -code, or calling own code in `rebuild`), moves (gliding, if the `gui` is shown), renames and +code, or calling own code in `rebuild`), moves (gliding if `animate`, see `_glide!`), renames and relabels. The changed objects get a box for a moment. Returns what was done, e.g. `"moved M1"`. """ -function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}()) +function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), animate::Bool = _shown(gui)) done = String[] touched = Any[] glides = Tuple{Any, Any, Any}[] @@ -500,12 +779,10 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}()) (se.name !== mine_se.name || se.label != mine_se.label) && _name!(gui, mine_se.object, se) end isempty(glides) && (_flash!(gui, touched); return done) - animate = _shown(gui) + # The autosave waits until they landed, a save lands them at once + foreach(((obj, (P, R), e),) -> (gui.sync.gliding[obj] = (P, R, e)), glides) finish() = begin - for (obj, (P, R), e) in glides - _glide!(gui, obj, P, R; animate) - _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) - end + foreach(g -> _glide!(gui, first(g); animate), glides) _flash!(gui, Any[touched..., first.(glides)...]) return nothing end @@ -537,16 +814,20 @@ function _load_from_disk!(gui::LiveView) catch e e isa InterruptException && rethrow() gui.sync.failed = true - gui.status.text[] = "project not reloaded: $(_load_message(e))" + gui.sync.load_error = _load_message(e) + gui.status.text[] = "project not reloaded: $(gui.sync.load_error)" + _note_session!(gui) return nothing end + _finish_glides!(gui) _align_uids!(gui, disk) mine = _collect_project(gui, gui.project) keys = Set{String}(_key(x) for x in Iterators.flatten((disk.entries, disk.systems, mine.entries, mine.systems))) _release_info!(gui, _ProjectConflict) done = _adopt!(gui, mine, disk, keys; rebuild = Set(_calls_own_code(disk))) - _saved!(gui, disk) + # Taken in from the files, not written by the window + _saved!(gui, disk; written = false) gui.status.text[] = isempty(done) ? "project reloaded, nothing changed" : "reloaded: " * join(done, ", ") return nothing end @@ -597,41 +878,29 @@ function _show_conflict!(gui::LiveView, keys::Vector{String}) return nothing end -""" -`Ctrl+S` found the files changed since the window last agreed with them (`ProjectChanged`): merges -them now (see `_sync!`) and saves again if that left no conflict. -""" -function _show_conflict!(gui::LiveView) - gui.sync.changed_at = nothing - _sync!(gui) === :applied || return nothing - try - save_project(gui, gui.project) - catch e - e isa InterruptException && rethrow() - gui.status.text[] = "project not saved: $(_message_line(e))" - end - return nothing -end - """ Resolves the conflict of the `gui` (see `_ProjectConflict`) as the user chose, `how`: `:theirs` takes the objects of the conflict from the files (their changes here are lost), `:mine` keeps them -as they are here, which the next save writes, `:save_as` saves the window as a new project (see -`_save_as_key!`). +as they are here, which the autosave writes, `:save_as` saves the window as a new project (see +`_save_as_key!`): the conflict is over once that is saved (`_saved!`); if the dialog is cancelled, +it stays open, and the autosave of the project keeps waiting for a decision. """ function _resolve_conflict!(gui::LiveView, how::Symbol) s = gui.sync _release_info!(gui, _ProjectConflict) + if how === :save_as + _save_as_key!(gui) + return nothing + end if how === :theirs && !isnothing(s.theirs) mine = _collect_project(gui, gui.project) done = _adopt!(gui, mine, s.theirs, Set(s.conflicts)) gui.status.text[] = "taken from the files: " * join(done, ", ") elseif how === :mine - gui.status.text[] = "kept as here; Ctrl+S writes it to the files" - elseif how === :save_as - _save_as_key!(gui) + gui.status.text[] = "kept as here; written to the files" end empty!(s.conflicts) + _note_session!(gui) return nothing end diff --git a/src/LiveView.jl b/src/LiveView.jl index 5900a08..e7bbd81 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -587,9 +587,17 @@ What the view knows of the files of its project, which an agent may change while - `marks`: the times and sizes of the files at the last look of the watcher, `src` those of `src/` when `base` was taken, `changed_at` the time the files last changed (`nothing`: settled); - `handled`: the stamp of the files that the view last reacted to, `failed` whether that failed - (e.g. a syntax error, shown in the status line), `conflicts` the names of the objects changed + (e.g. a syntax error, shown in the status line) and `load_error` the reason, `conflicts` the names of the objects changed both in the view and in the files, and `theirs` the project of the files of that conflict; -- `timer`: the watcher. +- `written`: per machine id the last states of an entry that the view itself wrote, each after the + state it overwrote, `nothing` for a removal (the guard against an agent that writes from an older + read, see `_stale`), `text` the `system.jl` text of the view at the last look of the autosave and + `edited_at` the time it last changed, `skipped` the objects without a recipe that the last save + left out (warned once); +- `journal`: the recent changes of the person in the view, `(time, line)`, newest last, and + `session` the listeners that write them to `.bmo/session.md`, see `LiveProjectSession.jl`; +- `gliding`: the objects that glide to a pose of the files, `obj => (P, R, entry)`, see `_glide!`; +- `timer`: the watcher, and `closed` whether the view was closed (no watcher starts again). """ Base.@kwdef mutable struct _ProjectSync base::Any = nothing @@ -600,9 +608,18 @@ Base.@kwdef mutable struct _ProjectSync changed_at::Union{Nothing, Float64} = nothing handled::Union{Nothing, String} = nothing failed::Bool = false + load_error::String = "" conflicts::Vector{String} = String[] theirs::Any = nothing + written::Dict{String, Vector{Any}} = Dict{String, Vector{Any}}() + text::Union{Nothing, String} = nothing + edited_at::Union{Nothing, Float64} = nothing + journal::Vector{Tuple{Float64, String}} = Tuple{Float64, String}[] + session::Vector{Any} = Any[] + gliding::IdDict{Any, Any} = IdDict{Any, Any}() + skipped::Vector{String} = String[] timer::Union{Nothing, Timer} = nothing + closed::Bool = false end """ diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl index c256961..426bf20 100644 --- a/test/TestLiveProject.jl +++ b/test/TestLiveProject.jl @@ -298,10 +298,13 @@ const GUI = BeamletOpticsGUI @test isfile(joinpath(dir, "Laser bench.bmo")) && isfile(joinpath(dir, "system.jl")) @test load_project(joinpath(dir, "Laser bench.bmo")).name == "Laser_bench" @test gui.status.text[] == "project saved to $dir" - # with a project: saved without the dialog - rm(joinpath(dir, "system.jl")) + # the new project has its first version + @test [v.message for v in versions(dir)] == ["New project"] + # with a project: saved without the dialog, then the card of the version + translate3d!(gui, t.b3, [0.0, 0.0, 0.01]) _ctrl!(gui, Keyboard.s) - @test calls[] == 2 && isfile(joinpath(dir, "system.jl")) + @test calls[] == 2 && position(load_project(dir)[first(gui.sync.ids[t.b3])]) ≈ position(t.b3) + GUI._cancel_version!(gui) # the ids are kept, the labels stay as they are @test first(gui.sync.ids[t.b2]) === :B2 && gui.labels[t.b2] == "laser 2" @test !haskey(gui.labels, t.prism) @@ -327,9 +330,11 @@ const GUI = BeamletOpticsGUI @test gui.status.text[] == "project saved as $copy" # the copy keeps the module of the project @test load_project(copy).name == "Laser_bench" && isfile(joinpath(copy, "src", "Laser_bench.jl")) - rm(joinpath(copy, "system.jl")) + translate3d!(gui, t.b3, [0.0, 0.0, 0.01]) _ctrl!(gui, Keyboard.s) - @test calls[] == 5 && isfile(joinpath(copy, "system.jl")) + @test calls[] == 5 && position(load_project(copy)[first(gui.sync.ids[t.b3])]) ≈ position(t.b3) + @test position(load_project(dir)[first(gui.sync.ids[t.b3])]) ≉ position(t.b3) + GUI._cancel_version!(gui) # the tool "Save as" does the same as the keys saved[] = "" GUI._save_as_key!(gui) @@ -360,6 +365,137 @@ const GUI = BeamletOpticsGUI end end + @testset "Ctrl+S saves a version" begin + dir = joinpath(mktempdir(), "bench") + t = _setup(:compact) + gui = t.gui + save_project(gui, dir) + @test length(versions(dir)) == 1 + _messages() = [v.message for v in versions(dir)] + # nothing changed since the last version: no card + GUI._save_key!(gui) + @test isnothing(GUI._version_card(gui)) + @test gui.status.text[] == "no changes since the last version" + # a move: written to the files, then the card with the summary in its focused box + lens = first(gui.sync.ids[t.lens]) + translate3d!(gui, t.lens, [0.0, 0.0, 0.01]) + GUI._save_key!(gui) + c = GUI._version_card(gui) + @test !isnothing(c) + box = GUI._version_box(gui) + @test box.displayed_string[] == "moved $lens" == version_summary(dir) + @test box.focused[] && gui.controls.ignore_keys() + @test position(load_project(dir)[lens]) ≈ position(t.lens) + # Esc: no version + events(gui.ax.scene).keyboardbutton[] = Makie.KeyEvent(Keyboard.escape, Keyboard.press) + @test isnothing(GUI._version_card(gui)) && !box.focused[] + @test length(versions(dir)) == 1 && gui.status.text[] == "no version saved" + # the button "Cancel" does the same + GUI._save_key!(gui) + GUI._cancel_version!(gui) + @test isnothing(GUI._version_card(gui)) && length(versions(dir)) == 1 + # Enter in the box: a version with the typed message + GUI._save_key!(gui) + box = GUI._version_box(gui) + box.displayed_string[] = "lens moved up" + box.stored_string[] = "lens moved up" + @test isnothing(GUI._version_card(gui)) + @test _messages() == ["lens moved up", "New project"] + @test gui.status.text[] == "version saved: lens moved up" + GUI._save_key!(gui) + @test isnothing(GUI._version_card(gui)) + @test gui.status.text[] == "no changes since the last version" + # the button "Save version": the text of the box + translate3d!(gui, t.lens, [0.0, 0.0, -0.01]) + GUI._save_key!(gui) + @test GUI._version_box(gui).displayed_string[] == "moved $lens" + GUI._save_version!(gui) + @test _messages() == ["moved $lens", "lens moved up", "New project"] + @test gui.status.text[] == "version saved: moved $lens" + @test !gui.controls.ignore_keys() + close(gui) + end + + @testset "Ctrl+S: journal, conflict, message of the version" begin + dir = joinpath(mktempdir(), "bench") + t = _setup(:compact) + gui = t.gui + save_project(gui, dir) + _messages() = [v.message for v in versions(dir)] + _session() = read(joinpath(dir, ".bmo", "session.md"), String) + GUI._connect_session!(gui) + lens = string(first(gui.sync.ids[t.lens])) + prism = string(first(gui.sync.ids[t.prism])) + # a move that the autosave has not written yet: Ctrl+S writes it and notes it in the journal + translate3d!(gui, t.lens, [0.0, 0.0, 0.01]) + GUI._save_key!(gui) + @test any(l -> occursin("moved $lens", l[2]), gui.sync.journal) + @test occursin("moved $lens", _session()) + # the caret is at the end of the summary: typing adds to it + box = GUI._version_box(gui) + @test box.editor.cursors[] == [Makie.EditCursor(length("moved $lens"))] + events(gui.ax.scene).unicode_input[] = '!' + @test box.displayed_string[] == "moved $(lens)!" + # the refreshes of the cards keep what was typed, also when the box lost the focus + Makie.defocus!(box) + GUI._update_inspector!(gui; force = true) + @test box.displayed_string[] == "moved $(lens)!" + GUI._cancel_version!(gui) + # the summary left as it was is computed again when the version is saved: the prism that + # moved while the card was open is in the message + GUI._save_key!(gui) + @test GUI._version_box(gui).displayed_string[] == "moved $lens" + translate3d!(gui, t.prism, [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui; force = true) === :saved + GUI._save_version!(gui) + @test occursin(lens, first(_messages())) && occursin(prism, first(_messages())) + # a typed message is kept + translate3d!(gui, t.prism, [0.0, 0.0, 0.01]) + GUI._save_key!(gui) + translate3d!(gui, t.lens, [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui; force = true) === :saved + GUI._save_version!(gui, "prism up") + @test first(_messages()) == "prism up" + # an open conflict: Ctrl+S saves nothing and asks to decide on the card first + n = length(versions(dir)) + file = read(joinpath(dir, "system.jl"), String) + translate3d!(gui, t.lens, [0.0, 0.0, 0.01]) + GUI._show_info!(gui, GUI._ProjectConflict(Point3f(0), ["$lens"])) + GUI._save_key!(gui) + @test gui.status.text[] == "decide on the card first: $lens" + @test GUI._info_card(gui).obj isa GUI._ProjectConflict + @test read(joinpath(dir, "system.jl"), String) == file && length(versions(dir)) == n + # also while the card is not shown + GUI._release_info!(gui, GUI._ProjectConflict) + push!(gui.sync.conflicts, "$prism") + GUI._save_key!(gui) + @test gui.status.text[] == "decide on the card first: $prism" + @test isnothing(GUI._version_card(gui)) + @test read(joinpath(dir, "system.jl"), String) == file && length(versions(dir)) == n + empty!(gui.sync.conflicts) + close(gui) + end + + @testset "save as closes the session of the old folder" begin + dir = joinpath(mktempdir(), "bench") + t = _setup(:compact) + gui = t.gui + save_project(gui, dir) + GUI._connect_session!(gui) + lens = first(gui.sync.ids[t.lens]) + translate3d!(gui, t.lens, [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui; force = true) === :saved + @test occursin("moved $lens", read(joinpath(dir, ".bmo", "session.md"), String)) + target = joinpath(mktempdir(), "copy") + save_project(gui, target) + @test occursin("No window is open on this project.", read(joinpath(dir, ".bmo", "session.md"), String)) + # the new project starts without the journal of the old one, and its session is connected + @test isempty(gui.sync.journal) && !isempty(gui.sync.session) + new = read(joinpath(target, ".bmo", "session.md"), String) + @test !occursin("moved $lens", new) && occursin("Recent changes by the person: none", new) + close(gui) + end + @testset "target of save as" begin parent = mktempdir() @test GUI._save_target(joinpath(parent, "a")).dir == joinpath(parent, "a") diff --git a/test/TestLiveProjectSession.jl b/test/TestLiveProjectSession.jl new file mode 100644 index 0000000..fbe16d8 --- /dev/null +++ b/test/TestLiveProjectSession.jl @@ -0,0 +1,166 @@ +module TestLiveProjectSession + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const LENS = "SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5)" +const START = """ + @component L1 = $LENS pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, D1] sources = [B1] + """ + +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + return gui, dir +end + +obj(gui, id) = first(x for (x, (name, _)) in gui.sync.ids if name === id) +session(dir) = read(joinpath(dir, ".bmo", "session.md"), String) + +@testset "session.md" begin + @testset "no file without a project" begin + gui = live_view(BeamletOptics.System(); trace_budget = Inf, throttle = false, detectors = []) + @test GUI._write_session!(gui) === nothing + @test GUI._connect_session!(gui) === nothing + @test GUI._session_closed!(gui) === nothing + end + + gui, dir = opened() + file = joinpath(dir, ".bmo", "session.md") + sys = joinpath(dir, "system.jl") + + @testset "connect, selection" begin + GUI._connect_session!(gui) + GUI._connect_session!(gui) + @test length(gui.sync.session) == 1 + text = session(dir) + @test startswith(text, "# Window of the project \"$(basename(dir))\", ") + @test occursin("Selected: nothing", text) + @test occursin("Recent changes by the person: none", text) + @test !occursin("Open conflict", text) + @test !occursin("does not load", text) + gui.controls.selected[] = obj(gui, :L1) + text = session(dir) + @test occursin(r"Selected: L1 \(Lens\) at \(0\.0, 0\.1, 0\.0\)", text) + gui.labels[obj(gui, :L1)] = "Kollimator" + gui.controls.selected[] = obj(gui, :M1) + gui.controls.selected[] = obj(gui, :L1) + @test occursin("Selected: L1 \"Kollimator\" (Lens)", session(dir)) + @test !isfile(file * ".tmp") + end + + @testset "conflict and failure" begin + uid = gui.sync.ids[obj(gui, :M1)][2] + gui.sync.conflicts = ["id:D1", uid] + gui.sync.failed = true + gui.sync.load_error = "system.jl:12:5: boom" + gui.status.text[] = "something else" + GUI._write_session!(gui) + text = session(dir) + @test occursin("Open conflict: D1, M1 (window and file differ; the person decides on the card)", text) + @test !occursin(uid, text) + @test occursin("The file does not load: system.jl:12:5: boom (the window keeps its state)", text) + @test occursin(".bmo/window-backup.jl", text) + gui.sync.conflicts = String[] + gui.sync.failed = false + GUI._write_session!(gui) + @test !occursin("Open conflict", session(dir)) + end + + @testset "a failing write does not propagate" begin + bad = mktempdir() + write(joinpath(bad, ".bmo"), "a file where the folder should be") + old = gui.project + gui.project = bad + try + @test_logs (:warn, "could not write session.md") GUI._write_session!(gui) + @test_logs GUI._write_session!(gui) + @test (gui.controls.selected[] = obj(gui, :M1)) !== nothing + finally + gui.project = old + end + end + + @testset "lines of the journal" begin + before = BeamletOpticsProjects.load_project(dir) + t = read(sys, String) + t = replace(t, "L1 = $LENS pos = (0.0, 0.1, 0.0)" => "L1 = $LENS pos = (0.0, 0.10512, 0.0)") + t = replace(t, "@component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0)" => "@component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0) rot = (0.0, 0.0, 30.0)") + t = replace(t, "@component M1 = RoundPlanoMirror(0.0254, 0.006)" => "@component M1 = RoundPlanoMirror(0.0254, 0.007)") + t *= "@component M4 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.5, 0.0)\n" + write(sys, t) + after = BeamletOpticsProjects.load_project(dir) + changes = BeamletOpticsProjects.diff_projects(before, after) + empty!(gui.sync.journal) + GUI._journal!(gui, changes) + lines = [l for (_, l) in gui.sync.journal] + @test "moved L1 to (0.0, 0.1051, 0.0)" in lines + @test "rotated D1" in lines + @test "rebuilt M1" in lines + @test any(l -> startswith(l, "added M4 = RoundPlanoMirror("), lines) + text = session(dir) + @test occursin("Recent changes by the person (newest first):", text) + @test occursin(r"- \d\d:\d\d:\d\d moved L1 to \(0\.0, 0\.1051, 0\.0\)", text) + # newest first + @test findfirst("added M4", text).start < findfirst("moved L1", text).start + + both = BeamletOpticsProjects.Entry(:component, :Q1, "Detector(0.02)", nothing; + pos = (0.0, 0.0, 0.0), pose0 = ([0.0, 0.0, 0.0], [1.0 0 0; 0 1 0; 0 0 1]), uid = "u1") + moved = BeamletOpticsProjects.Entry(:component, :Q1, "Detector(0.02)", nothing; + pos = (1.0, 0.0, 0.0), rot = (0.0, 0.0, 10.0), + pose0 = ([0.0, 0.0, 0.0], [1.0 0 0; 0 1 0; 0 0 1]), uid = "u1") + EC = BeamletOpticsProjects.EntryChange + SC = BeamletOpticsProjects.SystemChange + @test GUI._entry_line(EC("u1", :moved, both, moved)) == "moved and rotated Q1 to (1.0, 0.0, 0.0)" + @test GUI._entry_line(EC("u1", :removed, both, nothing)) == "removed Q1" + @test GUI._entry_line(EC("u1", :renamed, both, moved)) == "renamed Q1 → Q1" + lab = BeamletOpticsProjects.Entry(:component, :Q1, "Detector(0.02)", nothing; + pos = (0.0, 0.0, 0.0), pose0 = ([0.0, 0.0, 0.0], [1.0 0 0; 0 1 0; 0 0 1]), uid = "u1", label = "Kollimator") + @test GUI._entry_line(EC("u1", :relabeled, both, lab)) == "relabeled Q1 \"Kollimator\"" + + s1 = BeamletOpticsProjects.SystemEntry(:S1, [:L1, :M1], [:B1]; uid = "s") + s2 = BeamletOpticsProjects.SystemEntry(:S1, [:L1, :M4], [:B1]; uid = "s") + @test GUI._member_lines(SC("s", :members, s1, s2)) == ["added M4 to S1", "removed M1 from S1"] + # a renamed member did not leave the system + s3 = BeamletOpticsProjects.SystemEntry(:S1, [:L7, :M1, :M4], [:B1]; uid = "s") + @test GUI._member_lines(SC("s", :members, s1, s3), Dict(:L1 => :L7)) == ["added M4 to S1"] + @test GUI._system_line(SC("s", :added, nothing, s2)) == "added system S1" + end + + @testset "at most 20 entries" begin + empty!(gui.sync.journal) + EC = BeamletOpticsProjects.EntryChange + e = BeamletOpticsProjects.Entry(:component, :Q1, "Detector(0.02)", nothing; + pose0 = ([0.0, 0.0, 0.0], [1.0 0 0; 0 1 0; 0 0 1]), uid = "u1") + for i in 1:25 + GUI._journal!(gui, (; entries = [EC("u1", :removed, e, nothing)], systems = [])) + end + @test length(gui.sync.journal) == 20 + @test count("- ", session(dir)) >= 20 + @test count(l -> occursin("removed Q1", l), split(session(dir), "\n")) == 20 + end + + @testset "closed" begin + GUI._session_closed!(gui) + @test isempty(gui.sync.session) + text = session(dir) + @test occursin("No window is open on this project.", text) + @test !occursin("Selected", text) + gui.controls.selected[] = obj(gui, :M1) + @test !occursin("Selected", session(dir)) + end +end + +end diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 2f3d4fe..9c8cba5 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -198,4 +198,357 @@ end close(gui) end +"""Looks of the autosave: the setup changed, not settled yet, settled.""" +function autosave!(gui) + t = time() + first = GUI._autosave!(gui; now = t) + first === :pending || return first + @test GUI._autosave!(gui; now = t + 0.2) === :pending + return GUI._autosave!(gui; now = t + 1) +end + +"""Makes the hidden `gui` count as a shown window: a watcher (that never fires) and session.md.""" +function watched!(gui) + gui.sync.timer = Timer(3600) + GUI._connect_session!(gui) + return gui +end + +pos_line(dir, id) = only(l for l in split(read(system(dir), String), "\n") + if startswith(l, "@component $id = ") || startswith(l, "@source $id = ")) + +@testset "Autosave" begin + gui, dir = opened() + + @testset "a move is written once it settled" begin + @test GUI._autosave!(gui) === :none + before = read(system(dir), String) + translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) + t = time() + @test GUI._autosave!(gui; now = t) === :pending + @test GUI._autosave!(gui; now = t + 0.2) === :pending + @test read(system(dir), String) == before + @test GUI._autosave!(gui; now = t + 0.6) === :saved + @test occursin("pos = (0.0, 0.1, 0.01)", pos_line(dir, "L1")) + # Its own write is no change of the files + @test tick!(gui) === :none + @test last(gui.sync.journal)[2] == "moved L1 to (0.0, 0.1, 0.01)" + @test occursin("moved L1", read(joinpath(dir, ".bmo", "session.md"), String)) + end + + @testset "nothing to write" begin + m = mtime(system(dir)) + @test GUI._autosave!(gui; now = time() + 10) === :none + @test mtime(system(dir)) == m + end + + @testset "not while dragging or with changed files" begin + translate3d!(gui, obj(gui, :M1), [0.0, 0.0, 0.01]) + gui.controls.dragging = true + @test GUI._autosave!(gui) === :paused + gui.controls.dragging = false + gui.sync.changed_at = time() + @test GUI._autosave!(gui) === :paused + gui.sync.changed_at = nothing + @test autosave!(gui) === :saved + @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) + end + + @testset "a new object is written once, with one uid" begin + lens = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) + add_component!(gui, lens; system = only(GUI._systems(gui)), select = false, code = LENS) + translate3d!(gui, lens, [0.0, 0.6, 0.0]) + @test autosave!(gui) === :saved + line = only(l for l in split(read(system(dir), String), "\n") if occursin("0.6", l)) + @test occursin(r"uid = \"[0-9a-f-]{36}\"", line) + @test GUI._autosave!(gui; now = time() + 10) === :none + @test any(l -> startswith(l[2], "added L"), gui.sync.journal) + end + + @testset "an agent's change and a change in the window, both written" begin + translate3d!(gui, obj(gui, :L1), [0.01, 0.0, 0.0]) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.3, 0.0)" => "pos = (0.0, 0.31, 0.0)"), text, "D1"), dir) + @test tick!(gui) === :applied + @test autosave!(gui) === :saved + @test occursin("pos = (0.01, 0.1, 0.01)", pos_line(dir, "L1")) + @test occursin("pos = (0.0, 0.31, 0.0)", pos_line(dir, "D1")) + # The agent's change is no change of the person + @test !any(l -> occursin("D1", l[2]), gui.sync.journal) + end + + @testset "the files do not load: backup, then saved when they load again" begin + good = read(system(dir), String) + edit!(text -> text * "@component Z1 = $(LENS[1:end-1])\n", dir) + @test tick!(gui) === :failed + translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) + @test autosave!(gui) === :paused + backup = joinpath(dir, ".bmo", "window-backup.jl") + @test isfile(backup) + @test occursin("window-backup.jl", gui.status.text[]) + @test GUI._autosave!(gui; now = time() + 10) === :paused + # The backup loads as a project + copy = mktempdir() + new_project(copy; name = "Bench") + cp(backup, system(copy); force = true) + @test length(load_project(copy).entries) == length(gui.sync.base.entries) + # The agent fixes the file, with another change + write(system(dir), replace(good, "pos = (0.0, 0.31, 0.0)" => "pos = (0.0, 0.32, 0.0)")) + @test tick!(gui) === :applied + @test autosave!(gui) === :saved + @test occursin("pos = (0.01, 0.1, 0.02)", pos_line(dir, "L1")) + @test occursin("pos = (0.0, 0.32, 0.0)", pos_line(dir, "D1")) + end + + @testset "an agent writing from an older read is a conflict" begin + old = read(system(dir), String) + m1 = obj(gui, :M1) + translate3d!(gui, m1, [0.0, 0.0, 0.01]) + @test autosave!(gui) === :saved + mine = BeamletOptics.position(m1) + # The agent writes the whole file it read before, with a change of its own + write(system(dir), replace(old, "pos = (0.0, 0.32, 0.0)" => "pos = (0.0, 0.33, 0.0)")) + @test tick!(gui) === :conflict + @test BeamletOptics.position(m1) == mine + @test gui.sync.conflicts == [gui.sync.ids[m1][2]] + @test BeamletOptics.position(obj(gui, :D1)) == [0.0, 0.33, 0.0] + GUI._autosave!(gui) + @test GUI._autosave!(gui; now = time() + 10) === :paused + GUI._resolve_conflict!(gui, :mine) + @test autosave!(gui) === :saved + @test occursin("pos = (0.0, 0.2, 0.02)", pos_line(dir, "M1")) + end + + @testset "an agent setting back its own change is no conflict" begin + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.33, 0.0)" => "pos = (0.0, 0.4, 0.0)"), text, "D1"), dir) + @test tick!(gui) === :applied + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.4, 0.0)" => "pos = (0.0, 0.33, 0.0)"), text, "D1"), dir) + @test tick!(gui) === :applied + @test BeamletOptics.position(obj(gui, :D1)) == [0.0, 0.33, 0.0] + end + + @testset "closing a watched window saves the last change" begin + watched!(gui) + translate3d!(gui, obj(gui, :M1), [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui) === :pending + close(gui) + @test occursin("pos = (0.0, 0.2, 0.03)", pos_line(dir, "M1")) + @test occursin("No window is open", read(joinpath(dir, ".bmo", "session.md"), String)) + # No watcher starts again, also not by that save + @test isnothing(gui.sync.timer) && gui.sync.closed + end +end + +@testset "Corner cases of the autosave and the merge" begin + @testset "a write that leaves the times and sizes of the files as they were" begin + gui, dir = opened() + l1 = obj(gui, :L1) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.2, 0.0)"), text, "L1"), dir) + # As if the watcher had seen these times and sizes already, e.g. of its own save + gui.sync.marks = GUI._file_marks(dir) + @test tick!(gui) === :applied + @test BeamletOptics.position(l1) == [0.0, 0.2, 0.0] + @test tick!(gui) === :none + close(gui) + end + + @testset "closing a hidden view saves nothing" begin + gui, dir = opened() + before = read(system(dir), String) + translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) + close(gui) + @test read(system(dir), String) == before + @test !isfile(joinpath(dir, ".bmo", "session.md")) + end + + @testset "an object gliding to a pose of the files" begin + gui, dir = opened() + GUI._autosave!(gui) + m1 = obj(gui, :M1) + # The merge of a shown window glides; here it is asked for + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.2, 0.0)" => "pos = (0.0, 0.3, 0.0)"), text, "M1"), dir) + @test GUI._sync!(gui; animate = true) === :applied + @test haskey(gui.sync.gliding, m1) + # The autosave waits until it landed + translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui; now = time() + 10) === :paused + # A save lands it at once and writes the pose of the files, not the one of the glide + @test GUI._autosave!(gui; force = true) === :saved + @test isempty(gui.sync.gliding) + @test BeamletOptics.position(m1) == [0.0, 0.3, 0.0] + @test occursin("pos = (0.0, 0.3, 0.0)", pos_line(dir, "M1")) + @test !any(l -> occursin("M1", l[2]), gui.sync.journal) + sleep(0.5) + @test BeamletOptics.position(m1) == [0.0, 0.3, 0.0] + close(gui) + end + + @testset "files that break before a forced save: backup" begin + gui, dir = opened() + watched!(gui) + translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui) === :pending + # The agent breaks the file, the watcher did not look yet; the window closes + edit!(text -> text * "@component Z1 = $(LENS[1:end-1])\n", dir) + close(gui) + backup = joinpath(dir, ".bmo", "window-backup.jl") + @test isfile(backup) && occursin("pos = (0.0, 0.1, 0.01)", read(backup, String)) + @test gui.sync.failed && !isempty(gui.sync.load_error) + end + + @testset "a conflict stays until the person decides" begin + gui, dir = opened() + GUI._autosave!(gui) + l1, d1 = obj(gui, :L1), obj(gui, :D1) + translate3d!(gui, l1, [0.01, 0.0, 0.0]) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.11, 0.0)"), text, "L1"), dir) + @test tick!(gui) === :conflict + key = gui.sync.ids[l1][2] + @test gui.sync.conflicts == [key] + # The agent goes on with D1 only: the conflict on L1 stays + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.3, 0.0)" => "pos = (0.0, 0.31, 0.0)"), text, "D1"), dir) + @test tick!(gui) === :conflict + @test gui.sync.conflicts == [key] && GUI._conflict_shown(gui) + @test BeamletOptics.position(d1) == [0.0, 0.31, 0.0] + GUI._autosave!(gui) + @test GUI._autosave!(gui; now = time() + 10) === :paused + # The card is closed without a decision; a change of the person shows it again + GUI._release_info!(gui, GUI._ProjectConflict) + translate3d!(gui, d1, [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui) === :pending + @test GUI._conflict_shown(gui) + @test GUI._autosave!(gui; now = time() + 10) === :paused + # Take theirs: L1 of the files + GUI._resolve_conflict!(gui, :theirs) + @test BeamletOptics.position(l1) == [0.0, 0.11, 0.0] + @test isempty(gui.sync.conflicts) + @test autosave!(gui) === :saved + @test occursin("pos = (0.0, 0.11, 0.0)", pos_line(dir, "L1")) + @test occursin("pos = (0.0, 0.31, 0.01)", pos_line(dir, "D1")) + close(gui) + end + + @testset "Ctrl+R ends a conflict" begin + gui, dir = opened() + GUI._autosave!(gui) + translate3d!(gui, obj(gui, :L1), [0.01, 0.0, 0.0]) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.11, 0.0)"), text, "L1"), dir) + @test tick!(gui) === :conflict + GUI._reload_key!(gui) + @test isempty(gui.sync.conflicts) && !GUI._conflict_shown(gui) + @test GUI._autosave!(gui) === :none + close(gui) + end + + @testset "Save as… of a conflict, cancelled: the conflict stays" begin + gui, dir = opened() + GUI._autosave!(gui) + translate3d!(gui, obj(gui, :L1), [0.01, 0.0, 0.0]) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.11, 0.0)"), text, "L1"), dir) + @test tick!(gui) === :conflict + dialog = GUI._SAVE_DIALOG[] + GUI._SAVE_DIALOG[] = _ -> nothing + try + GUI._resolve_conflict!(gui, :save_as) + wait(GUI._DIALOG_TASK[]) + finally + GUI._SAVE_DIALOG[] = dialog + end + @test !isempty(gui.sync.conflicts) + GUI._autosave!(gui) + @test GUI._autosave!(gui; now = time() + 10) === :paused + @test occursin("pos = (0.0, 0.11, 0.0)", pos_line(dir, "L1")) + close(gui) + end + + @testset "no false conflicts of an agent's own changes" begin + gui, dir = opened() + GUI._autosave!(gui) + # The agent moves L1, the person moves M1 (an autosave writes L1 as merged), the agent + # sets L1 back + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.15, 0.0)"), text, "L1"), dir) + @test tick!(gui) === :applied + translate3d!(gui, obj(gui, :M1), [0.0, 0.0, 0.01]) + @test autosave!(gui) === :saved + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.15, 0.0)" => "pos = (0.0, 0.1, 0.0)"), text, "L1"), dir) + @test tick!(gui) === :applied + @test BeamletOptics.position(obj(gui, :L1)) == [0.0, 0.1, 0.0] + # The agent removes D1 and brings it back as it was + full = read(system(dir), String) + edit!(dir) do text + text = join(filter(l -> !startswith(l, "@component D1 = "), split(text, "\n")), "\n") + return line!(l -> replace(l, ", D1]" => "]"), text, "S1") + end + @test tick!(gui) === :applied + @test isnothing(obj(gui, :D1)) + write(system(dir), full) + @test tick!(gui) === :applied + @test !isnothing(obj(gui, :D1)) + close(gui) + end + + @testset "an agent overwriting the person's undo is a conflict" begin + gui, dir = opened() + GUI._autosave!(gui) + l1 = obj(gui, :L1) + # The person moves L1, the agent moves it on, the person undoes the move (Ctrl+Z) + translate3d!(gui, l1, [0.0, 0.0, 0.01]) + @test autosave!(gui) === :saved + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.01)" => "pos = (0.0, 0.1, 0.02)"), text, "L1"), dir) + @test tick!(gui) === :applied + stale = read(system(dir), String) + translate3d!(gui, l1, [0.0, 0.0, -0.02]) + @test autosave!(gui) === :saved + # The agent writes the file of its older read + write(system(dir), stale) + @test tick!(gui) === :conflict + @test BeamletOptics.position(l1) == [0.0, 0.1, 0.0] + close(gui) + end + + @testset "objects without a recipe: warned once" begin + gui, dir = opened() + GUI._autosave!(gui) + add_component!(gui, RoundPlanoMirror(0.0254, 0.006); system = :none, select = false) + translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) + t = time() + GUI._autosave!(gui; now = t) + @test_logs (:warn, r"1 objects without a recipe") GUI._autosave!(gui; now = t + 1) + @test occursin("without a recipe", gui.status.text[]) + translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) + t = time() + GUI._autosave!(gui; now = t) + @test_logs GUI._autosave!(gui; now = t + 1) + close(gui) + end +end + +@testset "Time of a merge and an autosave of a larger setup" begin + dir = mktempdir() + new_project(dir; name = "Bench") + lines = ["@component L$i = $LENS pos = (0.0, $(0.01 * i), 0.0)" for i in 1:23] + write(system(dir), join(lines, "\n") * "\n@system S1 = [$(join(("L$i" for i in 1:23), ", "))]\n") + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + GUI._autosave!(gui) + function round!(id, from, to, other) + edit!(text -> line!(l -> replace(l, from => to), text, id), dir) + translate3d!(gui, obj(gui, other), [0.0, 0.0, 0.001]) + t = time() + GUI._check_files!(gui; now = t) + merge = @elapsed GUI._check_files!(gui; now = t + 1) + GUI._autosave!(gui; now = t + 1) + save = @elapsed GUI._autosave!(gui; now = t + 2) + return merge, save + end + # The first round compiles + round!("L2", "pos = (0.0, 0.02, 0.0)", "pos = (0.0, 0.021, 0.0)", :L3) + merge, save = round!("L5", "pos = (0.0, 0.05, 0.0)", "pos = (0.0, 0.051, 0.0)", :L9) + # The merge loads the project, which runs `system.jl` (most of its time) + @info "with 23 objects, a merge took $(round(merge * 1000; digits = 1)) ms, an autosave $(round(save * 1000; digits = 1)) ms" + @test save < 0.05 + @test occursin("pos = (0.0, 0.051, 0.0)", pos_line(dir, "L5")) + @test occursin("pos = (0.0, 0.09, 0.001)", pos_line(dir, "L9")) + close(gui) +end + end diff --git a/test/TestPrecompileSession.jl b/test/TestPrecompileSession.jl index 12ea40a..766b684 100644 --- a/test/TestPrecompileSession.jl +++ b/test/TestPrecompileSession.jl @@ -45,6 +45,25 @@ using Test # each entry is placed, dragged and removed @test length(names) == 3 * length(component_catalog()) end + + @testset "project" begin + empty!(failed) + empty!(names) + # the project lies in a repository, whose history the workload must not change: the + # versions go into the repository of the project + LibGit2 = BeamletOpticsGUI.BMOP.LibGit2 + outer = mktempdir() + close(LibGit2.init(outer)) + dir = joinpath(outer, "project") + gui, screen = ext._project_session(step; dir) + @test isempty(failed) + @test length(names) == 7 + @test LibGit2.with(LibGit2.isorphan, LibGit2.GitRepo(outer)) + @test LibGit2.with(r -> realpath(LibGit2.path(r)) == realpath(dir), LibGit2.GitRepoExt(dir)) + # the steps call the watcher themselves: none runs alongside them + @test isnothing(gui.sync.timer) || !isopen(gui.sync.timer) + ext._close!(gui, screen) + end end end diff --git a/test/runtests.jl b/test/runtests.jl index 6960df9..b4f0889 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -40,6 +40,7 @@ include(joinpath(@__DIR__, "TestLiveEdit.jl")) include(joinpath(@__DIR__, "TestLiveCopy.jl")) include(joinpath(@__DIR__, "TestLiveProject.jl")) include(joinpath(@__DIR__, "TestLiveProjectSync.jl")) +include(joinpath(@__DIR__, "TestLiveProjectSession.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) From a98de37c06de5d064c2fcd000a11040a2281e322 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Tue, 27 Oct 2026 22:15:42 +0100 Subject: [PATCH 07/47] Projects prototype: people and agents on one project, also on a shared drive - Participants, people and agents alike: each window writes .bmo/presence-.toml (name, kind, beat, ttl, selection, stamp of its last save) and its own .bmo/session-.md; the window shows the others with a box and their name at the object they work on; the status line names who wrote merged files ("Anna: moved L1"), else the one active agent - The view of each person in .bmo/view-.toml, view.toml stays the default - The watcher ignores dotfiles of src/ (._X.jl of macOS on shared drives) - A save as removes the session and presence files of the old folder - Precompile step: another participant works on an object and leaves Co-Authored-By: Claude Opus 5.5 --- ext/BeamletOpticsGUIGLMakieExt/session.jl | 22 +- src/BeamletOpticsGUI.jl | 1 + src/LiveProject.jl | 76 ++++-- src/LiveProjectPresence.jl | 295 ++++++++++++++++++++++ src/LiveProjectSession.jl | 41 ++- src/LiveProjectSync.jl | 56 +++- src/LiveView.jl | 6 + test/TestLiveProject.jl | 48 +++- test/TestLiveProjectPresence.jl | 289 +++++++++++++++++++++ test/TestLiveProjectSession.jl | 44 +++- test/TestLiveProjectSync.jl | 32 ++- test/TestPrecompileSession.jl | 2 +- test/runtests.jl | 1 + 13 files changed, 841 insertions(+), 72 deletions(-) create mode 100644 src/LiveProjectPresence.jl create mode 100644 test/TestLiveProjectPresence.jl diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index 59b5fe0..8f0ec3a 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -736,7 +736,8 @@ Opens a new project in the empty folder `dir`, with a repository of its own, in and works on it like a person with an agent: drags the lens, which the autosave writes to the files; changes the files like an agent, which the window merges and glides; `Ctrl+S` with the card of the version, closed by a typed message and `Enter`, by `Esc`, by the button "Cancel" and by the -button "Save version". The steps call the autosave and the watcher themselves, with times that do +button "Save version"; another participant (a presence file) works on the mirror, shown with its +name, and leaves. The steps call the autosave and the watcher themselves, with times that do not depend on how long a step takes: the watcher of the window never starts, and the step of the agent waits until the mirror has glided. Returns the live view and its screen, which the caller closes. @@ -824,6 +825,25 @@ function _project_session(step; dir = mktempdir()) _check(isnothing(GUI._version_card(gui)), "Save version did not close the card of the version") _check(length(GUI.BMOP.versions(dir)) == 3, "Save version did not save a version") end + step("project: another participant works on the mirror, then leaves") do + file = GUI._presence_file(dir, "Anna@pc2") + mkpath(dirname(file)) + write(file, """ + name = "Anna" + host = "pc2" + beat = 1 + selected = "$(gui.sync.ids[mirror][2])" + selected_id = "$(gui.sync.ids[mirror][1])" + written = "" + """) + GUI._check_presence!(gui) + frame!() + _check(!isempty(gui.sync.presence), "the mirror of the other participant is not shown") + rm(file) + GUI._check_presence!(gui) + frame!() + _check(isempty(gui.sync.presence), "the other participant did not leave") + end return gui, screen end diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 60aaca2..df0f05f 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -111,6 +111,7 @@ include("LiveCopy.jl") include("LiveProject.jl") include("LiveProjectSync.jl") include("LiveProjectSession.jl") +include("LiveProjectPresence.jl") include("LiveDebug.jl") include("LiveDebugHUD.jl") include("LiveDebugSpheres.jl") diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 53f911e..dc1f621 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -1,13 +1,14 @@ #= Projects: saving a view to a project folder and opening one (see BeamletOpticsProjects). The setup is `system.jl` of the folder, written and read by BeamletOpticsProjects; how the view looks (camera, -saved views, clip planes, table, the colors of the beams, ...) is `view.toml`, which the GUI writes -and reads itself. +saved views, clip planes, table, the colors of the beams, ...) is `.bmo/view-.toml`, one per +person, which the GUI writes and reads itself; `view.toml` of the folder is only the default for a +person without a file. =# const BMOP = BeamletOpticsProjects -# The file of the folder of a project that holds how the view looks +# The default view of the folder of a project; the view of a person is `_view_file` const _VIEW_TOML = "view.toml" # The keys of saving and opening, in the help of the controls, see `_help_sections` @@ -274,7 +275,7 @@ function _move_id!(gui::LiveView, @nospecialize(old), @nospecialize(new)) end #= -view.toml +.bmo/view-.toml (default: view.toml) =# # The color of a beam in view.toml: "wavelength" or "#rrggbb"; `nothing` for any other @@ -288,7 +289,7 @@ _toml_color_value(s::AbstractString) = s == "wavelength" ? :wavelength : RGBf(Ma """ _view_dict(gui, names) -> Dict -What `view.toml` holds of the `gui`: the `camera`, the saved `views`, the `clip_planes` and +What the view file holds of the `gui`: the `camera`, the saved `views`, the `clip_planes` and `clip_beams`, the `table`, `auto_trace`, `orthographic`, `show_sources`, `snap`, per source (by its name in `names`) the `color`, `alpha` and `linewidth` of its beam, and the sources that are off. """ @@ -328,19 +329,38 @@ function _view_dict(gui::LiveView, names) return d end -"""Writes `view.toml` of the `gui` into the folder of the `project`, with the `names` of its sources.""" -function _write_view_toml(gui::LiveView, project, names) - path = BMOP.project_file(project, _VIEW_TOML) +"""The view file of the person of this window in the folder `dir`: `.bmo/view-.toml`.""" +_view_file(dir) = joinpath(dir, ".bmo", "view-$(_person().id).toml") + +# Writes `text` to `path` atomically +function _put_atomic(path::AbstractString, text::AbstractString) mkpath(dirname(path)) + tmp = path * ".tmp" + write(tmp, text) + mv(tmp, path; force = true) + return nothing +end + +""" +Writes the view of the `gui` to `.bmo/view-.toml` of the person, in the folder of the `project`, +with the `names` of its sources. `view.toml` in the folder is written only if it does not exist: it is +the default for a person who has no view file yet. +""" +function _write_view_toml(gui::LiveView, project, names) + path = _view_file(project.path) text = sprint(io -> TOML.print(io, _view_dict(gui, names); sorted = true)) - write(path, text) + _put_atomic(path, text) + default = BMOP.project_file(project, _VIEW_TOML) + isfile(default) || _put_atomic(default, text) return path end -"""`view.toml` of the `project`, an empty `Dict` without one.""" +"""The view of the person in the `project` (`.bmo/view-.toml`), else `view.toml`, else an empty `Dict`.""" function _read_view(project) - path = BMOP.project_file(project, _VIEW_TOML) - return isfile(path) ? TOML.parsefile(path) : Dict{String, Any}() + for path in (_view_file(project.path), BMOP.project_file(project, _VIEW_TOML)) + isfile(path) && return TOML.parsefile(path) + end + return Dict{String, Any}() end _view_tuple(d) = (Vector{Float64}(d["eye"]), Vector{Float64}(d["lookat"]), Vector{Float64}(d["up"])) @@ -348,7 +368,7 @@ _view_tuple(d) = (Vector{Float64}(d["eye"]), Vector{Float64}(d["lookat"]), Vecto """ _view_kwargs(view, byname, paired) -> (kwargs, beam_kwargs, off) -The kwargs of `live_view` from the `view` (of `view.toml`), with the objects of the project by name +The kwargs of `live_view` from the `view` (of the view file), with the objects of the project by name in `byname`; `paired` are the sources of the pairs of the view. Also the `beam_kwargs` per source and the sources that are `off`, of which those without a system are set after `live_view`. """ @@ -457,8 +477,8 @@ The live view of a loaded `project` (see `BeamletOpticsProjects.load_project`), its systems with their sources, a `System()` for a project without systems, the objects and sources without a system, their labels (else their readable ids), their ids and uids (see `_ProjectSync`), the origins of the objects (their code, with which they are saved again and, from -the catalog, edited and copied), and `view.toml`. The `kwargs` of `live_view` win over those of -`view.toml`. The `project` becomes the `base` of the sync. +the catalog, edited and copied), and the view file of the person (`.bmo/view-.toml`, else `view.toml`). The `kwargs` of `live_view` win over those of +the view file. The `project` becomes the `base` of the sync. """ function _open_from(project; view = _read_view(project), kwargs...) @nospecialize @@ -516,11 +536,13 @@ Opens the project in the folder `path`, or of its project file `path` (`.b `BeamletOpticsProjects.load_project`, in a live view and shows it if `display` is `true`: its systems with their sources, the objects and sources without a system, their labels (else their ids) and how the view looked when it was saved -(`view.toml`: camera, saved views, clip planes, table, the colors of the beams, ...). The `kwargs` -are those of [`live_view`](@ref) and win over `view.toml`. While it is shown, the window writes +(`.bmo/view-.toml` of the person, else `view.toml`: camera, saved views, clip planes, +table, the colors of the beams, ...). The `kwargs` are those of [`live_view`](@ref) and win over +the view file. While it is shown, the window writes every finished change of the person to the files (autosave), applies the changes that others make -to them (e.g. an agent) and notes the selection and the recent changes of the person in -`.bmo/session.md`. `Ctrl+S` saves a version of the project (a git commit with a message), see +to them (an agent or the window of another person), notes the selection and the recent changes +of the person in `.bmo/session-.md` and shows where the others work (see +`LiveProjectPresence.jl`). `Ctrl+S` saves a version of the project (a git commit with a message), see [`save_project`](@ref). The components and sources from the catalog can be edited and copied. `Ctrl+O` or the tool "Open" of a window opens a project chosen in a dialog and closes the window. """ @@ -561,7 +583,7 @@ Saves the setup of the live view `gui` as the project in the folder `dir` and re its `system.jl`: each system with its members and sources, the objects and sources without a system, each with its constructor call and its pose, see `BeamletOpticsProjects`. A folder that is not a project yet becomes one, with the versions of BeamletOptics, BeamletOpticsProjects, -BeamletOpticsGUI and GLMakie. How the view looks is written to `view.toml`. An object whose +BeamletOpticsGUI and GLMakie. How the view looks is written to `.bmo/view-.toml` (per person; `view.toml` only as the default of new people). An object whose constructor is not known, e.g. one that the script gave to `live_view`, is not saved: a warning and the status line name it. Each object, source and system keeps its readable id (e.g. `L1`) and its machine id (uid) across saves; a new one gets the next free id of its kind and a new uid, and its @@ -571,14 +593,13 @@ of its window and `Ctrl+S` save to, and the title of its window. `Ctrl+Shift+S` a name, see `_save_as_key!`. A shown window saves itself: every finished change of the person is written to the files (see -`_autosave!`) and noted in `.bmo/session.md`. `Ctrl+S` writes what is still pending and then saves a +`_autosave!`) and noted in `.bmo/session-.md`. `Ctrl+S` writes what is still pending and then saves a version of the project, a git commit whose message the card of the version asks for (see `_save_key!`). Saving a view that has a project to another folder ("save as") copies the own code of the project (`src/`) there first, without overwriting a file, and keeps the name of its module, such that the -copy loads. A shown window notes in `.bmo/session.md` of the old folder that no window is open on -it any more. +copy loads. A shown window removes its session and presence files from the old folder. Throws `BeamletOpticsProjects.ProjectChanged` and saves nothing if the files of the project of the view changed since it opened or saved it, e.g. by an agent, unless `force` is `true`. The autosave @@ -604,15 +625,16 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) _write_view_toml(gui, parts.project, parts.names) # The uids that the save gave are those of `parts.project` _apply_project_names!(gui, parts.project) - # A window that writes `.bmo/session.md` (see `_connect_session!`) leaves the old folder: its - # `session.md` says that no window is open, and its journal is not of the new project + # A shown window (see `_connect_session!`) leaves the old folder: its session and presence + # files go, and its journal is not of the new project connected = save_as && !isempty(gui.sync.session) if connected try _session_closed!(gui) + _presence_closed!(gui) catch e e isa InterruptException && rethrow() - @warn "could not write session.md of $from" exception = e + @warn "could not remove the session or presence file of $from" exception = e end end save_as && empty!(gui.sync.journal) @@ -738,7 +760,7 @@ end """ `Ctrl+S` and the tool "Save" of the `gui`: saves a version of its project. What the autosave has -not written yet is written to the files first, without waiting, and noted in `.bmo/session.md` (see +not written yet is written to the files first, without waiting, and noted in the session file (see `_autosave!` with `force`); then the card of the version asks for its message, filled with what changed since the last version (see `_ask_version!`), or the status line says that nothing changed. Nothing is saved while a conflict is open (see `_ProjectConflict`), whose card is shown, or when the diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl new file mode 100644 index 0000000..9f95c07 --- /dev/null +++ b/src/LiveProjectPresence.jl @@ -0,0 +1,295 @@ +#= +Several participants on one project, e.g. on a shared drive: the open windows of people and the +coding agents that change the files. Each participant has a presence file +`.bmo/presence-.toml`; a window writes its own and shows where the others are (see +plans/projects-people.md). + +The file holds the name and host of the participant, its `kind` (`"person"` for a window, `"agent"` +for a coding agent; default `"person"`), a counter `beat` that grows with every write, `ttl` (the +seconds that a beat counts; windows write `_ALIVE`, agents e.g. 600, since they write only when +they act), the object it works on (`selected`: its uid, `selected_id`: its readable id; `""` if +nothing; the readable id counts when the uid is missing or unknown, an agent writes only that), +optionally `doing` (a few words, shown under the name) and the stamp of the files after its last +save (`written`). A window writes its file when it saves, when its selection changes and every +`_BEAT` seconds. It reads the files of the others at every look of the watcher: an other is there +while its `beat` changed within its `ttl` seconds of the clock of this machine, so the clocks of +the machines are never compared. People and agents are shown alike: a box at their object and +their name. +=# + +# How often a window writes its presence file at least [s] +const _BEAT = 20.0 +# How long an other is shown after its `beat` last changed [s]: the `ttl` of a window and the +# default of a file without one; the `ttl` of a file is clamped to `_TTL_RANGE` +const _ALIVE = 60.0 +const _TTL_RANGE = (5.0, 3600.0) +# At most this many characters of `doing` are shown +const _DOING_CHARS = 80 +# The color of the boxes and names of the others: the green of Julia, not the accent of the themes +# (the selection, the boxes of the parts), not the purple of the clip planes or the orange of aiming +const _OTHER_COLOR = RGBf(0.22, 0.596, 0.149) +const _OTHER_BOX_WIDTH = 2 +const _OTHER_FONTSIZE = 14 + +""" + _person() -> (; id, name, host) + +The person of this window: `name` from `ENV["BMO_PERSON"]`, else the user name of the system +(`USERNAME`, `USER`), else `"someone"`; `host` the name of the machine; `id` is `"name@host"` with +every character outside `[A-Za-z0-9_.@-]` replaced by `_`, for the names of the files of the person +in `.bmo/`. +""" +function _person() + name = get(ENV, "BMO_PERSON", get(ENV, "USERNAME", get(ENV, "USER", "someone"))) + isempty(strip(name)) && (name = "someone") + host = gethostname() + id = replace("$name@$host", r"[^A-Za-z0-9_.@-]" => "_") + return (; id, name = String(name), host = String(host)) +end + +"""The presence file of the person `id` in the project folder `dir`.""" +_presence_file(dir::AbstractString, id::AbstractString) = joinpath(dir, ".bmo", "presence-$id.toml") + +# The id of the person of a presence file name, `nothing` for other files +function _presence_id(name::AbstractString) + m = match(r"^presence-(.+)\.toml$", name) + return isnothing(m) ? nothing : String(m.captures[1]) +end + +"""The machine id and the readable id of the selected object of the `gui`, `("", "")` if none.""" +function _presence_selected(gui::LiveView) + sel = gui.controls.selected[] + isnothing(sel) && return ("", "") + chain = try + _chain(gui.controls, sel) + catch + Any[sel] + end + for x in chain + haskey(gui.sync.ids, x) || continue + id, uid = gui.sync.ids[x] + return (uid, String(id)) + end + return ("", "") +end + +# The `beat` of the presence file `file`, 0 if it is missing or does not parse: a window that is +# opened again continues the counter, so that the others see a change +function _file_beat(file::AbstractString) + isfile(file) || return 0 + try + b = get(TOML.parsefile(file), "beat", 0) + return b isa Integer ? Int(b) : 0 + catch + return 0 + end +end + +""" + _write_presence!(gui; now = time()) + +Writes the presence file of the person of the `gui` (see `_person`, `_presence_file`) atomically: +its name and host, `kind = "person"`, `ttl = _ALIVE`, the `beat` one higher than in the file, the +selected object and the stamp of the files after the last save (`gui.sync.stamp`). Sets `gui.sync.beat_at` to `now`. Does nothing +without a project. An error of writing propagates; the watcher logs it. +""" +function _write_presence!(gui::LiveView; now::Real = time()) + dir = gui.project + isnothing(dir) && return nothing + me = _person() + file = _presence_file(dir, me.id) + uid, id = _presence_selected(gui) + data = Dict{String, Any}("name" => me.name, "host" => me.host, "kind" => "person", "ttl" => _ALIVE, + "beat" => _file_beat(file) + 1, "selected" => uid, "selected_id" => id, "written" => something(gui.sync.stamp, "")) + mkpath(dirname(file)) + tmp = file * ".tmp" + open(io -> TOML.print(io, data; sorted = true), tmp, "w") + mv(tmp, file; force = true) + gui.sync.beat_at = Float64(now) + return nothing +end + +# Whether the other `o` (an entry of `gui.sync.others`) is there at the time `now` +_alive(o, now::Real) = now - o.seen_at < o.ttl + +# The `ttl` of a presence file: a positive real clamped to `_TTL_RANGE`, else `_ALIVE` +function _presence_ttl(x) + (x isa Real && isfinite(x) && x > 0) || return _ALIVE + return clamp(Float64(x), _TTL_RANGE...) +end + +# A string field of a presence file, `""` if it is missing or not a string +_presence_string(data, key) = (x = get(data, key, ""); x isa AbstractString ? String(x) : "") + +""" + _check_presence!(gui; now = time()) + +One look at the presence files of the project of the `gui`: writes the own file if the last write +is `_BEAT` seconds ago, reads the files of the others (never the own one; a file that does not +parse, e.g. half written on a network drive, is skipped and its participant kept as it was) and +updates `gui.sync.others`, people and agents alike. An other whose `beat` changed is seen at `now`; +one whose file is gone is dropped. One whose `beat` did not change for its `ttl` seconds stays in +`others` but is not shown, so that the file of a window that crashed or of an agent that did not +delete it does not come back. Then the boxes and names of the +selections of the others are drawn again if what they show changed (see `_draw_presence!`). Does +nothing without a project. +""" +function _check_presence!(gui::LiveView; now::Real = time()) + dir = gui.project + isnothing(dir) && return nothing + s = gui.sync + now - s.beat_at >= _BEAT && _write_presence!(gui; now) + me = _person().id + bmo = joinpath(dir, ".bmo") + names = isdir(bmo) ? readdir(bmo) : String[] + found = Set{String}() + for name in names + id = _presence_id(name) + (isnothing(id) || id == me) && continue + push!(found, id) + data = try + TOML.parsefile(joinpath(bmo, name)) + catch + continue + end + beat = get(data, "beat", nothing) + beat isa Integer || continue + old = get(s.others, id, nothing) + seen_at = isnothing(old) || old.beat != beat ? Float64(now) : old.seen_at + name = _presence_string(data, "name") + kind = _presence_string(data, "kind") + s.others[id] = (; name = isempty(name) ? id : name, kind = isempty(kind) ? "person" : kind, + selected_uid = _presence_string(data, "selected"), selected_id = _presence_string(data, "selected_id"), + doing = first(_presence_string(data, "doing"), _DOING_CHARS), written = _presence_string(data, "written"), + ttl = _presence_ttl(get(data, "ttl", nothing)), seen_at, beat = Int(beat)) + end + for id in collect(keys(s.others)) + id in found || delete!(s.others, id) + end + _draw_presence!(gui, now) + return nothing +end + +# The object of the view with the machine id `uid`, `nothing` if it is not in the view +function _object_of_uid(gui::LiveView, uid::AbstractString) + isempty(uid) && return nothing + for (obj, (_, u)) in gui.sync.ids + u == uid && return obj + end + return nothing +end + +# The object of the view with the readable id `id` (e.g. "L1"), `nothing` if it is not in the view +function _object_of_id(gui::LiveView, id::AbstractString) + isempty(id) && return nothing + for (obj, (i, _)) in gui.sync.ids + string(i) == id && return obj + end + return nothing +end + +# The object the other `o` works on: by its uid, else by its readable id, else `nothing` +function _object_of_other(gui::LiveView, o) + obj = _object_of_uid(gui, o.selected_uid) + return isnothing(obj) ? _object_of_id(gui, o.selected_id) : obj +end + +# The label of the other `o`: its name, with what it is doing in a second line +_other_label(o) = isempty(o.doing) ? o.name : o.name * "\n" * o.doing + +# What the overlay of the others shows at `now`: per alive other with an object in the view the +# edges of the box of the object and the label, sorted by the id of the participant +function _presence_shown(gui::LiveView, now::Real) + shown = Tuple{Vector{Point3f}, String}[] + for id in sort!(collect(keys(gui.sync.others))) + o = gui.sync.others[id] + _alive(o, now) || continue + obj = _object_of_other(gui, o) + isnothing(obj) && continue + bb = _part_box(gui, obj) + isnothing(bb) && continue + push!(shown, (_bbox_wireframe(bb), _other_label(o))) + end + return shown +end + +# What the plots of the overlay show now, in the form of `_presence_shown` +function _presence_drawn(gui::LiveView) + p = gui.sync.presence + return [(Vector{Point3f}(p[i][1][]), _label_text(p[i + 1])) for i in 1:2:(length(p) - 1)] +end + +# The text of a label of the overlay (Makie keeps it as a vector of one string) +_label_text(p) = (t = p.text[]; String(t isa AbstractString ? t : only(t))) + +""" + _draw_presence!(gui, now) + +Draws where the others of the `gui` (people and agents alike) are at `now` (see +`_check_presence!`): the box of the object each works on in `_OTHER_COLOR` and its name at the +upper corner, with what it is doing (`doing`) in a second line; overlays in `gui.sync.presence` (a +box and a label per other). Only an object of the view is shown. +Draws again only if the boxes or names changed; hidden in the spectator mode. +""" +function _draw_presence!(gui::LiveView, now::Real) + s = gui.sync + shown = _presence_shown(gui, now) + visible = !gui.controls.spectator[] + if shown != _presence_drawn(gui) + _delete_presence!(gui) + for (pts, name) in shown + box = linesegments!(gui.ax, pts; color = _OTHER_COLOR, linewidth = _OTHER_BOX_WIDTH, visible, + clip_planes = Plane3f[], inspectable = false) + label = text!(gui.ax, maximum(pts); text = name, color = _OTHER_COLOR, fontsize = _OTHER_FONTSIZE, + align = (:left, :bottom), visible, clip_planes = Plane3f[], inspectable = false) + push!(s.presence, box, label) + end + end + for p in s.presence + p.visible[] == visible || (p.visible[] = visible) + end + return nothing +end + +function _delete_presence!(gui::LiveView) + for p in gui.sync.presence + try + delete!(gui.ax, p) + catch + end + end + empty!(gui.sync.presence) + return nothing +end + +""" + _presence_closed!(gui) + +Removes the boxes and names of the others from the `gui` and deletes its own presence file, when +the window is closed. A missing file is fine. +""" +function _presence_closed!(gui::LiveView) + _delete_presence!(gui) + empty!(gui.sync.others) + isnothing(gui.project) || rm(_presence_file(gui.project, _person().id); force = true) + return nothing +end + +""" + _writer(gui, stamp) -> Union{Nothing, String} + +The name of the participant of the `gui` (an other person or an agent) who is there now and wrote +the files with the stamp `stamp` (`written` of its presence file). Else, if exactly one participant +that is there is an agent (`kind = "agent"`), its name, since an agent need not write `written`. +Else `nothing` (the status then says "agent"). +""" +function _writer(gui::LiveView, stamp; now::Real = time()) + (isnothing(stamp) || isempty(stamp)) && return nothing + others = [gui.sync.others[id] for id in sort!(collect(keys(gui.sync.others)))] + filter!(o -> _alive(o, now), others) + for o in others + o.written == stamp && return o.name + end + agents = filter(o -> o.kind == "agent", others) + return length(agents) == 1 ? only(agents).name : nothing +end diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl index ea632b4..af611ce 100644 --- a/src/LiveProjectSession.jl +++ b/src/LiveProjectSession.jl @@ -1,13 +1,15 @@ #= -The file `.bmo/session.md` of a project: what the person sees and did in the open window, for the -agents that work on the same project (see plans/projects-reverse-sync.md). The window writes it -when the selection changes and when the autosave saved changes of the person (`_journal!`). +The file `.bmo/session-.md` of a project: what the person sees and did in the open window, +for the agents that work on the same project (see plans/projects-reverse-sync.md and +plans/projects-people.md). Every window has its own file, which is deleted at closing. The window +writes it when the selection changes and when the autosave saved changes of the person (`_journal!`). =# # How many changes of the person `session.md` lists const _JOURNAL_MAX = 20 -_session_file(dir::AbstractString) = joinpath(dir, ".bmo", "session.md") +# The session file of the person of this window, see `_person` +_session_file(dir::AbstractString) = joinpath(dir, ".bmo", "session-$(_person().id).md") # A position in m with 4 significant digits, e.g. `(0.0, 0.105, 0.0)` _session_pos(p) = "(" * join((string(round(Float64(x); sigdigits = 4)) for x in p), ", ") * ")" @@ -68,7 +70,7 @@ end _journal!(gui, changes) Adds the changes of the person to the journal of the `gui` (`gui.sync.journal`, the last -`$(_JOURNAL_MAX)`) and writes `.bmo/session.md`. `changes` is the result of +`$(_JOURNAL_MAX)`) and writes the session file. `changes` is the result of `BeamletOpticsProjects.diff_projects` from the state before an autosave to the one it saved. """ function _journal!(gui::LiveView, changes) @@ -161,10 +163,13 @@ function _session_text(gui::LiveView) return String(take!(io)) end -_session_header(dir) = "# Window of the project \"$(basename(abspath(dir)))\", " * - Libc.strftime("%Y-%m-%d %H:%M:%S", time()) +function _session_header(dir) + p = _person() + return "# Window of $(p.name) ($(p.host)) on the project \"$(basename(abspath(dir)))\", " * + Libc.strftime("%Y-%m-%d %H:%M:%S", time()) +end -# Writes `text` to `.bmo/session.md` atomically +# Writes `text` to the session file atomically function _put_session(dir::AbstractString, text::AbstractString) file = _session_file(dir) mkpath(dirname(file)) @@ -177,7 +182,7 @@ end """ _write_session!(gui) -Writes `.bmo/session.md` of the project of the `gui` now: selection, open conflicts, load failure +Writes the session file of the project of the `gui` now: selection, open conflicts, load failure and the journal. Does nothing without a project. An error of writing never propagates; it is logged once per distinct message. """ @@ -190,7 +195,7 @@ function _write_session!(gui::LiveView) msg = sprint(showerror, e) if msg != _SESSION_WARNED[] _SESSION_WARNED[] = msg - @warn "could not write session.md" exception = e + @warn "could not write the session file" exception = e end end return nothing @@ -202,7 +207,7 @@ const _SESSION_WARNED = Ref("") """ _connect_session!(gui) -Starts writing `.bmo/session.md` whenever the selection of the `gui` changes, and writes it once. +Starts writing the session file whenever the selection of the `gui` changes, and writes it once. Does nothing if it is connected already. """ function _connect_session!(gui::LiveView) @@ -218,7 +223,8 @@ end """ _session_closed!(gui) -Removes the listener of the `gui` and writes `.bmo/session.md` with the note that no window is open. +Removes the listener of the `gui` and deletes its session file (a missing file is fine). An error of +deleting never propagates; it is logged once per distinct message. """ function _session_closed!(gui::LiveView) for l in gui.sync.session @@ -226,6 +232,15 @@ function _session_closed!(gui::LiveView) end empty!(gui.sync.session) isnothing(gui.project) && return nothing - _put_session(gui.project, _session_header(gui.project) * "\nNo window is open on this project.\n") + try + rm(_session_file(gui.project); force = true) + catch e + e isa InterruptException && rethrow() + msg = sprint(showerror, e) + if msg != _SESSION_WARNED[] + _SESSION_WARNED[] = msg + @warn "could not delete the session file" exception = e + end + end return nothing end diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 9c81efa..9e939b2 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -36,7 +36,8 @@ end """ The times and sizes of the files of the project in `dir` that define the setup: `system.jl` and -the `.jl` files of `src/`. Cheap enough to be looked at often, see `_check_files!`. +the `.jl` files of `src/`, without names that start with `.` (e.g. `._X.jl` that macOS writes on +a shared drive), like `project_stamp`. Cheap enough to be looked at often, see `_check_files!`. """ function _file_marks(dir::AbstractString) marks = Tuple{String, Float64, Int}[] @@ -44,10 +45,13 @@ function _file_marks(dir::AbstractString) isfile(file) && push!(marks, ("system.jl", mtime(file), filesize(file))) src = joinpath(dir, "src") if isdir(src) - for (root, _, names) in walkdir(src), n in names - endswith(n, ".jl") || continue - path = joinpath(root, n) - push!(marks, (relpath(path, dir), mtime(path), filesize(path))) + for (root, dirs, names) in walkdir(src) + filter!(d -> !startswith(d, "."), dirs) + for n in names + (endswith(n, ".jl") && !startswith(n, ".")) || continue + path = joinpath(root, n) + push!(marks, (relpath(path, dir), mtime(path), filesize(path))) + end end end return sort!(marks) @@ -62,7 +66,8 @@ Records that the `gui` and the files of the `project` agree, after it was opened files were merged: `project` becomes the `base` of the comparisons (see `_ProjectSync`), a conflict is over, the states that the window wrote are remembered (unless `written` is `false`, e.g. for files that `Ctrl+R` took in; see `_stale`), `.bmo/session.md` is written if the view writes it, -and the watcher starts if the `gui` is shown. +the watcher starts if the `gui` is shown, and a watched view publishes the stamp of the files in +its presence file (the others name it as the writer, see `_writer`). """ function _saved!(gui::LiveView, project; written::Bool = true) s = gui.sync @@ -81,6 +86,7 @@ function _saved!(gui::LiveView, project; written::Bool = true) written && _remember_written!(s, before) _note_session!(gui) _start_watching!(gui) + isnothing(s.timer) || _write_presence!(gui) return nothing end @@ -122,20 +128,33 @@ end """ Starts the watcher of the files of the project of the `gui` if it is shown, which calls -`_check_files!` and `_autosave!` every `_WATCH_INTERVAL` seconds until the window is closed, and -the file `.bmo/session.md` (see `_connect_session!`). A view that is not shown (e.g. in the tests -or of a script) is not watched and writes no `session.md`; the functions can be called instead. +`_check_files!`, `_autosave!` and `_check_presence!` every `_WATCH_INTERVAL` seconds until the +window is closed, the file `.bmo/session.md` (see `_connect_session!`) and the presence file of the +window, written again when the selection changes (see `LiveProjectPresence.jl`). A view that is +not shown (e.g. in the tests or of a script) is not watched and writes neither; the functions can +be called instead. """ function _start_watching!(gui::LiveView) s = gui.sync (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing _connect_session!(gui) + _write_presence!(gui) + push!(gui.controls.listeners, on(gui.controls.selected) do _ + try + isnothing(gui.sync.timer) || _write_presence!(gui) + catch e + e isa InterruptException && rethrow() + @debug "could not write the presence file" exception = e + end + return nothing + end) last_error = Ref("") s.timer = Timer(_WATCH_INTERVAL; interval = _WATCH_INTERVAL) do _ _shown(gui) || return _stop_watching!(gui) try _check_files!(gui) _autosave!(gui) + _check_presence!(gui) catch e e isa InterruptException && rethrow() last_error[] = _log_once(e, last_error[], "watching the project") @@ -146,9 +165,9 @@ end """ Stops the watcher of the `gui` (see `_start_watching!`) when it is closed: if it was watched, saves -what the person changed last, without waiting (`_autosave!` with `force`), and notes in -`.bmo/session.md` that no window is open. A view that was not watched saves nothing. No watcher -starts again afterwards, also not by that save. +what the person changed last, without waiting (`_autosave!` with `force`), and removes its session +and presence files (`_session_closed!`, `_presence_closed!`). A view that was not watched saves +nothing. No watcher starts again afterwards, also not by that save. """ function _stop_watching!(gui::LiveView) s = gui.sync @@ -163,6 +182,12 @@ function _stop_watching!(gui::LiveView) e isa InterruptException && rethrow() @error "saving the project at closing failed" exception = (e, catch_backtrace()) end + try + _presence_closed!(gui) + catch e + e isa InterruptException && rethrow() + @error "removing the presence file at closing failed" exception = (e, catch_backtrace()) + end end isempty(s.session) && return nothing try @@ -444,7 +469,8 @@ Loads the project of the `gui` from its files and merges it into the open window applied (`_adopt!`, moved objects glide if `animate`), conflicting ones are left and shown on a card (`_ProjectConflict`), also changes back to an older state of the window (see `_stale`) and the conflicts still open from before, unless the files now agree with the window there. The files -become the new `base`. Returns `:applied`, `:conflict` or `:failed` (the reason in the status line +become the new `base`; the status line names the person whose window wrote them (`_writer`), else +"agent". Returns `:applied`, `:conflict` or `:failed` (the reason in the status line and in `load_error`, the window as it was). """ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) @@ -485,7 +511,9 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) s.theirs = disk if isempty(conflicts) _release_info!(gui, _ProjectConflict) - isempty(done) || (gui.status.text[] = "agent: " * join(done, ", ")) + # The other person whose window wrote these files, else an agent + who = something(_writer(gui, disk.stamp), "agent") + isempty(done) || (gui.status.text[] = "$who: " * join(done, ", ")) _note_session!(gui) return :applied end diff --git a/src/LiveView.jl b/src/LiveView.jl index e7bbd81..7a65bae 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -597,6 +597,9 @@ What the view knows of the files of its project, which an agent may change while - `journal`: the recent changes of the person in the view, `(time, line)`, newest last, and `session` the listeners that write them to `.bmo/session.md`, see `LiveProjectSession.jl`; - `gliding`: the objects that glide to a pose of the files, `obj => (P, R, entry)`, see `_glide!`; +- `others`: the other people who have the project open (by the id of their presence file, see + `LiveProjectPresence.jl`), `presence` the plots that show their selection, `beat_at` the time + the view last wrote its own presence file; - `timer`: the watcher, and `closed` whether the view was closed (no watcher starts again). """ Base.@kwdef mutable struct _ProjectSync @@ -618,6 +621,9 @@ Base.@kwdef mutable struct _ProjectSync session::Vector{Any} = Any[] gliding::IdDict{Any, Any} = IdDict{Any, Any}() skipped::Vector{String} = String[] + others::Dict{String, Any} = Dict{String, Any}() + presence::Vector{Any} = Any[] + beat_at::Float64 = 0.0 timer::Union{Nothing, Timer} = nothing closed::Bool = false end diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl index 426bf20..5d696a2 100644 --- a/test/TestLiveProject.jl +++ b/test/TestLiveProject.jl @@ -422,7 +422,7 @@ const GUI = BeamletOpticsGUI gui = t.gui save_project(gui, dir) _messages() = [v.message for v in versions(dir)] - _session() = read(joinpath(dir, ".bmo", "session.md"), String) + _session() = read(GUI._session_file(dir), String) GUI._connect_session!(gui) lens = string(first(gui.sync.ids[t.lens])) prism = string(first(gui.sync.ids[t.prism])) @@ -485,13 +485,13 @@ const GUI = BeamletOpticsGUI lens = first(gui.sync.ids[t.lens]) translate3d!(gui, t.lens, [0.0, 0.0, 0.01]) @test GUI._autosave!(gui; force = true) === :saved - @test occursin("moved $lens", read(joinpath(dir, ".bmo", "session.md"), String)) + @test occursin("moved $lens", read(GUI._session_file(dir), String)) target = joinpath(mktempdir(), "copy") save_project(gui, target) - @test occursin("No window is open on this project.", read(joinpath(dir, ".bmo", "session.md"), String)) + @test !isfile(GUI._session_file(dir)) # the new project starts without the journal of the old one, and its session is connected @test isempty(gui.sync.journal) && !isempty(gui.sync.session) - new = read(joinpath(target, ".bmo", "session.md"), String) + new = read(GUI._session_file(target), String) @test !occursin("moved $lens", new) && occursin("Recent changes by the person: none", new) close(gui) end @@ -650,6 +650,46 @@ const GUI = BeamletOpticsGUI @test count(x -> GUI._editable(gui2, x), collect(keys(gui2.components.origin))) == 6 close(gui2) end + + @testset "view per person" begin + dir = mktempdir() + eye_a, eye_b = [0.4, -0.3, 0.2], [-0.3, 0.5, 0.25] + file(id) = joinpath(dir, ".bmo", "view-$id.toml") + id_a = withenv(() -> GUI._person().id, "BMO_PERSON" => "a") + id_b = withenv(() -> GUI._person().id, "BMO_PERSON" => "b") + @test id_a != id_b + # a saves: own file and the default + t = _setup(:app) + set_view(t.gui.ax, eye_a, [0.0, 0.1, 0.0], [0.0, 0.0, 1.0]) + withenv("BMO_PERSON" => "a") do + save_project(t.gui, dir) + end + close(t.gui) + @test isfile(file(id_a)) && !isfile(file(id_b)) && isfile(joinpath(dir, "view.toml")) + default = read(joinpath(dir, "view.toml"), String) + # b opens: no own file yet, takes the default (the camera of a) + gb = withenv("BMO_PERSON" => "b") do + open_project(project_marker(dir); display = false, const_kw...) + end + @test maximum(abs, GUI._current_view(gb)[1] - eye_a) < 1e-6 + # b moves the camera and saves: only the own file changes, the default stays + set_view(gb.ax, eye_b, [0.0, 0.1, 0.0], [0.0, 0.0, 1.0]) + withenv("BMO_PERSON" => "b") do + save_project(gb) + end + close(gb) + @test isfile(file(id_b)) + @test read(joinpath(dir, "view.toml"), String) == default + @test TOML.parsefile(file(id_a))["camera"] != TOML.parsefile(file(id_b))["camera"] + # each person gets the own camera back + for (who, eye) in (("a", eye_a), ("b", eye_b)) + g = withenv("BMO_PERSON" => who) do + open_project(project_marker(dir); display = false, const_kw...) + end + @test maximum(abs, GUI._current_view(g)[1] - eye) < 1e-6 + close(g) + end + end end end diff --git a/test/TestLiveProjectPresence.jl b/test/TestLiveProjectPresence.jl new file mode 100644 index 0000000..85bc4aa --- /dev/null +++ b/test/TestLiveProjectPresence.jl @@ -0,0 +1,289 @@ +module TestLiveProjectPresence + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const TOML = GUI.TOML # the TOML of the package, not a dependency of the tests +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const START = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1] sources = [B1] + """ + +"""A project with `START`, opened (not shown) and saved once, so that every line has a uid.""" +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + return gui, dir +end + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +uid(gui, id) = gui.sync.ids[obj(gui, id)][2] + +"""Writes the presence file of an other participant `id` by hand; `extra` adds fields (e.g. `kind`).""" +function other!(dir, id; name = id, beat = 1, selected = "", selected_id = "", written = "", extra...) + file = GUI._presence_file(dir, id) + mkpath(dirname(file)) + data = Dict{String, Any}("name" => name, "host" => "PC9", "beat" => beat, + "selected" => selected, "selected_id" => selected_id, "written" => written) + for (k, v) in extra + data[string(k)] = v + end + open(io -> TOML.print(io, data), file, "w") + return file +end + +"""Writes the presence file of an agent `id` by hand, as an agent would: no uid, no host.""" +function agent!(dir, id; name = id, beat = 1, selected_id = "", doing = nothing, ttl = 600, written = nothing) + file = GUI._presence_file(dir, id) + mkpath(dirname(file)) + data = Dict{String, Any}("name" => name, "kind" => "agent", "beat" => beat, "ttl" => ttl, + "selected_id" => selected_id) + isnothing(doing) || (data["doing"] = doing) + isnothing(written) || (data["written"] = written) + open(io -> TOML.print(io, data), file, "w") + return file +end + +boxes(gui) = filter(p -> p isa LineSegments, gui.sync.presence) +labels(gui) = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text] + +@testset "Presence of several people" begin + withenv("BMO_PERSON" => "anna") do + gui, dir = opened() + me = GUI._person() + own = GUI._presence_file(dir, me.id) + + @testset "own file" begin + gui.controls.selected[] = obj(gui, :L1) + GUI._write_presence!(gui; now = 100.0) + @test isfile(own) + @test !isfile(own * ".tmp") + d = TOML.parsefile(own) + @test d["name"] == "anna" && d["host"] == me.host + @test d["beat"] == 1 + @test d["kind"] == "person" && d["ttl"] == GUI._ALIVE + @test d["selected"] == uid(gui, :L1) && d["selected_id"] == "L1" + @test d["written"] == gui.sync.stamp + @test gui.sync.beat_at == 100.0 + GUI._write_presence!(gui; now = 101.0) + @test TOML.parsefile(own)["beat"] == 2 + gui.controls.selected[] = nothing + GUI._write_presence!(gui; now = 102.0) + d = TOML.parsefile(own) + @test d["selected"] == "" && d["selected_id"] == "" + # The heartbeat: written again only after `_BEAT` + GUI._check_presence!(gui; now = 102.0 + GUI._BEAT / 2) + @test TOML.parsefile(own)["beat"] == 3 + GUI._check_presence!(gui; now = 102.0 + GUI._BEAT) + @test TOML.parsefile(own)["beat"] == 4 + # The own file is never an other + @test isempty(gui.sync.others) + @test isempty(gui.sync.presence) + end + + @testset "an other with a selection" begin + file = other!(dir, "bob@PC9"; name = "Bob", selected = uid(gui, :M1)) + GUI._check_presence!(gui; now = 200.0) + @test haskey(gui.sync.others, "bob@PC9") + @test length(boxes(gui)) == 1 + @test labels(gui) == ["Bob"] + box = only(boxes(gui)) + bb = GUI._part_box(gui, obj(gui, :M1)) + @test box[1][] == GUI._bbox_wireframe(bb) + @test box.color[] == Makie.to_color(GUI._OTHER_COLOR) + # Nothing changed: not drawn again + GUI._check_presence!(gui; now = 201.0) + @test only(boxes(gui)) === box + + # Alive while the beat changes, gone after `_ALIVE` without a change + GUI._check_presence!(gui; now = 200.0 + GUI._ALIVE - 1) + @test length(boxes(gui)) == 1 + other!(dir, "bob@PC9"; name = "Bob", beat = 2, selected = uid(gui, :M1)) + GUI._check_presence!(gui; now = 200.0 + GUI._ALIVE - 1) + GUI._check_presence!(gui; now = 200.0 + 2 * GUI._ALIVE - 2) + @test length(boxes(gui)) == 1 + GUI._check_presence!(gui; now = 200.0 + 2 * GUI._ALIVE - 1) + @test isempty(gui.sync.presence) + # A stale file does not come back + GUI._check_presence!(gui; now = 200.0 + 3 * GUI._ALIVE) + @test isempty(gui.sync.presence) + # ... until its beat changes again + other!(dir, "bob@PC9"; name = "Bob", beat = 3, selected = uid(gui, :M1)) + GUI._check_presence!(gui; now = 400.0) + @test labels(gui) == ["Bob"] + + # Another selection moves the box + other!(dir, "bob@PC9"; name = "Bob", beat = 4, selected = uid(gui, :L1)) + GUI._check_presence!(gui; now = 401.0) + @test only(boxes(gui))[1][] == GUI._bbox_wireframe(GUI._part_box(gui, obj(gui, :L1))) + + # A half-written file is skipped, the other kept + write(file, "name = \"Bob\nbeat = ") + GUI._check_presence!(gui; now = 402.0) + @test labels(gui) == ["Bob"] + + # An empty selection + other!(dir, "bob@PC9"; name = "Bob", beat = 5, selected = "") + GUI._check_presence!(gui; now = 403.0) + @test isempty(gui.sync.presence) + + # A uid that is not in the view + other!(dir, "bob@PC9"; name = "Bob", beat = 6, selected = "no-such-uid") + GUI._check_presence!(gui; now = 404.0) + @test haskey(gui.sync.others, "bob@PC9") + @test isempty(gui.sync.presence) + + # The file is deleted + other!(dir, "bob@PC9"; name = "Bob", beat = 7, selected = uid(gui, :M1)) + GUI._check_presence!(gui; now = 405.0) + @test length(boxes(gui)) == 1 + rm(file) + GUI._check_presence!(gui; now = 406.0) + @test isempty(gui.sync.presence) + @test !haskey(gui.sync.others, "bob@PC9") + end + + @testset "two others, spectator mode" begin + other!(dir, "bob@PC9"; name = "Bob", selected = uid(gui, :M1)) + other!(dir, "carl@PC8"; name = "Carl", selected = uid(gui, :L1)) + GUI._check_presence!(gui; now = 500.0) + @test sort(labels(gui)) == ["Bob", "Carl"] + @test all(p -> p.visible[], gui.sync.presence) + GUI._set_spectator!(gui.controls, true) + GUI._check_presence!(gui; now = 501.0) + @test length(gui.sync.presence) == 4 + @test !any(p -> p.visible[], gui.sync.presence) + GUI._set_spectator!(gui.controls, false) + GUI._check_presence!(gui; now = 502.0) + @test all(p -> p.visible[], gui.sync.presence) + end + + @testset "writer" begin + other!(dir, "bob@PC9"; name = "Bob", beat = 10, written = "stamp-b") + other!(dir, "carl@PC8"; name = "Carl", beat = 10, written = "stamp-c") + GUI._check_presence!(gui; now = 600.0) + @test GUI._writer(gui, "stamp-b"; now = 600.0) == "Bob" + @test GUI._writer(gui, "stamp-c"; now = 600.0) == "Carl" + @test isnothing(GUI._writer(gui, "stamp-x"; now = 600.0)) + @test isnothing(GUI._writer(gui, ""; now = 600.0)) + @test isnothing(GUI._writer(gui, nothing; now = 600.0)) + # Not alive any more + @test isnothing(GUI._writer(gui, "stamp-b"; now = 600.0 + GUI._ALIVE)) + # The own stamp is never an other's + GUI._write_presence!(gui; now = 600.0) + GUI._check_presence!(gui; now = 600.0) + @test isnothing(GUI._writer(gui, gui.sync.stamp; now = 600.0)) + end + + @testset "agents" begin + for id in ("bob@PC9", "carl@PC8") + rm(GUI._presence_file(dir, id); force = true) + end + GUI._check_presence!(gui; now = 1000.0) + @test isempty(gui.sync.others) + + # An agent with only the readable id is shown at that object, with its name + claude = agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", selected_id = "L1") + GUI._check_presence!(gui; now = 1000.0) + @test gui.sync.others["claude@agent"].kind == "agent" + @test only(boxes(gui))[1][] == GUI._bbox_wireframe(GUI._part_box(gui, obj(gui, :L1))) + @test labels(gui) == ["Claude (Opus 5.5)"] + + # `doing` in a second line of the label, at most `_DOING_CHARS` characters + agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 2, selected_id = "L1", doing = "moving L1") + GUI._check_presence!(gui; now = 1001.0) + @test labels(gui) == ["Claude (Opus 5.5)\nmoving L1"] + agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 3, selected_id = "L1", doing = "x"^200) + GUI._check_presence!(gui; now = 1002.0) + @test labels(gui) == ["Claude (Opus 5.5)\n" * "x"^GUI._DOING_CHARS] + + # A uid that is not in the view: the readable id counts + bob = other!(dir, "bob@PC9"; name = "Bob", beat = 1, selected = "no-such-uid", selected_id = "M1", + kind = "person", ttl = 60) + GUI._check_presence!(gui; now = 1002.0) + @test gui.sync.others["bob@PC9"].kind == "person" + @test sort(labels(gui)) == ["Bob", "Claude (Opus 5.5)\n" * "x"^GUI._DOING_CHARS] + + # The `ttl` of each: the agent's 600 s keep it, the window's 60 s do not + GUI._check_presence!(gui; now = 1002.0 + 300) + @test haskey(gui.sync.others, "bob@PC9") + @test labels(gui) == ["Claude (Opus 5.5)\n" * "x"^GUI._DOING_CHARS] + @test only(boxes(gui))[1][] == GUI._bbox_wireframe(GUI._part_box(gui, obj(gui, :L1))) + GUI._check_presence!(gui; now = 1002.0 + 600) + @test isempty(gui.sync.presence) + + # The `ttl` of a file is clamped, a wrong one is the default + for (ttl, want) in ((1.0e6, 3600.0), (1, 5.0), (-3, GUI._ALIVE), ("long", GUI._ALIVE)) + other!(dir, "bob@PC9"; name = "Bob", beat = 2, ttl) + GUI._check_presence!(gui; now = 2000.0) + @test gui.sync.others["bob@PC9"].ttl == want + end + rm(bob) + + # `_writer`: the one alive agent, even without `written` + agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 4) + GUI._check_presence!(gui; now = 3000.0) + @test GUI._writer(gui, "stamp-z"; now = 3000.0) == "Claude (Opus 5.5)" + # A person with a matching `written` comes first + other!(dir, "bob@PC9"; name = "Bob", beat = 3, written = "stamp-z") + GUI._check_presence!(gui; now = 3000.0) + @test GUI._writer(gui, "stamp-z"; now = 3000.0) == "Bob" + # An other person without a match: still the one agent + @test GUI._writer(gui, "stamp-y"; now = 3000.0) == "Claude (Opus 5.5)" + rm(GUI._presence_file(dir, "bob@PC9")) + # Two agents: nothing, unless the `written` of one matches + agent!(dir, "codex@agent"; name = "Codex", beat = 1, written = "stamp-c") + GUI._check_presence!(gui; now = 3000.0) + @test isnothing(GUI._writer(gui, "stamp-z"; now = 3000.0)) + @test GUI._writer(gui, "stamp-c"; now = 3000.0) == "Codex" + rm(GUI._presence_file(dir, "codex@agent")) + # None alive: nothing + GUI._check_presence!(gui; now = 3000.0) + @test isnothing(GUI._writer(gui, "stamp-z"; now = 3000.0 + 600)) + rm(claude) + GUI._check_presence!(gui; now = 3000.0) + @test isempty(gui.sync.others) + @test isnothing(GUI._writer(gui, "stamp-z"; now = 3000.0)) + end + + @testset "closing" begin + other = other!(dir, "bob@PC9"; name = "Bob", beat = 20, selected = uid(gui, :M1)) + GUI._check_presence!(gui; now = 700.0) + @test !isempty(gui.sync.presence) + plots = copy(gui.sync.presence) + GUI._presence_closed!(gui) + @test isempty(gui.sync.presence) + @test !any(p -> p in gui.ax.scene.plots, plots) + @test !isfile(own) + @test isfile(other) + GUI._presence_closed!(gui) # a missing file is fine + end + end +end + +@testset "Presence without a project" begin + gui = live_view(BeamletOptics.System(); const_kw...) + @test isnothing(GUI._write_presence!(gui)) + @test isnothing(GUI._check_presence!(gui)) + @test isnothing(GUI._presence_closed!(gui)) + @test isnothing(GUI._writer(gui, "x")) +end + +end diff --git a/test/TestLiveProjectSession.jl b/test/TestLiveProjectSession.jl index fbe16d8..f455a0b 100644 --- a/test/TestLiveProjectSession.jl +++ b/test/TestLiveProjectSession.jl @@ -27,9 +27,9 @@ function opened() end obj(gui, id) = first(x for (x, (name, _)) in gui.sync.ids if name === id) -session(dir) = read(joinpath(dir, ".bmo", "session.md"), String) +session(dir) = read(GUI._session_file(dir), String) -@testset "session.md" begin +@testset "session file" begin @testset "no file without a project" begin gui = live_view(BeamletOptics.System(); trace_budget = Inf, throttle = false, detectors = []) @test GUI._write_session!(gui) === nothing @@ -38,7 +38,7 @@ session(dir) = read(joinpath(dir, ".bmo", "session.md"), String) end gui, dir = opened() - file = joinpath(dir, ".bmo", "session.md") + file = GUI._session_file(dir) sys = joinpath(dir, "system.jl") @testset "connect, selection" begin @@ -46,7 +46,9 @@ session(dir) = read(joinpath(dir, ".bmo", "session.md"), String) GUI._connect_session!(gui) @test length(gui.sync.session) == 1 text = session(dir) - @test startswith(text, "# Window of the project \"$(basename(dir))\", ") + p = GUI._person() + @test basename(file) == "session-$(p.id).md" + @test startswith(text, "# Window of $(p.name) ($(p.host)) on the project \"$(basename(dir))\", ") @test occursin("Selected: nothing", text) @test occursin("Recent changes by the person: none", text) @test !occursin("Open conflict", text) @@ -85,7 +87,7 @@ session(dir) = read(joinpath(dir, ".bmo", "session.md"), String) old = gui.project gui.project = bad try - @test_logs (:warn, "could not write session.md") GUI._write_session!(gui) + @test_logs (:warn, "could not write the session file") GUI._write_session!(gui) @test_logs GUI._write_session!(gui) @test (gui.controls.selected[] = obj(gui, :M1)) !== nothing finally @@ -153,13 +155,37 @@ session(dir) = read(joinpath(dir, ".bmo", "session.md"), String) end @testset "closed" begin + @test isfile(file) GUI._session_closed!(gui) @test isempty(gui.sync.session) - text = session(dir) - @test occursin("No window is open on this project.", text) - @test !occursin("Selected", text) + @test !isfile(file) + @test GUI._session_closed!(gui) === nothing # a missing file is fine gui.controls.selected[] = obj(gui, :M1) - @test !occursin("Selected", session(dir)) + @test !isfile(file) + end + + @testset "one file per person" begin + dir2 = mktempdir() + new_project(dir2; name = "Bench") + write(joinpath(dir2, "system.jl"), START) + open_as(who) = withenv(() -> open_project(dir2; display = false, const_kw...), "BMO_PERSON" => who) + ga, gb = open_as("anna"), open_as("ben") + fa = withenv(() -> GUI._session_file(dir2), "BMO_PERSON" => "anna") + fb = withenv(() -> GUI._session_file(dir2), "BMO_PERSON" => "ben") + @test fa != fb + withenv("BMO_PERSON" => "anna") do + GUI._write_session!(ga) + end + withenv("BMO_PERSON" => "ben") do + GUI._write_session!(gb) + end + @test startswith(read(fa, String), "# Window of anna (") + @test startswith(read(fb, String), "# Window of ben (") + withenv("BMO_PERSON" => "anna") do + GUI._session_closed!(ga) + end + @test !isfile(fa) + @test isfile(fb) end end diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 9c8cba5..1c26a3f 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -233,7 +233,7 @@ pos_line(dir, id) = only(l for l in split(read(system(dir), String), "\n") # Its own write is no change of the files @test tick!(gui) === :none @test last(gui.sync.journal)[2] == "moved L1 to (0.0, 0.1, 0.01)" - @test occursin("moved L1", read(joinpath(dir, ".bmo", "session.md"), String)) + @test occursin("moved L1", read(GUI._session_file(dir), String)) end @testset "nothing to write" begin @@ -332,13 +332,39 @@ pos_line(dir, id) = only(l for l in split(read(system(dir), String), "\n") @test GUI._autosave!(gui) === :pending close(gui) @test occursin("pos = (0.0, 0.2, 0.03)", pos_line(dir, "M1")) - @test occursin("No window is open", read(joinpath(dir, ".bmo", "session.md"), String)) + @test !isfile(GUI._session_file(dir)) && !isfile(GUI._presence_file(dir, GUI._person().id)) # No watcher starts again, also not by that save @test isnothing(gui.sync.timer) && gui.sync.closed end end @testset "Corner cases of the autosave and the merge" begin + @testset "a shared drive: dotfiles, the window of another person" begin + gui, dir = opened() + # macOS writes ._X files beside the files on a shared drive + write(joinpath(dir, "src", "._Bench.jl"), "\0\0 not julia") + @test GUI._file_marks(dir) == gui.sync.marks + @test tick!(gui) === :none + # The window of Anna wrote the next state of the files and published its stamp + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.15, 0.0)"), text, "L1"), dir) + write(GUI._presence_file(dir, "Anna@pc2"), """ + name = "Anna" + host = "pc2" + beat = 1 + selected = "" + selected_id = "" + written = "$(project_stamp(dir))" + """) + GUI._check_presence!(gui) + @test tick!(gui) === :applied + @test startswith(gui.status.text[], "Anna: moved") + # Without a match it was an agent + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.15, 0.0)" => "pos = (0.0, 0.16, 0.0)"), text, "L1"), dir) + @test tick!(gui) === :applied + @test startswith(gui.status.text[], "agent: moved") + close(gui) + end + @testset "a write that leaves the times and sizes of the files as they were" begin gui, dir = opened() l1 = obj(gui, :L1) @@ -357,7 +383,7 @@ end translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) close(gui) @test read(system(dir), String) == before - @test !isfile(joinpath(dir, ".bmo", "session.md")) + @test !isfile(GUI._session_file(dir)) end @testset "an object gliding to a pose of the files" begin diff --git a/test/TestPrecompileSession.jl b/test/TestPrecompileSession.jl index 766b684..97762af 100644 --- a/test/TestPrecompileSession.jl +++ b/test/TestPrecompileSession.jl @@ -57,7 +57,7 @@ using Test dir = joinpath(outer, "project") gui, screen = ext._project_session(step; dir) @test isempty(failed) - @test length(names) == 7 + @test length(names) == 8 @test LibGit2.with(LibGit2.isorphan, LibGit2.GitRepo(outer)) @test LibGit2.with(r -> realpath(LibGit2.path(r)) == realpath(dir), LibGit2.GitRepoExt(dir)) # the steps call the watcher themselves: none runs alongside them diff --git a/test/runtests.jl b/test/runtests.jl index b4f0889..d41fde0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -41,6 +41,7 @@ include(joinpath(@__DIR__, "TestLiveCopy.jl")) include(joinpath(@__DIR__, "TestLiveProject.jl")) include(joinpath(@__DIR__, "TestLiveProjectSync.jl")) include(joinpath(@__DIR__, "TestLiveProjectSession.jl")) +include(joinpath(@__DIR__, "TestLiveProjectPresence.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) From e93f7aa8aef929dce90d679b7bd534a22869b378 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Tue, 27 Oct 2026 22:21:50 +0100 Subject: [PATCH 08/47] BeamletOpticsProjects from its GitHub repository in [sources] Co-Authored-By: Claude Opus 5.5 --- Project.toml | 2 +- docs/Project.toml | 2 +- test/Project.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Project.toml b/Project.toml index 21acacc..e3252b8 100644 --- a/Project.toml +++ b/Project.toml @@ -26,7 +26,7 @@ BeamletOpticsGUIGLMakieExt = "GLMakie" [sources] BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} -BeamletOpticsProjects = {path = "../BeamletOpticsProjects"} +BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} [compat] AbstractTrees = "0.4" diff --git a/docs/Project.toml b/docs/Project.toml index 149bbc4..9845d06 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -8,7 +8,7 @@ GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" [sources] BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} -BeamletOpticsProjects = {path = "../../BeamletOpticsProjects"} +BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} [compat] BeamletOptics = "0.14" diff --git a/test/Project.toml b/test/Project.toml index a3dd5ec..f5cef92 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -12,7 +12,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [sources] BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} -BeamletOpticsProjects = {path = "../../BeamletOpticsProjects"} +BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} [compat] Aqua = "0.8" From be51b64e51e5009a83623e62a1aa404344cdc57d Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 01:05:53 +0100 Subject: [PATCH 09/47] Projects prototype: the window on the protocol of BeamletOpticsProjects - Presence and session files through the participant protocol of BeamletOpticsProjects; the window keeps the drawing and the journal; the writer of merged files is named by the stamp it published, for people and agents alike, also when its presence comes after the merge - _sync! on merge_plan: members of a system that both the window and the files changed are merged as sets; removed objects stay out; a renamed member no longer resets the members - A new project saved by the window also gets the agent skill of the GUI (not versioned) Co-Authored-By: Claude Opus 5.5 --- src/LiveProject.jl | 8 ++ src/LiveProjectPresence.jl | 197 +++++++++++++++++++------------- src/LiveProjectSession.jl | 2 +- src/LiveProjectSync.jl | 80 ++++++------- test/TestLiveProject.jl | 7 ++ test/TestLiveProjectPresence.jl | 106 +++++++++++++++-- test/TestLiveProjectSync.jl | 63 ++++++++++ 7 files changed, 333 insertions(+), 130 deletions(-) diff --git a/src/LiveProject.jl b/src/LiveProject.jl index dc1f621..dccddc9 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -621,7 +621,13 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) # Before checking whether the folder is a project: the copy must not miss its module BMOP.copy_code(from, dir) end + new = !isfile(joinpath(dir, "Project.toml")) path = save_project(parts.project; packages = _project_packages(), force) + # A new project gets the skills of BeamletOptics and BeamletOpticsProjects from its scaffold, + # and the one of the GUI here; derived copies, not part of the versions + new && Base.CoreLogging.with_logger(Base.CoreLogging.NullLogger()) do + install_agent_skill(joinpath(dir, ".claude", "skills")) + end _write_view_toml(gui, parts.project, parts.names) # The uids that the save gave are those of `parts.project` _apply_project_names!(gui, parts.project) @@ -639,6 +645,8 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) end save_as && empty!(gui.sync.journal) gui.project = dir + # The files of this stamp are the window's own write, see `_own_written` + _own_save!(gui, parts.project.stamp) _saved!(gui, parts.project) connected && _connect_session!(gui) dir == from || _set_title!(gui, basename(dir)) diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index 9f95c07..8de6bad 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -10,7 +10,7 @@ seconds that a beat counts; windows write `_ALIVE`, agents e.g. 600, since they they act), the object it works on (`selected`: its uid, `selected_id`: its readable id; `""` if nothing; the readable id counts when the uid is missing or unknown, an agent writes only that), optionally `doing` (a few words, shown under the name) and the stamp of the files after its last -save (`written`). A window writes its file when it saves, when its selection changes and every +write (`written`; of a window: its last save, never files it merged). A window writes its file when it saves, when its selection changes and every `_BEAT` seconds. It reads the files of the others at every look of the watcher: an other is there while its `beat` changed within its `ttl` seconds of the clock of this machine, so the clocks of the machines are never compared. People and agents are shown alike: a box at their object and @@ -20,9 +20,8 @@ their name. # How often a window writes its presence file at least [s] const _BEAT = 20.0 # How long an other is shown after its `beat` last changed [s]: the `ttl` of a window and the -# default of a file without one; the `ttl` of a file is clamped to `_TTL_RANGE` +# default of a file without one (`BeamletOpticsProjects.read_presences` clamps the `ttl` of a file) const _ALIVE = 60.0 -const _TTL_RANGE = (5.0, 3600.0) # At most this many characters of `doing` are shown const _DOING_CHARS = 80 # The color of the boxes and names of the others: the green of Julia, not the accent of the themes @@ -34,27 +33,12 @@ const _OTHER_FONTSIZE = 14 """ _person() -> (; id, name, host) -The person of this window: `name` from `ENV["BMO_PERSON"]`, else the user name of the system -(`USERNAME`, `USER`), else `"someone"`; `host` the name of the machine; `id` is `"name@host"` with -every character outside `[A-Za-z0-9_.@-]` replaced by `_`, for the names of the files of the person -in `.bmo/`. +The person of this window, see `BeamletOpticsProjects.person`. """ -function _person() - name = get(ENV, "BMO_PERSON", get(ENV, "USERNAME", get(ENV, "USER", "someone"))) - isempty(strip(name)) && (name = "someone") - host = gethostname() - id = replace("$name@$host", r"[^A-Za-z0-9_.@-]" => "_") - return (; id, name = String(name), host = String(host)) -end - -"""The presence file of the person `id` in the project folder `dir`.""" -_presence_file(dir::AbstractString, id::AbstractString) = joinpath(dir, ".bmo", "presence-$id.toml") +_person() = BMOP.person() -# The id of the person of a presence file name, `nothing` for other files -function _presence_id(name::AbstractString) - m = match(r"^presence-(.+)\.toml$", name) - return isnothing(m) ? nothing : String(m.captures[1]) -end +"""The presence file of the person `id` in the project folder `dir`, see `BeamletOpticsProjects.presence_file`.""" +_presence_file(dir::AbstractString, id::AbstractString) = BMOP.presence_file(dir, id) """The machine id and the readable id of the selected object of the `gui`, `("", "")` if none.""" function _presence_selected(gui::LiveView) @@ -73,53 +57,71 @@ function _presence_selected(gui::LiveView) return ("", "") end -# The `beat` of the presence file `file`, 0 if it is missing or does not parse: a window that is -# opened again continues the counter, so that the others see a change -function _file_beat(file::AbstractString) - isfile(file) || return 0 - try - b = get(TOML.parsefile(file), "beat", 0) - return b isa Integer ? Int(b) : 0 - catch - return 0 - end -end - """ _write_presence!(gui; now = time()) Writes the presence file of the person of the `gui` (see `_person`, `_presence_file`) atomically: its name and host, `kind = "person"`, `ttl = _ALIVE`, the `beat` one higher than in the file, the -selected object and the stamp of the files after the last save (`gui.sync.stamp`). Sets `gui.sync.beat_at` to `now`. Does nothing -without a project. An error of writing propagates; the watcher logs it. +selected object and `written`, the stamp of the files after the last save of the window (see +`_own_written`). Sets `gui.sync.beat_at` to `now`. Does nothing without a project. An error of +writing propagates; the watcher logs it. """ function _write_presence!(gui::LiveView; now::Real = time()) dir = gui.project isnothing(dir) && return nothing me = _person() - file = _presence_file(dir, me.id) uid, id = _presence_selected(gui) data = Dict{String, Any}("name" => me.name, "host" => me.host, "kind" => "person", "ttl" => _ALIVE, - "beat" => _file_beat(file) + 1, "selected" => uid, "selected_id" => id, "written" => something(gui.sync.stamp, "")) - mkpath(dirname(file)) - tmp = file * ".tmp" - open(io -> TOML.print(io, data; sorted = true), tmp, "w") - mv(tmp, file; force = true) + "selected" => uid, "selected_id" => id, "written" => _own_written(gui, _presence_file(dir, me.id))) + BMOP.write_presence(dir, me.id, data) gui.sync.beat_at = Float64(now) return nothing end -# Whether the other `o` (an entry of `gui.sync.others`) is there at the time `now` -_alive(o, now::Real) = now - o.seen_at < o.ttl +# The `written` of the presence file `file`, `""` if it is missing, does not parse or has none +function _file_written(file::AbstractString) + isfile(file) || return "" + try + w = get(TOML.parsefile(file), "written", "") + return w isa AbstractString ? String(w) : "" + catch + return "" + end +end + +""" + _own_written(gui, file) -> String + +The stamp of the files that the window of the `gui` wrote last, for `written` of its presence file +`file`: `gui.sync.stamp` if the window wrote these files itself (`Ctrl+S`, the autosave, "save +as", see `_own_save!`); else the `written` of the file before is kept, so that the window never +claims files that it only opened, reloaded (`Ctrl+R`) or merged, e.g. the change of an other. +""" +function _own_written(gui::LiveView, file::AbstractString) + own = get(_OWN_SAVE, gui, nothing) + return !isnothing(own) && own == gui.sync.stamp ? own : _file_written(file) +end + +# Per view, the stamp of the files that its last save wrote, see `_own_written` +const _OWN_SAVE = WeakKeyDict{LiveView, String}() + +""" + _own_save!(gui, stamp) -# The `ttl` of a presence file: a positive real clamped to `_TTL_RANGE`, else `_ALIVE` -function _presence_ttl(x) - (x isa Real && isfinite(x) && x > 0) || return _ALIVE - return clamp(Float64(x), _TTL_RANGE...) +Records that the window of the `gui` wrote the files of the project with the `stamp` itself, by a +save (`save_project`), such that its presence publishes it as `written` (see `_own_written`). +""" +function _own_save!(gui::LiveView, stamp) + if isnothing(stamp) + delete!(_OWN_SAVE, gui) + else + _OWN_SAVE[gui] = String(stamp) + end + return nothing end -# A string field of a presence file, `""` if it is missing or not a string -_presence_string(data, key) = (x = get(data, key, ""); x isa AbstractString ? String(x) : "") +# Whether the other `o` (an entry of `gui.sync.others`) is there at the time `now` +_alive(o, now::Real) = BMOP.alive(o, now) """ _check_presence!(gui; now = time()) @@ -130,7 +132,8 @@ parse, e.g. half written on a network drive, is skipped and its participant kept updates `gui.sync.others`, people and agents alike. An other whose `beat` changed is seen at `now`; one whose file is gone is dropped. One whose `beat` did not change for its `ttl` seconds stays in `others` but is not shown, so that the file of a window that crashed or of an agent that did not -delete it does not come back. Then the boxes and names of the +delete it does not come back. A merge whose status line says "agent" gets the name of the other +that published its stamp meanwhile (see `_name_writer!`). Then the boxes and names of the selections of the others are drawn again if what they show changed (see `_draw_presence!`). Does nothing without a project. """ @@ -140,32 +143,19 @@ function _check_presence!(gui::LiveView; now::Real = time()) s = gui.sync now - s.beat_at >= _BEAT && _write_presence!(gui; now) me = _person().id - bmo = joinpath(dir, ".bmo") - names = isdir(bmo) ? readdir(bmo) : String[] - found = Set{String}() - for name in names - id = _presence_id(name) - (isnothing(id) || id == me) && continue - push!(found, id) - data = try - TOML.parsefile(joinpath(bmo, name)) - catch - continue - end - beat = get(data, "beat", nothing) - beat isa Integer || continue + for (id, data) in BMOP.read_presences(dir; except = me) + beat = data["beat"] old = get(s.others, id, nothing) seen_at = isnothing(old) || old.beat != beat ? Float64(now) : old.seen_at - name = _presence_string(data, "name") - kind = _presence_string(data, "kind") - s.others[id] = (; name = isempty(name) ? id : name, kind = isempty(kind) ? "person" : kind, - selected_uid = _presence_string(data, "selected"), selected_id = _presence_string(data, "selected_id"), - doing = first(_presence_string(data, "doing"), _DOING_CHARS), written = _presence_string(data, "written"), - ttl = _presence_ttl(get(data, "ttl", nothing)), seen_at, beat = Int(beat)) + s.others[id] = (; name = data["name"], kind = data["kind"], selected_uid = data["selected"], + selected_id = data["selected_id"], doing = first(data["doing"], _DOING_CHARS), written = data["written"], + ttl = data["ttl"], seen_at, beat) end + # A file that is skipped (it does not parse) keeps its participant as it was; a file that is gone drops it for id in collect(keys(s.others)) - id in found || delete!(s.others, id) + isfile(_presence_file(dir, id)) || delete!(s.others, id) end + _name_writer!(gui, now) _draw_presence!(gui, now) return nothing end @@ -275,21 +265,64 @@ function _presence_closed!(gui::LiveView) return nothing end +# Per view, the stamp of the files of the last merge whose writer `_writer` did not find, see +# `_name_writer!` +const _UNNAMED = WeakKeyDict{LiveView, String}() + +# The start of the status line of a merge whose writer was not found +const _UNNAMED_PREFIX = "agent: " + """ _writer(gui, stamp) -> Union{Nothing, String} -The name of the participant of the `gui` (an other person or an agent) who is there now and wrote -the files with the stamp `stamp` (`written` of its presence file). Else, if exactly one participant -that is there is an agent (`kind = "agent"`), its name, since an agent need not write `written`. -Else `nothing` (the status then says "agent"). +The name of the participant of the `gui` (an other person or an agent, alike) who is there now and +wrote the files with the stamp `stamp` (`written` of its presence file), else `nothing` (the status +then says "agent"). Called once per merge. An agent publishes the stamp of its write with +`BeamletOpticsProjects.announce(...; wrote = true)`, often only after the window merged it: a merge +whose writer is not found is therefore remembered, and `_name_writer!` names the writer in the +status line once its stamp arrives. """ function _writer(gui::LiveView, stamp; now::Real = time()) + name = _writer_of(gui, stamp, now) + if isnothing(name) && !isnothing(stamp) && !isempty(stamp) + _UNNAMED[gui] = String(stamp) + else + delete!(_UNNAMED, gui) + end + return name +end + +# The name of the other of the `gui` that is there at `now` and whose `written` is `stamp`, else `nothing` +function _writer_of(gui::LiveView, stamp, now::Real) (isnothing(stamp) || isempty(stamp)) && return nothing - others = [gui.sync.others[id] for id in sort!(collect(keys(gui.sync.others)))] - filter!(o -> _alive(o, now), others) - for o in others - o.written == stamp && return o.name + for id in sort!(collect(keys(gui.sync.others))) + o = gui.sync.others[id] + _alive(o, now) && o.written == stamp && return o.name + end + return nothing +end + +""" + _name_writer!(gui, now) + +Names the writer of the last merge of the `gui` whose writer `_writer` did not find, once an other +that is there at `now` published the stamp of its files: the status line of that merge, +`"agent: …"`, becomes `": …"`. The merge is forgotten once it is named, or once the files or +the status line are no longer those of a merge (a save, a new merge, another message). The status +line is only checked for its start: a merge that changed nothing in the window keeps the status line +`"agent: …"` of a merge before it, which is then named after the writer of the later files. +""" +function _name_writer!(gui::LiveView, now::Real) + stamp = get(_UNNAMED, gui, nothing) + isnothing(stamp) && return nothing + text = gui.status.text[] + if gui.sync.stamp != stamp || !startswith(text, _UNNAMED_PREFIX) + delete!(_UNNAMED, gui) + return nothing end - agents = filter(o -> o.kind == "agent", others) - return length(agents) == 1 ? only(agents).name : nothing + name = _writer_of(gui, stamp, now) + isnothing(name) && return nothing + gui.status.text[] = name * ": " * text[(ncodeunits(_UNNAMED_PREFIX) + 1):end] + delete!(_UNNAMED, gui) + return nothing end diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl index af611ce..71700af 100644 --- a/src/LiveProjectSession.jl +++ b/src/LiveProjectSession.jl @@ -9,7 +9,7 @@ writes it when the selection changes and when the autosave saved changes of the const _JOURNAL_MAX = 20 # The session file of the person of this window, see `_person` -_session_file(dir::AbstractString) = joinpath(dir, ".bmo", "session-$(_person().id).md") +_session_file(dir::AbstractString) = BMOP.session_file(dir, _person().id) # A position in m with 4 significant digits, e.g. `(0.0, 0.105, 0.0)` _session_pos(p) = "(" * join((string(round(Float64(x); sigdigits = 4)) for x in p), ", ") * ")" diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 9e939b2..befbad6 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -425,19 +425,6 @@ _uses(x::Symbol, names) = x in names _uses(x::Expr, names) = any(a -> _uses(a, names), x.args) _uses(x, names) = false -"""Whether the state of the `key` is the same in the projects `a` and `b`.""" -function _same_state(a, b, key::String) - ea, eb = _by_key(a.entries, key), _by_key(b.entries, key) - if !(isnothing(ea) && isnothing(eb)) - (isnothing(ea) || isnothing(eb)) && return false - return BMOP.same_entry(ea, eb) - end - sa, sb = _by_key(a.systems, key), _by_key(b.systems, key) - (isnothing(sa) && isnothing(sb)) && return true - (isnothing(sa) || isnothing(sb)) && return false - return BMOP.same_system(sa, a, sb, b) -end - """ _stale(s, key, disk) -> Bool @@ -465,8 +452,9 @@ end _sync!(gui; animate = _shown(gui)) -> Symbol Loads the project of the `gui` from its files and merges it into the open window, see -`LiveProjectSync.jl`: the changes of the files since `base` that the window did not change too are -applied (`_adopt!`, moved objects glide if `animate`), conflicting ones are left and shown on a +`LiveProjectSync.jl` and `BeamletOpticsProjects.merge_plan`: the changes of the files since `base` +that the window did not change too are applied (`_adopt!`, moved objects glide if `animate`), the +members of a system that both changed are merged as sets, conflicting ones are left and shown on a card (`_ProjectConflict`), also changes back to an older state of the window (see `_stale`) and the conflicts still open from before, unless the files now agree with the window there. The files become the new `base`; the status line names the person whose window wrote them (`_writer`), else @@ -493,17 +481,15 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) # The lines without uid (of an agent) get those of their objects in the window _align_uids!(gui, disk) rebuild = _src_marks(s.marks) == s.src ? Symbol[] : _calls_own_code(disk) - d = BMOP.diff_projects(s.base, disk; rebuild) mine = _collect_project(gui, dir) - g = BMOP.diff_projects(s.base, mine) - theirs = Set{String}(_change_key(c) for c in Iterators.flatten((d.entries, d.systems))) - ours = Set{String}(_change_key(c) for c in Iterators.flatten((g.entries, g.systems))) - conflicts = String[k for k in theirs - if k in ours ? !_same_state(mine, disk, k) : _stale(s, k, disk)] - # A conflict of before stays until the person decides, or the files agree with the window - still = String[k for k in s.conflicts if !(k in conflicts) && !_same_state(mine, disk, k)] - conflicts = vcat(still, conflicts) - done = _adopt!(gui, mine, disk, setdiff(theirs, conflicts); rebuild = Set(rebuild), animate) + # A conflict of before stays until the person decides, or the files agree with the window. A + # new id of the files that an unsaved object of the window has too stays the files': the + # window's object gets the next id when it is saved (see `_project_ids`), so `renames` of the + # plan is not applied here + plan = BMOP.merge_plan(s.base, mine, disk; stale = k -> _stale(s, k, disk), rebuild, + keep = s.conflicts) + conflicts = plan.conflicts + done = _adopt!(gui, mine, disk, plan.take; rebuild = Set(rebuild), animate, merged = plan.merged) s.base = _align_uids!(gui, disk) s.stamp = disk.stamp s.src = _src_marks(s.marks) @@ -703,20 +689,38 @@ function _flash!(gui::LiveView, objs) return boxes end +"""The object, source or system of the `gui` of the entry key `key` (a uid, or `"id:L1"`).""" +function _object_of_key(gui::LiveView, key::String) + name = startswith(key, "id:") ? Symbol(key[4:end]) : nothing + for (obj, (id, uid)) in gui.sync.ids + (isnothing(name) ? uid == key : id === name) && return obj + end + return nothing +end + """ -Makes the systems of the `gui` that are in `keys` (or that the entries `changed` are members of) -have the members and sources of the files `disk`. +Makes the systems of the `gui` that are in `keys` have the members and sources of the files `disk`; +those of `merged` (see `BeamletOpticsProjects.merge_plan`) get the merged members and sources, given +by entry keys. Objects and sources that the person removed from the view stay removed. """ -function _sync_members!(gui::LiveView, disk, keys, changed) +function _sync_members!(gui::LiveView, disk, keys; merged = Dict{String, Any}()) + # The ids of objects removed from the view are kept, see `_project_ids` + shown(@nospecialize o) = _shown_top(gui, o) || _has(_sources(gui), o) for se in disk.systems - member = any(n -> n in changed, se.members) || any(n -> n in changed, se.sources) - (_key(se) in keys || member) || continue + (_key(se) in keys || haskey(merged, _key(se))) || continue sys = _window_object(gui, se) sys isa BMO.System || continue objs(names) = Any[o for n in names for o in (_window_object(gui, _by_key(disk.entries, "id:" * String(n))),) - if !isnothing(o)] + if !isnothing(o) && shown(o)] want, sources = objs(se.members), objs(se.sources) + if haskey(merged, _key(se)) + # Changed both in the window and in the files: the union of what each added (an object + # of the window that is not saved yet has no id here and is not removed) + m = merged[_key(se)] + by_keys(ks) = Any[o for k in ks for o in (_object_of_key(gui, k),) if !isnothing(o) && shown(o)] + want, sources = by_keys(m.members), by_keys(m.sources) + end _unrecorded(gui) do for o in want _has(sys.objects, o) || _add_member!(gui, o, sys) @@ -736,18 +740,19 @@ function _sync_members!(gui::LiveView, disk, keys, changed) end """ - _adopt!(gui, mine, disk, keys; rebuild = Set{Symbol}(), animate = _shown(gui)) -> Vector{String} + _adopt!(gui, mine, disk, keys; rebuild = Set{Symbol}(), animate = _shown(gui), merged = Dict()) -> Vector{String} Makes the entries and systems of the `keys` in the open `gui` (whose state is the project `mine`, see `_collect_project`) as in the files `disk`, without undo entries: removes, adds, rebuilds (other code, or calling own code in `rebuild`), moves (gliding if `animate`, see `_glide!`), renames and -relabels. The changed objects get a box for a moment. Returns what was done, e.g. `"moved M1"`. +relabels; the systems of `merged` get the merged members (see `_sync_members!`). The changed +objects get a box for a moment. Returns what was done, e.g. `"moved M1"`. """ -function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), animate::Bool = _shown(gui)) +function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), animate::Bool = _shown(gui), + merged = Dict{String, Any}()) done = String[] touched = Any[] glides = Tuple{Any, Any, Any}[] - changed = Set{Symbol}() m = BMOP.match_entries(disk.entries, mine.entries) # In the window only: removed in the files for e in m.added @@ -766,7 +771,6 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima for e in m.removed _key(e) in keys || continue push!(touched, _add_entry!(gui, e)) - push!(changed, e.name) push!(done, "added $(_shown_label(e))") end for (e, mine_e) in m.pairs @@ -775,7 +779,6 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima if e.kind != mine_e.kind || e.name in rebuild || BMOP.normalize_code(e.code) != BMOP.normalize_code(mine_e.code) push!(touched, _swap!(gui, obj, e)) - push!(changed, e.name) push!(done, "rebuilt $(_shown_label(e))") continue end @@ -786,12 +789,11 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima end if e.name !== mine_e.name || e.label != mine_e.label _name!(gui, obj, e) - e.name === mine_e.name || push!(changed, e.name) push!(done, "renamed $(_shown_label(e))") end isequal(Dict(e.meta), Dict(mine_e.meta)) || _update_record!(gui, obj; meta = Pair{Symbol, Any}[e.meta...]) end - _sync_members!(gui, disk, keys, changed) + _sync_members!(gui, disk, keys; merged) for se in ms.added _key(se) in keys || continue try diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl index 5d696a2..83bc329 100644 --- a/test/TestLiveProject.jl +++ b/test/TestLiveProject.jl @@ -601,6 +601,13 @@ const GUI = BeamletOpticsGUI t = _setup(:compact) save_project(t.gui, dir) close(t.gui) + # A new project gets the skills of BMO, Projects and the GUI, none of them in a version + skills = joinpath(dir, ".claude", "skills") + @test all(isfile(joinpath(skills, s, "SKILL.md")) for s in ("beamletoptics", "beamletopticsprojects", "beamletopticsgui")) + G2 = BMOP.LibGit2 + @test G2.with(G2.GitRepo(dir)) do r + G2.status(r, ".claude/skills/beamletopticsgui/SKILL.md") & G2.Consts.STATUS_IGNORED != 0 + end name = BMOP.project_name(dir) own = joinpath(dir, "src", name * ".jl") write(own, "module $name\nusing BeamletOptics\nexport extra_lens\n" * diff --git a/test/TestLiveProjectPresence.jl b/test/TestLiveProjectPresence.jl index 85bc4aa..349ae69 100644 --- a/test/TestLiveProjectPresence.jl +++ b/test/TestLiveProjectPresence.jl @@ -237,21 +237,24 @@ labels(gui) = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text end rm(bob) - # `_writer`: the one alive agent, even without `written` + # `_writer`: no fallback by kind, an agent without `written` is not named agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 4) GUI._check_presence!(gui; now = 3000.0) + @test isnothing(GUI._writer(gui, "stamp-z"; now = 3000.0)) + # An agent that announced after its write is named, like a person + agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 5, written = "stamp-z") + GUI._check_presence!(gui; now = 3000.0) @test GUI._writer(gui, "stamp-z"; now = 3000.0) == "Claude (Opus 5.5)" - # A person with a matching `written` comes first - other!(dir, "bob@PC9"; name = "Bob", beat = 3, written = "stamp-z") + # A person with a matching `written` is named alike; no match, no name + other!(dir, "bob@PC9"; name = "Bob", beat = 3, written = "stamp-y") GUI._check_presence!(gui; now = 3000.0) - @test GUI._writer(gui, "stamp-z"; now = 3000.0) == "Bob" - # An other person without a match: still the one agent - @test GUI._writer(gui, "stamp-y"; now = 3000.0) == "Claude (Opus 5.5)" + @test GUI._writer(gui, "stamp-y"; now = 3000.0) == "Bob" + @test isnothing(GUI._writer(gui, "stamp-x"; now = 3000.0)) rm(GUI._presence_file(dir, "bob@PC9")) - # Two agents: nothing, unless the `written` of one matches + # Two agents: each is named by its own `written` agent!(dir, "codex@agent"; name = "Codex", beat = 1, written = "stamp-c") GUI._check_presence!(gui; now = 3000.0) - @test isnothing(GUI._writer(gui, "stamp-z"; now = 3000.0)) + @test GUI._writer(gui, "stamp-z"; now = 3000.0) == "Claude (Opus 5.5)" @test GUI._writer(gui, "stamp-c"; now = 3000.0) == "Codex" rm(GUI._presence_file(dir, "codex@agent")) # None alive: nothing @@ -263,6 +266,93 @@ labels(gui) = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text @test isnothing(GUI._writer(gui, "stamp-z"; now = 3000.0)) end + @testset "writer named after the merge" begin + gui.controls.selected[] = nothing + GUI._write_presence!(gui; now = 4000.0) + saved = gui.sync.stamp + @test TOML.parsefile(own)["written"] == saved + # An agent writes the files; the window merges before the agent announces + file = joinpath(dir, "system.jl") + write(file, replace(read(file, String), "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.13, 0.0)")) + @test GUI._sync!(gui; animate = false) === :applied + merged = gui.sync.stamp + @test merged != saved + status = gui.status.text[] + @test startswith(status, "agent: ") && occursin("moved L1", status) + # The window does not claim the merged files as its write + GUI._write_presence!(gui; now = 4000.0) + @test TOML.parsefile(own)["written"] == saved + # An announce of the agent without its stamp changes nothing + claude = agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 1, written = "stamp-old") + GUI._check_presence!(gui; now = 4001.0) + @test gui.status.text[] == status + # Its announce after the write names it in the status line of the merge + agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 2, written = merged) + GUI._check_presence!(gui; now = 4002.0) + @test gui.status.text[] == "Claude (Opus 5.5): " * status[(length("agent: ") + 1):end] + # Once only: another status line is not renamed + gui.status.text[] = "agent: something else" + GUI._check_presence!(gui; now = 4003.0) + @test gui.status.text[] == "agent: something else" + # A status line that changed since the merge is not renamed either + write(file, replace(read(file, String), "pos = (0.0, 0.13, 0.0)" => "pos = (0.0, 0.14, 0.0)")) + @test GUI._sync!(gui; animate = false) === :applied + gui.status.text[] = "project files are as in the window" + agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 3, written = gui.sync.stamp) + GUI._check_presence!(gui; now = 4004.0) + @test gui.status.text[] == "project files are as in the window" + rm(claude) + GUI._check_presence!(gui; now = 4005.0) + end + + @testset "writer of two merges of the same change" begin + # The agent writes L1 twice before it announces: both merges say "agent: moved L1" + file = joinpath(dir, "system.jl") + write(file, replace(read(file, String), "pos = (0.0, 0.14, 0.0)" => "pos = (0.0, 0.15, 0.0)")) + @test GUI._sync!(gui; animate = false) === :applied + status = gui.status.text[] + @test startswith(status, "agent: ") && occursin("moved L1", status) + write(file, replace(read(file, String), "pos = (0.0, 0.15, 0.0)" => "pos = (0.0, 0.16, 0.0)")) + @test GUI._sync!(gui; animate = false) === :applied + @test gui.status.text[] == status + claude = agent!(dir, "claude@agent"; name = "Claude (Opus 5.5)", beat = 1, written = gui.sync.stamp) + GUI._check_presence!(gui; now = 5000.0) + @test gui.status.text[] == "Claude (Opus 5.5): " * status[(length("agent: ") + 1):end] + rm(claude) + GUI._check_presence!(gui; now = 5001.0) + end + + @testset "open and reload claim no write" begin + # Someone edits the files without announcing; the window merges them + file = joinpath(dir, "system.jl") + write(file, replace(read(file, String), "pos = (0.0, 0.16, 0.0)" => "pos = (0.0, 0.17, 0.0)")) + @test GUI._sync!(gui; animate = false) === :applied + edited = gui.sync.stamp + status = gui.status.text[] + @test startswith(status, "agent: ") + # Anna opens the project in a second window: its presence does not claim the files + rm(own) + gui2 = open_project(dir; display = false, const_kw...) + @test gui2.sync.stamp == edited + GUI._write_presence!(gui2; now = 6000.0) + @test TOML.parsefile(own)["written"] == "" + # Nor after `Ctrl+R` + GUI._load_from_disk!(gui2) + GUI._write_presence!(gui2; now = 6000.0) + @test TOML.parsefile(own)["written"] == "" + # So the window of the merge does not name her as the writer + anna = other!(dir, "anna@PC2"; name = "Anna", beat = 1, + written = TOML.parsefile(own)["written"]) + GUI._check_presence!(gui; now = 6001.0) + @test gui.status.text[] == status + # Her own save is claimed (the same text: the same stamp) + save_project(gui2) + GUI._write_presence!(gui2; now = 6002.0) + @test TOML.parsefile(own)["written"] == gui2.sync.stamp == edited + rm(anna) + GUI._write_presence!(gui; now = 6003.0) + end + @testset "closing" begin other = other!(dir, "bob@PC9"; name = "Bob", beat = 20, selected = uid(gui, :M1)) GUI._check_presence!(gui; now = 700.0) diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 1c26a3f..6ebb4cc 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -45,6 +45,8 @@ function obj(gui, id) return nothing end +_has_member(sys, o) = any(x -> x === o, sys.objects) + """Replaces the line of `id` in `text` by `f(line)`.""" function line!(f, text, id) lines = split(text, "\n") @@ -365,6 +367,67 @@ end close(gui) end + @testset "the window and the files both add to a system: no conflict" begin + gui, dir = opened() + GUI._autosave!(gui) + s1 = only(GUI._systems(gui)) + mine = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) + add_component!(gui, mine; system = s1, select = false, code = LENS) + translate3d!(gui, mine, [0.0, 0.45, 0.0]) + edit!(text -> line!(l -> replace(l, "D1]" => "D1, T1]"), + text * "@component T1 = $LENS pos = (0.0, 0.4, 0.0)\n", "S1"), dir) + @test tick!(gui) === :applied + t1 = obj(gui, :T1) + @test _has_member(s1, t1) && _has_member(s1, mine) + @test isempty(gui.sync.conflicts) + # The autosave writes both into the system + @test autosave!(gui) === :saved + line = only(l for l in split(read(system(dir), String), "\n") if startswith(l, "@system S1 = ")) + @test occursin("T1", line) && count(",", split(line, "]")[1]) == 4 + close(gui) + end + + @testset "a member renamed or rebuilt in the files keeps the window's members" begin + gui, dir = opened() + edit!(text -> text * "@component X1 = $LENS pos = (0.0, 0.6, 0.0)\n", dir) + @test tick!(gui) === :applied + s1, x1 = only(GUI._systems(gui)), obj(gui, :X1) + # Not saved yet: X1 into S1, while the files rename D1 (same uid), then rebuild L1 + GUI._add_member!(gui, x1, s1) + edit!(text -> replace(text, "D1 = " => "D2 = ", "D1]" => "D2]"), dir) + @test tick!(gui) === :applied + @test obj(gui, :D2) !== nothing && _has_member(s1, x1) + edit!(text -> line!(l -> replace(l, "0.005, 0.0254" => "0.006, 0.0254"), text, "L1"), dir) + @test tick!(gui) === :applied + @test _has_member(s1, x1) && isempty(gui.sync.conflicts) + close(gui) + end + + @testset "a member that the person removed stays removed" begin + gui, dir = opened() + edit!(text -> text * "@component X1 = $LENS pos = (0.0, 0.6, 0.0)\n", dir) + @test tick!(gui) === :applied + s1, x1 = only(GUI._systems(gui)), obj(gui, :X1) + # Not saved yet: X1 removed and a new lens in S1, while the files put X1 into S1 (merged) + remove_component!(gui, x1) + mine = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) + add_component!(gui, mine; system = s1, select = false, code = LENS) + edit!(text -> line!(l -> replace(l, "D1]" => "D1, X1]"), text, "S1"), dir) + @test tick!(gui) === :applied + @test _has_member(s1, mine) && !_has_member(s1, x1) && !GUI._shown_top(gui, x1) + close(gui) + # Only removed in the window: the system of the files is taken, without X1 + gui, dir = opened() + edit!(text -> text * "@component X1 = $LENS pos = (0.0, 0.6, 0.0)\n", dir) + @test tick!(gui) === :applied + s1, x1 = only(GUI._systems(gui)), obj(gui, :X1) + remove_component!(gui, x1) + edit!(text -> line!(l -> replace(l, "D1]" => "D1, X1]"), text, "S1"), dir) + @test tick!(gui) === :applied + @test !_has_member(s1, x1) && !GUI._shown_top(gui, x1) + close(gui) + end + @testset "a write that leaves the times and sizes of the files as they were" begin gui, dir = opened() l1 = obj(gui, :L1) From 37df142cbfbc643a79005516e9ac3a7a3b6cff30 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 13:23:20 +0100 Subject: [PATCH 10/47] Projects prototype: the watcher reads the files in the background On a shared drive every access to a file waits for the network. The watcher read the times, the contents, Project.toml and the presence files five times a second on the thread of the window, which then ran at a few frames per second. A look now reads them in a task of the background (_files_look, _take_look!) and also writes the own presence file there; the stamp of the contents is read at most every 2 s. A look that waits behind a solve is replaced by one on the thread of the window at most once a second, a failed look is thrown once, a look from before a save is dropped, and a merge reads the times of the own code itself. benchmark/watch_io.jl and benchmark/save_io.jl measure a look and an autosave over SMB. Co-Authored-By: Claude Opus 5.5 --- benchmark/save_io.jl | 36 ++++++++ benchmark/watch_io.jl | 51 +++++++++++ src/LiveProjectPresence.jl | 70 +++++++++++---- src/LiveProjectSync.jl | 146 ++++++++++++++++++++++++++------ src/LiveView.jl | 14 ++- test/TestLiveProjectPresence.jl | 16 ++++ test/TestLiveProjectSync.jl | 105 +++++++++++++++++++++++ 7 files changed, 394 insertions(+), 44 deletions(-) create mode 100644 benchmark/save_io.jl create mode 100644 benchmark/watch_io.jl diff --git a/benchmark/save_io.jl b/benchmark/save_io.jl new file mode 100644 index 0000000..a855e55 --- /dev/null +++ b/benchmark/save_io.jl @@ -0,0 +1,36 @@ +# The parts of an autosave after a change over SMB (\\localhost\C$\...), to find the file accesses +# that cost the thread of the window. +# +# julia --project=test benchmark/save_io.jl +using GLMakie, BeamletOptics, BeamletOpticsGUI, BeamletOpticsProjects +const G = BeamletOpticsGUI +const P = BeamletOpticsProjects + +ms(t) = round(1000t; digits = 1) + +copy_dir = joinpath(mktempdir(), basename(ARGS[1])) +cp(ARGS[1], copy_dir) +rm(joinpath(copy_dir, ".bmo"); force = true, recursive = true) +for dir in (copy_dir, raw"\\localhost\C$" * copy_dir[3:end]) + gui = open_project(dir; display = false, trace_budget = Inf, throttle = false, detectors = []) + obj = first(x for x in keys(gui.sync.ids) if haskey(gui.components.origin, x)) + times = Dict{String, Float64}() + for i in 1:4 + G.translate3d!(gui, obj, [0.0, 0.0, 1e-4]) + parts = G._project_parts(gui, dir; name = gui.sync.base.name) + t = Dict( + "project_stamp" => @elapsed(P.project_stamp(dir)), + "file_marks" => @elapsed(G._file_marks(dir)), + "project_parts" => @elapsed(G._project_parts(gui, dir; name = gui.sync.base.name)), + "save_project" => @elapsed(save_project(gui, dir)), + "write_presence" => @elapsed(G._write_presence!(gui)), + "write_session" => @elapsed(G._write_session!(gui)), + ) + i > 1 && mergewith!(+, times, t) + end + println(dir) + for (k, v) in sort(collect(times); by = last, rev = true) + println(" ", rpad(k, 16), ms(v / 3), " ms") + end + close(gui) +end diff --git a/benchmark/watch_io.jl b/benchmark/watch_io.jl new file mode 100644 index 0000000..b332a96 --- /dev/null +++ b/benchmark/watch_io.jl @@ -0,0 +1,51 @@ +# What one look of the project watcher costs the thread of the window, on a local path and over +# SMB (the same folder as \\localhost\C$\...), before (all files read in the look) and now (read in +# the background, see `_take_look!`), and what an autosave after a change costs. +# +# julia --project= benchmark/watch_io.jl +# +# The folder is copied to a temporary one first; the copy is opened without a window. +using GLMakie, BeamletOptics, BeamletOpticsGUI, BeamletOpticsProjects +const G = BeamletOpticsGUI + +ms(t) = round(1000t; digits = 1) + +function measure(dir; n = 20) + gui = open_project(dir; display = false, trace_budget = Inf, throttle = false, detectors = []) + G._autosave!(gui) + # Before: the look reads every file itself + before() = (G._check_files!(gui); G._autosave!(gui); G._check_presence!(gui)) + # Now: the window takes what the background read + function now() + look = G._take_look!(gui) + isnothing(look) || G._check_files!(gui; look) + G._autosave!(gui) + isnothing(look) || G._check_presence!(gui; look) + end + before(); now() + t_before = @elapsed foreach(_ -> before(), 1:n) + t_now = 0.0 + for _ in 1:n + isnothing(gui.sync.look) || wait(gui.sync.look.task) + t_now += @elapsed now() + end + t_autosave = @elapsed foreach(_ -> G._autosave!(gui), 1:n) + obj = first(x for x in keys(gui.sync.ids) if haskey(gui.components.origin, x)) + save = 0.0 + for i in 1:3 + G.translate3d!(gui, obj, [0.0, 0.0, 1e-4]) + save = @elapsed G._autosave!(gui; force = true) + end + println(rpad(dir, 60), " look before: ", ms(t_before / n), " ms, now: ", ms(t_now / n), + " ms (of which the autosave without a change: ", ms(t_autosave / n), + " ms); autosave after a change: ", ms(save), " ms") + close(gui) +end + +copy_dir = joinpath(mktempdir(), basename(ARGS[1])) +cp(ARGS[1], copy_dir) +rm(joinpath(copy_dir, ".bmo"); force = true, recursive = true) +for _ in 1:2 + measure(copy_dir) + measure(raw"\\localhost\C$" * copy_dir[3:end]) +end diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index 8de6bad..8311b86 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -64,20 +64,57 @@ Writes the presence file of the person of the `gui` (see `_person`, `_presence_f its name and host, `kind = "person"`, `ttl = _ALIVE`, the `beat` one higher than in the file, the selected object and `written`, the stamp of the files after the last save of the window (see `_own_written`). Sets `gui.sync.beat_at` to `now`. Does nothing without a project. An error of -writing propagates; the watcher logs it. +writing propagates; the watcher logs it. The watcher writes it in the background instead, see +`_presence_due!`. """ function _write_presence!(gui::LiveView; now::Real = time()) dir = gui.project isnothing(dir) && return nothing + _write_presence_file(dir, _presence_data(gui)) + gui.sync.beat_at = Float64(now) + return nothing +end + +""" + _presence_data(gui) -> (; id, data, own) + +What the presence file of the person of the `gui` holds, without `written`: the `id` of the +person, the fields `data` and `own`, the stamp of the own last save if the files are still those +(else `nothing`: the file keeps its `written`, see `_own_written`). Read on the thread of the +window, written by `_write_presence_file`, also in the background. +""" +function _presence_data(gui::LiveView) me = _person() uid, id = _presence_selected(gui) data = Dict{String, Any}("name" => me.name, "host" => me.host, "kind" => "person", "ttl" => _ALIVE, - "selected" => uid, "selected_id" => id, "written" => _own_written(gui, _presence_file(dir, me.id))) - BMOP.write_presence(dir, me.id, data) - gui.sync.beat_at = Float64(now) + "selected" => uid, "selected_id" => id) + own = get(_OWN_SAVE, gui, nothing) + return (; id = me.id, data, own = !isnothing(own) && own == gui.sync.stamp ? own : nothing) +end + +"""Writes the presence file of `_presence_data` `p` into the project folder `dir`.""" +function _write_presence_file(dir::AbstractString, p) + data = copy(p.data) + data["written"] = something(p.own, _file_written(_presence_file(dir, p.id))) + BMOP.write_presence(dir, p.id, data) return nothing end +""" + _presence_due!(gui, now) -> `_presence_data` or nothing + +The presence file of the `gui` to be written by the next look of the watcher (see `_take_look!`): +when the selection changed or the window saved since the last write (`gui.sync.presence_due`) or +the last write is `_BEAT` seconds ago; `nothing` otherwise. Counts it as written at `now`. +""" +function _presence_due!(gui::LiveView, now::Real) + s = gui.sync + (s.presence_due || now - s.beat_at >= _BEAT) || return nothing + s.presence_due = false + s.beat_at = Float64(now) + return _presence_data(gui) +end + # The `written` of the presence file `file`, `""` if it is missing, does not parse or has none function _file_written(file::AbstractString) isfile(file) || return "" @@ -97,10 +134,7 @@ The stamp of the files that the window of the `gui` wrote last, for `written` of as", see `_own_save!`); else the `written` of the file before is kept, so that the window never claims files that it only opened, reloaded (`Ctrl+R`) or merged, e.g. the change of an other. """ -function _own_written(gui::LiveView, file::AbstractString) - own = get(_OWN_SAVE, gui, nothing) - return !isnothing(own) && own == gui.sync.stamp ? own : _file_written(file) -end +_own_written(gui::LiveView, file::AbstractString) = something(_presence_data(gui).own, _file_written(file)) # Per view, the stamp of the files that its last save wrote, see `_own_written` const _OWN_SAVE = WeakKeyDict{LiveView, String}() @@ -124,10 +158,11 @@ end _alive(o, now::Real) = BMOP.alive(o, now) """ - _check_presence!(gui; now = time()) + _check_presence!(gui; now = time(), look = nothing) -One look at the presence files of the project of the `gui`: writes the own file if the last write -is `_BEAT` seconds ago, reads the files of the others (never the own one; a file that does not +One look at the presence files of the project of the `gui`: takes the files of the others from the +`look` of the watcher (see `_take_look!`, which also writes the own file); without a `look`, writes +the own file if the last write is `_BEAT` seconds ago and reads the others here (never the own one; a file that does not parse, e.g. half written on a network drive, is skipped and its participant kept as it was) and updates `gui.sync.others`, people and agents alike. An other whose `beat` changed is seen at `now`; one whose file is gone is dropped. One whose `beat` did not change for its `ttl` seconds stays in @@ -137,13 +172,15 @@ that published its stamp meanwhile (see `_name_writer!`). Then the boxes and nam selections of the others are drawn again if what they show changed (see `_draw_presence!`). Does nothing without a project. """ -function _check_presence!(gui::LiveView; now::Real = time()) +function _check_presence!(gui::LiveView; now::Real = time(), look = nothing) dir = gui.project isnothing(dir) && return nothing s = gui.sync - now - s.beat_at >= _BEAT && _write_presence!(gui; now) - me = _person().id - for (id, data) in BMOP.read_presences(dir; except = me) + if isnothing(look) + now - s.beat_at >= _BEAT && _write_presence!(gui; now) + look = _files_look(dir; stamp = false, except = _person().id, known = collect(keys(s.others))) + end + for (id, data) in look.presences beat = data["beat"] old = get(s.others, id, nothing) seen_at = isnothing(old) || old.beat != beat ? Float64(now) : old.seen_at @@ -153,7 +190,8 @@ function _check_presence!(gui::LiveView; now::Real = time()) end # A file that is skipped (it does not parse) keeps its participant as it was; a file that is gone drops it for id in collect(keys(s.others)) - isfile(_presence_file(dir, id)) || delete!(s.others, id) + haskey(look.presences, id) && continue + id in look.known && !(id in look.present) && delete!(s.others, id) end _name_writer!(gui, now) _draw_presence!(gui, now) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index befbad6..a54342e 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -13,6 +13,10 @@ that `system.jl` is always what the person sees, and lists them in `.bmo/session # How often the watcher looks at the times of the files [s] const _WATCH_INTERVAL = 0.2 +# How often a look reads the contents of the files for their stamp, while their times stay [s] +const _STAMP_EVERY = 2.0 +# How long a look may wait in the background before the window reads the files itself [s] +const _LOOK_WAIT = 1.0 # How long the files must stay unchanged before they are loaded [s], an agent writes in steps const _SETTLE = 0.3 # How long a moved object glides to its new pose [s], and the frames per second of that @@ -59,6 +63,81 @@ end _src_marks(marks) = filter(m -> m[1] != "system.jl", marks) +""" + _files_look(dir; stamp = true, except = nothing, known = String[], presence = nothing) -> (; dir, marks, stamp, presences, known, present) + +What one look of the watcher reads of the project folder `dir`: the times and sizes of its files +(`_file_marks`), the stamp of their contents if `stamp` (else `nothing`), the presence files of the +others without the id `except` (`BeamletOpticsProjects.read_presences`) and of the ids `known` those +whose presence file is still there (`present`), also if it does not parse. Writes the own presence +file first if `presence` (see `_presence_data`). Touches no state of a window, so that it can run +in the background. +""" +function _files_look(dir::AbstractString; stamp::Bool = true, except = nothing, known = String[], + presence = nothing) + isnothing(presence) || _write_presence_file(dir, presence) + marks = _file_marks(dir) + st = stamp ? BMOP.project_stamp(dir) : nothing + presences = BMOP.read_presences(dir; except) + known = Set{String}(known) + present = Set{String}(id for id in known if isfile(_presence_file(dir, id))) + return (; dir = String(dir), marks, stamp = st, presences, known, present) +end + +""" + _take_look!(gui; now = time()) -> look or nothing + +The result of the look of the watcher of the `gui` that ran in the background (see `_files_look`) +if it is done, and starts the next one; `nothing` while it runs. On a shared drive every access +to a file waits for the network, often longer than a frame: the window never waits for it. + +- A look reads the stamp of the contents at most every `_STAMP_EVERY` seconds and only while the + files are settled, and writes the own presence file when it is due (see `_presence_due!`). +- A look that started before the last save of the window (see `_saved!`) or before a look here is + dropped; one of another folder too. +- While a look waits longer than `_LOOK_WAIT` seconds, e.g. behind a solve on the only thread of + the background (a solve does not yield), the files are read here instead, without the stamp, at + most every `_LOOK_WAIT` seconds, so that changes of the files and the own presence go on. +- An error of a look is thrown here once, after the next look started. +""" +function _take_look!(gui::LiveView; now::Real = time()) + s = gui.sync + dir = gui.project + run = s.look + if !isnothing(run) && !istaskdone(run.task) + (isnothing(dir) || now - s.look_at < _LOOK_WAIT) && return nothing + s.look_at = Float64(now) + s.gen += 1 + # Two writes of the own presence file at a time would share its temporary file + presence = run.presence ? nothing : _presence_due!(gui, now) + return _files_look(dir; stamp = false, except = _person().id, known = collect(keys(s.others)), presence) + end + s.look = nothing + look = failed = nothing + if !isnothing(run) + try + look = fetch(run.task) + catch e + e isa TaskFailedException || rethrow() + failed = e.task.exception + # The presence file of the failed look is written by the next one + run.presence && (s.presence_due = true) + end + end + isnothing(dir) && return nothing + stamp = isnothing(s.changed_at) && now - s.stamp_at >= _STAMP_EVERY + stamp && (s.stamp_at = Float64(now)) + except = _person().id + known = collect(keys(s.others)) + presence = _presence_due!(gui, now) + task = Threads.@spawn _files_look(dir; stamp, except, known, presence) + s.look = (; task, gen = s.gen, presence = !isnothing(presence)) + s.look_at = Float64(now) + isnothing(failed) || throw(failed) + (isnothing(look) || run.gen != s.gen || look.dir != dir) && return nothing + return look +end + """ _saved!(gui, project; written = true) @@ -84,9 +163,12 @@ function _saved!(gui::LiveView, project; written::Bool = true) _release_info!(gui, _ProjectConflict) end written && _remember_written!(s, before) + # A look of the watcher that started before this read the files before it, see `_take_look!` + s.gen += 1 _note_session!(gui) _start_watching!(gui) - isnothing(s.timer) || _write_presence!(gui) + # written by the next look of the watcher + isnothing(s.timer) || (s.presence_due = true) return nothing end @@ -127,34 +209,33 @@ function _remember_written!(s::_ProjectSync, before) end """ -Starts the watcher of the files of the project of the `gui` if it is shown, which calls -`_check_files!`, `_autosave!` and `_check_presence!` every `_WATCH_INTERVAL` seconds until the -window is closed, the file `.bmo/session.md` (see `_connect_session!`) and the presence file of the -window, written again when the selection changes (see `LiveProjectPresence.jl`). A view that is -not shown (e.g. in the tests or of a script) is not watched and writes neither; the functions can -be called instead. +Starts the watcher of the files of the project of the `gui` if it is shown, the file +`.bmo/session.md` (see `_connect_session!`) and the presence file of the window, written again when +the selection changes (see `LiveProjectPresence.jl`). Every `_WATCH_INTERVAL` seconds until the +window is closed, the watcher takes the last look at the files, read in the background (see +`_take_look!`), and calls `_check_files!` and `_check_presence!` with it once there is a new one, +and `_autosave!` and `_draw_presence!` every time. A view that is not shown (e.g. in the tests or +of a script) is not watched and writes neither; the functions can be called instead. """ function _start_watching!(gui::LiveView) s = gui.sync (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing _connect_session!(gui) - _write_presence!(gui) + # The presence file is written by the looks of the watcher, see `_presence_due!` + s.presence_due = true push!(gui.controls.listeners, on(gui.controls.selected) do _ - try - isnothing(gui.sync.timer) || _write_presence!(gui) - catch e - e isa InterruptException && rethrow() - @debug "could not write the presence file" exception = e - end + gui.sync.presence_due = true return nothing end) last_error = Ref("") s.timer = Timer(_WATCH_INTERVAL; interval = _WATCH_INTERVAL) do _ _shown(gui) || return _stop_watching!(gui) try - _check_files!(gui) + # The files are read in the background; the window works with the last look + look = _take_look!(gui) + isnothing(look) || _check_files!(gui; look) _autosave!(gui) - _check_presence!(gui) + isnothing(look) ? _draw_presence!(gui, time()) : _check_presence!(gui; look) catch e e isa InterruptException && rethrow() last_error[] = _log_once(e, last_error[], "watching the project") @@ -182,6 +263,12 @@ function _stop_watching!(gui::LiveView) e isa InterruptException && rethrow() @error "saving the project at closing failed" exception = (e, catch_backtrace()) end + try + # A look that still runs may write the presence file after it was removed + isnothing(s.look) || wait(s.look.task) + catch + end + s.look = nothing try _presence_closed!(gui) catch e @@ -232,7 +319,8 @@ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) else (_busy(gui) || !isnothing(s.changed_at) || !isempty(s.gliding)) && return :paused end - parts = _project_parts(gui, dir) + # The name of the files that the view agrees with, not read from `Project.toml` at every look + parts = _project_parts(gui, dir; name = s.base.name) text = _uidless_code(parts.project) if text == _uidless_code(s.base) s.text, s.edited_at = text, nothing @@ -322,20 +410,23 @@ function _write_backup!(gui::LiveView, mine) end """ - _check_files!(gui; now = time()) -> Symbol + _check_files!(gui; now = time(), look = nothing) -> Symbol One look of the watcher at the files of the project of the `gui`: `:pending` while they change or have not stayed unchanged for `_SETTLE` seconds; then, once per state of the files, `:applied`, `:conflict` or `:failed`, see `_sync!`; `:none` otherwise, also for the files that the `gui` saved itself. The files changed if their times or sizes (`_file_marks`) or else the stamp of their -contents (`project_stamp`) changed. A load error shown before is cleared when the files are back to -the state of the `gui`. +contents (`project_stamp`) changed, as read by the `look` (see `_files_look`; read here if +`nothing`; the watcher reads it in the background, see `_take_look!`, and a look without a stamp +compares the times and sizes only). A load error shown before is cleared when the files are back +to the state of the `gui`. """ -function _check_files!(gui::LiveView; now::Real = time()) +function _check_files!(gui::LiveView; now::Real = time(), look = nothing) s = gui.sync dir = gui.project (isnothing(dir) || isnothing(s.base)) && return :none - marks = _file_marks(dir) + isnothing(look) && (look = _files_look(dir)) + marks = look.marks if marks != s.marks s.marks = marks s.changed_at = now @@ -344,8 +435,8 @@ function _check_files!(gui::LiveView; now::Real = time()) if isnothing(s.changed_at) # A write of the same size within the resolution of the file times (e.g. right after a save # of the view) leaves the marks as they were: the stamp of the contents tells - stamp = BMOP.project_stamp(dir) - (stamp == s.stamp || stamp == s.handled) && return :none + stamp = look.stamp + (isnothing(stamp) || stamp == s.stamp || stamp == s.handled) && return :none s.changed_at = now return :pending end @@ -464,6 +555,9 @@ and in `load_error`, the window as it was). function _sync!(gui::LiveView; animate::Bool = _shown(gui)) s = gui.sync dir = gui.project + # The own code as it is loaded, not as the last look saw it (read before the load, such that a + # change between both is found by the next look) + src = _src_marks(_file_marks(dir)) disk = try BMOP.load_project(dir) catch e @@ -480,7 +574,7 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) _finish_glides!(gui) # The lines without uid (of an agent) get those of their objects in the window _align_uids!(gui, disk) - rebuild = _src_marks(s.marks) == s.src ? Symbol[] : _calls_own_code(disk) + rebuild = src == s.src ? Symbol[] : _calls_own_code(disk) mine = _collect_project(gui, dir) # A conflict of before stays until the person decides, or the files agree with the window. A # new id of the files that an unsaved object of the window has too stays the files': the @@ -492,7 +586,7 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) done = _adopt!(gui, mine, disk, plan.take; rebuild = Set(rebuild), animate, merged = plan.merged) s.base = _align_uids!(gui, disk) s.stamp = disk.stamp - s.src = _src_marks(s.marks) + s.src = src s.conflicts = conflicts s.theirs = disk if isempty(conflicts) diff --git a/src/LiveView.jl b/src/LiveView.jl index 7a65bae..344e9d5 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -599,8 +599,13 @@ What the view knows of the files of its project, which an agent may change while - `gliding`: the objects that glide to a pose of the files, `obj => (P, R, entry)`, see `_glide!`; - `others`: the other people who have the project open (by the id of their presence file, see `LiveProjectPresence.jl`), `presence` the plots that show their selection, `beat_at` the time - the view last wrote its own presence file; -- `timer`: the watcher, and `closed` whether the view was closed (no watcher starts again). + the view last wrote its own presence file (or gave it to a look to write) and `presence_due` + whether it is to be written again, see `_presence_due!`; +- `timer`: the watcher, and `closed` whether the view was closed (no watcher starts again); + `look` the look of the watcher that reads the files in the background, `(; task, gen, + presence)` (see `_take_look!`), `look_at` the time it started, `gen` a counter that drops the + looks started before it (`gen` of a look), and `stamp_at` the time a look last read the stamp of + the contents of the files. """ Base.@kwdef mutable struct _ProjectSync base::Any = nothing @@ -626,6 +631,11 @@ Base.@kwdef mutable struct _ProjectSync beat_at::Float64 = 0.0 timer::Union{Nothing, Timer} = nothing closed::Bool = false + presence_due::Bool = false + look::Union{Nothing, @NamedTuple{task::Task, gen::Int, presence::Bool}} = nothing + look_at::Float64 = 0.0 + gen::Int = 0 + stamp_at::Float64 = 0.0 end """ diff --git a/test/TestLiveProjectPresence.jl b/test/TestLiveProjectPresence.jl index 349ae69..3ac82cc 100644 --- a/test/TestLiveProjectPresence.jl +++ b/test/TestLiveProjectPresence.jl @@ -353,6 +353,22 @@ labels(gui) = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text GUI._write_presence!(gui; now = 6003.0) end + @testset "from a look of the watcher" begin + look() = GUI._files_look(dir; stamp = false, except = me.id, known = collect(keys(gui.sync.others))) + file = other!(dir, "carl@PC3"; name = "Carl", beat = 1) + GUI._check_presence!(gui; now = 6500.0, look = look()) + @test haskey(gui.sync.others, "carl@PC3") + # A look that started before Carl was known does not drop him; one after his file is gone does + early = GUI._files_look(dir; stamp = false, except = me.id) + rm(file) + GUI._check_presence!(gui; now = 6501.0, look = early) + @test haskey(gui.sync.others, "carl@PC3") + GUI._check_presence!(gui; now = 6502.0, look = (; early..., presences = Dict{String, Dict{String, Any}}())) + @test haskey(gui.sync.others, "carl@PC3") + GUI._check_presence!(gui; now = 6503.0, look = look()) + @test !haskey(gui.sync.others, "carl@PC3") + end + @testset "closing" begin other = other!(dir, "bob@PC9"; name = "Bob", beat = 20, selected = uid(gui, :M1)) GUI._check_presence!(gui; now = 700.0) diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 6ebb4cc..36a6ffe 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -120,6 +120,13 @@ end write(own, "module Bench\nusing BeamletOptics\nexport flat_lens\nflat_lens(r) = SphericalLens(r, -r, 0.004, 0.0254, 1.5)\nend\n") @test tick!(gui) === :applied @test obj(gui, :Q1) !== q1 && BeamletOptics.position(obj(gui, :Q1)) == [0.0, 0.5, 0.0] + # The merge reads the times of the own code itself, not those of the last look (which the + # watcher reads in the background, possibly before the change) + q1 = obj(gui, :Q1) + write(own, "module Bench\nusing BeamletOptics\nexport flat_lens\nflat_lens(r) = SphericalLens(r, -2r, 0.004, 0.0254, 1.5)\nend\n") + edit!(text -> text * "\n", dir) + @test GUI._sync!(gui; animate = false) === :applied + @test obj(gui, :Q1) !== q1 end @testset "merged with changes in the window" begin @@ -640,4 +647,102 @@ end close(gui) end +@testset "The watcher reads the files in the background" begin + gui, dir = opened() + s = gui.sync + # The next look of the watcher, after the one that runs is done + function take!(now) + isnothing(s.look) || wait(s.look.task) + return GUI._take_look!(gui; now) + end + t = time() + @testset "a look at a time, the stamp of the contents seldom" begin + # The first call starts a look; the window does not wait for it + @test GUI._take_look!(gui; now = t) === nothing + @test s.look.task isa Task + look = take!(t + 0.1) + @test look.dir == dir && look.marks == GUI._file_marks(dir) + @test look.stamp == s.stamp + # Then without a stamp, until `_STAMP_EVERY` passed + @test take!(t + 0.2).stamp === nothing + @test take!(t + 0.2 + GUI._STAMP_EVERY).stamp === nothing + @test take!(t + 0.3 + GUI._STAMP_EVERY).stamp == s.stamp + end + @testset "a look that waits, e.g. behind a solve" begin + t2 = t + 5 + take!(t2) + wait(s.look.task) + slow = Threads.@spawn (sleep(1.0); GUI._files_look(dir)) + s.look = (; task = slow, gen = s.gen, presence = false) + # While it runs, nothing, until `_LOOK_WAIT` passed: then read here, without the stamp + @test GUI._take_look!(gui; now = t2 + 0.1) === nothing + look = GUI._take_look!(gui; now = t2 + GUI._LOOK_WAIT) + @test look.marks == GUI._file_marks(dir) && look.stamp === nothing + @test GUI._take_look!(gui; now = t2 + GUI._LOOK_WAIT + 0.1) === nothing + # The waiting one is dropped once it is done: it started before + wait(slow) + @test GUI._take_look!(gui; now = t2 + 3) === nothing + @test !isnothing(take!(t2 + 3.1)) + end + @testset "a look that failed is thrown once, the next one runs" begin + take!(t + 10) + wait(s.look.task) + failed = Threads.@spawn error("network gone") + s.look = (; task = failed, gen = s.gen, presence = true) + s.presence_due = false + try + wait(failed) + catch + end + @test_throws ErrorException("network gone") GUI._take_look!(gui; now = t + 10.1) + # its presence file is written by the next look + @test s.look.presence + @test !isnothing(take!(t + 10.2)) + end + @testset "a change of the files, through the looks" begin + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.13, 0.0)"), text, "L1"), dir) + take!(t + 20) + look = take!(t + 20.1) + @test GUI._check_files!(gui; now = t + 20.1, look) === :pending + look = take!(t + 21) + @test GUI._check_files!(gui; now = t + 21, look) === :applied + @test BeamletOptics.position(obj(gui, :L1)) ≈ [0.0, 0.13, 0.0] atol = 1e-15 + # A look without a stamp compares the times and sizes only + @test GUI._check_files!(gui; now = t + 22, look = (; GUI._files_look(dir)..., stamp = nothing)) === :none + end + @testset "a look from before a save or of another folder is dropped" begin + take!(t + 30) + save_project(gui) + @test take!(t + 30.1) === nothing + @test !isnothing(take!(t + 30.2)) + wait(s.look.task) + project = gui.project + gui.project = mktempdir() + @test GUI._take_look!(gui; now = t + 31) === nothing + gui.project = project + wait(s.look.task) + s.look = nothing + end + @testset "the own presence file, written by the looks" begin + withenv("BMO_PERSON" => "anna") do + own = GUI._presence_file(dir, GUI._person().id) + rm(own; force = true) + s.presence_due = true + take!(t + 40) + wait(s.look.task) + @test isfile(own) + beat = GUI.TOML.parsefile(own)["beat"] + # Not again until it is due: the selection changed or `_BEAT` passed + take!(t + 40.1) + wait(s.look.task) + @test GUI.TOML.parsefile(own)["beat"] == beat + take!(t + 40 + GUI._BEAT) + wait(s.look.task) + @test GUI.TOML.parsefile(own)["beat"] == beat + 1 + rm(own) + end + end + close(gui) +end + end From 1a8d610f7546c4df65ab2ee8b672f766c65c467f Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 18:29:46 +0100 Subject: [PATCH 11/47] Projects prototype: groups made in the window are lines of system.jl A group made in the window (Ctrl+G, nested, pasted) is saved as `@group G1 = [L1, M1] pos = ... rot = ...` of BeamletOpticsProjects: the members keep their own lines and poses, the systems list the outermost group; opening a project makes its groups window groups again (Ctrl+Shift+G). When another window or an agent changes groups in the files, the open window follows: a group whose members change is made anew from the window objects (also groups not saved yet), one that only moved or was renamed is changed in place, members taken out of a group leave the systems that do not list them. Undo of ungrouping a group of one member (from a file) makes it again; export_changes does not write a group of the project as a new grouping. Session step and skill. Co-Authored-By: Claude Opus 5.5 --- ext/BeamletOpticsGUIGLMakieExt/session.jl | 28 +- skills/beamletopticsgui/SKILL.md | 5 + src/LiveExport.jl | 5 +- src/LiveGroup.jl | 5 +- src/LiveProject.jl | 141 +++++++-- src/LiveProjectPresence.jl | 6 +- src/LiveProjectSession.jl | 10 +- src/LiveProjectSync.jl | 330 ++++++++++++++++++++-- test/TestLiveProjectGroups.jl | 257 +++++++++++++++++ test/TestLiveProjectSyncGroups.jl | 249 ++++++++++++++++ test/TestPrecompileSession.jl | 2 +- test/runtests.jl | 2 + 12 files changed, 982 insertions(+), 58 deletions(-) create mode 100644 test/TestLiveProjectGroups.jl create mode 100644 test/TestLiveProjectSyncGroups.jl diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index ec1fe8c..8b249b0 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -1038,7 +1038,8 @@ and works on it like a person with an agent: drags the lens, which the autosave files; changes the files like an agent, which the window merges and glides; `Ctrl+S` with the card of the version, closed by a typed message and `Enter`, by `Esc`, by the button "Cancel" and by the button "Save version"; another participant (a presence file) works on the mirror, shown with its -name, and leaves. The steps call the autosave and the watcher themselves, with times that do +name, and leaves; `Ctrl+G` groups the lens and the mirror, which the autosave writes as a line, and +an agent ungroups them in the file. The steps call the autosave and the watcher themselves, with times that do not depend on how long a step takes: the watcher of the window never starts, and the step of the agent waits until the mirror has glided. Returns the live view and its screen, which the caller closes. @@ -1145,6 +1146,31 @@ function _project_session(step; dir = mktempdir()) frame!() _check(isempty(gui.sync.presence), "the other participant did not leave") end + step("project: Ctrl+G, autosave; an agent ungroups in the file") do + _click!(gui, _px(gui, _pos(lens))) + frame!() + _ctrl!(gui, Keyboard.press) + _click!(gui, _px(gui, _pos(mirror))) + _ctrl!(gui, Keyboard.release) + frame!() + _key!(gui, Keyboard.g, Keyboard.left_control) + settle!() + _check(GUI._is_window_group(gui, gui.controls.selected[]), "Ctrl+G did not make a group") + t = time() + GUI._autosave!(gui; now = t) + _check(GUI._autosave!(gui; now = t + 1) === :saved, "the autosave did not save the group") + # not `file`, which the step of the other participant set to its presence file + system = joinpath(dir, "system.jl") + text = read(system, String) + _check(occursin("@group G1 = [L1, M1]", text), "the group is not a line of system.jl") + text = replace(text, r"@group G1 = [^\n]*\n" => "") + write(system, replace(text, r"@system S1 = \[G1\]" => "@system S1 = [L1, M1]")) + t = time() + GUI._check_files!(gui; now = t) + _check(GUI._check_files!(gui; now = t + 1) === :applied, "the window did not merge the file") + settle!() + _check(isempty(gui.components.groups), "the window did not ungroup") + end return gui, screen end diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index 0c57b24..b6423bb 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -101,6 +101,11 @@ When this Skill is active: of the members, the group, `push!` of the group; for a changed group of the script `delete!(sg, part)` of the parts it lost, the new group(s), `push!(sg, new)`; the group keeps its variable and its systems). +- Projects (prototype): `open_project(path)` opens a folder of BeamletOpticsProjects (or its `.bmo` + file), `save_project(gui, dir)` saves a view as one. The open window writes every finished action + to `system.jl` and merges what agents or other people write there; groups made in the window are + `@group` lines. `Ctrl+S` saves a version, `Ctrl+R` takes the files as they are. How to work on the + files is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). - A source belongs to at most one system: `live_view(sys1 => b, sys2 => b)` throws an `ArgumentError`, and a source without a system is not traced or drawn. The last system of a view cannot be removed. The auto tracing of a single system and the picking of members with "+" and "−" diff --git a/src/LiveExport.jl b/src/LiveExport.jl index 5260a44..0d37b98 100644 --- a/src/LiveExport.jl +++ b/src/LiveExport.jl @@ -525,9 +525,10 @@ function _export_code(gui::LiveView) for obj in comp.added n += _export_added_code!(lines, cx, obj) end - # a group made in the window that is not listed as added + # a group made in the window that is not listed as added; not one that the view started with, + # e.g. a group of a project (see `open_project`), which the script has already for top in shown - _exports_as_group(gui, copies, top) && !(top in cx.written) && + _exports_as_group(gui, copies, top) && !(top in cx.written) && !haskey(comp.start_systems, top) && (n += _export_grouping_lines!(lines, cx, top)) end n += _export_member_lines!(lines, gui, names, systems) diff --git a/src/LiveGroup.jl b/src/LiveGroup.jl index 7e5f8cc..93634e4 100644 --- a/src/LiveGroup.jl +++ b/src/LiveGroup.jl @@ -481,7 +481,10 @@ longer applies. function _do_group!(gui, @nospecialize(g), rec::_WindowGroup; label = nothing) ctrl = gui.controls members = rec.members - reason = length(members) < 2 ? "a group needs several objects" : + # A group of one member comes from a project (see `open_project`): its undo makes it again + reason = isempty(members) ? "a group needs objects" : + length(members) == 1 ? (_shown_top(gui, only(members)) ? nothing : + "$(_label(gui, only(members))) is no top-level object") : _group_reason(gui, _MultiSelection(members)) isnothing(reason) || throw(ArgumentError(reason)) systems = _member_systems(gui, first(members)) diff --git a/src/LiveProject.jl b/src/LiveProject.jl index dccddc9..985c245 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -47,8 +47,8 @@ _is_project_name(s::AbstractString) = _is_variable_name(s) && !_is_taken_name(s) """ _project_ids(gui, items) -> IdDict{Any, Tuple{Symbol, String}} -The readable id and the machine id of the objects, sources and systems `items` of the `gui` in a -project: those it has in `gui.sync.ids` (see `_ProjectSync`), else a new id of the prefix of its +The readable id and the machine id of the objects, sources, groups and systems `items` of the `gui` +in a project: those it has in `gui.sync.ids` (see `_ProjectSync`), else a new id of the prefix of its type (see `BeamletOpticsProjects.id_prefix` and `next_id`) and a new uid. Every id in `gui.sync.ids` is reserved first, also of an item later in `items` or of an object that is not in the view anymore, such that a new one never takes it: after `L5` the next lens is `L6`. An id or a @@ -100,16 +100,16 @@ function _project_label(gui::LiveView, @nospecialize(x), id::Symbol) end """ - _file_order(base, entries, systems) -> (entries, systems) + _file_order(base, entries, systems, groups) -> (entries, systems, groups) -The `entries` and `systems` of a project in the order of the project `base` (the state that the view -and the files last agreed on, see `_ProjectSync`): each by the place of the entry or system of the -same uid in `base`, else of the same readable id; the others after those, in their order. `system_code` -writes the components before the sources, so a new one is written at the end of its kind. Without -`base`, as they are. +The `entries`, `systems` and `groups` of a project in the order of the project `base` (the state +that the view and the files last agreed on, see `_ProjectSync`): each by the place of the entry, +system or group of the same uid in `base`, else of the same readable id; the others after those, in +their order. `system_code` writes the components before the sources and the inner groups before the +groups that hold them, so a new one is written at the end of its kind. Without `base`, as they are. """ -function _file_order(base, entries, systems) - isnothing(base) && return entries, systems +function _file_order(base, entries, systems, groups) + isnothing(base) && return entries, systems, groups function order(xs, old) by_uid = Dict{String, Int}() by_name = Dict{Symbol, Int}() @@ -122,7 +122,7 @@ function _file_order(base, entries, systems) get(by_name, x.name, typemax(Int)) return xs[sortperm([place(x) for x in xs]; alg = Base.Sort.DEFAULT_STABLE)] end - return order(entries, base.entries), order(systems, base.systems) + return order(entries, base.entries), order(systems, base.systems), order(groups, base.groups) end #= @@ -170,13 +170,70 @@ function _project_meta(origin) return meta end +""" + _group_pose(gui, g, uid) -> (pos, rot) + +The `pos` and `rot` of the `@group` line of the group `g` made in the window with the machine id +`uid`: those of the group of that uid in the `base` of the sync (see `_ProjectSync`) while `g` has +that pivot and orientation, such that the text of an unmoved group stays as it was; otherwise its +position and its orientation, `nothing` for the identity within `1e-13`. +""" +function _group_pose(gui::LiveView, @nospecialize(g), uid) + P = Vector{Float64}(position(g)) + R = Matrix{Float64}(BMO.orientation(g)) + base = gui.sync.base + if !isnothing(base) + i = findfirst(e -> e.uid == uid, base.groups) + if !isnothing(i) + e = base.groups[i] + collect(e.pos) == P && maximum(abs, R - BMOP.rotation_matrix(e.rot)) <= 1e-12 && + return e.pos, e.rot + end + end + rot = maximum(abs, R - I) <= 1e-13 ? nothing : R + return (P[1], P[2], P[3]), rot +end + +""" + _collect_saved!(gui, x, saved, groups, members, skipped) -> Bool + +Collects the object or source `x` of the `gui` for `_project_parts` and returns whether it is +saved: a group made in the window (see `gui.components.groups`) with those of its members that are +saved, in their order (`members[x]`), after its inner groups in `groups`, unless none of them is +saved; any other with a known constructor in `saved`; else its label in `skipped`. +""" +function _collect_saved!(gui::LiveView, @nospecialize(x), saved, groups, members, skipped) + comp = gui.components + if haskey(comp.groups, x) + ms = Any[] + for m in BMO.objects(x) + _collect_saved!(gui, m, saved, groups, members, skipped) && push!(ms, m) + end + isempty(ms) && return false + members[x] = ms + push!(groups, x) + return true + end + if isnothing(get(comp.origin, x, nothing)) + push!(skipped, _label(gui, x)) + return false + end + push!(saved, x) + return true +end + """ _project_parts(gui, dir; name = project_name(dir)) -> (; project, names, ids, skipped) The `project` of the `gui` in the folder `dir` with the module `name` (see `_collect_project`), the -readable ids (`names`) and the readable and machine `ids` of its objects, sources and systems (see -`_project_ids`) and the labels of the objects and sources that were `skipped`, since their -constructor is not known. +readable ids (`names`) and the readable and machine `ids` of its objects, sources, groups and +systems (see `_project_ids`) and the labels of the objects and sources that were `skipped`, since +their constructor is not known. + +A group made in the window (see `_group!`) is a `@group` of its members that are saved, with its +pivot and orientation (see `_group_pose`); each member is an entry or a group of its own, at its +absolute pose, and the systems list the group, not its members. A group of the script without a +constructor is skipped like any such object. """ function _project_parts(gui::LiveView, dir::AbstractString; name::AbstractString = BMOP.project_name(dir)) comp = gui.components @@ -191,14 +248,12 @@ function _project_parts(gui::LiveView, dir::AbstractString; name::AbstractString (src for src in _sources(gui) if isempty(_snapshot(gui, src).systems))...]) skipped = String[] saved = Any[] + groups = Any[] + members = IdDict{Any, Vector{Any}}() for x in Any[objects..., sources...] - if isnothing(get(comp.origin, x, nothing)) - push!(skipped, _label(gui, x)) - else - push!(saved, x) - end + _collect_saved!(gui, x, saved, groups, members, skipped) end - ids = _project_ids(gui, Any[saved..., systems...]) + ids = _project_ids(gui, Any[saved..., groups..., systems...]) names = IdDict{Any, Symbol}(x => first(v) for (x, v) in ids) entries = BMOP.Entry[] for x in saved @@ -210,14 +265,22 @@ function _project_parts(gui::LiveView, dir::AbstractString; name::AbstractString pose0 = (Vector{Float64}(P0), Matrix{Float64}(R0)), meta = _project_meta(origin), label = _project_label(gui, x, id), uid)) end - is_saved = Base.IdSet{Any}(saved) + group_entries = BMOP.GroupEntry[] + for g in groups + id, uid = ids[g] + pos, rot = _group_pose(gui, g, uid) + push!(group_entries, BMOP.GroupEntry(id, Symbol[names[m] for m in members[g]]; pos, rot, + object = g, label = _project_label(gui, g, id), uid)) + end + is_saved = Base.IdSet{Any}(Any[saved..., groups...]) system_entries = BMOP.SystemEntry[BMOP.SystemEntry(names[sys], Symbol[names[obj] for obj in sys.objects if obj in is_saved], Symbol[names[src] for src in _sources_of(gui, sys) if src in is_saved]; static = _static_system(sys), object = sys, label = _project_label(gui, sys, names[sys]), uid = last(ids[sys])) for sys in systems] - entries, system_entries = _file_order(gui.sync.base, entries, system_entries) - project = BMOP.Project(dir, name; entries, systems = system_entries) + entries, system_entries, group_entries = _file_order(gui.sync.base, entries, system_entries, + group_entries) + project = BMOP.Project(dir, name; entries, systems = system_entries, groups = group_entries) return (; project, names, ids, skipped) end @@ -228,7 +291,8 @@ The setup of the `gui` as a project in the folder `dir`: its systems (`System` o with their members and sources, the objects and sources without a system (not those of the `extras` kwarg), each object once, also one of several systems. Each is an entry with its constructor call and its pose, see `_project_pose`; one whose constructor is not known (see the -`origin` of `_ComponentState`) is left out. The inputs of the page "Edit" that are not applied are +`origin` of `_ComponentState`) is left out. A group made in the window is a `@group` of its +members, see `_project_parts`. The inputs of the page "Edit" that are not applied are ignored. """ _collect_project(gui::LiveView, dir::AbstractString) = _project_parts(gui, dir).project @@ -245,14 +309,14 @@ end """ _apply_project_names!(gui, project) -Writes the readable ids and the machine ids of the entries and systems of the saved `project` into +Writes the readable ids and the machine ids of the entries, groups and systems of the saved `project` into `gui.sync.ids` (see `_ProjectSync`), such that the next save gives the same ones; the uids that `BeamletOpticsProjects.save_project` gave are those of the entries in `project`. The labels of the `gui` stay as they are. """ function _apply_project_names!(gui::LiveView, project) ids = gui.sync.ids - for e in Iterators.flatten((project.entries, project.systems)) + for e in Iterators.flatten((project.entries, project.systems, project.groups)) isnothing(e.object) && continue ids[e.object] = (e.name, something(e.uid, BMOP.new_uid())) end @@ -479,10 +543,16 @@ sources without a system, their labels (else their readable ids), their ids and `_ProjectSync`), the origins of the objects (their code, with which they are saved again and, from the catalog, edited and copied), and the view file of the person (`.bmo/view-.toml`, else `view.toml`). The `kwargs` of `live_view` win over those of the view file. The `project` becomes the `base` of the sync. + +The groups of the project (`@group`, built by `BeamletOpticsProjects`) become groups made in the +window (see `_group!`), which `Ctrl+Shift+G` ungroups: an outermost group is a top-level object of +its systems or without a system, its members are no top-level objects. """ function _open_from(project; view = _read_view(project), kwargs...) @nospecialize byname = Dict{Symbol, Any}(e.name => e.object for e in project.entries) + # The entries and groups inside a group: only their outermost group is a top-level object + grouped = Set{Symbol}(m for g in project.groups for m in g.members) in_system = Set{Symbol}() args = Any[] for s in project.systems @@ -494,29 +564,40 @@ function _open_from(project; view = _read_view(project), kwargs...) paired = Base.IdSet{Any}(a.second for a in args if a isa Pair) # The label of each, else its readable id labels = IdDict{Any, String}() - for e in Iterators.flatten((project.entries, project.systems)) + for e in Iterators.flatten((project.entries, project.systems, project.groups)) labels[e.object] = something(e.label, String(e.name)) end vk = _view_kwargs(view, byname, paired) gui = live_view(args...; labels, vk.kwargs..., kwargs...) # The readable id and the uid of each; a line of an agent without a uid gets one, which the # next save writes (`diff_projects` pairs it with its line by the readable id meanwhile) - for e in Iterators.flatten((project.entries, project.systems)) + for e in Iterators.flatten((project.entries, project.systems, project.groups)) gui.sync.ids[e.object] = (e.name, something(e.uid, BMOP.new_uid())) end origins = IdDict{Any, Any}(e.object => _project_origin(gui, e) for e in project.entries) + # The groups are groups made in the window, also before they are added without a system (see + # `_record_units`); they have no origin, see `_regroup_reason` + comp = gui.components + for g in project.groups + comp.groups[g.object] = _current_record(g.object) + end _unrecorded(gui) do for e in project.entries - e.name in in_system && continue + (e.name in in_system || e.name in grouped) && continue _add_loose!(gui, e.object, labels[e.object], origins[e.object], vk.beam_kwargs) end + for g in project.groups + (g.name in in_system || g.name in grouped) && continue + _add_loose!(gui, g.object, labels[g.object], nothing, vk.beam_kwargs) + end for src in vk.off src in paired || _set_beam_on!(gui, src, false) end end for (obj, origin) in origins - gui.components.origin[obj] = origin + comp.origin[obj] = origin end + foreach(g -> (comp.origin[g.object] = nothing), project.groups) empty!(gui.controls.undo_stack) empty!(gui.controls.redo_stack) if haskey(view, "camera") diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index 8311b86..a62593c 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -40,7 +40,11 @@ _person() = BMOP.person() """The presence file of the person `id` in the project folder `dir`, see `BeamletOpticsProjects.presence_file`.""" _presence_file(dir::AbstractString, id::AbstractString) = BMOP.presence_file(dir, id) -"""The machine id and the readable id of the selected object of the `gui`, `("", "")` if none.""" +""" +The machine id and the readable id of the selected object of the `gui`, `("", "")` if none: of the +innermost item with an id that holds the selection, e.g. a group (`@group`), a member of a group, or +the component of a selected part of it. +""" function _presence_selected(gui::LiveView) sel = gui.controls.selected[] isnothing(sel) && return ("", "") diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl index 71700af..aa1ec16 100644 --- a/src/LiveProjectSession.jl +++ b/src/LiveProjectSession.jl @@ -95,7 +95,10 @@ function _journal!(gui::LiveView, changes) return nothing end -"""The id, label, type and position of the selected object of the `gui`, or `nothing`.""" +""" +The id, label, type and position of the selected object of the `gui`, or `nothing`: of the +innermost item with an id that holds the selection, as `_presence_selected`. +""" function _session_selected(gui::LiveView) sel = gui.controls.selected[] isnothing(sel) && return nothing @@ -122,12 +125,13 @@ end _session_clock(t) = Libc.strftime("%H:%M:%S", t) -# The readable id of the entry or system of a conflict `key` (a uid, or "id:L1") +# The readable id of the entry, group or system of a conflict `key` (a uid, or "id:L1") function _conflict_id(gui::LiveView, key::String) s = gui.sync for p in (s.theirs, s.base) isnothing(p) && continue - x = something(_by_key(p.entries, key), _by_key(p.systems, key), Some(nothing)) + x = something(_by_key(p.entries, key), _by_key(p.systems, key), _by_key(p.groups, key), + Some(nothing)) isnothing(x) || return String(x.name) end for (id, uid) in values(s.ids) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index a54342e..3f1b3b8 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -603,10 +603,17 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) end """ -Gives the entries and systems of the `project` without a machine id (lines of an agent) the one -that the `gui` gave their objects (see `ids`), such that the project and the window compare equal. +Gives the entries, systems and groups of the `project` without a machine id (lines of an agent) the +one that the `gui` gave their objects (see `ids`), such that the project and the window compare equal. """ function _align_uids!(gui::LiveView, project) + for (i, ge) in enumerate(project.groups) + isnothing(ge.uid) || continue + g = _window_object(gui, ge) + isnothing(g) && continue + project.groups[i] = BMOP.GroupEntry(ge.name, ge.members, ge.pos, ge.rot, ge.object, ge.label, + gui.sync.ids[g][2]) + end for (i, e) in enumerate(project.entries) isnothing(e.uid) || continue obj = _window_object(gui, e) @@ -792,28 +799,71 @@ function _object_of_key(gui::LiveView, key::String) return nothing end +""" +The objects of the `gui` that the names `names` (members of a system or group of the files `disk`) +stand for, those for which `shown` is `true`: the object of an entry, the window group of a group, +or for a group that the window has not (yet, see `_make_groups!`) the objects of its members. +""" +function _member_objects(gui::LiveView, disk, names, shown) + out = Any[] + for n in names + key = "id:" * String(n) + x = something(_by_key(disk.entries, key), _by_key(disk.groups, key), Some(nothing)) + isnothing(x) && continue + o = _window_object(gui, x) + if !isnothing(o) && shown(o) + push!(out, o) + elseif x isa BMOP.GroupEntry + append!(out, _member_objects(gui, disk, x.members, shown)) + end + end + return out +end + +""" +The objects of the `gui` of the keys `ks` of entries and groups (see `BeamletOpticsProjects.merge_plan`) +for which `shown` is `true`; a group that the window has not, by its members in the first of the +`projects` that has it. +""" +function _key_objects(gui::LiveView, projects, ks, shown) + out = Any[] + for k in ks + o = _object_of_key(gui, k) + if !isnothing(o) && shown(o) + push!(out, o) + continue + end + for p in projects + ge = _by_key(p.groups, k) + isnothing(ge) && continue + append!(out, _member_objects(gui, p, ge.members, shown)) + break + end + end + return out +end + """ Makes the systems of the `gui` that are in `keys` have the members and sources of the files `disk`; those of `merged` (see `BeamletOpticsProjects.merge_plan`) get the merged members and sources, given -by entry keys. Objects and sources that the person removed from the view stay removed. +by keys of entries and groups (`mine` is the state of the window). A group that the window has not +yet stands for its members until it is made, see `_make_groups!`. Objects and sources that the +person removed from the view stay removed. """ -function _sync_members!(gui::LiveView, disk, keys; merged = Dict{String, Any}()) +function _sync_members!(gui::LiveView, disk, keys; merged = Dict{String, Any}(), mine = nothing) # The ids of objects removed from the view are kept, see `_project_ids` shown(@nospecialize o) = _shown_top(gui, o) || _has(_sources(gui), o) for se in disk.systems (_key(se) in keys || haskey(merged, _key(se))) || continue sys = _window_object(gui, se) sys isa BMO.System || continue - objs(names) = Any[o for n in names - for o in (_window_object(gui, _by_key(disk.entries, "id:" * String(n))),) - if !isnothing(o) && shown(o)] - want, sources = objs(se.members), objs(se.sources) + want, sources = _member_objects(gui, disk, se.members, shown), _member_objects(gui, disk, se.sources, shown) if haskey(merged, _key(se)) # Changed both in the window and in the files: the union of what each added (an object # of the window that is not saved yet has no id here and is not removed) m = merged[_key(se)] - by_keys(ks) = Any[o for k in ks for o in (_object_of_key(gui, k),) if !isnothing(o) && shown(o)] - want, sources = by_keys(m.members), by_keys(m.sources) + projects = isnothing(mine) ? Any[disk] : Any[disk, mine] + want, sources = _key_objects(gui, projects, m.members, shown), _key_objects(gui, projects, m.sources, shown) end _unrecorded(gui) do for o in want @@ -833,13 +883,238 @@ function _sync_members!(gui::LiveView, disk, keys; merged = Dict{String, Any}()) return nothing end +#= +Groups of the files in the window: a group whose members change is made anew, one that only moves, +is renamed or relabeled is changed in place +=# + +# The keys of the members of the group `ge` of the project `p`, by the names of its entries and groups +_group_member_keys(p, ge) = String[_key(x) for n in ge.members + for x in (something(_by_key(p.entries, "id:" * String(n)), + _by_key(p.groups, "id:" * String(n)), Some(nothing)),) + if !isnothing(x)] + +""" + _group_plan(gui, mine, disk, keys, merged, rebuilt) -> (; dissolve, make) + +How the groups of the `gui` (whose state is `mine`, see `_collect_project`: the `object` of each of +its groups is the window group) take the groups of the files `disk` for the `keys` and `merged` of a +merge (see `_adopt!`): + +- `dissolve`: the outermost groups of the window whose tree is made anew, since a group in it is + added, removed or gets other members in the files, or it holds one of the objects `rebuilt` (that + are removed or rebuilt, which must be top-level for that); +- `make`: the groups to make afterwards, `(; key, old, name, label, uid, pos, rot, members)`, whose + members are keys of entries and groups or objects of the window: as in the files for the `keys`, + the merged members for `merged`, and the window group `old` as it is for the other groups of the + dissolved trees, also one that is not saved yet (it has no id then, `name` is `nothing`). +""" +function _group_plan(gui::LiveView, mine, disk, keys, merged, rebuilt) + comp = gui.components + restructure = Set{String}() + for ge in disk.groups + k = _key(ge) + (k in keys || haskey(merged, k)) || continue + mg = _by_key(mine.groups, k) + want = haskey(merged, k) ? merged[k].members : Set(_group_member_keys(disk, ge)) + (isnothing(mg) || Set(_group_member_keys(mine, mg)) != want) && push!(restructure, k) + end + for mg in mine.groups + k = _key(mg) + k in keys && isnothing(_by_key(disk.groups, k)) && push!(restructure, k) + end + # The outermost window groups of the trees to make anew + dissolve = Any[] + function mark!(@nospecialize(o)) + top = _component_top(gui, o) + haskey(comp.groups, top) && !_has(dissolve, top) && push!(dissolve, top) + return nothing + end + for k in restructure + mg = _by_key(mine.groups, k) + o = isnothing(mg) ? _object_of_key(gui, k) : mg.object + isnothing(o) || mark!(o) + end + foreach(mark!, rebuilt) + # Every group of these trees is made anew, also those that did not change + trees = Any[] + collect_groups!(@nospecialize(g)) = (push!(trees, g); + foreach(m -> haskey(comp.groups, m) && collect_groups!(m), BMO.objects(g))) + foreach(collect_groups!, dissolve) + make = NamedTuple[] + made = Set{String}() + function from_files!(k, old) + ge = _by_key(disk.groups, k) + isnothing(ge) && return nothing + members = haskey(merged, k) ? Any[merged[k].members...] : Any[_group_member_keys(disk, ge)...] + push!(make, (; key = k, old, ge.name, ge.label, ge.uid, ge.pos, ge.rot, members)) + return nothing + end + for g in trees + i = findfirst(x -> x.object === g, mine.groups) + mg = isnothing(i) ? nothing : mine.groups[i] + k = isnothing(mg) ? nothing : _key(mg) + isnothing(k) || push!(made, k) + if !isnothing(k) && (k in keys || haskey(merged, k)) + from_files!(k, g) + else + # As in the window, by its objects: also members that are not saved + name, label, uid = isnothing(mg) ? (nothing, nothing, nothing) : (mg.name, mg.label, mg.uid) + R = Matrix{Float64}(BMO.orientation(g)) + push!(make, (; key = k, old = g, name, label, uid, pos = Tuple(Vector{Float64}(position(g))), + rot = R, members = collect(Any, BMO.objects(g)))) + end + end + for k in restructure + k in made || from_files!(k, nothing) + end + return (; dissolve, make) +end + +""" +Dissolves the top-level group `g` made in the window of the `gui` and the window groups inside it, +without undo entries and without selecting: their members take their places in the systems. +Returns the objects that were members. +""" +function _unmake_group!(gui::LiveView, @nospecialize(g)) + ctrl = gui.controls + systems = _member_systems(gui, g) + members = collect(Any, BMO.objects(g)) + _change!(ctrl, nothing) do + for sys in systems + delete!(sys, g) + foreach(m -> push!(sys, m), members) + end + end + _unshow_group!(gui, g, systems) + _sync_structure!(gui) + isempty(systems) || _on_systems_change!(gui, systems) + # Also the linked views, which followed the ungrouping + foreach(v -> _drop_history!(v.controls, Any[g]), Any[gui, gui.links.views...]) + ctrl.selected[] === g && (ctrl.selected[] = nothing) + delete!(gui.sync.ids, g) + freed = copy(members) + for m in members + haskey(gui.components.groups, m) && append!(freed, _unmake_group!(gui, m)) + end + return freed +end + +""" +Makes the groups `specs` (see `_group_plan`) in the `gui`, inner groups first: each an +`ObjectGroup` of its members (keys of entries and groups, or objects; a group of the window that is +made anew stands for the new one), which are taken out of their systems, with the pivot and +orientation of its spec; the group goes into each of these systems. Without undo entries and +without selecting. A member that is not in the view or not top-level is left out, a group without +members is not made. A spec with a `name` gives the group that id (the stale object of a group that +had the uid loses it). Adds `"grouped G1"` to `done` and the groups to `touched`. +""" +function _make_groups!(gui::LiveView, specs, done, touched; replaced = IdDict{Any, Any}()) + ctrl = gui.controls + ids = gui.sync.ids + remade = IdDict{Any, Any}() + pending = collect(specs) + waits(s, ref) = ref isa String ? (ref == s.key) : (ref === s.old) + resolve(ref) = ref isa String ? _object_of_key(gui, ref) : get(remade, ref, get(replaced, ref, ref)) + while !isempty(pending) + i = findfirst(s -> !any(ref -> any(t -> t !== s && waits(t, ref), pending), s.members), pending) + # A cycle can not come from a loaded project; whatever is left is not made + isnothing(i) && break + s = popat!(pending, i) + members = Any[o for ref in s.members for o in (resolve(ref),) + if !isnothing(o) && _shown_top(gui, o)] + isempty(members) && continue + g = BMO.ObjectGroup(members) + BMO.set_pivot3d!(g, Float64[s.pos...]) + isnothing(s.rot) || BMO.orientation!(g, BMOP.rotation_matrix(s.rot)) + systems = unique(objectid, Any[sys for m in members for sys in _member_systems(gui, m)]) + _change!(ctrl, nothing) do + for sys in systems + foreach(m -> _has(sys.objects, m) && delete!(sys, m), members) + push!(sys, g) + end + end + isnothing(s.old) || (remade[s.old] = g) + if isnothing(s.name) + _show_group!(gui, g, _current_record(g), systems) + else + _show_group!(gui, g, _current_record(g), systems; label = _shown_label(s)) + for (o, (_, uid)) in collect(ids) + o !== g && !isnothing(s.uid) && uid == s.uid && delete!(ids, o) + end + _name!(gui, g, s) + end + _sync_structure!(gui) + isempty(systems) || _on_systems_change!(gui, systems) + push!(touched, g) + isnothing(s.old) && push!(done, "grouped $(_shown_label(s))") + end + return nothing +end + +""" +Takes the objects `freed` (members of dissolved groups, see `_unmake_group!`) that are top-level +objects after the groups were made again out of the systems of the `gui` that do not list them: +the system of the files `disk` for the `keys` and `merged`, else that of the window `mine`. A +dissolved group put its members into its systems, which list only the group. +""" +function _settle_freed!(gui::LiveView, mine, disk, keys, merged, freed) + for o in freed + (_shown_top(gui, o) && haskey(gui.sync.ids, o)) || continue + name, uid = gui.sync.ids[o] + for sys in _member_systems(gui, o) + haskey(gui.sync.ids, sys) || continue + k = gui.sync.ids[sys][2] + listed = if haskey(merged, k) + uid in merged[k].members || "id:" * String(name) in merged[k].members + else + se = _by_key((k in keys ? disk : mine).systems, k) + isnothing(se) || name in se.members + end + listed || _unrecorded(() -> _remove_member!(gui, o, sys), gui) + end + end + return nothing +end + +""" +The groups of the files `disk` that the window of the `gui` has and that only moved, were renamed or +relabeled for the `keys`: changed in place. The members keep the poses of their own lines, so the +pivot and the orientation are set without moving them. +""" +function _adopt_groups!(gui::LiveView, mine, disk, keys, done) + for ge in disk.groups + _key(ge) in keys || continue + mg = _by_key(mine.groups, _key(ge)) + isnothing(mg) && continue + g = _window_object(gui, ge) + (isnothing(g) || !haskey(gui.components.groups, g)) && continue + if ge.name !== mg.name || ge.label != mg.label + _name!(gui, g, ge) + push!(done, "renamed $(_shown_label(ge))") + end + R = BMOP.rotation_matrix(ge.rot) + if collect(ge.pos) != Vector{Float64}(position(g)) || maximum(abs, R - BMO.orientation(g)) > 1e-12 + _change!(gui.controls, nothing) do + BMO.set_pivot3d!(g, Float64[ge.pos...]) + BMO.orientation!(g, R) + end + gui.components.groups[g] = _current_record(g) + push!(done, "moved $(_shown_label(ge))") + end + end + return nothing +end + """ _adopt!(gui, mine, disk, keys; rebuild = Set{Symbol}(), animate = _shown(gui), merged = Dict()) -> Vector{String} -Makes the entries and systems of the `keys` in the open `gui` (whose state is the project `mine`, -see `_collect_project`) as in the files `disk`, without undo entries: removes, adds, rebuilds (other -code, or calling own code in `rebuild`), moves (gliding if `animate`, see `_glide!`), renames and -relabels; the systems of `merged` get the merged members (see `_sync_members!`). The changed +Makes the entries, systems and groups of the `keys` in the open `gui` (whose state is the project +`mine`, see `_collect_project`) as in the files `disk`, without undo entries: removes, adds, +rebuilds (other code, or calling own code in `rebuild`), moves (gliding if `animate`, see +`_glide!`), renames and relabels; the systems and groups of `merged` get the merged members (see +`_sync_members!`). A group whose members change is made anew with the window groups around and +inside it (see `_group_plan`), one that only moved or was renamed is changed in place. The changed objects get a box for a moment. Returns what was done, e.g. `"moved M1"`. """ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), animate::Bool = _shown(gui), @@ -848,6 +1123,18 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima touched = Any[] glides = Tuple{Any, Any, Any}[] m = BMOP.match_entries(disk.entries, mine.entries) + # The trees of groups that change are dissolved first and made again after the entries and the + # systems, so that removed and rebuilt members are top-level objects then + rebuilt = Any[e.object for e in m.added if _key(e) in keys] + for (e, mine_e) in m.pairs + _key(e) in keys || continue + (e.kind != mine_e.kind || e.name in rebuild || + BMOP.normalize_code(e.code) != BMOP.normalize_code(mine_e.code)) && push!(rebuilt, mine_e.object) + end + groups = _group_plan(gui, mine, disk, keys, merged, rebuilt) + freed = Any[o for g in groups.dissolve for o in _unmake_group!(gui, g)] + # The objects that a rebuild replaced, for the groups made again by their objects + replaced = IdDict{Any, Any}() # In the window only: removed in the files for e in m.added _key(e) in keys || continue @@ -872,7 +1159,9 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima obj = mine_e.object if e.kind != mine_e.kind || e.name in rebuild || BMOP.normalize_code(e.code) != BMOP.normalize_code(mine_e.code) - push!(touched, _swap!(gui, obj, e)) + new = _swap!(gui, obj, e) + replaced[obj] = new + push!(touched, new) push!(done, "rebuilt $(_shown_label(e))") continue end @@ -887,7 +1176,10 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima end isequal(Dict(e.meta), Dict(mine_e.meta)) || _update_record!(gui, obj; meta = Pair{Symbol, Any}[e.meta...]) end - _sync_members!(gui, disk, keys; merged) + _sync_members!(gui, disk, keys; merged, mine) + _make_groups!(gui, groups.make, done, touched; replaced) + _settle_freed!(gui, mine, disk, keys, merged, freed) + _adopt_groups!(gui, mine, disk, keys, done) for se in ms.added _key(se) in keys || continue try @@ -946,8 +1238,8 @@ function _load_from_disk!(gui::LiveView) _finish_glides!(gui) _align_uids!(gui, disk) mine = _collect_project(gui, gui.project) - keys = Set{String}(_key(x) for x in Iterators.flatten((disk.entries, disk.systems, mine.entries, - mine.systems))) + keys = Set{String}(_key(x) for x in Iterators.flatten((disk.entries, disk.systems, disk.groups, + mine.entries, mine.systems, mine.groups))) _release_info!(gui, _ProjectConflict) done = _adopt!(gui, mine, disk, keys; rebuild = Set(_calls_own_code(disk))) # Taken in from the files, not written by the window @@ -989,7 +1281,7 @@ function _show_conflict!(gui::LiveView, keys::Vector{String}) names = String[] for k in keys x = something(_by_key(gui.sync.theirs.entries, k), _by_key(gui.sync.theirs.systems, k), - Some(nothing)) + _by_key(gui.sync.theirs.groups, k), Some(nothing)) if isnothing(x) obj = findfirst(t -> t[2] == k, gui.sync.ids) push!(names, isnothing(obj) ? k : _label(gui, obj)) diff --git a/test/TestLiveProjectGroups.jl b/test/TestLiveProjectGroups.jl new file mode 100644 index 0000000..83c94a2 --- /dev/null +++ b/test/TestLiveProjectGroups.jl @@ -0,0 +1,257 @@ +module TestLiveProjectGroups + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using LinearAlgebra: norm +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const LENS = "SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5)" +const START = """ + @component L1 = $LENS pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, D1] sources = [B1] + """ + +"""A project with `text`, opened (not shown) and saved once, so that every line has a uid.""" +function opened(text = START) + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), text) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + return gui, dir +end + +system_text(dir) = read(joinpath(dir, "system.jl"), String) +reopen(dir) = open_project(dir; display = false, const_kw...) + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +"""The line of `id` in `text`.""" +id_line(text, id) = only(l for l in split(text, "\n") if occursin(Regex("^@\\w+ $id = "), l)) + +"""The autosave of a change: it waits for the change to settle, then saves.""" +function autosave!(gui) + t = time() + first = GUI._autosave!(gui; now = t) + first === :saved && return first + return GUI._autosave!(gui; now = t + 1) +end + +ev(gui) = events(gui.ax.scene) +# The key that types the letter of `key` in the keyboard layout of this machine, see `_layout_key` +_typed(key) = first(k for k in Keyboard.Button.(Int(Keyboard.a):Int(Keyboard.z)) if GUI._layout_key(k) == key) +function _chord!(gui, key, mods...) + state = ev(gui).keyboardstate + foreach(m -> push!(state, m), mods) + ev(gui).keyboardbutton[] = Makie.KeyEvent(_typed(key), Keyboard.press) + foreach(m -> delete!(state, m), mods) + return nothing +end +ctrl_g!(gui) = _chord!(gui, Keyboard.g, Keyboard.left_control) +ctrl_shift_g!(gui) = _chord!(gui, Keyboard.g, Keyboard.left_control, Keyboard.left_shift) + +_in(o, list) = any(x -> x === o, list) +_pose(o) = (Vector{Float64}(BMO.position(o)), Matrix{Float64}(BMO.orientation(o))) +_same_pose(a, b; tol = 1e-12) = norm(a[1] - b[1]) <= tol && norm(a[2] - b[2]) <= tol + +"""The poses of the objects of the readable `ids` in the window.""" +poses(gui, ids) = Dict(id => _pose(obj(gui, id)) for id in ids) + +@testset "Groups in projects" begin + + @testset "Ctrl+G, autosave, reopen, Ctrl+Shift+G" begin + gui, dir = opened() + l1, m1, d1 = obj(gui, :L1), obj(gui, :M1), obj(gui, :D1) + sys = obj(gui, :S1) + select!(gui, [l1, m1]) + ctrl_g!(gui) + g = gui.controls.selected[] + @test g isa ObjectGroup && GUI._is_window_group(gui, g) + # turned and moved as a whole: the group has its own axes + rotate3d!(gui, g, [0.0, 0.0, 1.0], deg2rad(10)) + translate3d!(gui, g, [0.01, 0.0, 0.0]) + @test autosave!(gui) === :saved + text = system_text(dir) + # no member line is missing, the system lists the group + for id in (:L1, :M1, :D1, :B1) + @test any(l -> occursin(Regex("^@(component|source) $id = "), l), split(text, "\n")) + end + @test startswith(id_line(text, "G1"), "@group G1 = [L1, M1] pos = ") + @test occursin("rot = (0.0, 0.0, 10.0)", id_line(text, "G1")) + @test occursin(r"^@system S1 = \[D1, G1\]", id_line(text, "S1")) + @test !occursin(r"\bL1\b", id_line(text, "S1")) && !occursin(r"\bM1\b", id_line(text, "S1")) + @test obj(gui, :G1) === g + # the window and the files agree: nothing to save + @test GUI._autosave!(gui) === :none + before = poses(gui, (:L1, :M1, :D1, :G1)) + pivot = Vector{Float64}(BMO.position(g)) + + gui2 = reopen(dir) + g2 = obj(gui2, :G1) + @test g2 isa ObjectGroup && GUI._is_window_group(gui2, g2) + @test isnothing(gui2.components.origin[g2]) + @test Tuple(BMO.objects(g2)) == (obj(gui2, :L1), obj(gui2, :M1)) + sys2 = obj(gui2, :S1) + @test _in(g2, sys2.objects) && !_in(obj(gui2, :L1), sys2.objects) && + !_in(obj(gui2, :M1), sys2.objects) && _in(obj(gui2, :D1), sys2.objects) + @test Vector{Float64}(BMO.position(g2)) == pivot + after = poses(gui2, (:L1, :M1, :D1, :G1)) + @test all(id -> _same_pose(before[id], after[id]), keys(before)) + # the members keep their origins: they are saved again as they are + @test !isnothing(gui2.components.origin[obj(gui2, :L1)]) + @test GUI._autosave!(gui2) === :none + # the selected group and a selected member have their ids + select!(gui2, g2) + @test GUI._presence_selected(gui2) == (gui2.sync.ids[g2][2], "G1") + @test startswith(GUI._session_selected(gui2), "G1 (ObjectGroup) at ") + select!(gui2, obj(gui2, :L1)) + @test GUI._presence_selected(gui2)[2] == "L1" + + # Ctrl+Shift+G ungroups it, the next save writes the members into the system again + select!(gui2, g2) + ctrl_shift_g!(gui2) + @test _in(obj(gui2, :L1), sys2.objects) && _in(obj(gui2, :M1), sys2.objects) && + !_in(g2, sys2.objects) + @test !GUI._is_window_group(gui2, g2) + @test autosave!(gui2) === :saved + text = system_text(dir) + @test !occursin("@group", text) + line = id_line(text, "S1") + @test occursin(r"\bL1\b", line) && occursin(r"\bM1\b", line) && occursin(r"\bD1\b", line) + close(gui2) + close(gui) + end + + @testset "a group inside a group" begin + gui, dir = opened() + l1, m1, d1 = obj(gui, :L1), obj(gui, :M1), obj(gui, :D1) + inner = GUI._group!(gui, GUI._MultiSelection(Any[l1, m1])) + outer = GUI._group!(gui, GUI._MultiSelection(Any[inner, d1])) + @test GUI._is_window_group(gui, outer) && _in(inner, BMO.objects(outer)) + rotate3d!(gui, outer, [1.0, 0.0, 0.0], deg2rad(20)) + @test autosave!(gui) === :saved + text = system_text(dir) + @test startswith(id_line(text, "G1"), "@group G1 = [L1, M1] pos = ") + @test startswith(id_line(text, "G2"), "@group G2 = [G1, D1] pos = ") + # inner groups first + @test findfirst("@group G1", text)[1] < findfirst("@group G2", text)[1] + @test occursin(r"^@system S1 = \[G2\]", id_line(text, "S1")) + before = poses(gui, (:L1, :M1, :D1, :G1, :G2)) + @test GUI._autosave!(gui) === :none + + gui2 = reopen(dir) + g1, g2 = obj(gui2, :G1), obj(gui2, :G2) + @test GUI._is_window_group(gui2, g1) && GUI._is_window_group(gui2, g2) + @test Tuple(BMO.objects(g2)) == (g1, obj(gui2, :D1)) + @test Tuple(BMO.objects(g1)) == (obj(gui2, :L1), obj(gui2, :M1)) + @test obj(gui2, :S1).objects == Any[g2] + after = poses(gui2, (:L1, :M1, :D1, :G1, :G2)) + @test all(id -> _same_pose(before[id], after[id]), keys(before)) + @test GUI._autosave!(gui2) === :none + # a part of the group has the id of the innermost item with one + select!(gui2, g1) + @test GUI._presence_selected(gui2)[2] == "G1" + # the outer one is ungrouped: the inner one is a top-level object of the system + select!(gui2, g2) + ctrl_shift_g!(gui2) + @test _in(g1, obj(gui2, :S1).objects) && _in(obj(gui2, :D1), obj(gui2, :S1).objects) + @test autosave!(gui2) === :saved + text = system_text(dir) + @test !occursin("@group G2", text) && occursin("@group G1 = [L1, M1]", text) + # and the inner one too + select!(gui2, g1) + ctrl_shift_g!(gui2) + @test autosave!(gui2) === :saved + @test !occursin("@group", system_text(dir)) + close(gui2) + close(gui) + end + + @testset "a group without a system, written by an agent" begin + text = START * """ + @component L2 = $LENS pos = (0.1, 0.1, 0.0) rot = (0.0, 0.0, 90.0) + @component M2 = RoundPlanoMirror(0.0254, 0.006) pos = (0.1, 0.2, 0.0) + @group G1 = [L2, M2] pos = (0.1, 0.15, 0.0) rot = (0.0, 0.0, 30.0) label = "Spare pair" + """ + gui, dir = opened(text) + g = obj(gui, :G1) + @test GUI._is_window_group(gui, g) && gui.labels[g] == "Spare pair" + @test isempty(GUI._member_systems(gui, g)) + @test GUI._shown_top(gui, g) && !GUI._shown_top(gui, obj(gui, :L2)) + @test BMO.position(obj(gui, :L2)) == [0.1, 0.1, 0.0] + @test norm(BMO.orientation(g) - BeamletOpticsProjects.rotation_matrix((0.0, 0.0, 30.0))) < 1e-12 + # saved once by `opened` with the same lines + written = system_text(dir) + @test occursin("@group G1 = [L2, M2] pos = (0.1, 0.15, 0.0) rot = (0.0, 0.0, 30.0) label = \"Spare pair\" uid = ", written) + @test GUI._autosave!(gui) === :none + # Ctrl+Shift+G: the members are objects without a system + select!(gui, g) + ctrl_shift_g!(gui) + @test GUI._shown_top(gui, obj(gui, :L2)) && GUI._shown_top(gui, obj(gui, :M2)) + @test autosave!(gui) === :saved + written = system_text(dir) + @test !occursin("@group", written) && occursin("@component L2 = ", written) + close(gui) + end + + @testset "a pasted group" begin + GUI._CLIPBOARD[] = nothing + entry(name) = only(e for e in component_catalog() if e.name == name) + defaults(e) = String[GUI._catalog_string(p) for p in e.params] + built(name) = GUI._catalog_component(entry(name), defaults(entry(name))) + sys = System() + gui = live_view(sys => Beam([0.0, 0, 0], [0.0, 1, 0], 632.8e-9); const_kw...) + lens, mirror = built("Thin lens"), built("Round mirror") + translate3d!(lens.obj, [0.0, 0.1, 0.0]) + translate3d!(mirror.obj, [0.0, 0.2, 0.0]) + add_component!(gui, lens.obj; origin = lens.origin, select = false) + add_component!(gui, mirror.obj; origin = mirror.origin, select = false) + g = GUI._group!(gui, GUI._MultiSelection(Any[lens.obj, mirror.obj])) + rotate3d!(gui, g, [0.0, 0.0, 1.0], deg2rad(15)) + select!(gui, g) + @test !isnothing(GUI._copy_selected!(gui)) + pasted = GUI._paste!(gui) + @test pasted isa ObjectGroup + GUI._drop_placement!(gui) + @test _in(pasted, sys.objects) && GUI._is_window_group(gui, pasted) + dir = mktempdir() + save_project(gui, dir) + text = system_text(dir) + @test count("@group", text) == 2 && count("@component", text) == 4 + @test occursin(r"^@system S1 = \[G1, G2\]", id_line(text, "S1")) + ids = Dict(first(v) => x for (x, v) in gui.sync.ids) + before = Dict(id => _pose(x) for (id, x) in ids if !(x isa BMO.AbstractSystem) && !(x isa Beam)) + GUI._CLIPBOARD[] = nothing + + gui2 = reopen(dir) + for (id, pose) in before + @test _same_pose(pose, _pose(obj(gui2, id))) + end + p2 = obj(gui2, Symbol(only(id for (id, x) in ids if x === pasted))) + @test GUI._is_window_group(gui2, p2) && length(BMO.objects(p2)) == 2 + # the members of the catalog stay editable and can be copied + @test all(m -> haskey(gui2.components.origin[m], :entry), BMO.objects(p2)) + @test GUI._autosave!(gui2) === :none + select!(gui2, p2) + ctrl_shift_g!(gui2) + @test all(m -> _in(m, obj(gui2, :S1).objects), BMO.objects(p2)) + close(gui2) + close(gui) + end +end + +end diff --git a/test/TestLiveProjectSyncGroups.jl b/test/TestLiveProjectSyncGroups.jl new file mode 100644 index 0000000..30a65bf --- /dev/null +++ b/test/TestLiveProjectSyncGroups.jl @@ -0,0 +1,249 @@ +module TestLiveProjectSyncGroups + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using LinearAlgebra: norm +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const LENS = "SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5)" +const START = """ + @component L1 = $LENS pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, D1] sources = [B1] + """ + +"""Two windows (not shown) on one project with `START`, saved once by the first.""" +function two_windows() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + a = open_project(dir; display = false, const_kw...) + save_project(a) + b = open_project(dir; display = false, const_kw...) + return a, b, dir +end + +system_text(dir) = read(joinpath(dir, "system.jl"), String) +sync!(gui) = GUI._sync!(gui; animate = false) + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +"""The groups made in the window, by their readable ids.""" +window_groups(gui) = Dict(gui.sync.ids[g][1] => g for g in keys(gui.components.groups) if haskey(gui.sync.ids, g)) +member_ids(gui, g) = Set(gui.sync.ids[m][1] for m in BMO.objects(g)) +top_ids(gui, sys) = Set(gui.sync.ids[o][1] for o in sys.objects if haskey(gui.sync.ids, o)) +pose(o) = (Vector{Float64}(BMO.position(o)), Matrix{Float64}(BMO.orientation(o))) +same_pose(a, b; tol = 1e-12) = norm(a[1] - b[1]) <= tol && norm(a[2] - b[2]) <= tol + +"""Groups the objects of the readable `ids` in the window `gui`, as `Ctrl+G` does.""" +function group!(gui, ids...) + select!(gui, Any[obj(gui, id) for id in ids]) + return GUI._group!(gui, gui.controls.selected[]) +end + +"""The autosave of the window: waits for the change to settle, then saves.""" +function autosave!(gui) + t = time() + first = GUI._autosave!(gui; now = t) + first === :saved && return first + return GUI._autosave!(gui; now = t + 1) +end + +@testset "Groups of the files in an open window" begin + a, b, dir = two_windows() + undo = length(b.controls.undo_stack) + + @testset "a group made in another window" begin + group!(a, :L1, :M1) + @test autosave!(a) === :saved + @test occursin("@group G1 = [L1, M1]", system_text(dir)) + @test sync!(b) === :applied + g = window_groups(b)[:G1] + @test member_ids(b, g) == Set([:L1, :M1]) + @test top_ids(b, obj(b, :S1)) == Set([:G1, :D1]) + @test same_pose(pose(g), pose(window_groups(a)[:G1])) + # the members are the objects the window had, no new ones + @test any(x -> x === obj(b, :L1), BMO.objects(g)) + # nothing to save afterwards, no undo entries + @test autosave!(b) === :none + @test length(b.controls.undo_stack) == undo + # the window can ungroup it + @test GUI._is_window_group(b, g) + end + + @testset "a group moved and rotated in another window" begin + ga = window_groups(a)[:G1] + translate3d!(a, ga, [0.0, 0.0, 0.01]) + a.controls.selected[] = ga + rotate3d!(ga, BMO.rotate3d([0.0, 0.0, 1.0], deg2rad(10))) + GUI._apply_update!(a.controls, ga) + @test autosave!(a) === :saved + gb = window_groups(b)[:G1] + @test sync!(b) === :applied + @test window_groups(b)[:G1] === gb + @test same_pose(pose(gb), pose(ga)) + for id in (:L1, :M1) + @test same_pose(pose(obj(b, id)), pose(obj(a, id))) + end + @test autosave!(b) === :none + end + + @testset "a group made while this window moved another object" begin + # B groups M1 and D1? no: B moves D1, A ungroups G1 and groups M1 with D1 meanwhile + translate3d!(b, obj(b, :D1), [0.0, 0.0, 0.005]) + GUI._ungroup!(a, window_groups(a)[:G1]) + @test autosave!(a) === :saved + @test sync!(b) === :applied + @test isempty(window_groups(b)) + @test top_ids(b, obj(b, :S1)) == Set([:L1, :M1, :D1]) + # B saves its move, A takes it + @test autosave!(b) === :saved + @test sync!(a) === :applied + @test same_pose(pose(obj(a, :D1)), pose(obj(b, :D1))) + end + + @testset "a group inside a group" begin + g1 = group!(a, :L1, :M1) + @test autosave!(a) === :saved + # the ids of removed groups stay taken, see `_project_ids`: the new one gets the next + id1 = a.sync.ids[g1][1] + g2 = group!(a, id1, :D1) + @test autosave!(a) === :saved + id2 = a.sync.ids[g2][1] + text = system_text(dir) + @test occursin("@group $id1 = [L1, M1]", text) + @test occursin(Regex("@group $id2 = \\[($id1, D1|D1, $id1)\\]"), text) + @test sync!(b) === :applied + outer = window_groups(b)[id2] + @test GUI._shown_top(b, outer) + inner = window_groups(b)[id1] + @test any(x -> x === inner, BMO.objects(outer)) + @test member_ids(b, inner) == Set([:L1, :M1]) + @test top_ids(b, obj(b, :S1)) == Set([b.sync.ids[outer][1]]) + @test autosave!(b) === :none + end + + @testset "an agent adds a member to a group in the file" begin + a2, b2, dir2 = two_windows() + group!(a2, :L1, :M1) + @test autosave!(a2) === :saved + @test sync!(b2) === :applied + text = system_text(dir2) + text = replace(text, r"@group G1 = \[L1, M1\]" => "@group G1 = [L1, M1, D1]") + text = replace(text, r"@system S1 = \[[^\]]*\]" => "@system S1 = [G1]") + write(joinpath(dir2, "system.jl"), text) + @test sync!(b2) === :applied + @test member_ids(b2, window_groups(b2)[:G1]) == Set([:L1, :M1, :D1]) + @test top_ids(b2, obj(b2, :S1)) == Set([:G1]) + @test autosave!(b2) === :none + # and removes the group again: the members are in the system + text = replace(system_text(dir2), r"@group G1 = [^\n]*\n" => "") + text = replace(text, r"@system S1 = \[G1\]" => "@system S1 = [L1, M1, D1]") + write(joinpath(dir2, "system.jl"), text) + @test sync!(b2) === :applied + @test isempty(window_groups(b2)) + @test top_ids(b2, obj(b2, :S1)) == Set([:L1, :M1, :D1]) + close(a2) + close(b2) + end + + @testset "the same member grouped differently in both: a conflict" begin + a3, b3, dir3 = two_windows() + group!(b3, :L1, :D1) + group!(a3, :L1, :M1) + @test autosave!(a3) === :saved + @test sync!(b3) === :conflict + # the window keeps its own group, not saved yet (no id) + @test member_ids(b3, only(keys(b3.components.groups))) == Set([:L1, :D1]) + close(a3) + close(b3) + end + close(a) + close(b) +end + +@testset "Groups of the window in a merge, found in review" begin + @testset "a group not saved yet survives a rebuild of its member" begin + a, b, dir = two_windows() + g = group!(b, :L1, :M1) + @test !haskey(b.sync.ids, g) + text = replace(system_text(dir), "SphericalLens(0.05, -0.05," => "SphericalLens(0.06, -0.05,") + write(joinpath(dir, "system.jl"), text) + @test sync!(b) === :applied + g2 = only(keys(b.components.groups)) + @test member_ids(b, g2) == Set([:L1, :M1]) + @test BMO.objects(g2)[1] !== nothing && obj(b, :L1) !== nothing && any(x -> x === obj(b, :L1), BMO.objects(g2)) + # made again with the id it gets when it is saved + @test any(x -> x === g2, obj(b, :S1).objects) + @test top_ids(b, obj(b, :S1)) == Set([:D1, b.sync.ids[g2][1]]) + close(a) + close(b) + end + + @testset "Ctrl+R drops a group not saved yet" begin + a, b, dir = two_windows() + group!(b, :L1, :M1) + GUI._load_from_disk!(b) + @test isempty(b.components.groups) + @test top_ids(b, obj(b, :S1)) == Set([:L1, :M1, :D1]) + close(a) + close(b) + end + + @testset "a member taken out of a group in the file leaves the system" begin + a, b, dir = two_windows() + group!(a, :L1, :M1) + @test autosave!(a) === :saved + @test sync!(b) === :applied + write(joinpath(dir, "system.jl"), replace(system_text(dir), r"@group G1 = \[L1, M1\]" => "@group G1 = [L1]")) + @test sync!(b) === :applied + @test member_ids(b, window_groups(b)[:G1]) == Set([:L1]) + @test !any(x -> x === obj(b, :M1), obj(b, :S1).objects) + @test autosave!(b) === :none + close(a) + close(b) + end + + @testset "a group of one member: ungroup and undo" begin + a, b, dir = two_windows() + close(b) + group!(a, :L1, :M1) + @test autosave!(a) === :saved + write(joinpath(dir, "system.jl"), replace(system_text(dir), r"@group G1 = \[L1, M1\]" => "@group G1 = [L1]", + r"@system S1 = \[[^\]]*\]" => "@system S1 = [M1, D1, G1]")) + c = open_project(dir; display = false, const_kw...) + g = window_groups(c)[:G1] + @test GUI._ungroup!(c, g) + @test isempty(c.components.groups) + GUI._undo!(c.controls) + @test haskey(c.components.groups, g) + close(a) + close(c) + end + + @testset "export_changes of a reopened project with a group" begin + a, b, dir = two_windows() + close(b) + group!(a, :L1, :M1) + @test autosave!(a) === :saved + c = open_project(dir; display = false, const_kw...) + @test occursin("# no changes", sprint(io -> export_changes(c; io))) + close(a) + close(c) + end +end + +end diff --git a/test/TestPrecompileSession.jl b/test/TestPrecompileSession.jl index 97762af..cbd08da 100644 --- a/test/TestPrecompileSession.jl +++ b/test/TestPrecompileSession.jl @@ -57,7 +57,7 @@ using Test dir = joinpath(outer, "project") gui, screen = ext._project_session(step; dir) @test isempty(failed) - @test length(names) == 8 + @test length(names) == 9 @test LibGit2.with(LibGit2.isorphan, LibGit2.GitRepo(outer)) @test LibGit2.with(r -> realpath(LibGit2.path(r)) == realpath(dir), LibGit2.GitRepoExt(dir)) # the steps call the watcher themselves: none runs alongside them diff --git a/test/runtests.jl b/test/runtests.jl index b94ce8a..f8635a9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -42,6 +42,8 @@ include(joinpath(@__DIR__, "TestLiveProject.jl")) include(joinpath(@__DIR__, "TestLiveProjectSync.jl")) include(joinpath(@__DIR__, "TestLiveProjectSession.jl")) include(joinpath(@__DIR__, "TestLiveProjectPresence.jl")) +include(joinpath(@__DIR__, "TestLiveProjectGroups.jl")) +include(joinpath(@__DIR__, "TestLiveProjectSyncGroups.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) From a5c2092a8e3b9a4d5b4f528954777d0b891fb82e Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 20:29:57 +0100 Subject: [PATCH 12/47] Projects prototype: the watcher and the glides in files of their own No change of behavior: the watcher (looks, merge after the files settled, start and stop) moves to LiveProjectWatch.jl, the glides to LiveProjectGlide.jl (_adopt! calls _glide_all!), and _ProjectSync gets the fields of the next steps (first_change_at, adopting, monitor, claim). Co-Authored-By: Claude Opus 5.5 --- src/BeamletOpticsGUI.jl | 2 + src/LiveProjectGlide.jl | 83 +++++++++++ src/LiveProjectSync.jl | 309 +--------------------------------------- src/LiveProjectWatch.jl | 242 +++++++++++++++++++++++++++++++ src/LiveView.jl | 9 +- 5 files changed, 336 insertions(+), 309 deletions(-) create mode 100644 src/LiveProjectGlide.jl create mode 100644 src/LiveProjectWatch.jl diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 31edc51..f2f255f 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -113,6 +113,8 @@ include("LiveGroup.jl") include("LiveGroupKeys.jl") include("LiveProject.jl") include("LiveProjectSync.jl") +include("LiveProjectWatch.jl") +include("LiveProjectGlide.jl") include("LiveProjectSession.jl") include("LiveProjectPresence.jl") include("LiveDebug.jl") diff --git a/src/LiveProjectGlide.jl b/src/LiveProjectGlide.jl new file mode 100644 index 0000000..5a5e4d4 --- /dev/null +++ b/src/LiveProjectGlide.jl @@ -0,0 +1,83 @@ +#= +Objects that glide to the poses of the files after a merge, see `_adopt!` in `LiveProjectSync.jl` +=# + +# How long a moved object glides to its new pose [s], and the frames per second of that +const _GLIDE = 0.3 +const _GLIDE_FPS = 60 + +"""The rotation by `angle` about the unit vector `axis`.""" +function _axis_rotation(axis, angle) + K = [0.0 -axis[3] axis[2]; axis[3] 0.0 -axis[1]; -axis[2] axis[1] 0.0] + return Matrix{Float64}(I, 3, 3) + sin(angle) * K + (1 - cos(angle)) * K * K +end + +""" +Moves `obj` of the `gui`, which is in `gliding` of its `_ProjectSync`, to the pose `(P, R)` of its +entry there: gliding over `_GLIDE` seconds if `animate`, the position along the line, the +orientation about the axis of the rotation between the two; then lands it (`_land!`). Stops if it +was landed meanwhile, see `_finish_glides!`. +""" +function _glide!(gui::LiveView, @nospecialize(obj); animate::Bool) + gliding = gui.sync.gliding + haskey(gliding, obj) || return nothing + P1, R1, _ = gliding[obj] + ctrl = gui.controls + set!(P, R) = (_change!(() -> _set_pose!(obj, P, R), ctrl, obj); _apply_update!(ctrl, obj)) + if animate + P0, R0 = _pose(obj) + axis, angle = _rotation_axis_angle(Matrix{Float64}(R1) * Matrix{Float64}(R0)') + n = max(1, round(Int, _GLIDE * _GLIDE_FPS)) + for i in 1:(n - 1) + haskey(gliding, obj) || return nothing + f = i / n + f = f * f * (3 - 2f) + R = angle > 0 ? _axis_rotation(axis, f * angle) * R0 : R0 + set!(Point3{Float64}(P0 .+ f .* (Vector{Float64}(P1) .- P0)), R) + sleep(1 / _GLIDE_FPS) + end + end + _land!(gui, obj) + return nothing +end + +""" +Puts `obj` of `gliding` of the `gui` exactly into the pose of its entry of the files and records +that pose, see `_glide!`. +""" +function _land!(gui::LiveView, @nospecialize(obj)) + haskey(gui.sync.gliding, obj) || return nothing + P, R, e = pop!(gui.sync.gliding, obj) + ctrl = gui.controls + _change!(() -> _place_exactly!(obj, P, R), ctrl, obj) + _apply_update!(ctrl, obj) + _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) + return nothing +end + +""" +Lands every object of the `gui` that glides to a pose of the files at once, e.g. before a save, +which must see the poses of the files; their glides stop. +""" +_finish_glides!(gui::LiveView) = foreach(obj -> _land!(gui, obj), collect(keys(gui.sync.gliding))) + +""" +Sets the pose of `obj` to `(P, R)` with the position exactly `P`: rotates about its position, then +moves it to the origin, which is exact, and from there by `P`, like `BeamletOpticsProjects` +poses the objects of a project. +""" +function _place_exactly!(@nospecialize(obj), P, R) + axis, angle = _rotation_axis_angle(Matrix{Float64}(R) * _pose(obj)[2]') + angle > 0 && rotate3d!(obj, axis, angle) + reset_translation3d!(obj) + translate3d!(obj, Vector{Float64}(P)) + return nothing +end + +""" + _glide_all!(gui, glides; animate) + +Glides the objects of one merge of the `gui` to the poses of the files, see `_adopt!`: `glides` +holds `(obj, (P, R), entry)` of each, which `gliding` of its `_ProjectSync` holds too. +""" +_glide_all!(gui::LiveView, glides; animate::Bool) = foreach(g -> _glide!(gui, first(g); animate), glides) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 3f1b3b8..81d6aba 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -11,17 +11,6 @@ that `system.jl` is always what the person sees, and lists them in `.bmo/session (see `LiveProjectSession.jl`). =# -# How often the watcher looks at the times of the files [s] -const _WATCH_INTERVAL = 0.2 -# How often a look reads the contents of the files for their stamp, while their times stay [s] -const _STAMP_EVERY = 2.0 -# How long a look may wait in the background before the window reads the files itself [s] -const _LOOK_WAIT = 1.0 -# How long the files must stay unchanged before they are loaded [s], an agent writes in steps -const _SETTLE = 0.3 -# How long a moved object glides to its new pose [s], and the frames per second of that -const _GLIDE = 0.3 -const _GLIDE_FPS = 60 # How long the boxes of the changed objects are shown [s] const _FLASH = 1.0 # How long the setup in the window must stay unchanged before it is saved [s], and how long the @@ -38,106 +27,6 @@ function _shown(gui::LiveView) return applicable(isopen, screen) ? isopen(screen) : true end -""" -The times and sizes of the files of the project in `dir` that define the setup: `system.jl` and -the `.jl` files of `src/`, without names that start with `.` (e.g. `._X.jl` that macOS writes on -a shared drive), like `project_stamp`. Cheap enough to be looked at often, see `_check_files!`. -""" -function _file_marks(dir::AbstractString) - marks = Tuple{String, Float64, Int}[] - file = joinpath(dir, "system.jl") - isfile(file) && push!(marks, ("system.jl", mtime(file), filesize(file))) - src = joinpath(dir, "src") - if isdir(src) - for (root, dirs, names) in walkdir(src) - filter!(d -> !startswith(d, "."), dirs) - for n in names - (endswith(n, ".jl") && !startswith(n, ".")) || continue - path = joinpath(root, n) - push!(marks, (relpath(path, dir), mtime(path), filesize(path))) - end - end - end - return sort!(marks) -end - -_src_marks(marks) = filter(m -> m[1] != "system.jl", marks) - -""" - _files_look(dir; stamp = true, except = nothing, known = String[], presence = nothing) -> (; dir, marks, stamp, presences, known, present) - -What one look of the watcher reads of the project folder `dir`: the times and sizes of its files -(`_file_marks`), the stamp of their contents if `stamp` (else `nothing`), the presence files of the -others without the id `except` (`BeamletOpticsProjects.read_presences`) and of the ids `known` those -whose presence file is still there (`present`), also if it does not parse. Writes the own presence -file first if `presence` (see `_presence_data`). Touches no state of a window, so that it can run -in the background. -""" -function _files_look(dir::AbstractString; stamp::Bool = true, except = nothing, known = String[], - presence = nothing) - isnothing(presence) || _write_presence_file(dir, presence) - marks = _file_marks(dir) - st = stamp ? BMOP.project_stamp(dir) : nothing - presences = BMOP.read_presences(dir; except) - known = Set{String}(known) - present = Set{String}(id for id in known if isfile(_presence_file(dir, id))) - return (; dir = String(dir), marks, stamp = st, presences, known, present) -end - -""" - _take_look!(gui; now = time()) -> look or nothing - -The result of the look of the watcher of the `gui` that ran in the background (see `_files_look`) -if it is done, and starts the next one; `nothing` while it runs. On a shared drive every access -to a file waits for the network, often longer than a frame: the window never waits for it. - -- A look reads the stamp of the contents at most every `_STAMP_EVERY` seconds and only while the - files are settled, and writes the own presence file when it is due (see `_presence_due!`). -- A look that started before the last save of the window (see `_saved!`) or before a look here is - dropped; one of another folder too. -- While a look waits longer than `_LOOK_WAIT` seconds, e.g. behind a solve on the only thread of - the background (a solve does not yield), the files are read here instead, without the stamp, at - most every `_LOOK_WAIT` seconds, so that changes of the files and the own presence go on. -- An error of a look is thrown here once, after the next look started. -""" -function _take_look!(gui::LiveView; now::Real = time()) - s = gui.sync - dir = gui.project - run = s.look - if !isnothing(run) && !istaskdone(run.task) - (isnothing(dir) || now - s.look_at < _LOOK_WAIT) && return nothing - s.look_at = Float64(now) - s.gen += 1 - # Two writes of the own presence file at a time would share its temporary file - presence = run.presence ? nothing : _presence_due!(gui, now) - return _files_look(dir; stamp = false, except = _person().id, known = collect(keys(s.others)), presence) - end - s.look = nothing - look = failed = nothing - if !isnothing(run) - try - look = fetch(run.task) - catch e - e isa TaskFailedException || rethrow() - failed = e.task.exception - # The presence file of the failed look is written by the next one - run.presence && (s.presence_due = true) - end - end - isnothing(dir) && return nothing - stamp = isnothing(s.changed_at) && now - s.stamp_at >= _STAMP_EVERY - stamp && (s.stamp_at = Float64(now)) - except = _person().id - known = collect(keys(s.others)) - presence = _presence_due!(gui, now) - task = Threads.@spawn _files_look(dir; stamp, except, known, presence) - s.look = (; task, gen = s.gen, presence = !isnothing(presence)) - s.look_at = Float64(now) - isnothing(failed) || throw(failed) - (isnothing(look) || run.gen != s.gen || look.dir != dir) && return nothing - return look -end - """ _saved!(gui, project; written = true) @@ -208,84 +97,6 @@ function _remember_written!(s::_ProjectSync, before) return nothing end -""" -Starts the watcher of the files of the project of the `gui` if it is shown, the file -`.bmo/session.md` (see `_connect_session!`) and the presence file of the window, written again when -the selection changes (see `LiveProjectPresence.jl`). Every `_WATCH_INTERVAL` seconds until the -window is closed, the watcher takes the last look at the files, read in the background (see -`_take_look!`), and calls `_check_files!` and `_check_presence!` with it once there is a new one, -and `_autosave!` and `_draw_presence!` every time. A view that is not shown (e.g. in the tests or -of a script) is not watched and writes neither; the functions can be called instead. -""" -function _start_watching!(gui::LiveView) - s = gui.sync - (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing - _connect_session!(gui) - # The presence file is written by the looks of the watcher, see `_presence_due!` - s.presence_due = true - push!(gui.controls.listeners, on(gui.controls.selected) do _ - gui.sync.presence_due = true - return nothing - end) - last_error = Ref("") - s.timer = Timer(_WATCH_INTERVAL; interval = _WATCH_INTERVAL) do _ - _shown(gui) || return _stop_watching!(gui) - try - # The files are read in the background; the window works with the last look - look = _take_look!(gui) - isnothing(look) || _check_files!(gui; look) - _autosave!(gui) - isnothing(look) ? _draw_presence!(gui, time()) : _check_presence!(gui; look) - catch e - e isa InterruptException && rethrow() - last_error[] = _log_once(e, last_error[], "watching the project") - end - end - return nothing -end - -""" -Stops the watcher of the `gui` (see `_start_watching!`) when it is closed: if it was watched, saves -what the person changed last, without waiting (`_autosave!` with `force`), and removes its session -and presence files (`_session_closed!`, `_presence_closed!`). A view that was not watched saves -nothing. No watcher starts again afterwards, also not by that save. -""" -function _stop_watching!(gui::LiveView) - s = gui.sync - s.closed = true - t = s.timer - s.timer = nothing - if !isnothing(t) - close(t) - try - _autosave!(gui; force = true) - catch e - e isa InterruptException && rethrow() - @error "saving the project at closing failed" exception = (e, catch_backtrace()) - end - try - # A look that still runs may write the presence file after it was removed - isnothing(s.look) || wait(s.look.task) - catch - end - s.look = nothing - try - _presence_closed!(gui) - catch e - e isa InterruptException && rethrow() - @error "removing the presence file at closing failed" exception = (e, catch_backtrace()) - end - end - isempty(s.session) && return nothing - try - _session_closed!(gui) - catch e - e isa InterruptException && rethrow() - @error "writing .bmo/session.md at closing failed" exception = (e, catch_backtrace()) - end - return nothing -end - #= Autosave =# @@ -409,56 +220,6 @@ function _write_backup!(gui::LiveView, mine) return file end -""" - _check_files!(gui; now = time(), look = nothing) -> Symbol - -One look of the watcher at the files of the project of the `gui`: `:pending` while they change or -have not stayed unchanged for `_SETTLE` seconds; then, once per state of the files, `:applied`, -`:conflict` or `:failed`, see `_sync!`; `:none` otherwise, also for the files that the `gui` saved -itself. The files changed if their times or sizes (`_file_marks`) or else the stamp of their -contents (`project_stamp`) changed, as read by the `look` (see `_files_look`; read here if -`nothing`; the watcher reads it in the background, see `_take_look!`, and a look without a stamp -compares the times and sizes only). A load error shown before is cleared when the files are back -to the state of the `gui`. -""" -function _check_files!(gui::LiveView; now::Real = time(), look = nothing) - s = gui.sync - dir = gui.project - (isnothing(dir) || isnothing(s.base)) && return :none - isnothing(look) && (look = _files_look(dir)) - marks = look.marks - if marks != s.marks - s.marks = marks - s.changed_at = now - return :pending - end - if isnothing(s.changed_at) - # A write of the same size within the resolution of the file times (e.g. right after a save - # of the view) leaves the marks as they were: the stamp of the contents tells - stamp = look.stamp - (isnothing(stamp) || stamp == s.stamp || stamp == s.handled) && return :none - s.changed_at = now - return :pending - end - now - s.changed_at < _SETTLE && return :pending - s.changed_at = nothing - disk = BMOP.project_stamp(dir) - if disk == s.stamp - # Saved by the view itself, or back to the state of the view - if s.failed - s.failed = false - s.load_error = "" - gui.status.text[] = "project files are as in the window" - _note_session!(gui) - end - s.handled = nothing - return :none - end - disk == s.handled && return :none - s.handled = disk - return _sync!(gui) -end - #= Comparing and applying =# @@ -708,74 +469,6 @@ function _swap!(gui::LiveView, @nospecialize(old), e) return new end -"""The rotation by `angle` about the unit vector `axis`.""" -function _axis_rotation(axis, angle) - K = [0.0 -axis[3] axis[2]; axis[3] 0.0 -axis[1]; -axis[2] axis[1] 0.0] - return Matrix{Float64}(I, 3, 3) + sin(angle) * K + (1 - cos(angle)) * K * K -end - -""" -Moves `obj` of the `gui`, which is in `gliding` of its `_ProjectSync`, to the pose `(P, R)` of its -entry there: gliding over `_GLIDE` seconds if `animate`, the position along the line, the -orientation about the axis of the rotation between the two; then lands it (`_land!`). Stops if it -was landed meanwhile, see `_finish_glides!`. -""" -function _glide!(gui::LiveView, @nospecialize(obj); animate::Bool) - gliding = gui.sync.gliding - haskey(gliding, obj) || return nothing - P1, R1, _ = gliding[obj] - ctrl = gui.controls - set!(P, R) = (_change!(() -> _set_pose!(obj, P, R), ctrl, obj); _apply_update!(ctrl, obj)) - if animate - P0, R0 = _pose(obj) - axis, angle = _rotation_axis_angle(Matrix{Float64}(R1) * Matrix{Float64}(R0)') - n = max(1, round(Int, _GLIDE * _GLIDE_FPS)) - for i in 1:(n - 1) - haskey(gliding, obj) || return nothing - f = i / n - f = f * f * (3 - 2f) - R = angle > 0 ? _axis_rotation(axis, f * angle) * R0 : R0 - set!(Point3{Float64}(P0 .+ f .* (Vector{Float64}(P1) .- P0)), R) - sleep(1 / _GLIDE_FPS) - end - end - _land!(gui, obj) - return nothing -end - -""" -Puts `obj` of `gliding` of the `gui` exactly into the pose of its entry of the files and records -that pose, see `_glide!`. -""" -function _land!(gui::LiveView, @nospecialize(obj)) - haskey(gui.sync.gliding, obj) || return nothing - P, R, e = pop!(gui.sync.gliding, obj) - ctrl = gui.controls - _change!(() -> _place_exactly!(obj, P, R), ctrl, obj) - _apply_update!(ctrl, obj) - _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) - return nothing -end - -""" -Lands every object of the `gui` that glides to a pose of the files at once, e.g. before a save, -which must see the poses of the files; their glides stop. -""" -_finish_glides!(gui::LiveView) = foreach(obj -> _land!(gui, obj), collect(keys(gui.sync.gliding))) - -""" -Sets the pose of `obj` to `(P, R)` with the position exactly `P`: rotates about its position, then -moves it to the origin, which is exact, and from there by `P`, like `BeamletOpticsProjects` -poses the objects of a project. -""" -function _place_exactly!(@nospecialize(obj), P, R) - axis, angle = _rotation_axis_angle(Matrix{Float64}(R) * _pose(obj)[2]') - angle > 0 && rotate3d!(obj, axis, angle) - reset_translation3d!(obj) - translate3d!(obj, Vector{Float64}(P)) - return nothing -end - """Shows a box around each of the `objs` of the `gui` for `_FLASH` seconds.""" function _flash!(gui::LiveView, objs) color = _part_box_color(gui) @@ -1198,7 +891,7 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima # The autosave waits until they landed, a save lands them at once foreach(((obj, (P, R), e),) -> (gui.sync.gliding[obj] = (P, R, e)), glides) finish() = begin - foreach(g -> _glide!(gui, first(g); animate), glides) + _glide_all!(gui, glides; animate) _flash!(gui, Any[touched..., first.(glides)...]) return nothing end diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl new file mode 100644 index 0000000..aaeadb6 --- /dev/null +++ b/src/LiveProjectWatch.jl @@ -0,0 +1,242 @@ +#= +The watcher of the files of a project of a shown window: it looks at the files in the background +(see `_take_look!`), merges a change of them once they settled (`_check_files!`, `_sync!`) and calls +the autosave and the presence; see `LiveProjectSync.jl` +=# + +# How often the watcher looks at the times of the files [s] +const _WATCH_INTERVAL = 0.2 +# How often a look reads the contents of the files for their stamp, while their times stay [s] +const _STAMP_EVERY = 2.0 +# How long a look may wait in the background before the window reads the files itself [s] +const _LOOK_WAIT = 1.0 +# How long the files must stay unchanged before they are loaded [s], an agent writes in steps +const _SETTLE = 0.3 + +""" +The times and sizes of the files of the project in `dir` that define the setup: `system.jl` and +the `.jl` files of `src/`, without names that start with `.` (e.g. `._X.jl` that macOS writes on +a shared drive), like `project_stamp`. Cheap enough to be looked at often, see `_check_files!`. +""" +function _file_marks(dir::AbstractString) + marks = Tuple{String, Float64, Int}[] + file = joinpath(dir, "system.jl") + isfile(file) && push!(marks, ("system.jl", mtime(file), filesize(file))) + src = joinpath(dir, "src") + if isdir(src) + for (root, dirs, names) in walkdir(src) + filter!(d -> !startswith(d, "."), dirs) + for n in names + (endswith(n, ".jl") && !startswith(n, ".")) || continue + path = joinpath(root, n) + push!(marks, (relpath(path, dir), mtime(path), filesize(path))) + end + end + end + return sort!(marks) +end + +_src_marks(marks) = filter(m -> m[1] != "system.jl", marks) + +""" + _files_look(dir; stamp = true, except = nothing, known = String[], presence = nothing) -> (; dir, marks, stamp, presences, known, present) + +What one look of the watcher reads of the project folder `dir`: the times and sizes of its files +(`_file_marks`), the stamp of their contents if `stamp` (else `nothing`), the presence files of the +others without the id `except` (`BeamletOpticsProjects.read_presences`) and of the ids `known` those +whose presence file is still there (`present`), also if it does not parse. Writes the own presence +file first if `presence` (see `_presence_data`). Touches no state of a window, so that it can run +in the background. +""" +function _files_look(dir::AbstractString; stamp::Bool = true, except = nothing, known = String[], + presence = nothing) + isnothing(presence) || _write_presence_file(dir, presence) + marks = _file_marks(dir) + st = stamp ? BMOP.project_stamp(dir) : nothing + presences = BMOP.read_presences(dir; except) + known = Set{String}(known) + present = Set{String}(id for id in known if isfile(_presence_file(dir, id))) + return (; dir = String(dir), marks, stamp = st, presences, known, present) +end + +""" + _take_look!(gui; now = time()) -> look or nothing + +The result of the look of the watcher of the `gui` that ran in the background (see `_files_look`) +if it is done, and starts the next one; `nothing` while it runs. On a shared drive every access +to a file waits for the network, often longer than a frame: the window never waits for it. + +- A look reads the stamp of the contents at most every `_STAMP_EVERY` seconds and only while the + files are settled, and writes the own presence file when it is due (see `_presence_due!`). +- A look that started before the last save of the window (see `_saved!`) or before a look here is + dropped; one of another folder too. +- While a look waits longer than `_LOOK_WAIT` seconds, e.g. behind a solve on the only thread of + the background (a solve does not yield), the files are read here instead, without the stamp, at + most every `_LOOK_WAIT` seconds, so that changes of the files and the own presence go on. +- An error of a look is thrown here once, after the next look started. +""" +function _take_look!(gui::LiveView; now::Real = time()) + s = gui.sync + dir = gui.project + run = s.look + if !isnothing(run) && !istaskdone(run.task) + (isnothing(dir) || now - s.look_at < _LOOK_WAIT) && return nothing + s.look_at = Float64(now) + s.gen += 1 + # Two writes of the own presence file at a time would share its temporary file + presence = run.presence ? nothing : _presence_due!(gui, now) + return _files_look(dir; stamp = false, except = _person().id, known = collect(keys(s.others)), presence) + end + s.look = nothing + look = failed = nothing + if !isnothing(run) + try + look = fetch(run.task) + catch e + e isa TaskFailedException || rethrow() + failed = e.task.exception + # The presence file of the failed look is written by the next one + run.presence && (s.presence_due = true) + end + end + isnothing(dir) && return nothing + stamp = isnothing(s.changed_at) && now - s.stamp_at >= _STAMP_EVERY + stamp && (s.stamp_at = Float64(now)) + except = _person().id + known = collect(keys(s.others)) + presence = _presence_due!(gui, now) + task = Threads.@spawn _files_look(dir; stamp, except, known, presence) + s.look = (; task, gen = s.gen, presence = !isnothing(presence)) + s.look_at = Float64(now) + isnothing(failed) || throw(failed) + (isnothing(look) || run.gen != s.gen || look.dir != dir) && return nothing + return look +end + +""" +Starts the watcher of the files of the project of the `gui` if it is shown, the file +`.bmo/session.md` (see `_connect_session!`) and the presence file of the window, written again when +the selection changes (see `LiveProjectPresence.jl`). Every `_WATCH_INTERVAL` seconds until the +window is closed, the watcher takes the last look at the files, read in the background (see +`_take_look!`), and calls `_check_files!` and `_check_presence!` with it once there is a new one, +and `_autosave!` and `_draw_presence!` every time. A view that is not shown (e.g. in the tests or +of a script) is not watched and writes neither; the functions can be called instead. +""" +function _start_watching!(gui::LiveView) + s = gui.sync + (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing + _connect_session!(gui) + # The presence file is written by the looks of the watcher, see `_presence_due!` + s.presence_due = true + push!(gui.controls.listeners, on(gui.controls.selected) do _ + gui.sync.presence_due = true + return nothing + end) + last_error = Ref("") + s.timer = Timer(_WATCH_INTERVAL; interval = _WATCH_INTERVAL) do _ + _shown(gui) || return _stop_watching!(gui) + try + # The files are read in the background; the window works with the last look + look = _take_look!(gui) + isnothing(look) || _check_files!(gui; look) + _autosave!(gui) + isnothing(look) ? _draw_presence!(gui, time()) : _check_presence!(gui; look) + catch e + e isa InterruptException && rethrow() + last_error[] = _log_once(e, last_error[], "watching the project") + end + end + return nothing +end + +""" +Stops the watcher of the `gui` (see `_start_watching!`) when it is closed: if it was watched, saves +what the person changed last, without waiting (`_autosave!` with `force`), and removes its session +and presence files (`_session_closed!`, `_presence_closed!`). A view that was not watched saves +nothing. No watcher starts again afterwards, also not by that save. +""" +function _stop_watching!(gui::LiveView) + s = gui.sync + s.closed = true + t = s.timer + s.timer = nothing + if !isnothing(t) + close(t) + try + _autosave!(gui; force = true) + catch e + e isa InterruptException && rethrow() + @error "saving the project at closing failed" exception = (e, catch_backtrace()) + end + try + # A look that still runs may write the presence file after it was removed + isnothing(s.look) || wait(s.look.task) + catch + end + s.look = nothing + try + _presence_closed!(gui) + catch e + e isa InterruptException && rethrow() + @error "removing the presence file at closing failed" exception = (e, catch_backtrace()) + end + end + isempty(s.session) && return nothing + try + _session_closed!(gui) + catch e + e isa InterruptException && rethrow() + @error "writing .bmo/session.md at closing failed" exception = (e, catch_backtrace()) + end + return nothing +end + +""" + _check_files!(gui; now = time(), look = nothing) -> Symbol + +One look of the watcher at the files of the project of the `gui`: `:pending` while they change or +have not stayed unchanged for `_SETTLE` seconds; then, once per state of the files, `:applied`, +`:conflict` or `:failed`, see `_sync!`; `:none` otherwise, also for the files that the `gui` saved +itself. The files changed if their times or sizes (`_file_marks`) or else the stamp of their +contents (`project_stamp`) changed, as read by the `look` (see `_files_look`; read here if +`nothing`; the watcher reads it in the background, see `_take_look!`, and a look without a stamp +compares the times and sizes only). A load error shown before is cleared when the files are back +to the state of the `gui`. +""" +function _check_files!(gui::LiveView; now::Real = time(), look = nothing) + s = gui.sync + dir = gui.project + (isnothing(dir) || isnothing(s.base)) && return :none + isnothing(look) && (look = _files_look(dir)) + marks = look.marks + if marks != s.marks + s.marks = marks + s.changed_at = now + return :pending + end + if isnothing(s.changed_at) + # A write of the same size within the resolution of the file times (e.g. right after a save + # of the view) leaves the marks as they were: the stamp of the contents tells + stamp = look.stamp + (isnothing(stamp) || stamp == s.stamp || stamp == s.handled) && return :none + s.changed_at = now + return :pending + end + now - s.changed_at < _SETTLE && return :pending + s.changed_at = nothing + disk = BMOP.project_stamp(dir) + if disk == s.stamp + # Saved by the view itself, or back to the state of the view + if s.failed + s.failed = false + s.load_error = "" + gui.status.text[] = "project files are as in the window" + _note_session!(gui) + end + s.handled = nothing + return :none + end + disk == s.handled && return :none + s.handled = disk + return _sync!(gui) +end diff --git a/src/LiveView.jl b/src/LiveView.jl index 8973a60..a90b179 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -623,7 +623,10 @@ What the view knows of the files of its project, which an agent may change while `look` the look of the watcher that reads the files in the background, `(; task, gen, presence)` (see `_take_look!`), `look_at` the time it started, `gen` a counter that drops the looks started before it (`gen` of a look), and `stamp_at` the time a look last read the stamp of - the contents of the files. + the contents of the files; `first_change_at` the time the files began to change since the last + merge, `adopting` whether a merge is being applied (its solves wait for its end) and `monitor` + the notifications of changes of the files, see `LiveProjectWatch.jl`; +- `claim`: the claims of the person of the view and of the others, see `LiveProjectClaims.jl`. """ Base.@kwdef mutable struct _ProjectSync base::Any = nothing @@ -654,6 +657,10 @@ Base.@kwdef mutable struct _ProjectSync look_at::Float64 = 0.0 gen::Int = 0 stamp_at::Float64 = 0.0 + first_change_at::Union{Nothing, Float64} = nothing + adopting::Bool = false + monitor::Any = nothing + claim::Any = nothing end """ From d0df14c9499965c38f9594145f585707949c17bd Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 20:54:08 +0100 Subject: [PATCH 13/47] Projects prototype: a conflict holds only its own objects While a conflict is open, the window saves everything else; the objects of the conflict keep the state of the files until the person decides (BeamletOpticsProjects.compose), per object on the card ("theirs" / "mine") or for all. The card comes for new conflicts only, not after every action. Saves pass the participant to save_project, which refuses lines claimed by others. The backup while the files do not load is per person. Co-Authored-By: Claude Opus 5.5 --- src/LiveProject.jl | 38 ++++--- src/LiveProjectSession.jl | 8 +- src/LiveProjectSync.jl | 167 +++++++++++++++++++------------ test/TestLiveProject.jl | 2 +- test/TestLiveProjectConflicts.jl | 106 ++++++++++++++++++++ test/TestLiveProjectSession.jl | 4 +- test/TestLiveProjectSync.jl | 29 ++++-- test/runtests.jl | 1 + 8 files changed, 257 insertions(+), 98 deletions(-) create mode 100644 test/TestLiveProjectConflicts.jl diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 985c245..9c63600 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -307,17 +307,18 @@ function _project_packages() end """ - _apply_project_names!(gui, project) + _apply_project_names!(gui, project; skip = String[]) Writes the readable ids and the machine ids of the entries, groups and systems of the saved `project` into `gui.sync.ids` (see `_ProjectSync`), such that the next save gives the same ones; the uids that -`BeamletOpticsProjects.save_project` gave are those of the entries in `project`. The labels of the +`BeamletOpticsProjects.save_project` gave are those of the entries in `project`. Not those of the +keys `skip` (the objects of an open conflict, whose lines hold the files' state). The labels of the `gui` stay as they are. """ -function _apply_project_names!(gui::LiveView, project) +function _apply_project_names!(gui::LiveView, project; skip = String[]) ids = gui.sync.ids for e in Iterators.flatten((project.entries, project.systems, project.groups)) - isnothing(e.object) && continue + (isnothing(e.object) || _key(e) in skip) && continue ids[e.object] = (e.name, something(e.uid, BMOP.new_uid())) end return nothing @@ -696,22 +697,27 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) name = save_as && !isfile(joinpath(dir, "Project.toml")) ? BMOP.project_name(from) : BMOP.project_name(dir) parts = _project_parts(gui, dir; name) + # The objects of an open conflict keep the files' state in its own project, see `_saved_state` + keep = save_as ? String[] : copy(gui.sync.conflicts) + project = save_as ? parts.project : _saved_state(gui, parts.project) # The files as the view knows them: only of its own project - dir == from && (parts.project.stamp = gui.sync.stamp) + dir == from && (project.stamp = gui.sync.stamp) if save_as # Before checking whether the folder is a project: the copy must not miss its module BMOP.copy_code(from, dir) end new = !isfile(joinpath(dir, "Project.toml")) - path = save_project(parts.project; packages = _project_packages(), force) + # Refuses a line that another participant claimed (see `BeamletOpticsProjects.claim!`) + path = save_project(project; packages = _project_packages(), force, by = _person().id) # A new project gets the skills of BeamletOptics and BeamletOpticsProjects from its scaffold, # and the one of the GUI here; derived copies, not part of the versions new && Base.CoreLogging.with_logger(Base.CoreLogging.NullLogger()) do install_agent_skill(joinpath(dir, ".claude", "skills")) end _write_view_toml(gui, parts.project, parts.names) - # The uids that the save gave are those of `parts.project` - _apply_project_names!(gui, parts.project) + # The uids that the save gave are those of `project`; at the objects of a conflict it holds + # those of the files, not of the window + _apply_project_names!(gui, project; skip = keep) # A shown window (see `_connect_session!`) leaves the old folder: its session and presence # files go, and its journal is not of the new project connected = save_as && !isempty(gui.sync.session) @@ -727,8 +733,8 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) save_as && empty!(gui.sync.journal) gui.project = dir # The files of this stamp are the window's own write, see `_own_written` - _own_save!(gui, parts.project.stamp) - _saved!(gui, parts.project) + _own_save!(gui, project.stamp) + _saved!(gui, project; keep) connected && _connect_session!(gui) dir == from || _set_title!(gui, basename(dir)) msg = save_as ? "project saved as $dir" : "project saved to $dir" @@ -756,10 +762,10 @@ _message_line(e) = first(split(sprint(showerror, e), '\n')) """ Saves the `gui` to `dir`; if the files changed meanwhile, they are merged first (see `_sync!`) and -saved again if that left no conflict, else the card of the conflict is shown (see -`_ProjectConflict`); any other error, also of the second save, is logged and shown in the status -line. Whether the files hold the window afterwards: `false` after an error, a conflict or files -that do not load. +saved again, also with a conflict, whose objects keep the files' state (see `_saved_state`); any +other error, also of the second save, is logged and shown in the status line. Whether the files +hold the window afterwards (except the objects of a conflict): `false` after an error or files that +do not load. """ function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) try @@ -769,8 +775,8 @@ function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) e isa BMOP.ProjectChanged || return _save_failed!(gui, e) gui.sync.changed_at = nothing # Without gliding: the save right after must see the poses of the files - _sync!(gui; animate = false) === :applied || return false - (isempty(gui.sync.conflicts) && !gui.sync.failed) || return false + _sync!(gui; animate = false) === :failed && return false + gui.sync.failed && return false try save_project(gui, dir; force) catch e2 diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl index aa1ec16..f2f911a 100644 --- a/src/LiveProjectSession.jl +++ b/src/LiveProjectSession.jl @@ -145,14 +145,14 @@ function _session_text(gui::LiveView) io = IOBuffer() println(io, _session_header(gui.project)) println(io, "Written by the open BeamletOpticsGUI window: what the person sees and did there.") - println(io, "system.jl holds the state of the window (it saves every finished action), except while the") - println(io, "files do not load or a conflict is open: then the window keeps its state, which") - println(io, ".bmo/window-backup.jl holds.") + println(io, "system.jl holds the state of the window (it saves every finished action), except the objects") + println(io, "of an open conflict, which keep the state of the file until the person decides, and while the") + println(io, "files do not load: then the window keeps its state, which .bmo/window-backup-.jl holds.") println(io) sel = _session_selected(gui) println(io, "Selected: ", isnothing(sel) ? "nothing" : sel) isempty(s.conflicts) || println(io, "Open conflict: ", join((_conflict_id(gui, k) for k in s.conflicts), ", "), - " (window and file differ; the person decides on the card)") + " (window and file differ: the file keeps its state of them until the person decides; all else is saved)") s.failed && println(io, "The file does not load: ", s.load_error, " (the window keeps its state)") println(io) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 81d6aba..4be0853 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -28,16 +28,18 @@ function _shown(gui::LiveView) end """ - _saved!(gui, project; written = true) + _saved!(gui, project; written = true, keep = String[]) Records that the `gui` and the files of the `project` agree, after it was opened, saved or the -files were merged: `project` becomes the `base` of the comparisons (see `_ProjectSync`), a conflict -is over, the states that the window wrote are remembered (unless `written` is `false`, e.g. for -files that `Ctrl+R` took in; see `_stale`), `.bmo/session.md` is written if the view writes it, -the watcher starts if the `gui` is shown, and a watched view publishes the stamp of the files in -its presence file (the others name it as the writer, see `_writer`). +files were merged: `project` becomes the `base` of the comparisons (see `_ProjectSync`), the +conflicts are over except those of `keep` (a save of the window writes the files' state of the +objects in conflict and keeps them open, see `_saved_state`), the states that the window wrote are +remembered (unless `written` is `false`, e.g. for files that `Ctrl+R` took in; see `_stale`), +`.bmo/session.md` is written if the view writes it, the watcher starts if the `gui` is shown, and a +watched view publishes the stamp of the files in its presence file (the others name it as the +writer, see `_writer`). """ -function _saved!(gui::LiveView, project; written::Bool = true) +function _saved!(gui::LiveView, project; written::Bool = true, keep = String[]) s = gui.sync before = s.base s.base = _align_uids!(gui, project) @@ -48,8 +50,8 @@ function _saved!(gui::LiveView, project; written::Bool = true) s.failed = false s.load_error = "" if !isempty(s.conflicts) - empty!(s.conflicts) - _release_info!(gui, _ProjectConflict) + filter!(in(keep), s.conflicts) + isempty(s.conflicts) && _release_info!(gui, _ProjectConflict) end written && _remember_written!(s, before) # A look of the watcher that started before this read the files before it, see `_take_look!` @@ -105,21 +107,34 @@ Autosave # object only when it saves, a new one at every look before _uidless_code(p) = replace(BMOP.system_code(p), r" uid = \"[^\"]*\"" => "") +""" + _saved_state(gui, project) -> Project + +What a save of the `gui` writes of its setup `project` (see `_project_parts`): the setup, except at +the objects of an open conflict (`gui.sync.conflicts`), where the files keep their state until the +person decides (see `_ProjectConflict`), see `BeamletOpticsProjects.compose`. +""" +function _saved_state(gui::LiveView, project) + s = gui.sync + (isempty(s.conflicts) || isnothing(s.base)) && return project + return BMOP.compose(s.base, project, s.base, s.conflicts).project +end + """ _autosave!(gui; now = time(), force = false) -> Symbol One look of the autosave at the setup of the `gui`, after `_check_files!`: once the setup differs from the files (`base`) and has not changed for `_AUTOSAVE_DELAY` seconds, it is saved (`:saved`) and the changes of the person are added to the journal of `.bmo/session.md` (`_journal!`); files -changed meanwhile are merged first. `:pending` while it waits, `:none` if the setup is as in the +changed meanwhile are merged first. An open conflict holds only its own objects: the save writes +everything else, see `_saved_state`. `:pending` while it waits, `:none` if the setup is as in the files. `:paused` while the mouse drags or places an object (also in a linked view, see -`open_system`), an object glides to a pose of the files, the files changed and are not merged yet, -or a conflict is open: a change of the person then shows its card again if it was closed without -a decision. While the files do not load (e.g. an agent is half through an edit) or a conflict is -open, the setup is written to `.bmo/window-backup.jl` instead (`:paused`) and saved once they load -again. `:failed` if the save failed, the reason in the status line; tried again after -`_AUTOSAVE_RETRY` seconds. `force` saves without waiting and while the files changed or objects -glide (when the window closes or `Ctrl+S` saves a version); a conflict then writes the backup. +`open_system`), an object glides to a pose of the files, or the files changed and are not merged +yet. While the files do not load (e.g. an agent is half through an edit), the setup is written to +the backup of the person (see `_write_backup!`) instead (`:paused`) and saved once they load again. +`:failed` if the save failed, the reason in the status line; tried again after `_AUTOSAVE_RETRY` +seconds. `force` saves without waiting and while the files changed or objects glide (when the +window closes or `Ctrl+S` saves a version). """ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) s = gui.sync @@ -132,23 +147,20 @@ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) end # The name of the files that the view agrees with, not read from `Project.toml` at every look parts = _project_parts(gui, dir; name = s.base.name) - text = _uidless_code(parts.project) + text = _uidless_code(_saved_state(gui, parts.project)) if text == _uidless_code(s.base) s.text, s.edited_at = text, nothing return :none end if text != s.text s.text, s.edited_at = text, now - # A change of the person while a conflict is open whose card was closed: ask again - !force && !isempty(s.conflicts) && !_conflict_shown(gui) && _show_conflict!(gui, s.conflicts) force || return :pending end - !force && !isempty(s.conflicts) && return :paused # Written to the backup already, the files still do not load isnothing(s.edited_at) && s.failed && !force && return :paused !force && !isnothing(s.edited_at) && now - s.edited_at < _AUTOSAVE_DELAY && return :pending s.edited_at = nothing - if s.failed || !isempty(s.conflicts) + if s.failed _write_backup!(gui, parts.project) return :paused end @@ -167,30 +179,30 @@ end """ Saves the setup (`parts`, see `_project_parts`) of the `gui` for `_autosave!`. Files that changed since the last look are merged first, without gliding, such that the save sees the poses of the -files; a conflict or files that do not load then write the backup. A new list of objects without a -recipe stays in the status line and is warned once; otherwise the status line stays as it was. +files; files that do not load then write the backup, a conflict holds only its objects (see +`_saved_state`). A new list of objects without a recipe stays in the status line and is warned +once; otherwise the status line stays as it was. """ function _autosave_write!(gui::LiveView, parts, now::Real) s = gui.sync dir = gui.project if BMOP.project_stamp(dir) != s.stamp s.changed_at = nothing - if _sync!(gui; animate = false) !== :applied || !isempty(s.conflicts) + if _sync!(gui; animate = false) === :failed _write_backup!(gui, _collect_project(gui, dir)) return :paused end parts = _project_parts(gui, dir) - _uidless_code(parts.project) == _uidless_code(s.base) && return :none + _uidless_code(_saved_state(gui, parts.project)) == _uidless_code(s.base) && return :none end - changes = BMOP.diff_projects(s.base, parts.project) + changes = BMOP.diff_projects(s.base, _saved_state(gui, parts.project)) status = gui.status.text[] saved = Base.CoreLogging.with_logger(Base.CoreLogging.SimpleLogger(stderr, Base.CoreLogging.Error)) do _save_to!(gui, dir) end if !saved - # A conflict, files that do not load (both found by a merge of the save), or an error in - # the status line - if !isempty(s.conflicts) || s.failed + # Files that do not load (found by a merge of the save), or an error in the status line + if s.failed _write_backup!(gui, _collect_project(gui, dir)) return :paused end @@ -208,12 +220,16 @@ function _autosave_write!(gui::LiveView, parts, now::Real) return :saved end -"""Writes the setup `mine` of the `gui` to `.bmo/window-backup.jl`, while the files do not load.""" +""" +Writes the setup `mine` of the `gui` to `.bmo/window-backup-.jl` (see `_person`), while +the files do not load: each person has a backup of their own. +""" function _write_backup!(gui::LiveView, mine) - file = joinpath(gui.project, ".bmo", "window-backup.jl") + name = "window-backup-$(_person().id).jl" + file = joinpath(gui.project, ".bmo", name) mkpath(dirname(file)) write(file, BMOP.system_code(mine)) - note = "the window is kept in .bmo/window-backup.jl" + note = "the window is kept in .bmo/$name" st = gui.status.text[] occursin(note, st) || (gui.status.text[] = isempty(st) ? note : "$st; $note") _note_session!(gui) @@ -344,6 +360,7 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) plan = BMOP.merge_plan(s.base, mine, disk; stale = k -> _stale(s, k, disk), rebuild, keep = s.conflicts) conflicts = plan.conflicts + new_conflicts = setdiff(conflicts, s.conflicts) done = _adopt!(gui, mine, disk, plan.take; rebuild = Set(rebuild), animate, merged = plan.merged) s.base = _align_uids!(gui, disk) s.stamp = disk.stamp @@ -358,7 +375,9 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) _note_session!(gui) return :applied end - _show_conflict!(gui, conflicts) + # The card comes for new conflicts only; one that the person closed without deciding stays + # closed, its objects keep the files' state (see `_saved_state`) + isempty(new_conflicts) || _show_conflict!(gui, conflicts) _note_session!(gui) return :conflict end @@ -945,12 +964,16 @@ end _ProjectConflict Objects that were changed both in the window and in the files of its project (e.g. by an agent), -differently: an info card at `point` naming them (`names`), with three buttons, see -`_resolve_conflict!`. All other changes of the files are already applied, see `_sync!`. +differently: an info card at `point` with a row per object (`names`, their `keys`) to take the +state of the files ("theirs") or keep the window's ("mine"), and buttons for all of them and for +"Save as…", see `_resolve_conflict!`. All other changes of the files are already applied (see +`_sync!`), and the window saves all other changes meanwhile; the files keep their state of these +objects until the person decides (see `_saved_state`). """ struct _ProjectConflict <: _InfoItem point::Point3f names::Vector{String} + keys::Vector{String} end _points(x::_ProjectConflict) = [x.point] @@ -958,57 +981,71 @@ _title(::_ProjectConflict) = "Changed here and in the files" _tree_kind(::_ProjectConflict) = :warning _keep_plots!(::LiveView, ::_ProjectConflict) = nothing _keepable(::_ProjectConflict) = false -card_rows(x::_ProjectConflict) = ( - _text_row("objects", :conflict_names, (_, _) -> join(x.names, ", "); width = 48), - _text_row("files", :conflict_files, (_, _) -> "the rest of the files is applied"; width = 48), - CardRow( - CardWidget(Button; name = :conflict_theirs, label = "Take theirs", +function card_rows(x::_ProjectConflict) + rows = Any[_text_row("files", :conflict_files, + (_, _) -> "the rest is applied and saved; these keep the files' state until you choose"; width = 48)] + for (i, (name, key)) in enumerate(zip(x.names, x.keys)) + push!(rows, CardRow(name, + CardWidget(Button; name = Symbol("conflict_theirs_", i), label = "theirs", + on = (gui, _, _) -> _resolve_conflict!(gui, :theirs, [key])), + CardWidget(Button; name = Symbol("conflict_mine_", i), label = "mine", + on = (gui, _, _) -> _resolve_conflict!(gui, :mine, [key])))) + end + push!(rows, CardRow( + CardWidget(Button; name = :conflict_theirs, label = "All theirs", on = (gui, _, _) -> _resolve_conflict!(gui, :theirs)), - CardWidget(Button; name = :conflict_mine, label = "Keep mine", + CardWidget(Button; name = :conflict_mine, label = "All mine", on = (gui, _, _) -> _resolve_conflict!(gui, :mine)), CardWidget(Button; name = :conflict_save_as, label = "Save as…", on = (gui, _, _) -> _resolve_conflict!(gui, :save_as)))) + return Tuple(rows) +end + +# The name of the entry, group or system of the conflict `key` of the `gui` +function _conflict_name(gui::LiveView, key::String) + t = gui.sync.theirs + x = isnothing(t) ? nothing : + something(_by_key(t.entries, key), _by_key(t.systems, key), _by_key(t.groups, key), Some(nothing)) + isnothing(x) || return _shown_label(x) + obj = findfirst(v -> v[2] == key, gui.sync.ids) + return isnothing(obj) ? key : _label(gui, obj) +end -"""Shows the card of the conflict of the `gui` for the entries and systems of the `keys`.""" +"""Shows the card of the conflict of the `gui` for the entries, systems and groups of the `keys`.""" function _show_conflict!(gui::LiveView, keys::Vector{String}) - names = String[] - for k in keys - x = something(_by_key(gui.sync.theirs.entries, k), _by_key(gui.sync.theirs.systems, k), - _by_key(gui.sync.theirs.groups, k), Some(nothing)) - if isnothing(x) - obj = findfirst(t -> t[2] == k, gui.sync.ids) - push!(names, isnothing(obj) ? k : _label(gui, obj)) - else - push!(names, _shown_label(x)) - end - end - _show_info!(gui, _ProjectConflict(_error_anchor(gui), names)) + names = String[_conflict_name(gui, k) for k in keys] + _show_info!(gui, _ProjectConflict(_error_anchor(gui), names, copy(keys))) gui.status.text[] = "changed here and in the files: $(join(names, ", ")); choose on the card" return nothing end """ -Resolves the conflict of the `gui` (see `_ProjectConflict`) as the user chose, `how`: `:theirs` -takes the objects of the conflict from the files (their changes here are lost), `:mine` keeps them -as they are here, which the autosave writes, `:save_as` saves the window as a new project (see -`_save_as_key!`): the conflict is over once that is saved (`_saved!`); if the dialog is cancelled, -it stays open, and the autosave of the project keeps waiting for a decision. + _resolve_conflict!(gui, how, keys = all open ones) + +Resolves the conflict of the `gui` (see `_ProjectConflict`) for the objects of the `keys` as the +person chose, `how`: `:theirs` takes them from the files (their changes here are lost), `:mine` +keeps them as they are here, which the autosave writes next; `:save_as` saves the window as a new +project (see `_save_as_key!`), which ends all conflicts once it is saved (`_saved!`). The card shows +the objects still open, if any. """ -function _resolve_conflict!(gui::LiveView, how::Symbol) +function _resolve_conflict!(gui::LiveView, how::Symbol, keys = copy(gui.sync.conflicts)) s = gui.sync _release_info!(gui, _ProjectConflict) if how === :save_as _save_as_key!(gui) return nothing end + keys = String[k for k in keys if k in s.conflicts] + names = String[_conflict_name(gui, k) for k in keys] if how === :theirs && !isnothing(s.theirs) mine = _collect_project(gui, gui.project) - done = _adopt!(gui, mine, s.theirs, Set(s.conflicts)) - gui.status.text[] = "taken from the files: " * join(done, ", ") + done = _adopt!(gui, mine, s.theirs, Set(keys)) + gui.status.text[] = "taken from the files: " * join(isempty(done) ? names : done, ", ") elseif how === :mine - gui.status.text[] = "kept as here; written to the files" + gui.status.text[] = "kept as here, written to the files: " * join(names, ", ") end - empty!(s.conflicts) + filter!(k -> !(k in keys), s.conflicts) + isempty(s.conflicts) || _show_conflict!(gui, s.conflicts) _note_session!(gui) return nothing end diff --git a/test/TestLiveProject.jl b/test/TestLiveProject.jl index 83bc329..2f4bffe 100644 --- a/test/TestLiveProject.jl +++ b/test/TestLiveProject.jl @@ -460,7 +460,7 @@ const GUI = BeamletOpticsGUI n = length(versions(dir)) file = read(joinpath(dir, "system.jl"), String) translate3d!(gui, t.lens, [0.0, 0.0, 0.01]) - GUI._show_info!(gui, GUI._ProjectConflict(Point3f(0), ["$lens"])) + GUI._show_info!(gui, GUI._ProjectConflict(Point3f(0), ["$lens"], ["id:$lens"])) GUI._save_key!(gui) @test gui.status.text[] == "decide on the card first: $lens" @test GUI._info_card(gui).obj isa GUI._ProjectConflict diff --git a/test/TestLiveProjectConflicts.jl b/test/TestLiveProjectConflicts.jl new file mode 100644 index 0000000..3d2b09f --- /dev/null +++ b/test/TestLiveProjectConflicts.jl @@ -0,0 +1,106 @@ +module TestLiveProjectConflicts + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const LENS = "SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5)" +const START = """ + @component L1 = $LENS pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, D1] sources = [B1] + """ + +system(dir) = joinpath(dir, "system.jl") +pos_line(dir, id) = only(l for l in split(read(system(dir), String), "\n") if startswith(l, "@component $id ")) +obj(gui, id) = first(x for (x, (n, _)) in gui.sync.ids if n === id) +sync!(gui) = GUI._sync!(gui; animate = false) +function autosave!(gui) + t = time() + first = GUI._autosave!(gui; now = t) + first === :saved && return first + return GUI._autosave!(gui; now = t + 1) +end + +"""Two windows (not shown) on one project, saved once by the first.""" +function two_windows() + dir = mktempdir() + new_project(dir; name = "Bench") + write(system(dir), START) + a = open_project(dir; display = false, const_kw...) + save_project(a) + b = open_project(dir; display = false, const_kw...) + return a, b, dir +end + +@testset "a conflict holds only its own objects" begin + a, b, dir = two_windows() + # both move L1 differently; B saves first, A merges: a conflict on L1 + translate3d!(b, obj(b, :L1), [0.0, 0.0, 0.02]) + @test autosave!(b) === :saved + translate3d!(a, obj(a, :L1), [0.0, 0.0, 0.01]) + @test sync!(a) === :conflict + key = a.sync.ids[obj(a, :L1)][2] + @test a.sync.conflicts == [key] + card = GUI._info_card(a) + @test card.obj isa GUI._ProjectConflict && card.obj.keys == [key] && card.obj.names == ["L1"] + + @testset "the rest is saved meanwhile, L1 keeps the files' state" begin + translate3d!(a, obj(a, :M1), [0.0, 0.0, 0.03]) + @test autosave!(a) === :saved + @test occursin("pos = (0.0, 0.2, 0.03)", pos_line(dir, "M1")) + @test occursin("pos = (0.0, 0.1, 0.02)", pos_line(dir, "L1")) + @test a.sync.conflicts == [key] + # the other window takes the move of M1 + @test sync!(b) === :applied + @test BeamletOptics.position(obj(b, :M1)) == [0.0, 0.2, 0.03] + # the window keeps its own L1 until the person decides + @test BeamletOptics.position(obj(a, :L1)) == [0.0, 0.1, 0.01] + end + + @testset "a closed card does not come back after every action" begin + GUI._release_info!(a, GUI._ProjectConflict) + translate3d!(a, obj(a, :D1), [0.0, 0.0, 0.01]) + @test autosave!(a) === :saved + @test isnothing(GUI._info_card(a)) + @test a.sync.conflicts == [key] + end + + @testset "decided per object: mine is written" begin + GUI._resolve_conflict!(a, :mine, [key]) + @test isempty(a.sync.conflicts) + @test autosave!(a) === :saved + @test occursin("pos = (0.0, 0.1, 0.01)", pos_line(dir, "L1")) + end + + @testset "decided per object: theirs is taken" begin + @test sync!(b) === :applied + translate3d!(b, obj(b, :L1), [0.0, 0.0, 0.04]) + translate3d!(b, obj(b, :M1), [0.0, 0.0, 0.04]) + @test autosave!(b) === :saved + translate3d!(a, obj(a, :L1), [0.0, 0.0, 0.05]) + translate3d!(a, obj(a, :M1), [0.0, 0.0, 0.05]) + @test sync!(a) === :conflict + @test length(a.sync.conflicts) == 2 + kl = a.sync.ids[obj(a, :L1)][2] + GUI._resolve_conflict!(a, :theirs, [kl]) + @test BeamletOptics.position(obj(a, :L1)) == [0.0, 0.1, 0.05] + # the other stays open, its card shows it + @test a.sync.conflicts == [a.sync.ids[obj(a, :M1)][2]] + @test GUI._info_card(a).obj.names == ["M1"] + GUI._resolve_conflict!(a, :mine) + @test isempty(a.sync.conflicts) + @test autosave!(a) === :saved + @test occursin("pos = (0.0, 0.2, 0.08)", pos_line(dir, "M1")) + end + close(a) + close(b) +end + +end diff --git a/test/TestLiveProjectSession.jl b/test/TestLiveProjectSession.jl index f455a0b..ac77699 100644 --- a/test/TestLiveProjectSession.jl +++ b/test/TestLiveProjectSession.jl @@ -71,10 +71,10 @@ session(dir) = read(GUI._session_file(dir), String) gui.status.text[] = "something else" GUI._write_session!(gui) text = session(dir) - @test occursin("Open conflict: D1, M1 (window and file differ; the person decides on the card)", text) + @test occursin("Open conflict: D1, M1 (window and file differ: the file keeps its state of them until the person decides; all else is saved)", text) @test !occursin(uid, text) @test occursin("The file does not load: system.jl:12:5: boom (the window keeps its state)", text) - @test occursin(".bmo/window-backup.jl", text) + @test occursin(".bmo/window-backup-.jl", text) gui.sync.conflicts = String[] gui.sync.failed = false GUI._write_session!(gui) diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 36a6ffe..3f5976c 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -291,9 +291,10 @@ pos_line(dir, id) = only(l for l in split(read(system(dir), String), "\n") @test tick!(gui) === :failed translate3d!(gui, obj(gui, :L1), [0.0, 0.0, 0.01]) @test autosave!(gui) === :paused - backup = joinpath(dir, ".bmo", "window-backup.jl") + # one backup per person + backup = joinpath(dir, ".bmo", "window-backup-$(GUI._person().id).jl") @test isfile(backup) - @test occursin("window-backup.jl", gui.status.text[]) + @test occursin("window-backup-", gui.status.text[]) @test GUI._autosave!(gui; now = time() + 10) === :paused # The backup loads as a project copy = mktempdir() @@ -320,8 +321,10 @@ pos_line(dir, id) = only(l for l in split(read(system(dir), String), "\n") @test BeamletOptics.position(m1) == mine @test gui.sync.conflicts == [gui.sync.ids[m1][2]] @test BeamletOptics.position(obj(gui, :D1)) == [0.0, 0.33, 0.0] + # The conflict holds only M1: nothing else to save, and M1 keeps the files' state GUI._autosave!(gui) - @test GUI._autosave!(gui; now = time() + 10) === :paused + @test GUI._autosave!(gui; now = time() + 10) === :none + @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) GUI._resolve_conflict!(gui, :mine) @test autosave!(gui) === :saved @test occursin("pos = (0.0, 0.2, 0.02)", pos_line(dir, "M1")) @@ -486,7 +489,7 @@ end # The agent breaks the file, the watcher did not look yet; the window closes edit!(text -> text * "@component Z1 = $(LENS[1:end-1])\n", dir) close(gui) - backup = joinpath(dir, ".bmo", "window-backup.jl") + backup = joinpath(dir, ".bmo", "window-backup-$(GUI._person().id).jl") @test isfile(backup) && occursin("pos = (0.0, 0.1, 0.01)", read(backup, String)) @test gui.sync.failed && !isempty(gui.sync.load_error) end @@ -505,19 +508,24 @@ end @test tick!(gui) === :conflict @test gui.sync.conflicts == [key] && GUI._conflict_shown(gui) @test BeamletOptics.position(d1) == [0.0, 0.31, 0.0] + # Nothing else to save: the conflict holds only L1, which keeps the files' state GUI._autosave!(gui) - @test GUI._autosave!(gui; now = time() + 10) === :paused - # The card is closed without a decision; a change of the person shows it again + @test GUI._autosave!(gui; now = time() + 10) === :none + # The card is closed without a decision; a change of the person is saved, the card stays + # closed and the conflict open GUI._release_info!(gui, GUI._ProjectConflict) translate3d!(gui, d1, [0.0, 0.0, 0.01]) @test GUI._autosave!(gui) === :pending - @test GUI._conflict_shown(gui) - @test GUI._autosave!(gui; now = time() + 10) === :paused + @test !GUI._conflict_shown(gui) + @test GUI._autosave!(gui; now = time() + 10) === :saved + @test occursin("pos = (0.0, 0.31, 0.01)", pos_line(dir, "D1")) + @test occursin("pos = (0.0, 0.11, 0.0)", pos_line(dir, "L1")) && gui.sync.conflicts == [key] # Take theirs: L1 of the files GUI._resolve_conflict!(gui, :theirs) @test BeamletOptics.position(l1) == [0.0, 0.11, 0.0] @test isempty(gui.sync.conflicts) - @test autosave!(gui) === :saved + # the window is as in the files now + @test autosave!(gui) === :none @test occursin("pos = (0.0, 0.11, 0.0)", pos_line(dir, "L1")) @test occursin("pos = (0.0, 0.31, 0.01)", pos_line(dir, "D1")) close(gui) @@ -550,8 +558,9 @@ end GUI._SAVE_DIALOG[] = dialog end @test !isempty(gui.sync.conflicts) + # the conflict holds L1 with the files' state; nothing else to save GUI._autosave!(gui) - @test GUI._autosave!(gui; now = time() + 10) === :paused + @test GUI._autosave!(gui; now = time() + 10) === :none @test occursin("pos = (0.0, 0.11, 0.0)", pos_line(dir, "L1")) close(gui) end diff --git a/test/runtests.jl b/test/runtests.jl index f8635a9..3389cd3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -44,6 +44,7 @@ include(joinpath(@__DIR__, "TestLiveProjectSession.jl")) include(joinpath(@__DIR__, "TestLiveProjectPresence.jl")) include(joinpath(@__DIR__, "TestLiveProjectGroups.jl")) include(joinpath(@__DIR__, "TestLiveProjectSyncGroups.jl")) +include(joinpath(@__DIR__, "TestLiveProjectConflicts.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) From 47c66a016ca49349e2171358bfff06a594f6c8de Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 20:49:46 +0100 Subject: [PATCH 14/47] The optical table fits the setup again after a gesture (_table_fit!) Co-Authored-By: Claude Opus 5.5 --- src/LiveTable.jl | 25 ++++++++++++++-- test/TestLiveTableFit.jl | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 test/TestLiveTableFit.jl diff --git a/src/LiveTable.jl b/src/LiveTable.jl index c957f81..02010bc 100644 --- a/src/LiveTable.jl +++ b/src/LiveTable.jl @@ -24,7 +24,7 @@ the distance `pitch` [m] in the plane with the `normal` (the rotation axis of th time, then the lowest point of the components, see `_table_height`. The rows of the holes run along `e1` and `e2`, through the origin, see `_table_axes`. `range` are the holes that the table has, `(i0, i1, j0, j1)` along `e1` and `e2`, which grow with the setup while it is shown, see -`_table_include!`. While it is `shown`, components and sources `snap` onto its holes, see +`_table_include!`, and fit it again after a gesture, see `_table_fit!`. While it is `shown`, components and sources `snap` onto its holes, see `_table_snap`. Its `plots` are an outline and the `holes`, of the diameter `hole_size` [m], created when it is shown for the first time, see `_draw_table!`; `toggle` is its tool. """ @@ -205,7 +205,7 @@ end Shows or hides the optical table of the `gui`, see `_Table`. Shown for the first time, it gets its height (see `_table_height`) unless the `table` kwarg gave one; each time it is shown, it is -extended to the setup as it is, see `_table_range`, and never shrinks. While it is shown and the +extended to the setup as it is, see `_table_range`; it shrinks again only by `_table_fit!`. While it is shown and the snapping of the controls is switched on, dragged and placed components and sources snap onto its holes, see `_table_snap`; the status line tells if the snapping is off. """ @@ -250,6 +250,27 @@ function _table_include!(gui::LiveView, @nospecialize(obj::Union{BMO.ObjectOrGro return nothing end +""" + _table_fit!(gui) -> Bool + +Makes the table of the `gui` fit the setup as it is again, i.e. its holes below the visible +components, extras and sources with `_TABLE_MARGIN` holes around them (see `_table_range`), also if +that is fewer than it has: to be called when a gesture ends (the end of a drag, a removal, a merge +of the files). Does nothing, and returns `false`, while the table is hidden, while the mouse drags +(`gui.controls.dragging`) or a component is placed (so it does not flicker), and if the range does +not change. The drawn holes stay where they are, see `_table_holes`. +""" +function _table_fit!(gui::LiveView) + t = gui.components.table + (isnothing(t) || !t.shown || isnothing(t.range) || isnothing(t.height)) && return false + (gui.controls.dragging || _placing(gui)) && return false + range = _table_range(t, _table_points(gui)) + range == t.range && return false + t.range = range + _draw_table!(gui, t) + return true +end + # Components and sources snap onto the holes of the table, clip planes do not _grid_snaps(::Union{BMO.ObjectOrGroup, _Source}) = true _grid_snaps(_) = false diff --git a/test/TestLiveTableFit.jl b/test/TestLiveTableFit.jl new file mode 100644 index 0000000..b44659f --- /dev/null +++ b/test/TestLiveTableFit.jl @@ -0,0 +1,61 @@ +module TestLiveTableFit + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI + +@testset "Optical table fits the setup again" begin + function _fixture() + m = RoundPlanoMirror(25e-3, 5e-3) + lens = SphericalLens(0.05, -0.05, 0.01, 0.02) + translate_to3d!(lens, [0.0, 0.1, 0.0]) + sys = System([m, lens]) + gui = live_view(sys => Beam([0.0, 0, 0], [0.0, 1, 0], 1e-6); trace_budget = Inf, + throttle = false, plane_normal = [0, 0, 1], table = true) + return gui, sys, m, lens + end + + @testset "moved back from far away" begin + gui, sys, m, lens = _fixture() + t = gui.components.table + r0 = t.range + @test !GUI._table_fit!(gui) + translate_to3d!(lens, [1.0, 0.1, 0.0]) + GUI._table_include!(gui, lens) + @test t.range[2] > r0[2] + # not while the mouse drags or a component is placed + gui.controls.dragging = true + translate_to3d!(lens, [0.0, 0.1, 0.0]) + @test !GUI._table_fit!(gui) + @test t.range[2] > r0[2] + gui.controls.dragging = false + @test GUI._table_fit!(gui) + @test t.range == r0 + @test length(t.holes[]) == length(GUI._table_holes(t)[1]) + @test !GUI._table_fit!(gui) + end + + @testset "removed" begin + gui, sys, m, lens = _fixture() + t = gui.components.table + r0 = t.range + translate_to3d!(lens, [0.0, 1.0, 0.0]) + GUI._table_include!(gui, lens) + grown = t.range[4] + @test grown > r0[4] + remove_component!(gui, lens) + GUI._table_fit!(gui) + @test t.range == GUI._table_range(t, GUI._table_points(gui)) && t.range[4] < grown + end + + @testset "hidden table is left alone" begin + gui, sys, m, lens = _fixture() + t = gui.components.table + t.toggle.active[] = false + @test !GUI._table_fit!(gui) + end +end + +end From a3d2b9bf74fbfe349b772d0fcb04cdb6ff6c6b53 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 20:59:35 +0100 Subject: [PATCH 15/47] Projects: the objects of one merge glide together, a group moved rigidly glides as one Co-Authored-By: Claude Opus 5.5 --- src/LiveProjectGlide.jl | 250 ++++++++++++++++++++++++++++++----- test/TestLiveProjectGlide.jl | 234 ++++++++++++++++++++++++++++++++ 2 files changed, 453 insertions(+), 31 deletions(-) create mode 100644 test/TestLiveProjectGlide.jl diff --git a/src/LiveProjectGlide.jl b/src/LiveProjectGlide.jl index 5a5e4d4..b36b782 100644 --- a/src/LiveProjectGlide.jl +++ b/src/LiveProjectGlide.jl @@ -1,5 +1,6 @@ #= -Objects that glide to the poses of the files after a merge, see `_adopt!` in `LiveProjectSync.jl` +Objects that glide to the poses of the files after a merge, see `_adopt!` in `LiveProjectSync.jl`: +all objects of one merge at the same time, a window group that moved rigidly as one =# # How long a moved object glides to its new pose [s], and the frames per second of that @@ -13,45 +14,240 @@ function _axis_rotation(axis, angle) end """ -Moves `obj` of the `gui`, which is in `gliding` of its `_ProjectSync`, to the pose `(P, R)` of its -entry there: gliding over `_GLIDE` seconds if `animate`, the position along the line, the -orientation about the axis of the rotation between the two; then lands it (`_land!`). Stops if it -was landed meanwhile, see `_finish_glides!`. + _GroupGlide(group, poses) + +What `gliding` of the `_ProjectSync` of a window holds for a window `group` that glides as one, see +`_glide_all!`: `poses` are the pivots and orientations of the files `(g, P, R)` of the group and of +each window group inside it, which they get when it lands (`_land!`); its objects land on their own +entries of `gliding`. +""" +mutable struct _GroupGlide + group::Any + poses::Vector{Tuple{Any, Point3{Float64}, Matrix{Float64}}} +end + +""" +One object or window group that glides in `_glide_all!`: from `(P0, R0)` to `P1` and the rotation +by `angle` about `axis`. It glides while `gliding` holds `token` for `obj` and `tokens` for the +`members` (the objects of a group), see `_gliding`. +""" +struct _Glide + obj::Any + token::Any + P0::Point3{Float64} + R0::Matrix{Float64} + P1::Point3{Float64} + axis::Vector{Float64} + angle::Float64 + members::Vector{Any} + tokens::Vector{Any} +end + +function _Glide(@nospecialize(obj), token, P0, R0, P1, R1; members = Any[], tokens = Any[]) + axis, angle = _rotation_axis_angle(Matrix{Float64}(R1) * Matrix{Float64}(R0)') + return _Glide(obj, token, Point3{Float64}(P0), Matrix{Float64}(R0), Point3{Float64}(P1), + Vector{Float64}(axis), Float64(angle), members, tokens) +end + +# Whether the glide `x` still runs: neither it nor one of its members landed or glides anew meanwhile +function _gliding(gliding, x::_Glide) + get(gliding, x.obj, nothing) === x.token || return false + for (m, t) in zip(x.members, x.tokens) + get(gliding, m, nothing) === t || return false + end + return true +end + +# Sets the pose of the glide `x` at the fraction `f` of its way +function _glide_to!(x::_Glide, f) + R = x.angle > 0 ? _axis_rotation(x.axis, f * x.angle) * x.R0 : x.R0 + _set_pose!(x.obj, Point3{Float64}(x.P0 .+ f .* (x.P1 .- x.P0)), R) + return nothing +end + +""" + _window_tree(gui, g) -> (leaves, groups) + +The objects of the window group `g` of the `gui` that are no window groups, also those inside the +window groups among its objects, and these window groups with `g` first, see +`gui.components.groups`. +""" +function _window_tree(gui::LiveView, @nospecialize(g), leaves = Any[], groups = Any[g]) + for m in BMO.objects(g) + if haskey(gui.components.groups, m) + push!(groups, m) + _window_tree(gui, m, leaves, groups) + else + push!(leaves, m) + end + end + return leaves, groups +end + +""" + _rigid_motion(moves; tol = 1e-9) -> Union{Nothing, Tuple{Matrix{Float64}, Vector{Float64}}} + +The rigid motion `x ↦ ΔR x + t` that takes each pose `(p, Q)` of `moves` (pairs of poses +`(p, Q) => (p′, Q′)`) to `(p′, Q′) = (ΔR p + t, ΔR Q)` within `tol` [m] and `tol`, or `nothing` if +there is none. A group whose pivot and orientation the files changed from `(P, R)` to `(P′, R′)` +moved rigidly if its objects moved by `ΔR = R′ R⁻¹` and `t = P′ − ΔR P`. +""" +function _rigid_motion(moves; tol = 1e-9) + isempty(moves) && return nothing + (p, Q), (p1, Q1) = first(moves) + dR = Matrix{Float64}(Q1) * Matrix{Float64}(Q)' + t = Vector{Float64}(p1) .- dR * Vector{Float64}(p) + for ((p, Q), (p1, Q1)) in moves + maximum(abs, Matrix{Float64}(Q1) .- dR * Matrix{Float64}(Q)) <= tol || return nothing + maximum(abs, Vector{Float64}(p1) .- dR * Vector{Float64}(p) .- t) <= tol || return nothing + end + return dR, t +end + +""" + _rigid_groups(gui, targets) -> Vector + +The window groups of the `gui` that moved rigidly with all of their objects: each object inside +(see `_window_tree`) is in `targets` (`obj => (P, R)`, its pose of the files), and one rigid motion +`(ΔR, t)` takes all of them from their poses to these, see `_rigid_motion`. The outermost of them +only, as `(g, leaves, groups, ΔR, t)`. """ -function _glide!(gui::LiveView, @nospecialize(obj); animate::Bool) +function _rigid_groups(gui::LiveView, targets::IdDict) + found = Tuple{Any, Vector{Any}, Vector{Any}, Matrix{Float64}, Vector{Float64}}[] + for g in keys(gui.components.groups) + leaves, groups = _window_tree(gui, g) + (isempty(leaves) || !all(m -> haskey(targets, m), leaves)) && continue + motion = _rigid_motion([_pose(m) => targets[m] for m in leaves]) + isnothing(motion) || push!(found, (g, leaves, groups, motion...)) + end + # The groups inside another one glide with it + sort!(found; by = x -> -length(x[2])) + claimed = IdDict{Any, Nothing}() + outer = similar(found, 0) + for x in found + any(m -> haskey(claimed, m), x[2]) && continue + foreach(m -> (claimed[m] = nothing), x[2]) + push!(outer, x) + end + return outer +end + +# Shows the change of the objects `objs` of the `gui`: one update and one solve for all of them +function _update_glided!(gui::LiveView, objs) + isempty(objs) && return nothing + _apply_update!(gui.controls, length(objs) == 1 ? first(objs) : _MultiSelection(objs)) + return nothing +end + +""" + _glide_all!(gui, glides; animate) + +Glides the objects of one merge of the `gui` to the poses of the files, see `_adopt!`: `glides` +holds `(obj, (P, R), entry)` of each, which `gliding` of its `_ProjectSync` holds too. If `animate`, +all of them glide at the same time over `_GLIDE` seconds, the position along the line and the +orientation about the axis of the rotation between the two, with one update of the view per frame; +then they land (`_land!`). A window group whose objects all moved by one rigid motion (see +`_rigid_groups`) glides as one, such that its objects follow on arcs about its pivot: from the pose +that the motion takes to the pivot and orientation of the files, which `_adopt_groups!` gave it +already, back to these. A glide stops when its object lands meanwhile (`_finish_glides!`) or glides +anew, that of a group with each of its objects. +""" +function _glide_all!(gui::LiveView, glides; animate::Bool) gliding = gui.sync.gliding - haskey(gliding, obj) || return nothing - P1, R1, _ = gliding[obj] ctrl = gui.controls - set!(P, R) = (_change!(() -> _set_pose!(obj, P, R), ctrl, obj); _apply_update!(ctrl, obj)) + objs = Any[first(x) for x in glides] if animate - P0, R0 = _pose(obj) - axis, angle = _rotation_axis_angle(Matrix{Float64}(R1) * Matrix{Float64}(R0)') + targets = IdDict{Any, Any}() + for (obj, (P, R), _) in glides + haskey(gliding, obj) && (targets[obj] = (Point3{Float64}(P), Matrix{Float64}(R))) + end + items = _Glide[] + claimed = IdDict{Any, Nothing}() + for (g, leaves, groups, dR, t) in _rigid_groups(gui, targets) + # The pivots and orientations of the files, which the groups get back when they land, + # and before the motion, which matches the poses of their objects now + poses = Tuple{Any, Point3{Float64}, Matrix{Float64}}[(h, _pose(h)...) for h in groups] + _change!(ctrl, nothing) do + for (h, P, R) in poses + BMO.set_pivot3d!(h, dR' * (Vector{Float64}(P) .- t)) + BMO.orientation!(h, dR' * R) + end + end + token = _GroupGlide(g, poses) + gliding[g] = token + P0, R0 = _pose(g) + push!(items, _Glide(g, token, P0, R0, poses[1][2], poses[1][3]; + members = leaves, tokens = Any[gliding[m] for m in leaves])) + push!(objs, g) + foreach(m -> (claimed[m] = nothing), leaves) + end + for (obj, (P, R), _) in glides + (haskey(claimed, obj) || !haskey(gliding, obj)) && continue + P0, R0 = _pose(obj) + push!(items, _Glide(obj, gliding[obj], P0, R0, P, R)) + end + # What `gliding` holds for each of them now: a newer merge lands the ones of this one first + tokens = IdDict{Any, Any}(o => gliding[o] for o in objs if haskey(gliding, o)) n = max(1, round(Int, _GLIDE * _GLIDE_FPS)) for i in 1:(n - 1) - haskey(gliding, obj) || return nothing + filter!(x -> _gliding(gliding, x), items) + isempty(items) && break f = i / n f = f * f * (3 - 2f) - R = angle > 0 ? _axis_rotation(axis, f * angle) * R0 : R0 - set!(Point3{Float64}(P0 .+ f .* (Vector{Float64}(P1) .- P0)), R) + _change!(() -> foreach(x -> _glide_to!(x, f), items), ctrl, nothing) + _update_glided!(gui, Any[x.obj for x in items]) sleep(1 / _GLIDE_FPS) end + filter!(o -> haskey(tokens, o) && get(gliding, o, nothing) === tokens[o], objs) end - _land!(gui, obj) + _land_all!(gui, objs) return nothing end """ Puts `obj` of `gliding` of the `gui` exactly into the pose of its entry of the files and records -that pose, see `_glide!`. +that pose; a window group that glides as one (`_GroupGlide`) into the pivots and orientations of +the files. See `_land_all!`. +""" +_land!(gui::LiveView, @nospecialize(obj)) = _land_all!(gui, Any[obj]) + +""" +Lands those of the objects `objs` that are in `gliding` of the `gui` (see `_land!`): first the +objects, then the window groups. The records of the window groups that hold one of them or are one +of them are made anew (`_current_record`), then the view is updated once. """ -function _land!(gui::LiveView, @nospecialize(obj)) - haskey(gui.sync.gliding, obj) || return nothing - P, R, e = pop!(gui.sync.gliding, obj) +function _land_all!(gui::LiveView, objs) + gliding = gui.sync.gliding ctrl = gui.controls - _change!(() -> _place_exactly!(obj, P, R), ctrl, obj) - _apply_update!(ctrl, obj) - _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) + landed = Any[] + groups = _GroupGlide[] + for obj in objs + haskey(gliding, obj) || continue + v = gliding[obj] + v isa _GroupGlide && (push!(groups, v); continue) + P, R, e = pop!(gliding, obj) + _change!(() -> _place_exactly!(obj, P, R), ctrl, obj) + _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) + push!(landed, obj) + end + changed = IdDict{Any, Nothing}() + foreach(o -> (changed[o] = nothing), landed) + for gg in groups + delete!(gliding, gg.group) + _change!(ctrl, nothing) do + for (h, P, R) in gg.poses + BMO.set_pivot3d!(h, Vector{Float64}(P)) + BMO.orientation!(h, R) + end + end + foreach(x -> (changed[first(x)] = nothing), gg.poses) + end + isempty(changed) && return nothing + for g in collect(keys(gui.components.groups)) + (haskey(changed, g) || any(m -> haskey(changed, m), BMO.objects(g))) && + (gui.components.groups[g] = _current_record(g)) + end + _update_glided!(gui, landed) return nothing end @@ -59,7 +255,7 @@ end Lands every object of the `gui` that glides to a pose of the files at once, e.g. before a save, which must see the poses of the files; their glides stop. """ -_finish_glides!(gui::LiveView) = foreach(obj -> _land!(gui, obj), collect(keys(gui.sync.gliding))) +_finish_glides!(gui::LiveView) = _land_all!(gui, collect(keys(gui.sync.gliding))) """ Sets the pose of `obj` to `(P, R)` with the position exactly `P`: rotates about its position, then @@ -73,11 +269,3 @@ function _place_exactly!(@nospecialize(obj), P, R) translate3d!(obj, Vector{Float64}(P)) return nothing end - -""" - _glide_all!(gui, glides; animate) - -Glides the objects of one merge of the `gui` to the poses of the files, see `_adopt!`: `glides` -holds `(obj, (P, R), entry)` of each, which `gliding` of its `_ProjectSync` holds too. -""" -_glide_all!(gui::LiveView, glides; animate::Bool) = foreach(g -> _glide!(gui, first(g); animate), glides) diff --git a/test/TestLiveProjectGlide.jl b/test/TestLiveProjectGlide.jl new file mode 100644 index 0000000..94217a6 --- /dev/null +++ b/test/TestLiveProjectGlide.jl @@ -0,0 +1,234 @@ +module TestLiveProjectGlide + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using LinearAlgebra: norm, cross, dot, tr +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const LENS = "SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5)" +const START = """ + @component L1 = $LENS pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component L2 = $LENS pos = (0.0, 0.3, 0.0) + @component M2 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.4, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.5, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, L2, M2, D1] sources = [B1] + """ + +"""Two windows (not shown) on one project with `START`, saved once by the first.""" +function two_windows() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + a = open_project(dir; display = false, const_kw...) + save_project(a) + b = open_project(dir; display = false, const_kw...) + return a, b, dir +end + +system_text(dir) = read(joinpath(dir, "system.jl"), String) + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +window_groups(gui) = Dict(gui.sync.ids[g][1] => g for g in keys(gui.components.groups) if haskey(gui.sync.ids, g)) +pose(o) = (Vector{Float64}(BMO.position(o)), Matrix{Float64}(BMO.orientation(o))) +same_pose(a, b; tol = 1e-12) = norm(a[1] - b[1]) <= tol && norm(a[2] - b[2]) <= tol +rotation_angle(R) = acos(clamp((tr(R) - 1) / 2, -1.0, 1.0)) + +function group!(gui, ids...) + select!(gui, Any[obj(gui, id) for id in ids]) + return GUI._group!(gui, gui.controls.selected[]) +end + +function autosave!(gui) + t = time() + first = GUI._autosave!(gui; now = t) + first === :saved && return first + return GUI._autosave!(gui; now = t + 1) +end + +"""Waits (yielding to the glides) until `cond()` holds or `timeout` seconds passed.""" +function wait_for(cond; timeout = 30.0, step = 0.002) + t0 = time() + while !cond() + time() - t0 > timeout && return false + sleep(step) + end + return true +end + +moved(o, p0) = norm(Vector{Float64}(BMO.position(o)) - p0[1]) > 1e-9 || norm(Matrix{Float64}(BMO.orientation(o)) - p0[2]) > 1e-9 + +"""Moves the objects `ids` of the window `a` by different offsets, saves and merges in `b` with +the glides, waiting until they landed: a merge that the glides compiled once.""" +function warm_up!(a, b, ids) + for (i, id) in enumerate(ids) + translate3d!(a, obj(a, id), [0.0, 0.0, 0.001 * i]) + end + @test autosave!(a) === :saved + @test GUI._sync!(b; animate = true) === :applied + @test wait_for(() -> isempty(b.sync.gliding)) +end + +@testset "_rigid_motion" begin + R = BMO.rotate3d([0.0, 0.0, 1.0], deg2rad(30)) + t = [0.1, -0.2, 0.3] + p1, p2 = [0.0, 0.1, 0.0], [0.05, 0.2, -0.01] + Q1, Q2 = BMO.rotate3d([1.0, 0.0, 0.0], 0.2), Matrix{Float64}(BMO.rotate3d([0.0, 1.0, 0.0], -0.4)) + moves = [(p1, Q1) => (R * p1 + t, R * Q1), (p2, Q2) => (R * p2 + t, R * Q2)] + dR, t1 = GUI._rigid_motion(moves) + @test maximum(abs, dR - R) < 1e-14 + @test norm(t1 - t) < 1e-14 + # another offset of one of them, or another rotation + @test isnothing(GUI._rigid_motion([moves[1], (p2, Q2) => (R * p2 + t .+ [1e-6, 0, 0], R * Q2)])) + @test isnothing(GUI._rigid_motion([moves[1], (p2, Q2) => (R * p2 + t, Q2)])) + @test isnothing(GUI._rigid_motion(Pair[])) +end + +@testset "The objects of one merge glide together" begin + a, b, dir = two_windows() + ids = (:L1, :M1, :L2, :M2, :D1) + warm_up!(a, b, ids) + for (i, id) in enumerate(ids) + translate3d!(a, obj(a, id), [0.002 * i, 0.0, 0.0]) + end + @test autosave!(a) === :saved + starts = Dict(id => pose(obj(b, id)) for id in ids) + targets = Dict(id => pose(obj(a, id)) for id in ids) + t0 = time() + @test GUI._sync!(b; animate = true) === :applied + @test length(b.sync.gliding) == length(ids) + # the first frame moves all of them, none has landed + @test wait_for(() -> any(id -> moved(obj(b, id), starts[id]), ids)) + @test all(id -> moved(obj(b, id), starts[id]), ids) + @test !any(id -> same_pose(pose(obj(b, id)), targets[id]), ids) + @test wait_for(() -> isempty(b.sync.gliding)) + elapsed = time() - t0 + # one glide for all of them, not one after the other (5 * _GLIDE) + @test elapsed < 3 * GUI._GLIDE + for id in ids + @test same_pose(pose(obj(b, id)), targets[id]) + end + @test autosave!(b) === :none + close(a) + close(b) +end + +@testset "A group moved and rotated rigidly glides as one" begin + a, b, dir = two_windows() + warm_up!(a, b, (:L1, :M1)) + ga = group!(a, :L1, :M1) + @test autosave!(a) === :saved + @test GUI._sync!(b; animate = false) === :applied + gb = window_groups(b)[:G1] + ga = window_groups(a)[:G1] + # A turns the group by 90° about z and moves it + translate3d!(a, ga, [0.01, 0.0, 0.02]) + a.controls.selected[] = ga + rotate3d!(ga, BMO.rotate3d([0.0, 0.0, 1.0], deg2rad(90))) + GUI._apply_update!(a.controls, ga) + @test autosave!(a) === :saved + P0, R0 = pose(gb) + starts = Dict(id => pose(obj(b, id)) for id in (:L1, :M1)) + targets = Dict(id => pose(obj(a, id)) for id in (:L1, :M1)) + @test GUI._sync!(b; animate = true) === :applied + @test wait_for(() -> haskey(b.sync.gliding, gb)) + # about half way: the members lie where the group carries them, on arcs about its pivot + halfway() = deg2rad(30) < rotation_angle(pose(gb)[2] * R0') < deg2rad(60) + @test wait_for(halfway) + Pf, Rf = pose(gb) + dR = Rf * R0' + for id in (:L1, :M1) + p0, Q0 = starts[id] + p, Q = pose(obj(b, id)) + @test norm(p - (Pf + dR * (p0 - P0))) < 1e-9 + @test maximum(abs, Q - dR * Q0) < 1e-9 + # not on the straight line between start and end + p1 = targets[id][1] + d = p1 - p0 + @test norm(cross(p - p0, d)) / norm(d) > 1e-3 + end + # the pivot glides on the line from the old one to the one of the files + P1 = pose(ga)[1] + @test norm(cross(Pf - P0, P1 - P0)) < 1e-9 + @test wait_for(() -> isempty(b.sync.gliding)) + @test same_pose(pose(gb), pose(ga)) + for id in (:L1, :M1) + @test same_pose(pose(obj(b, id)), targets[id]) + end + @test same_pose(b.components.groups[gb].poses[obj(b, :L1)], GUI._current_record(ga).poses[obj(a, :L1)]; tol = 1e-9) + @test autosave!(b) === :none + + @testset "landed at once by a save" begin + rotate3d!(ga, BMO.rotate3d([0.0, 0.0, 1.0], deg2rad(-45))) + GUI._apply_update!(a.controls, ga) + @test autosave!(a) === :saved + @test GUI._sync!(b; animate = true) === :applied + @test wait_for(() -> moved(obj(b, :L1), targets[:L1])) + GUI._finish_glides!(b) + @test isempty(b.sync.gliding) + @test same_pose(pose(gb), pose(ga)) + for id in (:L1, :M1) + @test same_pose(pose(obj(b, id)), pose(obj(a, id))) + end + # the stopped glide does not move them afterwards + sleep(2 * GUI._GLIDE) + @test same_pose(pose(gb), pose(ga)) + @test same_pose(pose(obj(b, :M1)), pose(obj(a, :M1))) + @test autosave!(b) === :none + end + close(a) + close(b) +end + +@testset "A group whose members moved differently glides member by member" begin + a, b, dir = two_windows() + warm_up!(a, b, (:L1, :M1)) + group!(a, :L1, :M1) + @test autosave!(a) === :saved + @test GUI._sync!(b; animate = false) === :applied + gb = window_groups(b)[:G1] + P0, R0 = pose(gb) + # an agent moves the members in the file, not the group + text = system_text(dir) + line(id) = only(filter(l -> occursin("@component $id ", l), split(text, '\n'))) + l1, m1 = line(:L1), line(:M1) + text = replace(text, l1 => replace(l1, r"pos = \([^)]*\)" => "pos = (0.0, 0.1, 0.01)"), + m1 => replace(m1, r"pos = \([^)]*\)" => "pos = (0.01, 0.2, 0.0)")) + write(joinpath(dir, "system.jl"), text) + starts = Dict(id => pose(obj(b, id)) for id in (:L1, :M1)) + @test GUI._sync!(b; animate = true) === :applied + @test !haskey(b.sync.gliding, gb) + @test wait_for(() -> moved(obj(b, :L1), starts[:L1])) + # both glide on straight lines, the group stays + @test moved(obj(b, :M1), starts[:M1]) + for (id, p1) in ((:L1, [0.0, 0.1, 0.01]), (:M1, [0.01, 0.2, 0.0])) + p, p0 = pose(obj(b, id))[1], starts[id][1] + d = p1 - p0 + @test norm(cross(p - p0, d)) < 1e-12 + @test 0 < dot(p - p0, d) < dot(d, d) + end + @test !haskey(b.sync.gliding, gb) + @test same_pose(pose(gb), (P0, R0)) + @test wait_for(() -> isempty(b.sync.gliding)) + @test pose(obj(b, :L1))[1] == [0.0, 0.1, 0.01] + @test pose(obj(b, :M1))[1] == [0.01, 0.2, 0.0] + @test same_pose(pose(gb), (P0, R0)) + @test autosave!(b) === :none + close(a) + close(b) +end + +end From 6275710ba1e52ba0abe93a4e8286057cc12d4051 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 21:07:42 +0100 Subject: [PATCH 16/47] Claims of the people in the window: implicit claims, refused gestures, the lock at the object Co-Authored-By: Claude Opus 5.5 --- src/BeamletOpticsGUI.jl | 1 + src/LiveCards.jl | 20 +- src/LiveComponents.jl | 19 +- src/LiveEdit.jl | 11 +- src/LiveGroup.jl | 11 +- src/LiveInteraction.jl | 31 ++- src/LiveProjectClaims.jl | 494 ++++++++++++++++++++++++++++++++++ src/LiveProjectPresence.jl | 74 +++-- test/TestLiveProjectClaims.jl | 255 ++++++++++++++++++ 9 files changed, 880 insertions(+), 36 deletions(-) create mode 100644 src/LiveProjectClaims.jl create mode 100644 test/TestLiveProjectClaims.jl diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index f2f255f..3a1fd94 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -117,6 +117,7 @@ include("LiveProjectWatch.jl") include("LiveProjectGlide.jl") include("LiveProjectSession.jl") include("LiveProjectPresence.jl") +include("LiveProjectClaims.jl") include("LiveDebug.jl") include("LiveDebugHUD.jl") include("LiveDebugSpheres.jl") diff --git a/src/LiveCards.jl b/src/LiveCards.jl index 7e86d76..32ee09d 100644 --- a/src/LiveCards.jl +++ b/src/LiveCards.jl @@ -85,7 +85,7 @@ Applies the input `s` of the pose box `k` of a card (see `_POSE_FIELDS`) to its green or blue axis of the controls [mrad], like a key step in the rotate mode. The change is recorded in the undo history and solved like a key step. An invalid input only shows a message in the status line, and so does an input for an object that is not movable, e.g. an inspected one (see -`_inspect!`). +`_inspect!`), or that another participant of the project claims (see `_gesture!`). """ _apply_pose_input!(gui::LiveView, ::Nothing, ::Int, _) = _update_inspector!(gui; force = true) function _apply_pose_input!(gui::LiveView, @nospecialize(obj), k::Int, s) @@ -95,6 +95,11 @@ function _apply_pose_input!(gui::LiveView, @nospecialize(obj), k::Int, s) _update_inspector!(gui; force = true) return nothing end + if !_gesture!(gui, obj) + # claimed by another participant, see `_gesture!` + _update_inspector!(gui; force = true) + return nothing + end x = isnothing(s) ? nothing : tryparse(Float64, strip(s)) if isnothing(x) || !isfinite(x) gui.status.text[] = "invalid input \"$(something(s, ""))\" for $(_POSE_FIELDS[k]), enter a number" @@ -652,12 +657,19 @@ end Applies the input `v` of the declared widget `i` of the card `c` to the object of the card (see `_card_object`) with `on` of its declaration (and solves again for `solve = true`), then shows the -new values on all cards. Ignored while the card shows new values (`refreshing`). +new values on all cards. Ignored while the card shows new values (`refreshing`). An input on an +object that another participant of the project claims is refused (see `_gesture!`): the card shows +the values of the object again. """ function _on_input!(gui::LiveView, c::_AbstractCard, i::Int, v) c.refreshing && return nothing w = c.widgets[i][2] - _apply_input!(gui, w.on, _card_object(gui, c), v, w.solve) + obj = _card_object(gui, c) + if !isnothing(w.on) && !isnothing(obj) && !_gesture!(gui, obj) + _refresh_card!(gui, c; force = true) + return nothing + end + _apply_input!(gui, w.on, obj, v, w.solve) _update_inspector!(gui) _update_cards!(gui) return nothing @@ -1245,6 +1257,8 @@ function _connect_cards!(gui::LiveView) _over_catalog(gui) || _card_menu_open(gui) # not in `over`: a press on the debug card ends the input into the textboxes of the cards ctrl.ignore_mouse = () -> over() || _over_debug(gui) || _outside_view(gui) + # the gestures of the controls ask the claims of the project, see `LiveProjectClaims.jl` + _connect_claims!(gui) foreach(c -> _connect_card!(gui, c), gui.cards.all) push!(ctrl.listeners, on(ev.tick) do tick # a tick tells whether the frame before it was drawn, see `_settle_cards!` diff --git a/src/LiveComponents.jl b/src/LiveComponents.jl index 351f6de..eae2838 100644 --- a/src/LiveComponents.jl +++ b/src/LiveComponents.jl @@ -529,6 +529,14 @@ function _removal_reason(gui::LiveView, @nospecialize(obj)) return "$name is not a component of the live view" end +# Why the person can not remove `obj` from the `gui` ("remove" on its card, the key `Delete`): the +# `_removal_reason`, else the claim of another participant (see `_claim_refusal`), else `nothing`. +# Not in `_removal_reason`, which also guards the removals of a merge of the files of the project. +function _removal_refusal(gui::LiveView, @nospecialize(obj)) + reason = _removal_reason(gui, obj) + return isnothing(reason) ? _claim_refusal(gui, obj) : reason +end + """Returns `obj`, the objects of its groups and their parts (recursively), see `_part_children`.""" function _component_parts(@nospecialize(obj)) parts = Base.IdSet{Any}() @@ -711,10 +719,11 @@ end Removes `obj` from the `gui` like `remove_component!`, after "remove" on its card or the key `Delete`. An object that can not be removed is kept, and the status line names the reason, see -`_removal_reason`. Returns whether `obj` was removed. +`_removal_reason`, or the participant who claims it, see `_claim_refusal`. Returns whether `obj` was +removed. """ function _remove_selected!(gui::LiveView, @nospecialize(obj)) - reason = _removal_reason(gui, obj) + reason = _removal_refusal(gui, obj) if !isnothing(reason) gui.status.text[] = reason return false @@ -729,14 +738,14 @@ end Removes the objects of the selection `s` of several objects that can be removed (see `_removal_reason`) from the `gui`, each like `remove_component!`, with one solve afterwards and as one action of the undo history: undo adds all of them again and selects them, redo removes them -again. The objects that can not be removed stay selected, the status line names the reasons. -Returns whether an object was removed. +again. The objects that can not be removed (also those that another participant claims) stay +selected, the status line names the reasons. Returns whether an object was removed. """ function _remove_selected!(gui::LiveView, s::_MultiSelection) removable = Any[] reasons = String[] for obj in s.objects - reason = _removal_reason(gui, obj) + reason = _removal_refusal(gui, obj) isnothing(reason) ? push!(removable, obj) : push!(reasons, reason) end if isempty(removable) diff --git a/src/LiveEdit.jl b/src/LiveEdit.jl index be6c5ad..63237d5 100644 --- a/src/LiveEdit.jl +++ b/src/LiveEdit.jl @@ -108,9 +108,16 @@ _takes_edit_text(::LiveView, _, ::CatalogSurface, ::Int, text) = !isempty(text) (see `_read_edit_inputs!`, unless `read` is `false`) and puts the new object in the place of `obj`, see `_replace!`. An invalid input or an error of the constructor only shows a message in the status line; unchanged -inputs do nothing. +inputs do nothing. An `obj` that another participant of the project claims is not changed, the +status line names the holder (see `_claim_refusal`); else the new object holds the claims of `obj` +(see `_hold!`). """ function _apply_edit!(gui::LiveView, @nospecialize(obj); read::Bool = true) + refusal = _claim_refusal(gui, obj) + if !isnothing(refusal) + gui.status.text[] = refusal + return nothing + end _editable(gui, obj) || return nothing read && _read_edit_inputs!(gui, obj) origin = gui.components.origin[obj] @@ -128,7 +135,9 @@ function _apply_edit!(gui::LiveView, @nospecialize(obj); read::Bool = true) return nothing end name = _label(gui, obj) + claims = isnothing(gui.project) ? nothing : _claim_keys(gui, obj) _replace!(gui, obj, c.obj, c.origin) + isnothing(claims) || _hold!(gui, claims) gui.status.text[] = "$name changed" return nothing end diff --git a/src/LiveGroup.jl b/src/LiveGroup.jl index 93634e4..54ae071 100644 --- a/src/LiveGroup.jl +++ b/src/LiveGroup.jl @@ -75,12 +75,15 @@ _same_systems(a, b) = length(a) == length(b) && all(sys -> _has(b, sys), a) Why the selection `sel` of the controls of the `gui` can not be made a group (see `_group!`), for the status line, or `nothing` if it can: a selection of several movable top-level components with the same systems (or none), no source, clip plane or static object; or several, not all, parts of -the same group, see `_subgroup_reason`. +the same group, see `_subgroup_reason`; none of them claimed by another participant of the project, +see `_claim_refusal`. """ function _group_reason(gui, @nospecialize(sel)) _is_multi(sel) || return "select several objects to group them" objs = sel.objects length(objs) >= 2 || return "select several objects to group them" + refusal = _claim_refusal(gui, sel) + isnothing(refusal) || return refusal parent = _part_parent(gui, first(objs)) for obj in objs _part_parent(gui, obj) === parent && continue @@ -165,6 +168,8 @@ function _group!(gui, @nospecialize(sel)) gui.status.text[] = reason return nothing end + # the claims of the members stay while the group is selected, see `_claims_selected!` + _claim!(gui, sel) parent = _part_parent(gui, first(sel.objects)) isnothing(parent) || return _subgroup!(gui, sel.objects, parent) members = collect(Any, sel.objects) @@ -185,6 +190,8 @@ function _ungroup_reason(gui, @nospecialize(g)) g isa BMO.AbstractObjectGroup || return "select a group made in the window to ungroup it" _is_window_group(gui, g) || return "$(_label(gui, g)) is a group of the script, only groups made in the window are ungrouped" + refusal = _claim_refusal(gui, g) + isnothing(refusal) || return refusal parent = _part_parent(gui, g) return isnothing(parent) ? nothing : _regroup_reason(gui, parent) end @@ -204,6 +211,8 @@ function _ungroup!(gui, @nospecialize(g)) gui.status.text[] = reason return false end + # the claims of the members stay while they are selected, see `_claims_selected!` + _claim!(gui, g) parent = _part_parent(gui, g) isnothing(parent) || return _dissolve!(gui, g, parent) rec = gui.components.groups[g] diff --git a/src/LiveInteraction.jl b/src/LiveInteraction.jl index e52b5e5..ec69a40 100644 --- a/src/LiveInteraction.jl +++ b/src/LiveInteraction.jl @@ -564,6 +564,11 @@ mutable struct KinematicController{H <: AbstractSystemRenderHandle} # changed object (or `nothing`) and the duration of the update [s], e.g. for the debug mode of # `live_view`, see `_debug_updated!` after_update::Function + # `obj -> Bool`, called at the start of each gesture of the mouse or the keys that changes the + # selection `obj`: a drag, a key step, a reset, an undo or redo onto it; `false` refuses the + # gesture, e.g. `live_view` refuses one on an object that another participant of its project + # claims, see `_gesture!` + gesture::Function end function Base.show(io::IO, ctrl::KinematicController) @@ -1403,9 +1408,16 @@ _redo_entry!(::KinematicController, e::_ActionEntry) = (e.redo(); nothing) Takes the entry `e` of the history back or does it again by `step!(ctrl, e)`, after it was moved to the `stack`. An action that no longer applies, e.g. on a component that another window of the same system removed meanwhile (see [`open_system`](@ref)), is dropped with a warning instead of an -error. Returns whether the step was done. +error. A step that the `gesture` of the `ctrl` refuses on the object of the entry (e.g. one that +another participant of the project claims) is not done, and the entry goes back to the stack it +came from. Returns whether the step was done. """ function _history_step!(step!, ctrl::KinematicController, e, stack) + if !ctrl.gesture(e.obj) + filter!(x -> x !== e, stack) + push!(stack === ctrl.undo_stack ? ctrl.redo_stack : ctrl.undo_stack, e) + return false + end try step!(ctrl, e) catch err @@ -1466,8 +1478,9 @@ end _key_step!(ctrl, obj, key, factor) Moves or rotates the `obj` depending on the mode of the `ctrl`. Returns `false` if the `key` is -not a control key. If the corresponding axis is locked by the `constraints` of `obj`, the key is -still consumed (returns `true`) but nothing moves. +not a control key. If the corresponding axis is locked by the `constraints` of `obj` or the +`gesture` of the `ctrl` refuses the step, the key is still consumed (returns `true`) but nothing +moves. """ function _key_step!(ctrl::KinematicController, @nospecialize(obj), key, factor) y, x, v = _control_axes(ctrl, obj) @@ -1485,6 +1498,7 @@ function _key_step!(ctrl::KinematicController, @nospecialize(obj), key, factor) end isnothing(axis) && return false axis_sym in _allowed_axes(ctrl, obj, ctrl.mode[]) || return true # locked: consumed, no motion + ctrl.gesture(obj) || return true # refused: consumed, no motion _change!(ctrl, obj) do if ctrl.mode[] == :move translate3d!(obj, (sign * factor * ctrl.fine_step) .* axis) @@ -2141,7 +2155,8 @@ function kinematic_controls!( nothing, obj -> false, () -> nothing, () -> false, pick, leaf -> false, _CLICK_HELP, Observable(:off), (obj, point) -> nothing, (obj, point) -> nothing, 0, 0.0, 0.0, nothing, - center, nothing, nothing, nothing, nothing, false, rect_obs, rect_visible, (obj, dt) -> nothing + center, nothing, nothing, nothing, nothing, false, rect_obs, rect_visible, (obj, dt) -> nothing, + obj -> true ) # High priority, so that the camera does not receive events while an object is dragged @@ -2317,6 +2332,12 @@ function kinematic_controls!( cur = _px(scene) moved = hypot((cur .- ctrl.press_pos)...) moved >= ctrl.drag_threshold || return Consume(true) + if !ctrl.gesture(ctrl.selected[]) + # Refused: neither the object nor the camera moves until the release + ctrl.press_kind = :none + ctrl.press_leaf = nothing + return Consume(true) + end ctrl.dragging = true ctrl.drag_start = _pose(ctrl.selected[]) _start_snap!(ctrl, ctrl.selected[]) @@ -2363,6 +2384,8 @@ function kinematic_controls!( # One level up in the hierarchy of groups, deselect at the top level ctrl.selected[] = _parent_selection(ctrl, obj) _update_selection_box!(ctrl) + elseif event.key == Keyboard.backspace && !ctrl.gesture(obj) + # a reset that is refused, see `gesture` elseif event.key == Keyboard.backspace && _is_multi(obj) _reset_poses!(ctrl, obj) elseif event.key == Keyboard.backspace diff --git a/src/LiveProjectClaims.jl b/src/LiveProjectClaims.jl new file mode 100644 index 0000000..e027ee2 --- /dev/null +++ b/src/LiveProjectClaims.jl @@ -0,0 +1,494 @@ +#= +Claims of the people in a window of a project: whoever edits an object holds its claim, and the +others do not change it meanwhile (see `BeamletOpticsProjects.claim!`). The window of a person +claims an object implicitly, with the first gesture that changes it (a drag, a key step, an input +on a card, "Apply" of the page "Edit", grouping), and releases it when it is deselected or +`_CLAIM_HOLD` seconds after the last gesture. A gesture on an object that another participant +claims is refused, and the status line names the holder. The claims of the others are drawn at +their objects with the boxes and names of the presence (see `_draw_presence!`): a lock and +"editing". + +No file is touched in the handlers of the mouse and the keys: they look up the claims of the others +in the cache of the last read and queue the own ones; the file work runs in a task of its own, +started by `_claims_tick!` at every look of the watcher (see `_check_presence!`). +=# + +# How long a person holds the claim of an object after the last gesture on it [s] (D3) +const _CLAIM_HOLD = 30.0 +# How often the claims of the others are read at least [s] +const _CLAIM_READ = 0.5 +# The lock drawn at an object claimed by an other [px] +const _LOCK_SIZE = 16 +# The text of an object claimed by an other, after the name of the holder +const _EDITING = " · editing" + +# The lock, "lock" of Material Symbols (outlined, weight 400, grade 0, optical size 24) by Google, +# Apache License 2.0, see `LiveIcons.jl` +const _LOCK_ICON = _svg_path("M240-80q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640h40v-80q0-83 " * + "58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240Zm0-80h480v-400H240v400Zm240-120q33 " * + "0 56.5-23.5T560-360q0-33-23.5-56.5T480-440q-33 0-56.5 23.5T400-360q0 33 23.5 56.5T480-280ZM360-640h240v-80q0-50-35-85t-85-35q-50 " * + "0-85 35t-35 85v80Z") + +# The claims of others, by the key of their line (the machine id, else "id:L1") +const _ClaimMap = Dict{String, @NamedTuple{by::String, id::Symbol}} + +""" + _ClaimState + +The claims of the person of a view and of the others, `gui.sync.claim` (see `_claims`): +- `held`: the objects that the person holds or is about to hold, by the key of their line (the + machine id, else `"id:L1"`) => readable id; `filed` those that the claim files have after the + last run of `_claim_files`; +- `gesture_at`: the time of the last gesture that claimed (`_CLAIM_HOLD`); +- `others`: the claims of the other participants at the last read, by key (`BeamletOpticsProjects.claims`); +- `task`: the run of the file work in the background, `run_at` the time it started, `dirty` + whether the claims of the person changed since; +- `drawn`: what the overlay of the presence shows (`_presence_shown`), `locks` the plots of the + locks of the claimed objects; +- `last_error`: the last error of the file work, logged once. +""" +Base.@kwdef mutable struct _ClaimState + held::Dict{String, Symbol} = Dict{String, Symbol}() + filed::Dict{String, Symbol} = Dict{String, Symbol}() + gesture_at::Float64 = -Inf + others::_ClaimMap = _ClaimMap() + task::Union{Nothing, Task} = nothing + run_at::Float64 = -Inf + dirty::Bool = false + drawn::Vector{Tuple{Vector{Point3f}, String, Bool}} = Tuple{Vector{Point3f}, String, Bool}[] + locks::Vector{Any} = Any[] + last_error::String = "" +end + +"""The claims of the `gui`, see `_ClaimState`; created at the first use.""" +function _claims(gui::LiveView) + c = gui.sync.claim + c isa _ClaimState && return c + c = _ClaimState() + gui.sync.claim = c + return c +end + +# The participant id of the holder `by` of a claim, as in its presence file: as given if it has a +# host (`"anna@pc2"`), else an agent (`"w1@agent"`), see `BeamletOpticsProjects.claim!` +_holder_pid(by::AbstractString) = occursin('@', by) ? String(by) : string(by, "@agent") + +# The name of the holder `by` of a claim: of its presence, else its id without the host +function _holder_name(gui::LiveView, by::AbstractString) + o = get(gui.sync.others, _holder_pid(by), nothing) + isnothing(o) || return String(o.name) + return String(first(split(by, '@'))) +end + +# The innermost item with an id of the project that holds `x` (e.g. the component of a selected +# part of it, see `_presence_selected`), `nothing` if none +function _id_holder(gui::LiveView, @nospecialize(x)) + chain = try + _chain(gui.controls, x) + catch + Any[x] + end + for y in chain + haskey(gui.sync.ids, y) && return y + end + return nothing +end + +""" + _claim_objects(gui, sel) -> Vector{Any} + +The objects of the project that a gesture on the selection `sel` of the `gui` (an object, a +selection of several, a vector of objects or `nothing`) claims (D2): per object the innermost item +with an id that holds it (else the object itself), and the objects with an id of a group, +recursively. +""" +function _claim_objects(gui::LiveView, @nospecialize(sel)) + out = Any[] + xs = sel isa AbstractVector ? sel : _selection_objects(sel) + for x in xs + # a group without an id yet, e.g. one just made in the window: its members + _with_members!(out, gui, something(_id_holder(gui, x), x)) + end + return out +end + +# Adds `x` if it has an id and the members of a group `x` (recursively) to `out` +function _with_members!(out, gui::LiveView, @nospecialize(x)) + (haskey(gui.sync.ids, x) && !_has(out, x)) && push!(out, x) + if x isa BMO.AbstractObjectGroup + for m in BMO.objects(x) + _with_members!(out, gui, m) + end + end + return out +end + +# The key of the line of the object `x` of the project: its machine id, else "id:" +function _claim_key(gui::LiveView, @nospecialize(x)) + id, uid = gui.sync.ids[x] + return isempty(uid) ? "id:" * String(id) : uid +end + +""" + _claim_keys(gui, sel) -> Dict{String, Symbol} + +The keys of the lines (see `_claim_key`) of the objects that a gesture on `sel` claims (see +`_claim_objects`) => their readable ids. +""" +_claim_keys(gui::LiveView, @nospecialize(sel)) = + Dict{String, Symbol}(_claim_key(gui, x) => gui.sync.ids[x][1] for x in _claim_objects(gui, sel)) + +# The claim of an other on the object `x` of the project, `nothing` if none +function _other_claim(gui::LiveView, @nospecialize(x)) + others = _claims(gui).others + isempty(others) && return nothing + id, uid = gui.sync.ids[x] + c = isempty(uid) ? nothing : get(others, uid, nothing) + return isnothing(c) ? get(others, "id:" * String(id), nothing) : c +end + +""" + _claimed_by(gui, obj) -> Union{Nothing, String} + +The name of the participant other than the person of the `gui` who claims `obj` or an object that +a gesture on it claims (see `_claim_objects`), at the last read of the claims (no file access); +`nothing` if none. +""" +function _claimed_by(gui::LiveView, @nospecialize(obj)) + r = _claim_holder(gui, obj) + return isnothing(r) ? nothing : r.name +end + +# The name of the holder and the readable id of the first object of a gesture on `sel` that an +# other claims, `nothing` if none +function _claim_holder(gui::LiveView, @nospecialize(sel)) + (isnothing(gui.project) || isempty(_claims(gui).others)) && return nothing + for x in _claim_objects(gui, sel) + c = _other_claim(gui, x) + isnothing(c) && continue + return (; name = _holder_name(gui, c.by), id = gui.sync.ids[x][1]) + end + return nothing +end + +""" + _claim_refusal(gui, sel) -> Union{Nothing, String} + +Why a gesture on the selection `sel` of the `gui` is refused, for the status line: an object of it +is claimed by another participant (`"Anna is editing L1"`), see `_claimed_by`; `nothing` if it is +not. Reads only the cache of the claims, never a file. +""" +function _claim_refusal(gui::LiveView, @nospecialize(sel)) + r = _claim_holder(gui, sel) + return isnothing(r) ? nothing : "$(r.name) is editing $(r.id)" +end + +""" + _gesture!(gui, sel; now = time()) -> Bool + +The start of a gesture of the person on the selection `sel` of the `gui` that changes it (a drag, a +key step, an undo onto it, an input on a card, see `gesture` of `KinematicController`): `false` with +the reason in the status line if an object of it is claimed by another participant (see +`_claim_refusal`), else `true`, and the objects are claimed (see `_claim!`). Without a project, every +gesture is allowed and nothing is claimed. +""" +function _gesture!(gui::LiveView, @nospecialize(sel); now::Real = time()) + isnothing(gui.project) && return true + reason = _claim_refusal(gui, sel) + if !isnothing(reason) + gui.status.text[] = reason + return false + end + _claim!(gui, sel; now) + return true +end + +""" + _claim!(gui, sel; now = time()) + +Claims the objects that a gesture on `sel` claims (see `_claim_objects`) for the person of the +`gui`, from `now` on: they are queued, and the next run of `_claims_tick!` writes the claim files +in the background. Each gesture holds them `_CLAIM_HOLD` seconds longer. Does nothing without a +project. +""" +function _claim!(gui::LiveView, @nospecialize(sel); now::Real = time()) + isnothing(gui.project) && return nothing + _hold!(gui, _claim_keys(gui, sel); now) + return nothing +end + +""" + _hold!(gui, keys; now = time()) + +Adds the claims `keys` (key => readable id, see `_claim_keys`) to those that the person of the +`gui` holds, see `_claim!`; e.g. of an object that a gesture replaces by another (see +`_apply_edit!`). +""" +function _hold!(gui::LiveView, keys::AbstractDict; now::Real = time()) + (isnothing(gui.project) || isempty(keys)) && return nothing + c = _claims(gui) + c.gesture_at = Float64(now) + changed = false + for (k, id) in keys + get(c.held, k, nothing) === id && continue + c.held[k] = id + changed = true + end + changed && _claims_changed!(gui) + return nothing +end + +# The claims of the person changed: written by the next run, published in the presence +function _claims_changed!(gui::LiveView) + _claims(gui).dirty = true + gui.sync.presence_due = true + return nothing +end + +""" + _release!(gui; keep = nothing) + +Releases the claims of the person of the `gui`, all of them or those not in `keep` (keys of +lines): queued like `_claim!`. Called when the selection changes (see `_claims_selected!`) and +`_CLAIM_HOLD` seconds after the last gesture (see `_claims_tick!`). +""" +function _release!(gui::LiveView; keep = nothing) + c = _claims(gui) + isempty(c.held) && return nothing + n = length(c.held) + isnothing(keep) ? empty!(c.held) : filter!(p -> p.first in keep, c.held) + length(c.held) == n || _claims_changed!(gui) + return nothing +end + +""" + _claims_selected!(gui) + +The selection of the `gui` changed: the claims of the objects that are no longer selected are +released (D3), those of the new selection are kept, e.g. of the members of a group that was just +made of them. +""" +function _claims_selected!(gui::LiveView) + c = gui.sync.claim + (c isa _ClaimState && !isempty(c.held)) || return nothing + _release!(gui; keep = keys(_claim_keys(gui, gui.controls.selected[]))) + return nothing +end + +""" + _connect_claims!(gui) + +Connects the claims to the controls of the `gui`: every gesture asks `_gesture!` (`gesture` of the +`KinematicController`), and a change of the selection releases the claims of what is no longer +selected, see `_claims_selected!`. +""" +function _connect_claims!(gui::LiveView) + ctrl = gui.controls + ctrl.gesture = function (obj) + @nospecialize obj + return _gesture!(gui, obj) + end + push!(ctrl.listeners, on(ctrl.selected) do _ + _claims_selected!(gui) + return nothing + end) + return nothing +end + +""" + _claim_files(dir, by, want, filed) -> (; filed, refused, others) + +The file work of the claims of the participant `by` in the project folder `dir`, in the background +(see `_claims_tick!`): releases the claims of `filed` (key => readable id) that are not in `want` +(all of `by` if `want` is empty), claims those of `want` that are not filed yet, and reads the +claims of the others. Returns the claims now in the files (`filed`), the readable ids that another +participant held (`refused`, none of them was claimed then) and the claims of the others. +Touches no state of a window. +""" +function _claim_files(dir::AbstractString, by::AbstractString, want::Dict{String, Symbol}, + filed::Dict{String, Symbol}) + filed = copy(filed) + drop = [k for k in keys(filed) if !haskey(want, k)] + if !isempty(drop) + if isempty(want) + BMOP.release!(dir; by) + else + BMOP.release!(dir; by, ids = unique!([filed[k] for k in drop])) + end + foreach(k -> delete!(filed, k), drop) + end + add = [k for k in keys(want) if !haskey(filed, k)] + refused = Symbol[] + if !isempty(add) + ids = unique!([want[k] for k in add]) + if BMOP.claim!(dir; by, ids) + foreach(k -> (filed[k] = want[k]), add) + else + refused = ids + end + end + others = _ClaimMap() + for (k, c) in BMOP.claims(dir) + c.by == by || (others[k] = c) + end + return (; filed, refused, others) +end + +# The file work of the claims, see `_claim_files`; a `Ref` so that the tests can see where it runs +const _CLAIM_FILES = Ref{Function}(_claim_files) + +""" + _claims_tick!(gui; now = time()) + +One step of the claims of the `gui`, at every look of the watcher (see `_check_presence!`): takes +the result of the file work that ran in the background (see `_claim_files`): the claims of the +others and, if another participant held an object first, the status line `"Anna is editing L1"`; +releases the claims `_CLAIM_HOLD` seconds after the last gesture; then starts the next run if the +claims of the person changed or the last one is `_CLAIM_READ` seconds ago. Returns the task of +the run that it started, else `nothing`. Does nothing without a project. +""" +function _claims_tick!(gui::LiveView; now::Real = time()) + dir = gui.project + isnothing(dir) && return nothing + c = _claims(gui) + t = c.task + if !isnothing(t) + istaskdone(t) || return nothing + c.task = nothing + try + r = fetch(t) + c.filed = r.filed + c.others = r.others + if !isempty(r.refused) + filter!(p -> !(p.second in r.refused), c.held) + gui.sync.presence_due = true + gui.status.text[] = _refused_text(gui, r.refused) + end + c.last_error = "" + catch e + e isa InterruptException && rethrow() + err = e isa TaskFailedException ? e.task.exception : e + c.last_error = _log_once(err, c.last_error, "claiming the objects of the project") + c.dirty = true + end + end + !isempty(c.held) && now - c.gesture_at >= _CLAIM_HOLD && _release!(gui) + (c.dirty || c.held != c.filed || now - c.run_at >= _CLAIM_READ) || return nothing + c.dirty = false + c.run_at = Float64(now) + by = _person().id + want = copy(c.held) + filed = copy(c.filed) + f = _CLAIM_FILES[] + c.task = Threads.@spawn f(dir, by, want, filed) + return c.task +end + +# The status line of the readable ids `ids` that another participant claimed first +function _refused_text(gui::LiveView, ids) + for id in ids + for (k, c) in _claims(gui).others + c.id == id && return "$(_holder_name(gui, c.by)) is editing $id" + end + end + return "$(join(ids, ", ")) is claimed by another participant" +end + +""" + _claims_closed!(gui) + +Releases the claims of the person of the `gui` when its window is closed (see +`_presence_closed!`), after the run in the background is done. +""" +function _claims_closed!(gui::LiveView) + c = gui.sync.claim + c isa _ClaimState || return nothing + t = c.task + c.task = nothing + if !isnothing(t) + try + r = fetch(t) + c.filed = r.filed + catch + end + end + had = !isempty(c.filed) || !isempty(c.held) + empty!(c.held) + empty!(c.filed) + empty!(c.others) + (had && !isnothing(gui.project)) && BMOP.release!(gui.project; by = _person().id) + return nothing +end + +# The object of the view of the claim `c` with the key `k`, `nothing` if it is not in the view +function _object_of_claim(gui::LiveView, k::AbstractString, c) + obj = startswith(k, "id:") ? nothing : _object_of_uid(gui, k) + return isnothing(obj) ? _object_of_id(gui, String(c.id)) : obj +end + +""" + _claimed_objects(gui) -> Dict{String, Vector{Any}} + +The objects of the view that the others claim, by the participant id of the holder (see +`_holder_pid`), sorted by their readable ids: only the outermost of a group whose members are +claimed by the same holder, since the box of the group holds them. +""" +function _claimed_objects(gui::LiveView) + out = Dict{String, Vector{Any}}() + c = gui.sync.claim + c isa _ClaimState || return out + for k in sort!(collect(keys(c.others))) + cl = c.others[k] + obj = _object_of_claim(gui, k, cl) + isnothing(obj) && continue + objs = get!(out, _holder_pid(cl.by), Any[]) + _has(objs, obj) || push!(objs, obj) + end + for (pid, objs) in out + inner = filter(objs) do o + chain = try + _chain(gui.controls, o) + catch + Any[o] + end + return any(p -> p !== o && _has(objs, p), chain) + end + filter!(o -> !_has(inner, o), objs) + sort!(objs; by = o -> String(gui.sync.ids[o][1])) + end + return out +end + +# The label of an object that the holder `pid` claims (`o` its presence, or `nothing`) +function _claim_label(gui::LiveView, pid::AbstractString, o) + name = isnothing(o) ? String(first(split(pid, '@'))) : String(o.name) + text = name * _EDITING + return isnothing(o) || isempty(o.doing) ? text : text * "\n" * o.doing +end + +# Draws the locks at the claimed objects of `shown` (see `_presence_shown`), after the boxes +function _draw_locks!(gui::LiveView, shown, visible::Bool) + c = _claims(gui) + for (pts, _, locked) in shown + locked || continue + lock = scatter!(gui.ax, [maximum(pts)]; marker = _LOCK_ICON, markersize = _LOCK_SIZE, + color = _OTHER_COLOR, markerspace = :pixel, visible, clip_planes = Plane3f[], + inspectable = false) + push!(c.locks, lock) + end + return nothing +end + +function _delete_locks!(gui::LiveView) + c = gui.sync.claim + c isa _ClaimState || return nothing + for p in c.locks + try + delete!(gui.ax, p) + catch + end + end + empty!(c.locks) + empty!(c.drawn) + return nothing +end diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index a62593c..ee361b5 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -9,6 +9,7 @@ for a coding agent; default `"person"`), a counter `beat` that grows with every seconds that a beat counts; windows write `_ALIVE`, agents e.g. 600, since they write only when they act), the object it works on (`selected`: its uid, `selected_id`: its readable id; `""` if nothing; the readable id counts when the uid is missing or unknown, an agent writes only that), +of a window the keys of the lines that its person claims (`claimed`, see `LiveProjectClaims.jl`), optionally `doing` (a few words, shown under the name) and the stamp of the files after its last write (`written`; of a window: its last save, never files it merged). A window writes its file when it saves, when its selection changes and every `_BEAT` seconds. It reads the files of the others at every look of the watcher: an other is there @@ -91,11 +92,17 @@ function _presence_data(gui::LiveView) me = _person() uid, id = _presence_selected(gui) data = Dict{String, Any}("name" => me.name, "host" => me.host, "kind" => "person", "ttl" => _ALIVE, - "selected" => uid, "selected_id" => id) + "selected" => uid, "selected_id" => id, "claimed" => _claimed_keys(gui)) own = get(_OWN_SAVE, gui, nothing) return (; id = me.id, data, own = !isnothing(own) && own == gui.sync.stamp ? own : nothing) end +# The keys of the lines whose objects the person of the `gui` claims, sorted, see `LiveProjectClaims.jl` +function _claimed_keys(gui::LiveView) + c = gui.sync.claim + return c isa _ClaimState ? sort!(collect(keys(c.held))) : String[] +end + """Writes the presence file of `_presence_data` `p` into the project folder `dir`.""" function _write_presence_file(dir::AbstractString, p) data = copy(p.data) @@ -172,7 +179,9 @@ updates `gui.sync.others`, people and agents alike. An other whose `beat` change one whose file is gone is dropped. One whose `beat` did not change for its `ttl` seconds stays in `others` but is not shown, so that the file of a window that crashed or of an agent that did not delete it does not come back. A merge whose status line says "agent" gets the name of the other -that published its stamp meanwhile (see `_name_writer!`). Then the boxes and names of the +that published its stamp meanwhile (see `_name_writer!`). The claims go one step on (see +`_claims_tick!`: the own ones are written and those of the others read in the background). Then +the boxes and names of the selections of the others are drawn again if what they show changed (see `_draw_presence!`). Does nothing without a project. """ @@ -198,6 +207,7 @@ function _check_presence!(gui::LiveView; now::Real = time(), look = nothing) id in look.known && !(id in look.present) && delete!(s.others, id) end _name_writer!(gui, now) + _claims_tick!(gui; now) _draw_presence!(gui, now) return nothing end @@ -229,26 +239,35 @@ end # The label of the other `o`: its name, with what it is doing in a second line _other_label(o) = isempty(o.doing) ? o.name : o.name * "\n" * o.doing -# What the overlay of the others shows at `now`: per alive other with an object in the view the -# edges of the box of the object and the label, sorted by the id of the participant +# What the overlay of the others shows at `now`, sorted by the id of the participant: per alive +# other with an object in the view the edges of the box of the object, the label and `false`; per +# object of the view that a participant claims (see `_claimed_objects`, also of one whose presence +# is not shown) the box, the label with "editing" and `true` for the lock function _presence_shown(gui::LiveView, now::Real) - shown = Tuple{Vector{Point3f}, String}[] - for id in sort!(collect(keys(gui.sync.others))) - o = gui.sync.others[id] - _alive(o, now) || continue - obj = _object_of_other(gui, o) - isnothing(obj) && continue - bb = _part_box(gui, obj) - isnothing(bb) && continue - push!(shown, (_bbox_wireframe(bb), _other_label(o))) + shown = Tuple{Vector{Point3f}, String, Bool}[] + claimed = _claimed_objects(gui) + for id in sort!(unique!([collect(keys(gui.sync.others)); collect(keys(claimed))])) + o = get(gui.sync.others, id, nothing) + objs = get(claimed, id, Any[]) + if !isnothing(o) && _alive(o, now) + obj = _object_of_other(gui, o) + if !isnothing(obj) && !_has(objs, obj) + bb = _part_box(gui, obj) + isnothing(bb) || push!(shown, (_bbox_wireframe(bb), _other_label(o), false)) + end + end + for obj in objs + bb = _part_box(gui, obj) + isnothing(bb) || push!(shown, (_bbox_wireframe(bb), _claim_label(gui, id, o), true)) + end end return shown end # What the plots of the overlay show now, in the form of `_presence_shown` function _presence_drawn(gui::LiveView) - p = gui.sync.presence - return [(Vector{Point3f}(p[i][1][]), _label_text(p[i + 1])) for i in 1:2:(length(p) - 1)] + c = gui.sync.claim + return c isa _ClaimState ? c.drawn : Tuple{Vector{Point3f}, String, Bool}[] end # The text of a label of the overlay (Makie keeps it as a vector of one string) @@ -260,8 +279,9 @@ _label_text(p) = (t = p.text[]; String(t isa AbstractString ? t : only(t))) Draws where the others of the `gui` (people and agents alike) are at `now` (see `_check_presence!`): the box of the object each works on in `_OTHER_COLOR` and its name at the upper corner, with what it is doing (`doing`) in a second line; overlays in `gui.sync.presence` (a -box and a label per other). Only an object of the view is shown. -Draws again only if the boxes or names changed; hidden in the spectator mode. +box and a label per other). An object that a participant claims (see `LiveProjectClaims.jl`) has a +lock at the corner (`gui.sync.claim.locks`) and "editing" after the name. Only an object of the view +is shown. Draws again only if the boxes or names changed; hidden in the spectator mode. """ function _draw_presence!(gui::LiveView, now::Real) s = gui.sync @@ -269,21 +289,29 @@ function _draw_presence!(gui::LiveView, now::Real) visible = !gui.controls.spectator[] if shown != _presence_drawn(gui) _delete_presence!(gui) - for (pts, name) in shown + for (pts, name, locked) in shown box = linesegments!(gui.ax, pts; color = _OTHER_COLOR, linewidth = _OTHER_BOX_WIDTH, visible, clip_planes = Plane3f[], inspectable = false) + # the name of a claimed object after its lock label = text!(gui.ax, maximum(pts); text = name, color = _OTHER_COLOR, fontsize = _OTHER_FONTSIZE, - align = (:left, :bottom), visible, clip_planes = Plane3f[], inspectable = false) + align = (:left, :bottom), offset = (locked ? 0.6 * _LOCK_SIZE : 0.0, 0.0), visible, + clip_planes = Plane3f[], inspectable = false) push!(s.presence, box, label) end + _draw_locks!(gui, shown, visible) + append!(_claims(gui).drawn, shown) end - for p in s.presence + for p in Iterators.flatten((s.presence, _presence_locks(gui))) p.visible[] == visible || (p.visible[] = visible) end return nothing end +# The plots of the locks of the claimed objects, see `_draw_locks!` +_presence_locks(gui::LiveView) = (c = gui.sync.claim; c isa _ClaimState ? c.locks : Any[]) + function _delete_presence!(gui::LiveView) + _delete_locks!(gui) for p in gui.sync.presence try delete!(gui.ax, p) @@ -297,11 +325,13 @@ end """ _presence_closed!(gui) -Removes the boxes and names of the others from the `gui` and deletes its own presence file, when -the window is closed. A missing file is fine. +Removes the boxes and names of the others from the `gui`, releases the claims of its person (see +`_claims_closed!`) and deletes its own presence file, when the window is closed. A missing file is +fine. """ function _presence_closed!(gui::LiveView) _delete_presence!(gui) + _claims_closed!(gui) empty!(gui.sync.others) isnothing(gui.project) || rm(_presence_file(gui.project, _person().id); force = true) return nothing diff --git a/test/TestLiveProjectClaims.jl b/test/TestLiveProjectClaims.jl new file mode 100644 index 0000000..d3370aa --- /dev/null +++ b/test/TestLiveProjectClaims.jl @@ -0,0 +1,255 @@ +module TestLiveProjectClaims + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const BMOP = BeamletOpticsProjects +const TOML = GUI.TOML # the TOML of the package, not a dependency of the tests +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const START = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component M2 = RoundPlanoMirror(0.0254, 0.006) pos = (0.05, 0.2, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, M2] sources = [B1] + """ + +"""A project with `START`, opened (not shown) and saved once, so that every line has a uid.""" +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + return gui, dir +end + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +uid(gui, id) = gui.sync.ids[obj(gui, id)][2] + +"""Writes the presence file of an other participant `id` by hand, alive for the claims.""" +function other!(dir, id; name = id, beat = 1) + file = GUI._presence_file(dir, id) + mkpath(dirname(file)) + data = Dict{String, Any}("name" => name, "host" => "PC9", "beat" => beat, "selected" => "", + "selected_id" => "", "written" => "") + open(io -> TOML.print(io, data), file, "w") + return file +end + +"""One look of the window, with the run of the claims in the background done and taken.""" +function look!(gui; now = time()) + c = GUI._claims(gui) + # the claims of the others are read again, not only after `_CLAIM_READ` + c.run_at = -Inf + GUI._check_presence!(gui; now) + isnothing(c.task) || wait(c.task) + GUI._claims_tick!(gui; now) + isnothing(c.task) || wait(c.task) + GUI._claims_tick!(gui; now) + GUI._draw_presence!(gui, now) + return gui +end + +pose(x) = (Vector{Float64}(position(x)), Matrix{Float64}(BeamletOptics.orientation(x))) + +mine(dir, me) = sort!([String(c.id) for c in values(BMOP.claims(dir)) if c.by == me]) + +@testset "Claims of the people" begin + withenv("BMO_PERSON" => "anna") do + gui, dir = opened() + me = GUI._person().id + ctrl = gui.controls + L1, M1, M2 = obj(gui, :L1), obj(gui, :M1), obj(gui, :M2) + other!(dir, "bob@PC9"; name = "Bob") + @test BMOP.claim!(dir; by = "bob@PC9", ids = [:L1]) + look!(gui) + @test haskey(GUI._claims(gui).others, uid(gui, :L1)) + @test GUI._claimed_by(gui, L1) == "Bob" + @test isnothing(GUI._claimed_by(gui, M1)) + + @testset "gestures on a claimed object are refused" begin + P = pose(L1) + ctrl.selected[] = L1 + # a key step + gui.status.text[] = "" + @test GUI._key_step!(ctrl, L1, Keyboard.up, 1) + @test pose(L1) == P + @test gui.status.text[] == "Bob is editing L1" + # a drag: the press on the selected object crosses the threshold of a drag + gui.status.text[] = "" + scene = gui.ax.scene + events(scene).mouseposition[] = (100.0, 100.0) + ctrl.press_kind = :pending_drag + ctrl.press_pos = ctrl.last_mouse = (100.0, 100.0) + ctrl.plane_point = Vector{Float64}(position(L1)) + ctrl.grab_offset = zeros(3) + events(scene).mouseposition[] = (160.0, 170.0) + @test !ctrl.dragging + @test ctrl.press_kind == :none + @test pose(L1) == P + @test gui.status.text[] == "Bob is editing L1" + events(scene).mousebutton[] = Makie.MouseButtonEvent(Mouse.left, Mouse.release) + @test pose(L1) == P + # a pose on the card + gui.status.text[] = "" + GUI._apply_pose_input!(gui, L1, 1, "5") + @test pose(L1) == P + @test gui.status.text[] == "Bob is editing L1" + # "Apply" of the page "Edit" + gui.status.text[] = "" + GUI._apply_edit!(gui, L1; read = false) + @test obj(gui, :L1) === L1 + @test gui.status.text[] == "Bob is editing L1" + # remove + gui.status.text[] = "" + @test !GUI._remove_selected!(gui, L1) + @test obj(gui, :L1) === L1 + @test gui.status.text[] == "Bob is editing L1" + gui.status.text[] = "" + # of several, the others are removed; here M2 (and undone again) + @test GUI._remove_selected!(gui, GUI._MultiSelection(Any[L1, M2])) + @test obj(gui, :L1) === L1 + @test occursin("Bob is editing L1", gui.status.text[]) + @test GUI._undo!(ctrl) + @test any(o -> o === M2, keys(gui.sync.ids)) + # Ctrl+G + gui.status.text[] = "" + @test isnothing(GUI._group!(gui, GUI._MultiSelection(Any[L1, M1]))) + @test gui.status.text[] == "Bob is editing L1" + # an undo onto it stays in the history + GUI._push_history!(ctrl, L1, Point3{Float64}(P[1] .+ 0.01), P[2], Point3{Float64}(P[1]), P[2]) + n = length(ctrl.undo_stack) + @test !GUI._undo!(ctrl) + @test length(ctrl.undo_stack) == n && isempty(ctrl.redo_stack) + @test pose(L1) == P + # The refused gestures claimed nothing; the undo of the removal of M2 claimed M2 + @test collect(values(GUI._claims(gui).held)) == [:M2] + GUI._release!(gui) + end + + @testset "the lock at the claimed object" begin + c = GUI._claims(gui) + @test length(c.locks) == 1 + lock = only(c.locks) + @test lock isa Scatter + bb = GUI._part_box(gui, L1) + @test lock[1][] == [maximum(GUI._bbox_wireframe(bb))] + labels = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text] + @test labels == ["Bob · editing"] + # hidden in the spectator mode + GUI._set_spectator!(ctrl, true) + GUI._draw_presence!(gui, time()) + @test !lock.visible[] + GUI._set_spectator!(ctrl, false) + GUI._draw_presence!(gui, time()) + @test lock.visible[] + end + + @testset "no file access in the handlers" begin + calls = Task[] + GUI._CLAIM_FILES[] = function (args...) + push!(calls, current_task()) + return GUI._claim_files(args...) + end + try + ctrl.selected[] = M1 + P = pose(M1) + @test GUI._key_step!(ctrl, M1, Keyboard.up, 1) + @test pose(M1) != P + GUI._apply_pose_input!(gui, M1, 3, "1") + @test isempty(calls) + # the claim is queued and written by the run in the background + @test haskey(GUI._claims(gui).held, uid(gui, :M1)) + t = GUI._claims_tick!(gui) + @test t isa Task + wait(t) + @test length(calls) == 1 && only(calls) !== current_task() + finally + GUI._CLAIM_FILES[] = GUI._claim_files + end + GUI._claims_tick!(gui) + @test mine(dir, me) == ["M1"] + # published in the presence + @test GUI._presence_data(gui).data["claimed"] == [uid(gui, :M1)] + end + + @testset "released on deselect and after the hold" begin + look!(gui) + @test mine(dir, me) == ["M1"] + # another object: M1 is released, nothing claimed until a gesture + ctrl.selected[] = M2 + look!(gui) + @test isempty(mine(dir, me)) + @test GUI._gesture!(gui, M2; now = 1000.0) + look!(gui; now = 1000.1) + @test mine(dir, me) == ["M2"] + # each gesture holds it longer + @test GUI._gesture!(gui, M2; now = 1000.0 + GUI._CLAIM_HOLD - 1) + look!(gui; now = 1000.0 + GUI._CLAIM_HOLD) + @test mine(dir, me) == ["M2"] + look!(gui; now = 1000.0 + 2 * GUI._CLAIM_HOLD) + @test isempty(mine(dir, me)) + @test isempty(GUI._claims(gui).held) + # deselect + @test GUI._gesture!(gui, M2) + look!(gui) + @test mine(dir, me) == ["M2"] + ctrl.selected[] = nothing + look!(gui) + @test isempty(mine(dir, me)) + end + + @testset "a group claims its members" begin + ctrl.selected[] = nothing + g = GUI._group!(gui, GUI._MultiSelection(Any[M1, M2])) + @test !isnothing(g) + @test ctrl.selected[] === g + look!(gui) + @test mine(dir, me) == ["M1", "M2"] + # the other window sees them as claimed by anna + ctrl.selected[] = nothing + look!(gui) + @test isempty(mine(dir, me)) + end + + @testset "claimed first by another" begin + @test BMOP.claim!(dir; by = "bob@PC9", ids = [:M1]) + # the window did not read it yet: the gesture runs, the claim is refused in the background + @test isnothing(GUI._claimed_by(gui, M1)) + ctrl.selected[] = M1 + @test GUI._gesture!(gui, M1) + look!(gui) + @test gui.status.text[] == "Bob is editing M1" + @test isempty(GUI._claims(gui).held) + @test GUI._claimed_by(gui, M1) == "Bob" + BMOP.release!(dir; by = "bob@PC9") + look!(gui) + @test isnothing(GUI._claimed_by(gui, M1)) + @test isnothing(GUI._claimed_by(gui, L1)) + @test isempty(GUI._claims(gui).locks) + end + + @testset "released at closing" begin + @test GUI._gesture!(gui, M1) + look!(gui) + @test mine(dir, me) == ["M1"] + GUI._presence_closed!(gui) + @test isempty(mine(dir, me)) + end + close(gui) + end +end + +end From 98bceb3991e71e9d8b8443979764ed84a40ca043 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 21:24:17 +0100 Subject: [PATCH 17/47] Watcher keeps up: merge at the latest every 2 s, one solve per merge, file notifications - _check_files! merges when the files are quiet for _SETTLE or at the latest _MERGE_EVERY (2 s) after they began to change (first_change_at) - _adopting/_begin_adopt!/_end_adopt!: during a merge and its glides, _on_change! only marks the beams outdated; one solve of the union of the systems at the end (_finish_adopt!, from _on_idle! while objects glide) - FolderMonitor of the project folder, src/ and .bmo/ wakes the watcher at once; polling stays (_POLL_WITH_MONITOR, kept at 0.2 s until benchmark/watch_notify.jl was run on the shared drive) - FileWatching (stdlib) added to the dependencies - test/TestLiveProjectLoad.jl Co-Authored-By: Claude Opus 5.5 --- Project.toml | 2 + benchmark/watch_notify.jl | 139 +++++++++++++++++++++ src/LiveProjectWatch.jl | 228 +++++++++++++++++++++++++++++----- src/LiveTrace.jl | 144 +++++++++++++++++++++- test/TestLiveProjectLoad.jl | 236 ++++++++++++++++++++++++++++++++++++ 5 files changed, 720 insertions(+), 29 deletions(-) create mode 100644 benchmark/watch_notify.jl create mode 100644 test/TestLiveProjectLoad.jl diff --git a/Project.toml b/Project.toml index e3252b8..b73ce29 100644 --- a/Project.toml +++ b/Project.toml @@ -10,6 +10,7 @@ projects = ["test", "docs"] AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c" BeamletOptics = "c387492b-ffec-4e51-9a46-bd230226031c" BeamletOpticsProjects = "cd81f88b-d4c6-44ce-ae16-166b70b38414" +FileWatching = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" @@ -32,6 +33,7 @@ BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsPro AbstractTrees = "0.4" BeamletOptics = "0.14" BeamletOpticsProjects = "0.1" +FileWatching = "1" GLMakie = "0.13" GeometryBasics = "0.5" InteractiveUtils = "1" diff --git a/benchmark/watch_notify.jl b/benchmark/watch_notify.jl new file mode 100644 index 0000000..c3c415d --- /dev/null +++ b/benchmark/watch_notify.jl @@ -0,0 +1,139 @@ +# Whether a `FileWatching.FolderMonitor` on one machine sees the writes of another machine to a +# folder on a shared drive, how late, and how often an event is lost (D7 of the plan "projects-live": +# the watcher of a project polls every 0.2 s until this measurement says that the notifications can +# be relied on, see `_POLL_WITH_MONITOR` in `src/LiveProjectWatch.jl`). +# +# Start the watcher on machine B first, then the writer on machine A, with the same folder: +# +# B> julia benchmark/watch_notify.jl watch \\server\share\some\folder [timeout = 600] +# A> julia benchmark/watch_notify.jl write \\server\share\some\folder [n = 60] [rename|direct] +# +# The writer writes `watch-notify/probe.txt` in the folder every second, `n` times, with a counter +# and its clock, by default like the writers of a project (a temporary file, then `rename`), with +# `direct` in place. The watcher waits for the notifications of the folder `watch-notify` and +# polls the file every 0.05 s as well; it prints per write when the poll and the notification saw +# it, and at the end how many writes no notification announced (lost) and how late the others +# were. "late" is the time after the poll (the clocks of the two machines play no role); "clock" is +# the time after the writer's clock (includes the difference of the clocks). Both on one machine +# measure the local drive. Only the standard library is needed (no `--project`). +using FileWatching, Printf + +const POLL = 0.05 + +median(x) = (s = sort(x); n = length(s); isodd(n) ? s[(n + 1) ÷ 2] : (s[n ÷ 2] + s[n ÷ 2 + 1]) / 2) + +probe_dir(folder) = joinpath(folder, "watch-notify") +probe_file(folder) = joinpath(probe_dir(folder), "probe.txt") + +function write_probes(folder; n = 60, mode = "rename") + mkpath(probe_dir(folder)) + file = probe_file(folder) + tmp = joinpath(probe_dir(folder), ".probe.$(getpid()).tmp") + for k in 1:n + text = "$k $(time())\n" + if mode == "direct" + write(file, text) + else + write(tmp, text) + mv(tmp, file; force = true) + end + println("wrote $k") + sleep(1.0) + end + write(file, "done $(time())\n") + println("done") +end + +# The counter and the writer's time in the probe file, `nothing` if it can not be read now +function read_probe(file) + text = try + read(file, String) + catch + return nothing + end + parts = split(strip(text)) + length(parts) == 2 || return nothing + parts[1] == "done" && return (-1, parse(Float64, parts[2])) + k = tryparse(Int, parts[1]) + t = tryparse(Float64, parts[2]) + (isnothing(k) || isnothing(t)) && return nothing + return (k, t) +end + +function watch_probes(folder; timeout = 600.0) + mkpath(probe_dir(folder)) + file = probe_file(folder) + # per counter: the writer's time, the time the poll saw it, the time a notification saw it + written = Dict{Int, Float64}() + polled = Dict{Int, Float64}() + noted = Dict{Int, Float64}() + events = Ref(0) + finished = Ref(false) + function see!(seen, p, t) + (isnothing(p) || p[1] <= 0 || haskey(seen, p[1])) && return false + seen[p[1]] = t + written[p[1]] = p[2] + return true + end + monitor = FolderMonitor(probe_dir(folder)) + listener = @async while true + try + wait(monitor) + catch + break + end + t = time() + events[] += 1 + see!(noted, read_probe(file), t) + end + println("watching $(probe_dir(folder)); start the writer now") + t0 = time() + while time() - t0 < timeout + t = time() + p = read_probe(file) + if !isnothing(p) && p[1] == -1 && haskey(written, 1) + finished[] = true + break + end + if see!(polled, p, t) + k = p[1] + @printf("write %3d seen by the poll, clock %+7.1f ms\n", k, 1000 * (t - p[2])) + end + sleep(POLL) + end + sleep(1.0) + close(monitor) + wait(listener) + finished[] || println("no end of the writer seen within $timeout s") + ks = sort!(collect(keys(written))) + isempty(ks) && return println("no write seen: is the writer writing to the same folder?") + println() + println("write poll [clock ms] notification [clock ms] late after the poll [ms]") + lates = Float64[] + lost = Int[] + for k in ks + tw = written[k] + tp = get(polled, k, NaN) + tn = get(noted, k, NaN) + isnan(tn) ? push!(lost, k) : (isnan(tp) || push!(lates, tn - tp)) + @printf("%5d %15.1f %23.1f %24.1f\n", k, 1000 * (tp - tw), 1000 * (tn - tw), 1000 * (tn - tp)) + end + println() + println("writes seen: $(length(ks)), notifications: $(events[]), writes without a notification (lost): $(length(lost))", + isempty(lost) ? "" : " ($(join(lost, ", ")))") + if !isempty(lates) + @printf("notification after the poll (negative: before), of %d writes: median %+.1f ms, max %+.1f ms\n", + length(lates), 1000 * median(lates), 1000 * maximum(lates)) + println("(the poll sees a write up to $(Int(1000POLL)) ms late itself)") + end +end + +if length(ARGS) >= 2 && ARGS[1] == "write" + write_probes(ARGS[2]; n = length(ARGS) >= 3 ? parse(Int, ARGS[3]) : 60, + mode = length(ARGS) >= 4 ? ARGS[4] : "rename") +elseif length(ARGS) >= 2 && ARGS[1] == "watch" + watch_probes(ARGS[2]; timeout = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : 600.0) +else + println("usage: julia benchmark/watch_notify.jl watch [timeout = 600]\n" * + " julia benchmark/watch_notify.jl write [n = 60] [rename|direct]") +end diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl index aaeadb6..e937a68 100644 --- a/src/LiveProjectWatch.jl +++ b/src/LiveProjectWatch.jl @@ -1,17 +1,29 @@ #= The watcher of the files of a project of a shown window: it looks at the files in the background -(see `_take_look!`), merges a change of them once they settled (`_check_files!`, `_sync!`) and calls -the autosave and the presence; see `LiveProjectSync.jl` +(see `_take_look!`), merges a change of them once they settled, or at the latest every +`_MERGE_EVERY` seconds while they keep changing (`_check_files!`, `_sync!`), and calls the autosave +and the presence; see `LiveProjectSync.jl`. Notifications of the file system (`_FileNotes`) start a +look at once; the polling stays as a safety net =# -# How often the watcher looks at the times of the files [s] +import FileWatching + +# How often the watcher wakes up for the autosave and the presence, and looks at the times of the +# files without notifications [s] const _WATCH_INTERVAL = 0.2 +# How often the watcher looks at the times of the files while notifications run [s]. Stays at +# `_WATCH_INTERVAL` until `benchmark/watch_notify.jl` showed on the shared drive that the +# notifications of a write of another machine arrive (D7 of the plan: then 2 s) +const _POLL_WITH_MONITOR = 0.2 # How often a look reads the contents of the files for their stamp, while their times stay [s] const _STAMP_EVERY = 2.0 # How long a look may wait in the background before the window reads the files itself [s] const _LOOK_WAIT = 1.0 # How long the files must stay unchanged before they are loaded [s], an agent writes in steps const _SETTLE = 0.3 +# How long the files may keep changing before they are loaded anyway [s], e.g. while several +# participants write one after the other +const _MERGE_EVERY = 2.0 """ The times and sizes of the files of the project in `dir` that define the setup: `system.jl` and @@ -114,15 +126,106 @@ function _take_look!(gui::LiveView; now::Real = time()) end """ + _FileNotes + +The notifications of changes of the files of the project in `dir`, see `_file_notes`: `wake` wakes +the watcher up (also its timer, see `_start_watching!`), `notes` whether notifications are wanted, +`monitors` the `FileWatching.FolderMonitor`s (none: the watcher polls every `_WATCH_INTERVAL` +seconds), `poll` how often the watcher polls while they run, `first` the time of the first +notification that no look answered yet (`0.0`: none), `last` the time of the last one and `waited` +the task of the look whose end wakes the watcher up, see `_watch_step!`. +""" +mutable struct _FileNotes + wake::Base.Event + dir::String + notes::Bool + monitors::Vector{FileWatching.FolderMonitor} + poll::Float64 + first::Float64 + last::Float64 + waited::Union{Nothing, Task} +end + +""" + _file_notes(dir; notes = true, poll = _POLL_WITH_MONITOR) -> _FileNotes + +Starts the notifications of changes of the files of the project in `dir`: a +`FileWatching.FolderMonitor` of the folder and of its folders `src/` and `.bmo/` (those that +exist), each followed by a task that wakes the watcher up. Without monitors if `notes` is `false` +or if one of them fails to start (e.g. on a drive that does not send them); the watcher polls every +`_WATCH_INTERVAL` seconds then, else every `poll` seconds. +""" +function _file_notes(dir::AbstractString; notes::Bool = true, poll::Real = _POLL_WITH_MONITOR) + n = _FileNotes(Base.Event(true), String(dir), notes, FileWatching.FolderMonitor[], Float64(poll), + 0.0, 0.0, nothing) + _monitor!(n) + return n +end + +# Starts the monitors of the notifications `n` for the folders of `n.dir`, see `_file_notes` +function _monitor!(n::_FileNotes) + n.notes || return nothing + try + for d in (n.dir, joinpath(n.dir, "src"), joinpath(n.dir, ".bmo")) + isdir(d) && push!(n.monitors, FileWatching.FolderMonitor(d)) + end + catch e + e isa InterruptException && rethrow() + foreach(close, n.monitors) + empty!(n.monitors) + @debug "no notifications of changes of the files of $(n.dir), polling" exception = e + return nothing + end + foreach(m -> @async(_follow_notes!(n, m)), n.monitors) + return nothing +end + +# The notifications `n` follow the project of a view that was saved to another folder `dir` +function _renew_notes!(n::_FileNotes, dir::AbstractString) + foreach(close, n.monitors) + empty!(n.monitors) + n.dir = String(dir) + _monitor!(n) + return nothing +end + +# Wakes the watcher up for each change in the folder of the monitor `m`, until `m` is closed +function _follow_notes!(n::_FileNotes, m::FileWatching.FolderMonitor) + while true + try + wait(m) + catch + # closed (`EOFError`) or failed: the polling goes on + return nothing + end + n.last = time() + n.first == 0.0 && (n.first = n.last) + notify(n.wake) + end +end + +"""Stops the notifications `n` (see `_file_notes`) and wakes the watcher up for its end.""" +function _close_notes!(n::_FileNotes) + foreach(close, n.monitors) + empty!(n.monitors) + notify(n.wake) + return nothing +end + +""" + _start_watching!(gui; notes = true, poll = _POLL_WITH_MONITOR) + Starts the watcher of the files of the project of the `gui` if it is shown, the file `.bmo/session.md` (see `_connect_session!`) and the presence file of the window, written again when the selection changes (see `LiveProjectPresence.jl`). Every `_WATCH_INTERVAL` seconds until the -window is closed, the watcher takes the last look at the files, read in the background (see -`_take_look!`), and calls `_check_files!` and `_check_presence!` with it once there is a new one, -and `_autosave!` and `_draw_presence!` every time. A view that is not shown (e.g. in the tests or -of a script) is not watched and writes neither; the functions can be called instead. +window is closed, and at once after a notification of a change of the files (see `_file_notes`, +none if `notes` is `false`), the watcher takes a step, see `_watch_step!`: a look at the files every +`poll` seconds while the notifications run, else every `_WATCH_INTERVAL` seconds, read in the +background (see `_take_look!`), `_check_files!` and `_check_presence!` with it once there is a new +one, and `_autosave!` and `_draw_presence!` every time. A view that is not shown (e.g. in the tests +or of a script) is not watched and writes neither; the functions can be called instead. """ -function _start_watching!(gui::LiveView) +function _start_watching!(gui::LiveView; notes::Bool = true, poll::Real = _POLL_WITH_MONITOR) s = gui.sync (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing _connect_session!(gui) @@ -132,25 +235,77 @@ function _start_watching!(gui::LiveView) gui.sync.presence_due = true return nothing end) - last_error = Ref("") - s.timer = Timer(_WATCH_INTERVAL; interval = _WATCH_INTERVAL) do _ + n = _file_notes(gui.project; notes, poll) + s.monitor = n + s.timer = Timer(_ -> notify(n.wake), _WATCH_INTERVAL; interval = _WATCH_INTERVAL) + # One task takes the steps, of the timer and of the notifications, one after the other + @async _watch_loop!(gui, n) + return nothing +end + +# The task of the watcher of the `gui` with the notifications `n`, see `_start_watching!` +function _watch_loop!(gui::LiveView, n::_FileNotes) + s = gui.sync + last_error = "" + while true + wait(n.wake) + (s.closed || s.monitor !== n) && return nothing _shown(gui) || return _stop_watching!(gui) try - # The files are read in the background; the window works with the last look - look = _take_look!(gui) - isnothing(look) || _check_files!(gui; look) - _autosave!(gui) - isnothing(look) ? _draw_presence!(gui, time()) : _check_presence!(gui; look) + _watch_step!(gui, n) catch e e isa InterruptException && rethrow() - last_error[] = _log_once(e, last_error[], "watching the project") + last_error = _log_once(e, last_error, "watching the project") + end + end +end + +""" + _watch_step!(gui, n; now = time()) + +One step of the watcher of the `gui` (see `_start_watching!`): takes the last look at the files +(`_take_look!`) if one is due, i.e. `n.poll` seconds after the last one started while the +notifications `n` run (else `_WATCH_INTERVAL`, see `_file_notes`), while the files change (until +they are merged), while the own presence file is due, or after a notification until a look that +started after it was taken (a look that is still running then wakes the watcher up when it is +done); calls `_check_files!` and `_check_presence!` with a new look, and `_autosave!` and +`_draw_presence!` every time. The notifications follow the project to another folder. +""" +function _watch_step!(gui::LiveView, n::_FileNotes; now::Real = time()) + s = gui.sync + dir = gui.project + isnothing(dir) || dir == n.dir || _renew_notes!(n, dir) + poll = isempty(n.monitors) ? _WATCH_INTERVAL : n.poll + noted = n.first + take = noted > 0 || poll <= _WATCH_INTERVAL || !isnothing(s.changed_at) || s.presence_due || + now - s.look_at >= poll + run, started = s.look, s.look_at + # The files are read in the background; the window works with the last look + look = take ? _take_look!(gui; now) : nothing + isnothing(look) || _check_files!(gui; look) + _autosave!(gui) + isnothing(look) ? _draw_presence!(gui, time()) : _check_presence!(gui; look) + noted > 0 || return nothing + if !isnothing(run) && s.look !== run && started >= n.first + # Answered, unless notifications came after that look started + n.first = n.last > started ? n.last : 0.0 + end + if n.first > 0 && !isnothing(s.look) && s.look.task !== n.waited + task = n.waited = s.look.task + @async begin + try + wait(task) + catch + end + notify(n.wake) end end return nothing end """ -Stops the watcher of the `gui` (see `_start_watching!`) when it is closed: if it was watched, saves +Stops the watcher of the `gui` (see `_start_watching!`) and its notifications when it is closed: if +it was watched, saves what the person changed last, without waiting (`_autosave!` with `force`), and removes its session and presence files (`_session_closed!`, `_presence_closed!`). A view that was not watched saves nothing. No watcher starts again afterwards, also not by that save. @@ -160,6 +315,9 @@ function _stop_watching!(gui::LiveView) s.closed = true t = s.timer s.timer = nothing + n = s.monitor + s.monitor = nothing + n isa _FileNotes && _close_notes!(n) if !isnothing(t) close(t) try @@ -195,8 +353,10 @@ end _check_files!(gui; now = time(), look = nothing) -> Symbol One look of the watcher at the files of the project of the `gui`: `:pending` while they change or -have not stayed unchanged for `_SETTLE` seconds; then, once per state of the files, `:applied`, -`:conflict` or `:failed`, see `_sync!`; `:none` otherwise, also for the files that the `gui` saved +have not stayed unchanged for `_SETTLE` seconds, but at most `_MERGE_EVERY` seconds since they +began to change (`first_change_at`), e.g. while several participants write one after the other; +then, once per state of the files, `:applied`, `:conflict` or `:failed`, see `_sync!`, which +solves once per merge (see `_adopting`); `:none` otherwise, also for the files that the `gui` saved itself. The files changed if their times or sizes (`_file_marks`) or else the stamp of their contents (`project_stamp`) changed, as read by the `look` (see `_files_look`; read here if `nothing`; the watcher reads it in the background, see `_take_look!`, and a look without a stamp @@ -211,19 +371,19 @@ function _check_files!(gui::LiveView; now::Real = time(), look = nothing) marks = look.marks if marks != s.marks s.marks = marks - s.changed_at = now - return :pending - end - if isnothing(s.changed_at) + _files_changed!(s, now) + _merge_due(s, now) || return :pending + elseif isnothing(s.changed_at) # A write of the same size within the resolution of the file times (e.g. right after a save # of the view) leaves the marks as they were: the stamp of the contents tells stamp = look.stamp (isnothing(stamp) || stamp == s.stamp || stamp == s.handled) && return :none - s.changed_at = now + _files_changed!(s, now) return :pending + else + _merge_due(s, now) || return :pending end - now - s.changed_at < _SETTLE && return :pending - s.changed_at = nothing + s.changed_at = s.first_change_at = nothing disk = BMOP.project_stamp(dir) if disk == s.stamp # Saved by the view itself, or back to the state of the view @@ -238,5 +398,19 @@ function _check_files!(gui::LiveView; now::Real = time(), look = nothing) end disk == s.handled && return :none s.handled = disk - return _sync!(gui) + # One solve after the merge, also after the glides of the objects that it moved + return _adopting(() -> _sync!(gui), gui) +end + +# The files of `s` (a `_ProjectSync`) changed at `now`: since then, if they had settled +function _files_changed!(s::_ProjectSync, now::Real) + (isnothing(s.changed_at) || isnothing(s.first_change_at)) && (s.first_change_at = Float64(now)) + s.changed_at = Float64(now) + return nothing +end + +# Whether the files of `s` that changed are merged at `now`: they settled, or changed for too long +function _merge_due(s::_ProjectSync, now::Real) + now - s.changed_at >= _SETTLE && return true + return !isnothing(s.first_change_at) && now - s.first_change_at >= _MERGE_EVERY end diff --git a/src/LiveTrace.jl b/src/LiveTrace.jl index abdedc6..8e31ba4 100644 --- a/src/LiveTrace.jl +++ b/src/LiveTrace.jl @@ -600,7 +600,9 @@ end Called after each change of `obj` (or a slider, then `obj` is `nothing`). Solves the systems, with a preview of beam groups (see `_resolve!`) and a coarse preview of slow detector views, or marks them as outdated. If solving (the preview solve, if any) is slower than the `trace_budget`, the -solve is deferred until the movement pauses, see `_on_idle!`. +solve is deferred until the movement pauses, see `_on_idle!`. While a merge of the files of the +project is applied, only marks the beams as outdated; the merge solves once at its end, see +`_adopting`. """ function _on_change!(gui::LiveView, @nospecialize(obj); systems = nothing) trace = gui.trace @@ -609,6 +611,8 @@ function _on_change!(gui::LiveView, @nospecialize(obj); systems = nothing) affected = isnothing(systems) ? _affected_systems(gui, obj) : systems (!isnothing(affected) && isempty(affected)) && return nothing trace.last_change = time() + # A merge of the files of the project solves once, after it, see `_adopting` + gui.sync.adopting && return _note_adopted!(gui, obj, affected) preview = _has_preview(gui) auto, manual = _split_auto(gui, affected) if isempty(auto) @@ -631,9 +635,12 @@ end """ Solves deferred changes, completes a preview solve with a full solve and refines the preview of -the detector views once the movement pauses and no solve runs in the background. +the detector views once the movement pauses and no solve runs in the background. While a merge of +the files of the project is applied, only waits for its end, see `_finish_adopt!`. """ function _on_idle!(gui::LiveView) + # A merge of the files solves once its glides landed, see `_finish_adopt!` + gui.sync.adopting && return (_finish_adopt!(gui); nothing) time() - gui.trace.last_change > gui.trace.idle_delay || return nothing _running(gui) && return nothing if gui.trace.pending && gui.trace.auto[] @@ -649,6 +656,139 @@ function _on_idle!(gui::LiveView) return nothing end +#= +One solve per merge of the files of a project: while a merge is applied (`_adopt!` in +`LiveProjectSync.jl`) and its objects glide (`LiveProjectGlide.jl`), a change only marks the beams of +its systems as outdated; the systems of all changes are solved once at the end +=# + +# How long the glides of a merge may stand still before they are landed at once [s], e.g. after +# the task that glides them failed; see `_finish_adopt!` +const _GLIDES_STUCK = 2.0 + +""" + _Adoption + +A merge of the files of a project that is being applied to a view, see `_adopting`: `open` how many +callers are still applying it, `systems` the systems that its changes affected (`all`: all of +them), `changed` whether anything changed, `gliding` the number of the objects that glided at +`at`, the time the glides last made progress. +""" +Base.@kwdef mutable struct _Adoption + open::Int = 0 + systems::Vector{Any} = Any[] + all::Bool = false + changed::Bool = false + gliding::Int = 0 + at::Float64 = 0.0 +end + +# The merges being applied, per view; the flag `adopting` of its `_ProjectSync` tells +const _ADOPTIONS = WeakKeyDict{LiveView, _Adoption}() + +""" + _adopting(f, gui) + +Calls `f()`, which applies a merge of the files of the project to the `gui` (see `_sync!`), such +that the merge solves once: until `f` returned and the objects that it moved landed (see +`_glide!`), a change of the objects only marks the beams of its systems as outdated (see +`_on_change!`); then the systems of all changes are solved once, see `_finish_adopt!`. Calls may +nest; the outermost ends the merge. Returns what `f` returns. +""" +function _adopting(f, gui::LiveView) + _begin_adopt!(gui) + try + return f() + finally + _end_adopt!(gui) + end +end + +""" +Starts to apply a merge of the files of the project to the `gui`, see `_adopting`, which calls it +and `_end_adopt!`. A merge that still waits for its glides goes on with this one. +""" +function _begin_adopt!(gui::LiveView) + a = get!(_Adoption, _ADOPTIONS, gui) + a.open += 1 + gui.sync.adopting = true + return nothing +end + +""" +Ends what `_begin_adopt!` started; solves once the moved objects landed, see `_finish_adopt!`. +""" +function _end_adopt!(gui::LiveView) + a = get(_ADOPTIONS, gui, nothing) + isnothing(a) || (a.open = max(a.open - 1, 0)) + _finish_adopt!(gui) + return nothing +end + +""" +Records a change of `obj` (the `affected` systems, `nothing` for all) while a merge is applied to the +`gui`, see `_adopting`: its beams are outdated until the solve after the merge, without a word in +the status line, which tells what the merge did. +""" +function _note_adopted!(gui::LiveView, @nospecialize(obj), affected) + a = get!(_Adoption, _ADOPTIONS, gui) + a.changed = true + if isnothing(affected) + a.all = true + else + foreach(sys -> _has(a.systems, sys) || push!(a.systems, sys), affected) + end + trace = gui.trace + trace.stale = true + isnothing(affected) ? _note_stale!(gui, obj) : _note_stale_systems!(gui, affected) + _dim_beams!(gui) + _sync_links!(gui, obj; stale = true) + # As `_on_change!` does when no solve follows at once + auto, _ = _split_auto(gui, affected) + (isempty(auto) || (_has_preview(gui) ? trace.preview_time : trace.solve_time) > trace.budget) && + _detectors_moved!(gui, obj) + return nothing +end + +""" + _finish_adopt!(gui; now = time()) -> Bool + +Ends the merge of the files that is applied to the `gui` (see `_adopting`) once no caller applies it +anymore and its objects landed: solves the systems of its changes once (all of them if a change +affected all), like a change of them, see `_on_systems_change!`. Glides that made no progress for +`_GLIDES_STUCK` seconds are landed at once. Called by `_end_adopt!` and, while the objects glide, +by `_on_idle!`. Returns whether the merge ended (also if there was none). +""" +function _finish_adopt!(gui::LiveView; now::Real = time()) + s = gui.sync + a = get(_ADOPTIONS, gui, nothing) + if isnothing(a) + s.adopting = false + return true + end + a.open > 0 && return false + if !isempty(s.gliding) + n = length(s.gliding) + if n != a.gliding + a.gliding = n + a.at = Float64(now) + end + now - a.at < _GLIDES_STUCK && return false + # The changes of the landing are still part of the merge + _finish_glides!(gui) + end + delete!(_ADOPTIONS, gui) + s.adopting = false + a.changed || return true + if a.all + _on_change!(gui, nothing) + else + systems = BMO.AbstractSystem[sys for sys in _systems(gui) if _has(a.systems, sys)] + isempty(systems) || _on_systems_change!(gui, systems) + end + return true +end + """ Solves all systems of the `gui` on request, with the currently selected object. Ignored while a solve runs in the background. diff --git a/test/TestLiveProjectLoad.jl b/test/TestLiveProjectLoad.jl new file mode 100644 index 0000000..007f207 --- /dev/null +++ b/test/TestLiveProjectLoad.jl @@ -0,0 +1,236 @@ +module TestLiveProjectLoad + +# The watcher under load: files that keep changing, one solve per merge, notifications of the +# file system, see `LiveProjectWatch.jl` + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Test + +const GUI = BeamletOpticsGUI + +const LENS = "SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5)" +const START = """ + @component L1 = $LENS pos = (0.0, 0.1, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.3, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, D1] sources = [B1] + @component M1 = $LENS pos = (0.1, 0.1, 0.0) + @component D2 = Detector(0.02) pos = (0.1, 0.3, 0.0) + @source B2 = Beam([0.1, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S2 = [M1, D2] sources = [B2] + """ + +""" +A project with `START`, opened (not shown) and saved once, so that every line has a uid, and the +number of the solves since then (the `on_change` of the view is called once per full solve). +""" +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + solves = Ref(0) + gui = open_project(dir; display = false, trace_budget = Inf, throttle = false, detectors = [], + progress_delay = Inf, on_change = (_, _) -> (solves[] += 1)) + save_project(gui) + solves[] = 0 + return gui, dir, solves +end + +system(dir) = joinpath(dir, "system.jl") + +"""Sets the position of the line of `id` in `system.jl` of `dir` to `pos`.""" +function move!(dir, id, pos) + lines = split(read(system(dir), String), "\n") + i = findfirst(l -> startswith(l, "@component $id = "), lines) + lines[i] = replace(lines[i], r"pos = \([^)]*\)" => "pos = ($(join(pos, ", ")))") + write(system(dir), join(lines, "\n")) + return nothing +end + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +"""Two looks of the watcher: the files changed, then settled.""" +function tick!(gui) + t = time() + first = GUI._check_files!(gui; now = t) + return first === :pending ? GUI._check_files!(gui; now = t + 1) : first +end + +@testset "Files that keep changing" begin + gui, dir, solves = opened() + # The first merge compiles + move!(dir, "L1", (0.0, 0.1, 0.001)) + @test tick!(gui) === :applied + solves[] = 0 + # A look of the watcher every `_WATCH_INTERVAL`, a write every 0.1 s, for 10 s + merges = Float64[] + # the time from the first write after a merge to the next merge + delays = Float64[] + results = Symbol[] + t0 = time() + next_look = t0 + first_write = nothing + i = 0 + while time() - t0 < 10 + i += 1 + y = 0.1 + 1e-4 * i + move!(dir, "L1", (0.0, y, 0.0)) + isnothing(first_write) && (first_write = time()) + now = time() + if now >= next_look + r = GUI._check_files!(gui; now) + push!(results, r) + if r === :applied + push!(merges, now) + push!(delays, now - first_write) + first_write = nothing + end + next_look = time() + GUI._WATCH_INTERVAL + end + sleep(0.1) + end + @info "files changed every 0.1 s for 10 s: $(length(merges)) merges, each $(round.(delays; digits = 2)) s after the first write since the last one" + @test all(in((:pending, :applied)), results) + @test length(merges) >= 4 + # A merge at the latest `_MERGE_EVERY` after the files began to change: the next look sees + # the first write, the look after `_MERGE_EVERY` merges + @test maximum(delays) <= GUI._MERGE_EVERY + 2 * GUI._WATCH_INTERVAL + 0.1 + # One solve per merge, each moved L1 + @test solves[] == length(merges) + @test !gui.sync.adopting + # Once the files are quiet, their last state is merged (unless the last look did) + r = tick!(gui) + @test r in (:applied, :none) + @test BeamletOptics.position(obj(gui, :L1)) ≈ [0.0, 0.1 + 1e-4 * i, 0.0] atol = 1e-12 + @test solves[] == length(merges) + (r === :applied) + close(gui) +end + +@testset "One solve per merge" begin + gui, dir, solves = opened() + l1, m1 = obj(gui, :L1), obj(gui, :M1) + + @testset "a change of each of two systems solves twice outside of a merge" begin + translate3d!(l1, [0.0, 0.0, 0.001]) + GUI._on_change!(gui, l1) + translate3d!(m1, [0.0, 0.0, 0.001]) + GUI._on_change!(gui, m1) + @test solves[] == 2 + translate3d!(l1, [0.0, 0.0, -0.001]) + translate3d!(m1, [0.0, 0.0, -0.001]) + save_project(gui) + solves[] = 0 + end + + @testset "a merge that changes both systems solves once" begin + move!(dir, "L1", (0.0, 0.11, 0.0)) + move!(dir, "M1", (0.1, 0.12, 0.0)) + write(system(dir), read(system(dir), String) * "@component T1 = $LENS pos = (0.0, 0.2, 0.0)\n") + @test tick!(gui) === :applied + @test BeamletOptics.position(l1) ≈ [0.0, 0.11, 0.0] atol = 1e-12 + @test BeamletOptics.position(m1) ≈ [0.1, 0.12, 0.0] atol = 1e-12 + @test !isnothing(obj(gui, :T1)) + @test solves[] == 1 + @test !gui.sync.adopting && !gui.trace.stale + end + + @testset "the solve waits for the glides" begin + solves[] = 0 + move!(dir, "L1", (0.0, 0.12, 0.0)) + move!(dir, "M1", (0.1, 0.13, 0.0)) + @test GUI._adopting(() -> GUI._sync!(gui; animate = true), gui) === :applied + # The objects glide in a task of their own, one after the other + @test gui.sync.adopting && !isempty(gui.sync.gliding) + @test solves[] == 0 + t = time() + stale = false + while gui.sync.adopting && time() - t < 5 + sleep(0.02) + # as on each frame of a shown window + GUI._on_idle!(gui) + # outdated while they glide + stale |= gui.sync.adopting && gui.trace.stale && solves[] == 0 + end + @test stale + @test !gui.sync.adopting && isempty(gui.sync.gliding) + @test BeamletOptics.position(l1) ≈ [0.0, 0.12, 0.0] atol = 1e-12 + @test BeamletOptics.position(m1) ≈ [0.1, 0.13, 0.0] atol = 1e-12 + @test solves[] == 1 && !gui.trace.stale + end + + @testset "merges inside a merge solve once" begin + solves[] = 0 + GUI._adopting(gui) do + GUI._adopting(() -> GUI._on_change!(gui, l1), gui) + @test gui.sync.adopting && solves[] == 0 + GUI._on_change!(gui, m1) + end + @test !gui.sync.adopting && solves[] == 1 + # Nothing changed: no solve + GUI._adopting(() -> nothing, gui) + @test solves[] == 1 + end + close(gui) +end + +@testset "Notifications of changes of the files" begin + @testset "a monitor per folder, a write wakes the watcher up" begin + dir = mktempdir() + mkpath(joinpath(dir, "src")) + n = GUI._file_notes(dir) + @test length(n.monitors) == 2 + t = time() + write(joinpath(dir, "system.jl"), "# changed\n") + @test timedwait(() -> n.first > 0, 0.5; pollint = 0.01) === :ok + @info "the notification of a write arrived after $(round(1000 * (n.first - t); digits = 1)) ms" + # The project saved to another folder + other = mktempdir() + GUI._renew_notes!(n, other) + @test n.dir == other && length(n.monitors) == 1 + n.first = 0.0 + write(joinpath(other, "system.jl"), "# changed\n") + @test timedwait(() -> n.first > 0, 0.5; pollint = 0.01) === :ok + GUI._close_notes!(n) + @test isempty(n.monitors) + @test isempty(GUI._file_notes(dir; notes = false).monitors) + end + + @testset "a shown window sees a change of the files" begin + # Within 0.5 s with notifications, although it polls only every 10 s; within two looks + # without (the look that runs at the change read the files before it) + for (notes, poll, within) in ((true, 10.0, 0.5), (false, 10.0, 2 * GUI._WATCH_INTERVAL + 0.1)) + gui, dir, _ = opened() + screen = GLMakie.Screen(visible = false) + display(screen, gui.fig) + GUI._start_watching!(gui; notes, poll) + n = gui.sync.monitor + @test n isa GUI._FileNotes && isempty(n.monitors) == !notes + # Its own presence file is written first, by a look + @test timedwait(() -> !gui.sync.presence_due && !isnothing(gui.sync.look) && + istaskdone(gui.sync.look.task), 2.0; pollint = 0.01) === :ok + sleep(0.5) + marks = gui.sync.marks + t = time() + move!(dir, "L1", (0.0, 0.15, 0.0)) + @test timedwait(() -> gui.sync.marks != marks, 2.0; pollint = 0.005) === :ok + seen = time() - t + @info "a change of the files was seen after $(round(1000 * seen; digits = 1)) ms" notes + @test seen <= within + # and merged once it settled + @test timedwait(() -> BeamletOptics.position(obj(gui, :L1)) ≈ [0.0, 0.15, 0.0], 3.0; + pollint = 0.01) === :ok + close(screen) + close(gui) + @test isnothing(gui.sync.monitor) && isempty(n.monitors) + end + end +end + +end From 2fb625dfd35cfedee4af56a1459f6ffa4278ff06 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 21:45:56 +0100 Subject: [PATCH 18/47] Projects: integration of claims, conflicts, the watcher, glides and the table The table fits the setup after a drag (drag_end of the controller), a removal and a merge; every merge outside the watcher is one solve too (_adopting); session steps for a refused gesture on a claimed object and for a conflict decided on its card; skill text. Co-Authored-By: Claude Opus 5.5 --- ext/BeamletOpticsGUIGLMakieExt/session.jl | 53 +++++++++++++++++++++++ skills/beamletopticsgui/SKILL.md | 11 ++++- src/LiveComponents.jl | 2 + src/LiveInteraction.jl | 6 ++- src/LiveProject.jl | 2 +- src/LiveProjectSync.jl | 12 ++--- src/LiveSources.jl | 2 + src/LiveView.jl | 5 ++- test/TestPrecompileSession.jl | 2 +- test/runtests.jl | 4 ++ 10 files changed, 88 insertions(+), 11 deletions(-) diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index 8b249b0..152df8c 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -1171,6 +1171,59 @@ function _project_session(step; dir = mktempdir()) settle!() _check(isempty(gui.components.groups), "the window did not ungroup") end + # The claims of the others, read in the background (see `_claims_tick!`), until they are seen + function refresh_claims!() + for _ in 1:3 + GUI._check_presence!(gui) + c = GUI._claims(gui) + isnothing(c.task) || wait(c.task) + c.run_at = -Inf + end + GUI._check_presence!(gui) + frame!() + end + step("project: a gesture on an object that another participant claims is refused") do + other = GUI._presence_file(dir, "Bob@pc3") + mkpath(dirname(other)) + write(other, "name = \"Bob\"\nhost = \"pc3\"\nbeat = 1\nselected = \"\"\nselected_id = \"M1\"\nwritten = \"\"\n") + GUI.BMOP.claim!(dir; by = "Bob@pc3", ids = [:M1]) + refresh_claims!() + _check(!isempty(GUI._claims(gui).locks), "the lock of the claimed mirror is not shown") + before = _pos(mirror) + a = _px(gui, before) + _click!(gui, a) + frame!() + _drag!(gui, a, a .+ (14, 4)) + named = occursin("is editing", gui.status.text[]) + _key!(gui, Keyboard.up) + named = named || occursin("is editing", gui.status.text[]) + settle!() + _check(_pos(mirror) ≈ before, "the claimed mirror moved") + _check(named, "the status line does not name the holder: $(gui.status.text[])") + GUI.BMOP.release!(dir; by = "Bob@pc3") + rm(other) + refresh_claims!() + _check(isempty(GUI._claims(gui).locks), "the lock stays after the claim was released") + end + step("project: a conflict holds only its object, decided on the card") do + system = joinpath(dir, "system.jl") + # The window moves the lens, the file too: a conflict on the lens + a = _px(gui, _pos(lens)) + _click!(gui, a) + _drag!(gui, a, a .+ (10, 3)) + settle!() + text = read(system, String) + write(system, replace(text, r"(@component L1 = .*? pos = \()[^)]*" => s"\g<1>0.0, 0.13, 0.0")) + t = time() + GUI._check_files!(gui; now = t) + _check(GUI._check_files!(gui; now = t + 1) === :conflict, "the lens is no conflict") + frame!() + card = GUI._info_card(gui) + _check(!isnothing(card) && card.obj isa GUI._ProjectConflict, "the card of the conflict is not shown") + _click!(gui, _rect_center(GUI._card_widget(card, :conflict_mine_1))) + frame!() + _check(isempty(gui.sync.conflicts), "\"mine\" did not end the conflict") + end return gui, screen end diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index b6423bb..d9bd0d3 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -104,8 +104,15 @@ When this Skill is active: - Projects (prototype): `open_project(path)` opens a folder of BeamletOpticsProjects (or its `.bmo` file), `save_project(gui, dir)` saves a view as one. The open window writes every finished action to `system.jl` and merges what agents or other people write there; groups made in the window are - `@group` lines. `Ctrl+S` saves a version, `Ctrl+R` takes the files as they are. How to work on the - files is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). + `@group` lines. `Ctrl+S` saves a version, `Ctrl+R` takes the files as they are. The window claims + an object at the first gesture on it (drag, key, card, edit, remove, group) until it is deselected + or 30 s pass; a gesture on an object that another participant claims is refused, the status line + names the holder ("Bob is editing M1"), and the object shows a lock and "editing". A conflict + (an object changed here and in the files) holds only that object: everything else is saved, the + object keeps the files' state until the person picks "theirs" or "mine" on the card. The objects + of one change of the files glide together; a group moved as a whole glides as one. The table + fits the setup again after a drag, a removal or a change of the files. How to work on the files + is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). - A source belongs to at most one system: `live_view(sys1 => b, sys2 => b)` throws an `ArgumentError`, and a source without a system is not traced or drawn. The last system of a view cannot be removed. The auto tracing of a single system and the picking of members with "+" and "−" diff --git a/src/LiveComponents.jl b/src/LiveComponents.jl index eae2838..452b771 100644 --- a/src/LiveComponents.jl +++ b/src/LiveComponents.jl @@ -613,6 +613,8 @@ function remove_component!(gui::LiveView, @nospecialize(obj); system = nothing) # An object without a system was not traced traced && _on_change!(gui, nothing) _record_removed!(gui, obj, snap) + # The table fits what is left, see `_table_fit!` + _table_fit!(gui) return obj end diff --git a/src/LiveInteraction.jl b/src/LiveInteraction.jl index ec69a40..2b1d8a8 100644 --- a/src/LiveInteraction.jl +++ b/src/LiveInteraction.jl @@ -569,6 +569,9 @@ mutable struct KinematicController{H <: AbstractSystemRenderHandle} # gesture, e.g. `live_view` refuses one on an object that another participant of its project # claims, see `_gesture!` gesture::Function + # `obj -> nothing`, called when a drag of the mouse ends, with the dragged selection, e.g. + # `live_view` fits its table to the setup again, see `_table_fit!` + drag_end::Function end function Base.show(io::IO, ctrl::KinematicController) @@ -2156,7 +2159,7 @@ function kinematic_controls!( () -> nothing, () -> false, pick, leaf -> false, _CLICK_HELP, Observable(:off), (obj, point) -> nothing, (obj, point) -> nothing, 0, 0.0, 0.0, nothing, center, nothing, nothing, nothing, nothing, false, rect_obs, rect_visible, (obj, dt) -> nothing, - obj -> true + obj -> true, obj -> nothing ) # High priority, so that the camera does not receive events while an object is dragged @@ -2268,6 +2271,7 @@ function kinematic_controls!( ctrl.drag_start = nothing ctrl.last_key_step = nothing _push_history!(ctrl, obj, P0, R0, P1, R1) + ctrl.drag_end(obj) end elseif kind in (:pending_ring, :pending_arrow) consume = true diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 9c63600..8b5db9a 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -775,7 +775,7 @@ function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) e isa BMOP.ProjectChanged || return _save_failed!(gui, e) gui.sync.changed_at = nothing # Without gliding: the save right after must see the poses of the files - _sync!(gui; animate = false) === :failed && return false + _adopting(() -> _sync!(gui; animate = false), gui) === :failed && return false gui.sync.failed && return false try save_project(gui, dir; force) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 4be0853..06ac3aa 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -188,7 +188,7 @@ function _autosave_write!(gui::LiveView, parts, now::Real) dir = gui.project if BMOP.project_stamp(dir) != s.stamp s.changed_at = nothing - if _sync!(gui; animate = false) === :failed + if _adopting(() -> _sync!(gui; animate = false), gui) === :failed _write_backup!(gui, _collect_project(gui, dir)) return :paused end @@ -824,7 +824,7 @@ end Makes the entries, systems and groups of the `keys` in the open `gui` (whose state is the project `mine`, see `_collect_project`) as in the files `disk`, without undo entries: removes, adds, rebuilds (other code, or calling own code in `rebuild`), moves (gliding if `animate`, see -`_glide!`), renames and relabels; the systems and groups of `merged` get the merged members (see +`_glide_all!`), renames and relabels; the systems and groups of `merged` get the merged members (see `_sync_members!`). A group whose members change is made anew with the window groups around and inside it (see `_group_plan`), one that only moved or was renamed is changed in place. The changed objects get a box for a moment. Returns what was done, e.g. `"moved M1"`. @@ -906,12 +906,14 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima _key(se) in keys || continue (se.name !== mine_se.name || se.label != mine_se.label) && _name!(gui, mine_se.object, se) end - isempty(glides) && (_flash!(gui, touched); return done) + # The table fits the setup of the files, see `_table_fit!` + isempty(glides) && (_flash!(gui, touched); _table_fit!(gui); return done) # The autosave waits until they landed, a save lands them at once foreach(((obj, (P, R), e),) -> (gui.sync.gliding[obj] = (P, R, e)), glides) finish() = begin _glide_all!(gui, glides; animate) _flash!(gui, Any[touched..., first.(glides)...]) + _table_fit!(gui) return nothing end animate ? (@async finish()) : finish() @@ -953,7 +955,7 @@ function _load_from_disk!(gui::LiveView) keys = Set{String}(_key(x) for x in Iterators.flatten((disk.entries, disk.systems, disk.groups, mine.entries, mine.systems, mine.groups))) _release_info!(gui, _ProjectConflict) - done = _adopt!(gui, mine, disk, keys; rebuild = Set(_calls_own_code(disk))) + done = _adopting(() -> _adopt!(gui, mine, disk, keys; rebuild = Set(_calls_own_code(disk))), gui) # Taken in from the files, not written by the window _saved!(gui, disk; written = false) gui.status.text[] = isempty(done) ? "project reloaded, nothing changed" : "reloaded: " * join(done, ", ") @@ -1039,7 +1041,7 @@ function _resolve_conflict!(gui::LiveView, how::Symbol, keys = copy(gui.sync.con names = String[_conflict_name(gui, k) for k in keys] if how === :theirs && !isnothing(s.theirs) mine = _collect_project(gui, gui.project) - done = _adopt!(gui, mine, s.theirs, Set(keys)) + done = _adopting(() -> _adopt!(gui, mine, s.theirs, Set(keys)), gui) gui.status.text[] = "taken from the files: " * join(isempty(done) ? names : done, ", ") elseif how === :mine gui.status.text[] = "kept as here, written to the files: " * join(names, ", ") diff --git a/src/LiveSources.jl b/src/LiveSources.jl index 20be84e..85260e1 100644 --- a/src/LiveSources.jl +++ b/src/LiveSources.jl @@ -257,6 +257,8 @@ function remove_component!(gui::LiveView, src::_Source; system = nothing) _on_change!(gui, nothing) isempty(gui.pairs) && (gui.status.text[] = "$name removed, " * _NO_SOURCE) _record_removed!(gui, src, snap) + # The table fits what is left, see `_table_fit!` + _table_fit!(gui) return src end diff --git a/src/LiveView.jl b/src/LiveView.jl index a90b179..3a7098e 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -614,7 +614,8 @@ What the view knows of the files of its project, which an agent may change while left out (warned once); - `journal`: the recent changes of the person in the view, `(time, line)`, newest last, and `session` the listeners that write them to `.bmo/session.md`, see `LiveProjectSession.jl`; -- `gliding`: the objects that glide to a pose of the files, `obj => (P, R, entry)`, see `_glide!`; +- `gliding`: the objects that glide to a pose of the files, `obj => (P, R, entry)`, and a group + that glides as one, `group => _GroupGlide`, see `_glide_all!`; - `others`: the other people who have the project open (by the id of their presence file, see `LiveProjectPresence.jl`), `presence` the plots that show their selection, `beat_at` the time the view last wrote its own presence file (or gave it to a look to write) and `presence_due` @@ -1714,6 +1715,8 @@ function live_view( catalog = CatalogEntry[catalog...]), background_card, widgets, layout = lay) gui_ref[] = gui + # The table fits the setup again after a drag, see `_table_fit!` + controls.drag_end = obj -> (_table_fit!(gui); nothing) # Names of the objects without a label, e.g. for the object tree, see `_name_objects!` _name_objects!(gui) # The parents of the parts of groups and multi-shape objects, for the selection card diff --git a/test/TestPrecompileSession.jl b/test/TestPrecompileSession.jl index cbd08da..495839f 100644 --- a/test/TestPrecompileSession.jl +++ b/test/TestPrecompileSession.jl @@ -57,7 +57,7 @@ using Test dir = joinpath(outer, "project") gui, screen = ext._project_session(step; dir) @test isempty(failed) - @test length(names) == 9 + @test length(names) == 11 @test LibGit2.with(LibGit2.isorphan, LibGit2.GitRepo(outer)) @test LibGit2.with(r -> realpath(LibGit2.path(r)) == realpath(dir), LibGit2.GitRepoExt(dir)) # the steps call the watcher themselves: none runs alongside them diff --git a/test/runtests.jl b/test/runtests.jl index 3389cd3..3134392 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -45,10 +45,14 @@ include(joinpath(@__DIR__, "TestLiveProjectPresence.jl")) include(joinpath(@__DIR__, "TestLiveProjectGroups.jl")) include(joinpath(@__DIR__, "TestLiveProjectSyncGroups.jl")) include(joinpath(@__DIR__, "TestLiveProjectConflicts.jl")) +include(joinpath(@__DIR__, "TestLiveProjectGlide.jl")) +include(joinpath(@__DIR__, "TestLiveProjectLoad.jl")) +include(joinpath(@__DIR__, "TestLiveProjectClaims.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) include(joinpath(@__DIR__, "TestLiveTable.jl")) +include(joinpath(@__DIR__, "TestLiveTableFit.jl")) include(joinpath(@__DIR__, "TestLiveAlign.jl")) include(joinpath(@__DIR__, "TestLiveAim.jl")) include(joinpath(@__DIR__, "TestLiveLinks.jl")) From e27a4a87b164711d5e4969b7d53c2d413db162a2 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 22:10:40 +0100 Subject: [PATCH 19/47] Test of the table: it fits the setup when a drag ends Co-Authored-By: Claude Opus 5.5 --- test/TestLiveTable.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/TestLiveTable.jl b/test/TestLiveTable.jl index 6e16da3..ce67c02 100644 --- a/test/TestLiveTable.jl +++ b/test/TestLiveTable.jl @@ -304,10 +304,10 @@ const GUI = BeamletOpticsGUI @testset "grows with the setup" begin gui, sys, m, lens, a = _fixture(; table = true) t = gui.components.table - r = t.range - # moved within the table: as it is + # moved within the table: when the drag ends, the table fits the setup (see `_table_fit!`) _drag!(gui, lens, _to(gui, lens, [0.062, 0.139, 0.0])) - @test t.range == r + @test t.range == GUI._table_range(t, GUI._table_points(gui)) + r = t.range # added beyond it: two holes around the new component far = RoundPlanoMirror(25e-3, 5e-3) translate3d!(far, [0.5, 0.1, 0]) From 280195b60ceec6115abe0fc5306ea0eda25eb2a9 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 22:37:42 +0100 Subject: [PATCH 20/47] Claims: linked windows claim for the project, rename, aim and align are gestures, redo of an edit asks the claims, a drag holds its claims, a gesture lands glides first, claim! leaves the presence file to the window Co-Authored-By: Claude Opus 5.5 --- src/LiveAim.jl | 5 +- src/LiveAlign.jl | 4 +- src/LiveEdit.jl | 31 ++++-- src/LiveProjectClaims.jl | 155 +++++++++++++++++++++------ src/LiveSources.jl | 6 +- src/LiveSystemCard.jl | 5 +- test/TestLiveProjectClaims.jl | 196 ++++++++++++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 43 deletions(-) diff --git a/src/LiveAim.jl b/src/LiveAim.jl index 48e219a..397249b 100644 --- a/src/LiveAim.jl +++ b/src/LiveAim.jl @@ -64,7 +64,9 @@ end Turns the source `src` of the `gui` about its position such that it points at the `point` [m], by the smallest rotation (a half turn about the rotation axis of the controls). One gesture of the undo history, solved like a move. Returns whether it turned; otherwise the status line tells why -not: a source that is not movable or whose rotation is constrained, and a point at its position. +not: a source that is not movable or whose rotation is constrained, one that another participant of +the project claims (see `_gesture!`, also when it was claimed after the aiming started), and a +point at its position. """ function _aim!(gui::LiveView, src::_Source, point) ctrl = gui.controls @@ -77,6 +79,7 @@ function _aim!(gui::LiveView, src::_Source, point) gui.status.text[] = "$name can not rotate freely, see the constraints" return false end + _gesture!(gui, src) || return false P0, R0 = _pose(src) d = Vector{Float64}(point) .- Vector{Float64}(P0) if norm(d) < 1e-12 diff --git a/src/LiveAlign.jl b/src/LiveAlign.jl index 3361d3b..0e07808 100644 --- a/src/LiveAlign.jl +++ b/src/LiveAlign.jl @@ -31,7 +31,8 @@ end The beam that the component `obj` of the `gui` is aligned to: of the central beams that are switched on, as far as they do not depend on `obj` (see `_snap_lines`), the one closest to its position, see `_nearest_line`. `nothing` with the reason in the status line: for an object that is not movable, -while a solve in the background traces the beams, and without a beam. +that another participant of the project claims (see `_gesture!`), while a solve in the background +traces the beams, and without a beam. The start of the gesture of aligning `obj`. """ function _align_line(gui::LiveView, @nospecialize(obj)) name = _label(gui, obj) @@ -39,6 +40,7 @@ function _align_line(gui::LiveView, @nospecialize(obj)) gui.status.text[] = "$name is not movable, it can not be aligned" return nothing end + _gesture!(gui, obj) || return nothing if _running(gui) gui.status.text[] = "the beams are being traced, align $name afterwards" return nothing diff --git a/src/LiveEdit.jl b/src/LiveEdit.jl index 63237d5..452974c 100644 --- a/src/LiveEdit.jl +++ b/src/LiveEdit.jl @@ -135,9 +135,10 @@ function _apply_edit!(gui::LiveView, @nospecialize(obj); read::Bool = true) return nothing end name = _label(gui, obj) - claims = isnothing(gui.project) ? nothing : _claim_keys(gui, obj) + # none without a project + claims = _claim_keys(gui, obj) _replace!(gui, obj, c.obj, c.origin) - isnothing(claims) || _hold!(gui, claims) + _hold!(gui, claims) gui.status.text[] = "$name changed" return nothing end @@ -156,6 +157,21 @@ function _replacement_snapshot(gui::LiveView, old, new, origin, snap) return merge(snap, (; origin, names, init_poses = Pair{Any, Any}[])) end +""" + _Replacement(new, old) + +The object of the entry of the undo history of a replacement (see `_replace!`): `new` in the place +of `old`. Its objects (see `_selection_objects`) are both, of which the one in the view holds the +entry of the project (see `_move_id!`), such that an undo and a redo ask the claims of that entry +(see `gesture` of the controls) and the entry goes with the history of either of them, see +`_drop_history!`. +""" +struct _Replacement + new::Any + old::Any +end +_selection_objects(r::_Replacement) = Any[r.new, r.old] + """ _replace!(gui, old, new, origin) @@ -163,7 +179,8 @@ Puts the component or source `new` with its `origin` in the place of `old` in th the pose of `old`, i.e. it is moved and rotated from its pose as constructed like `old` was from its own, then `old` is removed and `new` is added to the same system with the label of `old` and selected, and the card of the selection shows its page "Edit" again. The replacement is one action -of the undo history: undo brings `old` back, redo `new`. +of the undo history (see `_Replacement`): undo brings `old` back, redo `new`; both are refused while +another participant of the project claims the entry. """ function _replace!(gui::LiveView, old, new, origin) comp = gui.components @@ -180,14 +197,16 @@ function _replace!(gui::LiveView, old, new, origin) swap!(out, out_state, in, in_state) = begin out_state[] = _remove_unrecorded!(gui, out) delete!(comp.edits, out) - # the same entry of the project, see `_move_id!` - _move_id!(gui, out, in) + # the same entry of the project, also of the view of a window of `open_system`, see + # `_move_id!` and `_project_view` + _move_id!(something(_project_view(gui), gui), out, in) _restore!(gui, in, in_state[]) _show_edit_page!(gui, in) return nothing end swap!(old, old_state, new, new_snap) - comp.recording && _push_action!(gui.controls, new, () -> swap!(new, new_snap, old, old_state), + comp.recording && _push_action!(gui.controls, _Replacement(new, old), + () -> swap!(new, new_snap, old, old_state), () -> swap!(old, old_state, new, new_snap)) return new end diff --git a/src/LiveProjectClaims.jl b/src/LiveProjectClaims.jl index e027ee2..964e3c7 100644 --- a/src/LiveProjectClaims.jl +++ b/src/LiveProjectClaims.jl @@ -2,9 +2,12 @@ Claims of the people in a window of a project: whoever edits an object holds its claim, and the others do not change it meanwhile (see `BeamletOpticsProjects.claim!`). The window of a person claims an object implicitly, with the first gesture that changes it (a drag, a key step, an input -on a card, "Apply" of the page "Edit", grouping), and releases it when it is deselected or -`_CLAIM_HOLD` seconds after the last gesture. A gesture on an object that another participant -claims is refused, and the status line names the holder. The claims of the others are drawn at +on a card, "Apply" of the page "Edit", grouping, a rename, the click that aims a source, an undo or +redo onto it), and releases it when it is deselected or `_CLAIM_HOLD` seconds after the last +gesture, but not while a drag holds it. A gesture on an object that another participant claims is +refused, and the status line names the holder; a gesture on an object that still glides to a pose +of the files lands it first (see `_land_glides!`). A window of `open_system` claims for the window +of the project that it is linked with, see `_project_view`. The claims of the others are drawn at their objects with the boxes and names of the presence (see `_draw_presence!`): a lock and "editing". @@ -69,6 +72,28 @@ function _claims(gui::LiveView) return c end +""" + _project_view(gui) -> Union{Nothing, LiveView} + +The view whose project the claims of the `gui` go by: the `gui` itself in a window of a project, +else the view of a project that it is linked with, e.g. the `gui` is a window of +[`open_system`](@ref), which shows the same objects (see `_ViewLinks`); `nothing` without a project. +""" +function _project_view(gui::LiveView) + isnothing(gui.project) || return gui + for v in gui.links.views + isnothing(v.project) || return v + end + return nothing +end + +# The views that claim for the view `pg` of a project (see `_project_view`): `pg` and the views +# linked with it +function _claiming_views(pg::LiveView) + isempty(pg.links.views) && return LiveView[pg] + return LiveView[v for v in pg.links.views if _project_view(v) === pg] +end + # The participant id of the holder `by` of a claim, as in its presence file: as given if it has a # host (`"anna@pc2"`), else an agent (`"w1@agent"`), see `BeamletOpticsProjects.claim!` _holder_pid(by::AbstractString) = occursin('@', by) ? String(by) : string(by, "@agent") @@ -100,18 +125,23 @@ end The objects of the project that a gesture on the selection `sel` of the `gui` (an object, a selection of several, a vector of objects or `nothing`) claims (D2): per object the innermost item with an id that holds it (else the object itself), and the objects with an id of a group, -recursively. +recursively. Those of the project of a linked view in a window of `open_system`, see +`_project_view`; none without a project. """ function _claim_objects(gui::LiveView, @nospecialize(sel)) out = Any[] - xs = sel isa AbstractVector ? sel : _selection_objects(sel) - for x in xs + pg = _project_view(gui) + isnothing(pg) && return out + for x in _gesture_objects(sel) # a group without an id yet, e.g. one just made in the window: its members - _with_members!(out, gui, something(_id_holder(gui, x), x)) + _with_members!(out, pg, something(_id_holder(pg, x), x)) end return out end +# The objects of the selection `sel` of a gesture, see `_claim_objects` +_gesture_objects(@nospecialize(sel)) = sel isa AbstractVector ? sel : _selection_objects(sel) + # Adds `x` if it has an id and the members of a group `x` (recursively) to `out` function _with_members!(out, gui::LiveView, @nospecialize(x)) (haskey(gui.sync.ids, x) && !_has(out, x)) && push!(out, x) @@ -133,10 +163,13 @@ end _claim_keys(gui, sel) -> Dict{String, Symbol} The keys of the lines (see `_claim_key`) of the objects that a gesture on `sel` claims (see -`_claim_objects`) => their readable ids. +`_claim_objects`) => their readable ids; empty without a project. """ -_claim_keys(gui::LiveView, @nospecialize(sel)) = - Dict{String, Symbol}(_claim_key(gui, x) => gui.sync.ids[x][1] for x in _claim_objects(gui, sel)) +function _claim_keys(gui::LiveView, @nospecialize(sel)) + pg = _project_view(gui) + isnothing(pg) && return Dict{String, Symbol}() + return Dict{String, Symbol}(_claim_key(pg, x) => pg.sync.ids[x][1] for x in _claim_objects(pg, sel)) +end # The claim of an other on the object `x` of the project, `nothing` if none function _other_claim(gui::LiveView, @nospecialize(x)) @@ -162,11 +195,12 @@ end # The name of the holder and the readable id of the first object of a gesture on `sel` that an # other claims, `nothing` if none function _claim_holder(gui::LiveView, @nospecialize(sel)) - (isnothing(gui.project) || isempty(_claims(gui).others)) && return nothing - for x in _claim_objects(gui, sel) - c = _other_claim(gui, x) + pg = _project_view(gui) + (isnothing(pg) || isempty(_claims(pg).others)) && return nothing + for x in _claim_objects(pg, sel) + c = _other_claim(pg, x) isnothing(c) && continue - return (; name = _holder_name(gui, c.by), id = gui.sync.ids[x][1]) + return (; name = _holder_name(pg, c.by), id = pg.sync.ids[x][1]) end return nothing end @@ -176,30 +210,62 @@ end Why a gesture on the selection `sel` of the `gui` is refused, for the status line: an object of it is claimed by another participant (`"Anna is editing L1"`), see `_claimed_by`; `nothing` if it is -not. Reads only the cache of the claims, never a file. +not. Reads only the cache of the claims, never a file. The start of a gesture: an object of `sel` +that still glides to a pose of the files lands first, see `_land_glides!`. """ function _claim_refusal(gui::LiveView, @nospecialize(sel)) - r = _claim_holder(gui, sel) + pg = _project_view(gui) + isnothing(pg) && return nothing + _land_glides!(pg, sel) + r = _claim_holder(pg, sel) return isnothing(r) ? nothing : "$(r.name) is editing $(r.id)" end +""" + _land_glides!(gui, sel) + +Lands the objects of the `gui` that glide to the poses of the files (see `_finish_glides!`) if an +object of the selection `sel` of a gesture glides, as a part of a group that glides or with a part +that glides: the gesture starts from the pose of the files, which its entry of the undo history +records, and no frame of the glide sets the pose over its steps. +""" +function _land_glides!(gui::LiveView, @nospecialize(sel)) + gliding = gui.sync.gliding + isempty(gliding) && return nothing + glides(y) = haskey(gliding, y) + for x in _gesture_objects(sel) + chain = try + _chain(gui.controls, x) + catch + Any[x] + end + (any(glides, chain) || (x isa BMO.AbstractObjectGroup && any(glides, _descendants(x)))) || + continue + _finish_glides!(gui) + return nothing + end + return nothing +end + """ _gesture!(gui, sel; now = time()) -> Bool The start of a gesture of the person on the selection `sel` of the `gui` that changes it (a drag, a key step, an undo onto it, an input on a card, see `gesture` of `KinematicController`): `false` with the reason in the status line if an object of it is claimed by another participant (see -`_claim_refusal`), else `true`, and the objects are claimed (see `_claim!`). Without a project, every -gesture is allowed and nothing is claimed. +`_claim_refusal`), else `true`, and the objects are claimed (see `_claim!`). In a window of +`open_system`, those of the project of the view that it is linked with (see `_project_view`). +Without a project, every gesture is allowed and nothing is claimed. """ function _gesture!(gui::LiveView, @nospecialize(sel); now::Real = time()) - isnothing(gui.project) && return true - reason = _claim_refusal(gui, sel) + pg = _project_view(gui) + isnothing(pg) && return true + reason = _claim_refusal(pg, sel) if !isnothing(reason) gui.status.text[] = reason return false end - _claim!(gui, sel; now) + _claim!(pg, sel; now) return true end @@ -209,11 +275,12 @@ end Claims the objects that a gesture on `sel` claims (see `_claim_objects`) for the person of the `gui`, from `now` on: they are queued, and the next run of `_claims_tick!` writes the claim files in the background. Each gesture holds them `_CLAIM_HOLD` seconds longer. Does nothing without a -project. +project; claims for the project of a linked view, see `_project_view`. """ function _claim!(gui::LiveView, @nospecialize(sel); now::Real = time()) - isnothing(gui.project) && return nothing - _hold!(gui, _claim_keys(gui, sel); now) + pg = _project_view(gui) + isnothing(pg) && return nothing + _hold!(pg, _claim_keys(pg, sel); now) return nothing end @@ -225,7 +292,9 @@ Adds the claims `keys` (key => readable id, see `_claim_keys`) to those that the `_apply_edit!`). """ function _hold!(gui::LiveView, keys::AbstractDict; now::Real = time()) - (isnothing(gui.project) || isempty(keys)) && return nothing + pg = _project_view(gui) + (isnothing(pg) || isempty(keys)) && return nothing + pg === gui || return _hold!(pg, keys; now) c = _claims(gui) c.gesture_at = Float64(now) changed = false @@ -253,6 +322,9 @@ lines): queued like `_claim!`. Called when the selection changes (see `_claims_s `_CLAIM_HOLD` seconds after the last gesture (see `_claims_tick!`). """ function _release!(gui::LiveView; keep = nothing) + pg = _project_view(gui) + isnothing(pg) && return nothing + pg === gui || return _release!(pg; keep) c = _claims(gui) isempty(c.held) && return nothing n = length(c.held) @@ -266,12 +338,19 @@ end The selection of the `gui` changed: the claims of the objects that are no longer selected are released (D3), those of the new selection are kept, e.g. of the members of a group that was just -made of them. +made of them. Also those of the selections of the views that claim for the same project, e.g. a +window of `open_system`, see `_project_view`. """ function _claims_selected!(gui::LiveView) - c = gui.sync.claim + pg = _project_view(gui) + isnothing(pg) && return nothing + c = pg.sync.claim (c isa _ClaimState && !isempty(c.held)) || return nothing - _release!(gui; keep = keys(_claim_keys(gui, gui.controls.selected[]))) + keep = Set{String}() + for v in _claiming_views(pg) + union!(keep, keys(_claim_keys(pg, v.controls.selected[]))) + end + _release!(pg; keep) return nothing end @@ -321,7 +400,8 @@ function _claim_files(dir::AbstractString, by::AbstractString, want::Dict{String refused = Symbol[] if !isempty(add) ids = unique!([want[k] for k in add]) - if BMOP.claim!(dir; by, ids) + # Not the presence file, which only the looks of the window write, see `_take_look!` + if BMOP.claim!(dir; by, ids, refresh = false) foreach(k -> (filed[k] = want[k]), add) else refused = ids @@ -343,9 +423,10 @@ const _CLAIM_FILES = Ref{Function}(_claim_files) One step of the claims of the `gui`, at every look of the watcher (see `_check_presence!`): takes the result of the file work that ran in the background (see `_claim_files`): the claims of the others and, if another participant held an object first, the status line `"Anna is editing L1"`; -releases the claims `_CLAIM_HOLD` seconds after the last gesture; then starts the next run if the -claims of the person changed or the last one is `_CLAIM_READ` seconds ago. Returns the task of -the run that it started, else `nothing`. Does nothing without a project. +releases the claims `_CLAIM_HOLD` seconds after the last gesture, unless a drag in the `gui` or in +a view linked with it holds them; then starts the next run if the claims of the person changed or +the last one is `_CLAIM_READ` seconds ago. Returns the task of the run that it started, else +`nothing`. Does nothing without a project. """ function _claims_tick!(gui::LiveView; now::Real = time()) dir = gui.project @@ -372,7 +453,15 @@ function _claims_tick!(gui::LiveView; now::Real = time()) c.dirty = true end end - !isempty(c.held) && now - c.gesture_at >= _CLAIM_HOLD && _release!(gui) + if !isempty(c.held) + if any(v -> v.controls.dragging, _claiming_views(gui)) + # A drag holds its claims until it ends: it asks `gesture` of the controls only when + # it starts + c.gesture_at = max(c.gesture_at, Float64(now)) + elseif now - c.gesture_at >= _CLAIM_HOLD + _release!(gui) + end + end (c.dirty || c.held != c.filed || now - c.run_at >= _CLAIM_READ) || return nothing c.dirty = false c.run_at = Float64(now) diff --git a/src/LiveSources.jl b/src/LiveSources.jl index 85260e1..c3b37e0 100644 --- a/src/LiveSources.jl +++ b/src/LiveSources.jl @@ -349,13 +349,17 @@ _rename!(gui::LiveView, src::_Source, name) = _rename_object!(gui, src, name) Names the component, the group or the source `obj` of the `gui` `name`, the input of the textbox of the title of its card: in the component menu, the object tree, on the cards, in the status line and in the exported code, like an entry of the `labels` kwarg of `live_view`. An empty name only shows -a message in the status line. The name is not part of the undo history. +a message in the status line. The name is not part of the undo history. A gesture on `obj`: an +`obj` that another participant of the project claims keeps its name, the status line names the +holder (see `_gesture!`). """ function _rename_object!(gui::LiveView, @nospecialize(obj), name) name = strip(something(name, "")) old = _label(gui, obj) if isempty(name) gui.status.text[] = "enter a name for $old" + elseif name != old && !_gesture!(gui, obj) + # claimed by another participant, see `_gesture!` elseif name != old gui.labels[obj] = gui.objects.names[obj] = String(name) _refresh_menu_options!(gui, gui.widgets.menu) diff --git a/src/LiveSystemCard.jl b/src/LiveSystemCard.jl index 19a1588..d5124c0 100644 --- a/src/LiveSystemCard.jl +++ b/src/LiveSystemCard.jl @@ -513,13 +513,16 @@ The name Names the system `sys` of the `gui` `name`, the input of the textbox of the title of its card: in the component menu, the object tree, the catalog and on the cards. An empty name only shows a -message in the status line. +message in the status line. A gesture on `sys`: a system that another participant of the project +claims keeps its name, the status line names the holder (see `_gesture!`). """ function _rename_system!(gui::LiveView, sys::BMO.AbstractSystem, name) name = strip(something(name, "")) old = _label(gui, sys) if isempty(name) gui.status.text[] = "enter a name for $old" + elseif name != old && !_gesture!(gui, sys) + # claimed by another participant, see `_gesture!` elseif name != old gui.labels[sys] = gui.objects.names[sys] = String(name) h = _system_handle(gui, sys) diff --git a/test/TestLiveProjectClaims.jl b/test/TestLiveProjectClaims.jl index d3370aa..2e375e2 100644 --- a/test/TestLiveProjectClaims.jl +++ b/test/TestLiveProjectClaims.jl @@ -241,6 +241,202 @@ mine(dir, me) = sort!([String(c.id) for c in values(BMOP.claims(dir)) if c.by == @test isempty(GUI._claims(gui).locks) end + @testset "a drag holds its claims" begin + ctrl.selected[] = M2 + @test GUI._gesture!(gui, M2; now = 2000.0) + look!(gui; now = 2000.1) + @test mine(dir, me) == ["M2"] + # longer than the hold after its start + ctrl.dragging = true + look!(gui; now = 2000.0 + 2 * GUI._CLAIM_HOLD) + @test mine(dir, me) == ["M2"] + # held from its end on + ctrl.dragging = false + look!(gui; now = 2000.0 + 2.5 * GUI._CLAIM_HOLD) + @test mine(dir, me) == ["M2"] + look!(gui; now = 2000.0 + 3.5 * GUI._CLAIM_HOLD) + @test isempty(mine(dir, me)) + ctrl.selected[] = nothing + end + + @testset "rename, the system card, aim and align are gestures" begin + S1, B1 = obj(gui, :S1), obj(gui, :B1) + @test BMOP.claim!(dir; by = "bob@PC9", ids = [:L1, :B1, :S1]) + look!(gui) + # the pencil of a card + gui.status.text[] = "" + GUI._rename_object!(gui, L1, "collimator") + @test GUI._label(gui, L1) == "L1" + @test gui.status.text[] == "Bob is editing L1" + gui.status.text[] = "" + GUI._rename_system!(gui, S1, "bench") + @test GUI._label(gui, S1) == "S1" + @test gui.status.text[] == "Bob is editing S1" + # "+" and "Remove system" of the card of the system + GUI._inspect!(gui, S1) + card = gui isa GUI.AppView ? gui.layout.inspector.card : gui.cards.selection + push(name) = (GUI._card_widget(card, name).clicks[] += 1; nothing) + gui.status.text[] = "" + push(:member_add) + @test isnothing(GUI._member_pick(gui)) + @test gui.status.text[] == "Bob is editing S1" + push(:remove) + @test !isnothing(GUI._system_handle(gui, S1)) + # the click that aims a source, also when it was claimed after the aiming started + P = GUI._pose(B1) + gui.status.text[] = "" + @test !GUI._aim!(gui, B1, [0.1, 0.1, 0.0]) + @test GUI._pose(B1) == P + @test gui.status.text[] == "Bob is editing B1" + # "onto beam" and "face beam" + P = pose(L1) + gui.status.text[] = "" + @test !GUI._center_on_beam!(gui, L1) + @test gui.status.text[] == "Bob is editing L1" + gui.status.text[] = "" + @test !GUI._face_beam!(gui, L1) + @test gui.status.text[] == "Bob is editing L1" + @test pose(L1) == P + @test isempty(GUI._claims(gui).held) + # released: the rename claims + BMOP.release!(dir; by = "bob@PC9") + look!(gui) + GUI._rename_object!(gui, L1, "collimator") + @test GUI._label(gui, L1) == "collimator" + @test haskey(GUI._claims(gui).held, uid(gui, :L1)) + GUI._release!(gui) + ctrl.selected[] = nothing + look!(gui) + @test isempty(mine(dir, me)) + end + + @testset "undo and redo of an edit ask the claims" begin + entry = only(e for e in component_catalog() if e.name == "Thin lens") + c = GUI._catalog_component(entry, String[GUI._catalog_string(p) for p in entry.params]) + lens = c.obj + add_component!(gui, lens; origin = c.origin) + save_project(gui) + id, u = gui.sync.ids[lens] + s = GUI._edit_strings(gui, lens)[1] + GUI._set_edit_string!(gui, lens, 1, string(2 * parse(Float64, s))) + GUI._apply_edit!(gui, lens; read = false) + new = obj(gui, id) + @test new !== lens + @test GUI._undo!(ctrl) + @test obj(gui, id) === lens + ctrl.selected[] = nothing + look!(gui) + @test isempty(mine(dir, me)) + @test BMOP.claim!(dir; by = "bob@PC9", ids = [id]) + look!(gui) + # the replacement is not done again, and stays in the history + gui.status.text[] = "" + @test !GUI._redo!(ctrl) + @test obj(gui, id) === lens + @test gui.status.text[] == "Bob is editing $id" + @test length(ctrl.redo_stack) == 1 + @test isempty(GUI._claims(gui).held) + BMOP.release!(dir; by = "bob@PC9") + look!(gui) + @test GUI._redo!(ctrl) + @test obj(gui, id) === new + @test haskey(GUI._claims(gui).held, u) + ctrl.selected[] = nothing + look!(gui) + @test isempty(mine(dir, me)) + end + + @testset "a window of open_system claims for the project" begin + S1 = obj(gui, :S1) + win = open_system(gui, S1; display = false) + try + @test GUI._project_view(win) === gui + @test BMOP.claim!(dir; by = "bob@PC9", ids = [:L1]) + look!(gui) + P = pose(L1) + win.status.text[] = "" + @test !GUI._gesture!(win, L1) + @test win.status.text[] == "Bob is editing L1" + @test GUI._key_step!(win.controls, L1, Keyboard.up, 1) + @test pose(L1) == P + @test GUI._claimed_by(win, L1) == "Bob" + # a gesture there claims for the project + win.controls.selected[] = M1 + @test GUI._gesture!(win, M1) + @test haskey(GUI._claims(gui).held, uid(gui, :M1)) + look!(gui) + @test mine(dir, me) == ["M1"] + # a drag there holds it + win.controls.dragging = true + look!(gui; now = time() + 2 * GUI._CLAIM_HOLD) + @test mine(dir, me) == ["M1"] + win.controls.dragging = false + # kept while it is selected in either window + ctrl.selected[] = nothing + look!(gui) + @test mine(dir, me) == ["M1"] + win.controls.selected[] = nothing + look!(gui) + @test isempty(mine(dir, me)) + BMOP.release!(dir; by = "bob@PC9") + look!(gui) + finally + GUI._unlink!(win) + close(win) + end + end + + @testset "a gesture lands a glide first" begin + P, R = pose(M1) + origin = gui.components.origin[M1] + rec = haskey(origin, :project) ? origin.project : (; pos = nothing, rot = nothing) + # halfway of a glide to the pose of the files + glide!() = begin + translate3d!(M1, [0.0, 0.0, 0.005]) + gui.sync.gliding[M1] = (Point3{Float64}(P), Matrix{Float64}(R), (; rec.pos, rec.rot)) + return nothing + end + glide!() + ctrl.selected[] = M1 + @test GUI._gesture!(gui, M1) + @test isempty(gui.sync.gliding) + @test pose(M1)[1] ≈ P + # a drag starts from the pose of the files, which its undo gives back + glide!() + scene = gui.ax.scene + events(scene).mouseposition[] = (100.0, 100.0) + ctrl.press_kind = :pending_drag + ctrl.press_pos = ctrl.last_mouse = (100.0, 100.0) + ctrl.plane_point = Vector{Float64}(position(M1)) + ctrl.grab_offset = zeros(3) + events(scene).mouseposition[] = (160.0, 170.0) + @test ctrl.dragging + @test isempty(gui.sync.gliding) + @test ctrl.drag_start[1] ≈ P + events(scene).mousebutton[] = Makie.MouseButtonEvent(Mouse.left, Mouse.release) + @test GUI._undo!(ctrl) + @test pose(M1)[1] ≈ P + # a glide of another object goes on + gui.sync.gliding[M2] = (Point3{Float64}(pose(M2)[1]), pose(M2)[2], (; rec.pos, rec.rot)) + @test GUI._gesture!(gui, M1) + @test haskey(gui.sync.gliding, M2) + empty!(gui.sync.gliding) + ctrl.selected[] = nothing + look!(gui) + end + + @testset "the claims leave the presence file to the window" begin + look!(gui) + file = GUI._presence_file(dir, me) + @test isfile(file) + before = read(file, String) + r = GUI._claim_files(dir, me, Dict(uid(gui, :M2) => :M2), Dict{String, Symbol}()) + @test r.filed == Dict(uid(gui, :M2) => :M2) + @test read(file, String) == before + GUI._claim_files(dir, me, Dict{String, Symbol}(), r.filed) + @test isempty(mine(dir, me)) + end + @testset "released at closing" begin @test GUI._gesture!(gui, M1) look!(gui) From 53342d6740d22d3da4f2d23d441a76bdc93b7431 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 22:39:51 +0100 Subject: [PATCH 21/47] Autosave of a live project: claims of others and busy write locks hold only their lines - The lines that another participant claims keep the state of the files in what is saved, like those of an open conflict (`_composed`); a save refused by a claim reads the claims again and saves without that line (F1, F9) - A ProjectChanged of a busy write lock is tried again after `_AUTOSAVE_DELAY`, not an error (F15, F22) - Systems and groups made during a conflict keep their ids (F16); the ids of the files at held lines stay reserved (F19) - A window closed with an open conflict writes the person's version to the backup, and the session file stays and names it (F17) - A change of the person is saved within `_AUTOSAVE_WAIT` while the files keep changing; a merge does not restart its delay (F20) - save_project(gui, dir) lands the glides first (F25) Co-Authored-By: Claude Opus 5.5 --- src/LiveProject.jl | 152 +++++++++++++++++++++++-------- src/LiveProjectSession.jl | 39 +++++++- src/LiveProjectSync.jl | 140 ++++++++++++++++++++++------ test/TestLiveProjectConflicts.jl | 141 ++++++++++++++++++++++++++++ test/TestLiveProjectSync.jl | 69 +++++++++++++- 5 files changed, 466 insertions(+), 75 deletions(-) diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 8b5db9a..49aabbd 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -51,8 +51,10 @@ The readable id and the machine id of the objects, sources, groups and systems ` in a project: those it has in `gui.sync.ids` (see `_ProjectSync`), else a new id of the prefix of its type (see `BeamletOpticsProjects.id_prefix` and `next_id`) and a new uid. Every id in `gui.sync.ids` is reserved first, also of an item later in `items` or of an object that is not in -the view anymore, such that a new one never takes it: after `L5` the next lens is `L6`. An id or a -uid that an item before took already is given anew, an empty uid too. +the view anymore, such that a new one never takes it: after `L5` the next lens is `L6`. So is the +id that the files give a line whose state of the files a save keeps (see `_held_keys`), e.g. an +object of an open conflict that an agent renamed. An id or a uid that an item before took already +is given anew, an empty uid too. """ function _project_ids(gui::LiveView, items) known = gui.sync.ids @@ -61,6 +63,14 @@ function _project_ids(gui::LiveView, items) taken = BMOP.Project(pwd(), "taken") reserve!(id) = (push!(taken.systems, BMOP.SystemEntry(id, Symbol[])); id) foreach(k -> reserve!(first(k)), values(known)) + base = gui.sync.base + if !isnothing(base) + for k in _held_keys(gui) + x = something(_by_key(base.entries, k), _by_key(base.systems, k), _by_key(base.groups, k), + Some(nothing)) + isnothing(x) || reserve!(x.name) + end + end used = Set{Symbol}() uids = Set{String}() for x in items @@ -307,23 +317,36 @@ function _project_packages() end """ - _apply_project_names!(gui, project; skip = String[]) + _apply_project_names!(gui, project; skip = String[], window = nothing) Writes the readable ids and the machine ids of the entries, groups and systems of the saved `project` into `gui.sync.ids` (see `_ProjectSync`), such that the next save gives the same ones; the uids that `BeamletOpticsProjects.save_project` gave are those of the entries in `project`. Not those of the -keys `skip` (the objects of an open conflict, whose lines hold the files' state). The labels of the -`gui` stay as they are. +keys `skip` (the lines that hold the files' state, see `_composed`). A system or group without its +object (`compose` builds them anew) is that of the project of the `window` (see `_project_parts`) +of the same machine id, else of the same readable id. The labels of the `gui` stay as they are. """ -function _apply_project_names!(gui::LiveView, project; skip = String[]) +function _apply_project_names!(gui::LiveView, project; skip = String[], window = nothing) ids = gui.sync.ids for e in Iterators.flatten((project.entries, project.systems, project.groups)) - (isnothing(e.object) || _key(e) in skip) && continue - ids[e.object] = (e.name, something(e.uid, BMOP.new_uid())) + _key(e) in skip && continue + obj = isnothing(e.object) ? _window_line_object(window, e) : e.object + isnothing(obj) && continue + ids[obj] = (e.name, something(e.uid, BMOP.new_uid())) end return nothing end +# The object of the system or group of the project `window` that the system or group `e` of a +# composed project stands for: of the same machine id, else of the same readable id +function _window_line_object(window, e) + isnothing(window) && return nothing + xs = e isa BMOP.GroupEntry ? window.groups : e isa BMOP.SystemEntry ? window.systems : window.entries + i = isnothing(e.uid) ? nothing : findfirst(x -> x.uid == e.uid, xs) + isnothing(i) && (i = findfirst(x -> x.name === e.name, xs)) + return isnothing(i) ? nothing : xs[i].object +end + """ _move_id!(gui, old, new) @@ -684,10 +707,13 @@ Saving a view that has a project to another folder ("save as") copies the own co copy loads. A shown window removes its session and presence files from the old folder. Throws `BeamletOpticsProjects.ProjectChanged` and saves nothing if the files of the project of the -view changed since it opened or saved it, e.g. by an agent, unless `force` is `true`. The autosave -and `Ctrl+S` merge such changes into the window first and save again; objects that were changed -differently in both places get a card with "Take theirs", "Keep mine" and "Save as…", see -`_ProjectConflict`. +view changed since it opened or saved it, e.g. by an agent, unless `force` is `true`, or while +another participant writes them. The autosave and `Ctrl+S` merge such changes into the window +first and save again; objects that were changed differently in both places get a card with "Take +theirs", "Keep mine" and "Save as…", see `_ProjectConflict`. The objects of such a conflict and +those that another participant claims (see `BeamletOpticsProjects.claim!`) keep the state of the +files in their own project; the window keeps its own and writes it once the conflict or the claim +is over. Objects that glide to a pose of the files land first. """ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) dir = abspath(dir) @@ -696,10 +722,14 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) # A new folder of "save as" gets the own code and the module of the project name = save_as && !isfile(joinpath(dir, "Project.toml")) ? BMOP.project_name(from) : BMOP.project_name(dir) + # The objects that glide to a pose of the files land first: the save writes that pose + _finish_glides!(gui) parts = _project_parts(gui, dir; name) - # The objects of an open conflict keep the files' state in its own project, see `_saved_state` + # The objects of an open conflict and those that others claim keep the files' state in its own + # project, see `_composed` keep = save_as ? String[] : copy(gui.sync.conflicts) - project = save_as ? parts.project : _saved_state(gui, parts.project) + composed = save_as ? (; parts.project, keys = String[]) : _composed(gui, parts.project) + project = composed.project # The files as the view knows them: only of its own project dir == from && (project.stamp = gui.sync.stamp) if save_as @@ -715,9 +745,9 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) install_agent_skill(joinpath(dir, ".claude", "skills")) end _write_view_toml(gui, parts.project, parts.names) - # The uids that the save gave are those of `project`; at the objects of a conflict it holds - # those of the files, not of the window - _apply_project_names!(gui, project; skip = keep) + # The uids that the save gave are those of `project`; at the lines that keep the files' state + # it holds those of the files, not of the window + _apply_project_names!(gui, project; skip = composed.keys, window = parts.project) # A shown window (see `_connect_session!`) leaves the old folder: its session and presence # files go, and its journal is not of the new project connected = save_as && !isempty(gui.sync.session) @@ -761,37 +791,69 @@ Keys, tools and dialogs _message_line(e) = first(split(sprint(showerror, e), '\n')) """ -Saves the `gui` to `dir`; if the files changed meanwhile, they are merged first (see `_sync!`) and -saved again, also with a conflict, whose objects keep the files' state (see `_saved_state`); any -other error, also of the second save, is logged and shown in the status line. Whether the files -hold the window afterwards (except the objects of a conflict): `false` after an error or files that -do not load. + _save_to!(gui, dir; force = false) -> Symbol + +Saves the `gui` to `dir` (`:saved`); if the files changed meanwhile, they are merged first (see +`_sync!`) and saved again, also with a conflict, whose objects keep the files' state (see +`_composed`). A line that another participant claimed since the last read of the claims (the save +refuses it) keeps the files' state too: the claims are read again and it is saved again without it. +`:busy` while another participant holds the write lock of the files (they did not change, or +changed again right after the merge): nothing is written, the status line stays. `:failed` after +any other error, also of the second save, which is logged and shown in the status line, or after +files that do not load. """ function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) - try - save_project(gui, dir; force) - catch e - e isa InterruptException && rethrow() - e isa BMOP.ProjectChanged || return _save_failed!(gui, e) - gui.sync.changed_at = nothing - # Without gliding: the save right after must see the poses of the files - _adopting(() -> _sync!(gui; animate = false), gui) === :failed && return false - gui.sync.failed && return false + merged = refreshed = false + while true try save_project(gui, dir; force) - catch e2 - e2 isa InterruptException && rethrow() - return _save_failed!(gui, e2) + return :saved + catch e + e isa InterruptException && rethrow() + if e isa BMOP.ProjectChanged + (merged || _write_busy(gui, dir)) && return :busy + merged = true + gui.sync.changed_at = nothing + # Without gliding: the save right after must see the poses of the files + _adopting(() -> _sync!(gui; animate = false), gui) === :failed && return :failed + gui.sync.failed && return :failed + elseif e isa ArgumentError && !refreshed && _refresh_claims!(gui, dir) + refreshed = true + else + return _save_failed!(gui, e) + end end end - return true end -# Logs the error `e` of saving the `gui` and shows it in the status line; returns `false` +""" +Whether a `ProjectChanged` of a save of the `gui` to `dir` came from the write lock of another +participant, not from a change of the files: of a folder other than that of the project of the +`gui` (which is not compared with the files), or files as the `gui` knows them. +""" +_write_busy(gui::LiveView, dir::AbstractString) = + dir != gui.project || BMOP.project_stamp(dir) == gui.sync.stamp + +""" +Reads the claims of the others in the project `dir` of the `gui` again, into its cache (see +`_ClaimState`), after a save refused a line claimed by another participant: whether a line of the +files is claimed now that was not before (see `_claimed_lines`), i.e. a save again keeps more lines. +""" +function _refresh_claims!(gui::LiveView, dir::AbstractString) + (dir == gui.project && !isnothing(gui.sync.base)) || return false + before = _claimed_lines(gui) + me = _holder_pid(_person().id) + c = _claims(gui) + c.others = _ClaimMap(k => x for (k, x) in BMOP.claims(dir) if _holder_pid(x.by) != me) + gui.sync.presence_due = true + return !issubset(_claimed_lines(gui), before) +end + +# Logs the error `e` of saving the `gui` and shows it in the status line; returns `:failed` function _save_failed!(gui::LiveView, e) @error "save_project failed" exception = (e, catch_backtrace()) gui.status.text[] = "project not saved: $(_message_line(e))" - return false + return :failed end """ @@ -834,6 +896,9 @@ function _save_target(path::AbstractString) message = "project not saved: \"$dir\" exists already; to overwrite a project, pick its .bmo file") end +# The status line of a save while another participant writes the files, see `_save_to!` +const _BUSY_TEXT = "project not saved: another participant is writing the files, try again" + # Runs `f` after the key callback returned, e.g. a dialog, which blocks _run_later(f) = (_DIALOG_TASK[] = @async f()) @@ -847,7 +912,11 @@ function _save_as_key!(gui::LiveView) path = _ask_path(gui, _SAVE_DIALOG) isnothing(path) && return nothing target = _save_target(path) - isnothing(target.dir) ? (gui.status.text[] = target.message) : _save_to!(gui, target.dir) + if isnothing(target.dir) + gui.status.text[] = target.message + elseif _save_to!(gui, target.dir) === :busy + gui.status.text[] = _BUSY_TEXT + end return nothing end return nothing @@ -859,13 +928,16 @@ not written yet is written to the files first, without waiting, and noted in the `_autosave!` with `force`); then the card of the version asks for its message, filled with what changed since the last version (see `_ask_version!`), or the status line says that nothing changed. Nothing is saved while a conflict is open (see `_ProjectConflict`), whose card is shown, or when the -flush ends in a conflict, in files that do not load or in an error. Without a project, like "Save +flush ends in a conflict, in files that do not load, in an error or while another participant +writes the files (the status line asks to try again). Without a project, like "Save as" (see `_save_as_key!`), whose new project has its first version. """ function _save_key!(gui::LiveView) isnothing(gui.project) && return _save_as_key!(gui) _conflict_open!(gui) && return nothing - _autosave!(gui; force = true) in (:none, :saved) || return nothing + r = _autosave!(gui; force = true) + r === :busy && (gui.status.text[] = _BUSY_TEXT) + r in (:none, :saved) || return nothing summary = try BMOP.version_summary(gui.project) catch e diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl index f2f911a..5a7c00a 100644 --- a/src/LiveProjectSession.jl +++ b/src/LiveProjectSession.jl @@ -140,15 +140,37 @@ function _conflict_id(gui::LiveView, key::String) return startswith(key, "id:") ? key[4:end] : key end -function _session_text(gui::LiveView) +""" + _closed_kept(gui) -> Vector{String} + +The readable ids of the objects of the `gui`, which is closed, whose state of the window the files +do not have (an open conflict, claimed by another participant, see `_kept_back`): the window wrote +it to the backup of the person when it closed (see `_autosave!`). Empty if it is not closed. +""" +function _closed_kept(gui::LiveView) + s = gui.sync + (s.closed && !isnothing(gui.project) && !isnothing(s.base)) || return String[] + try + mine = _project_parts(gui, gui.project; name = s.base.name).project + return String[_conflict_id(gui, k) for k in _kept_back(gui, mine)] + catch e + e isa InterruptException && rethrow() + return String[] + end +end + +function _session_text(gui::LiveView; kept = _closed_kept(gui)) s = gui.sync io = IOBuffer() println(io, _session_header(gui.project)) println(io, "Written by the open BeamletOpticsGUI window: what the person sees and did there.") println(io, "system.jl holds the state of the window (it saves every finished action), except the objects") - println(io, "of an open conflict, which keep the state of the file until the person decides, and while the") - println(io, "files do not load: then the window keeps its state, which .bmo/window-backup-.jl holds.") + println(io, "of an open conflict, which keep the state of the file until the person decides, those that") + println(io, "another participant claims, until the claim is released, and while the files do not load:") + println(io, "then the window keeps its state, which .bmo/window-backup-.jl holds.") println(io) + isempty(kept) || println(io, "The window was closed. The person's version of ", join(kept, ", "), + " is not in system.jl but in .bmo/window-backup-$(_person().id).jl (the file kept its state of them).\n") sel = _session_selected(gui) println(io, "Selected: ", isnothing(sel) ? "nothing" : sel) isempty(s.conflicts) || println(io, "Open conflict: ", join((_conflict_id(gui, k) for k in s.conflicts), ", "), @@ -227,7 +249,9 @@ end """ _session_closed!(gui) -Removes the listener of the `gui` and deletes its session file (a missing file is fine). An error of +Removes the listener of the `gui` and deletes its session file (a missing file is fine). A window +that closed with objects whose state of the window the files do not have (see `_closed_kept`) keeps +the file instead, which names them and the backup of the person that holds them. An error of deleting never propagates; it is logged once per distinct message. """ function _session_closed!(gui::LiveView) @@ -237,7 +261,12 @@ function _session_closed!(gui::LiveView) empty!(gui.sync.session) isnothing(gui.project) && return nothing try - rm(_session_file(gui.project); force = true) + kept = _closed_kept(gui) + if isempty(kept) + rm(_session_file(gui.project); force = true) + else + _put_session(gui.project, _session_text(gui; kept)) + end catch e e isa InterruptException && rethrow() msg = sprint(showerror, e) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 06ac3aa..922b2db 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -17,6 +17,9 @@ const _FLASH = 1.0 # autosave waits after a save that failed const _AUTOSAVE_DELAY = 0.5 const _AUTOSAVE_RETRY = 5.0 +# How long a change of the person waits at most for the merge of files that keep changing [s], see +# `_autosave!`: as long as the watcher waits at most before it merges them (`_MERGE_EVERY`) +const _AUTOSAVE_WAIT = 2.0 # How many states of each entry that the window wrote are kept, see `_stale` const _WRITTEN_MAX = 8 @@ -108,33 +111,97 @@ Autosave _uidless_code(p) = replace(BMOP.system_code(p), r" uid = \"[^\"]*\"" => "") """ - _saved_state(gui, project) -> Project + _claimed_lines(gui) -> Vector{String} + +The keys (see `_key`) of the lines of the files (`base`) that another participant claims, by the +claims of the others at the last read of the `gui` (see `_ClaimState`, no file access): a save of +the `gui` keeps their state of the files, see `_composed`. +""" +function _claimed_lines(gui::LiveView) + c = gui.sync.claim + base = gui.sync.base + out = String[] + (c isa _ClaimState && !isempty(c.others) && !isnothing(base)) || return out + for k in keys(c.others) + x = something(_by_key(base.entries, k), _by_key(base.systems, k), _by_key(base.groups, k), + Some(nothing)) + isnothing(x) || _key(x) in out || push!(out, _key(x)) + end + return out +end + +""" +The keys of the lines whose state of the files a save of the `gui` keeps: those of an open conflict +(`gui.sync.conflicts`) and those that another participant claims (`_claimed_lines`). +""" +_held_keys(gui::LiveView) = union(gui.sync.conflicts, _claimed_lines(gui)) + +""" + _composed(gui, project) -> (; project, keys) What a save of the `gui` writes of its setup `project` (see `_project_parts`): the setup, except at -the objects of an open conflict (`gui.sync.conflicts`), where the files keep their state until the -person decides (see `_ProjectConflict`), see `BeamletOpticsProjects.compose`. +the lines of an open conflict (see `_ProjectConflict`) and the lines that another participant claims +(see `_claimed_lines`), where the files keep their state (`base`): of a conflict until the person +decides, of a claim until it is released. The window keeps its own state of them meanwhile and +writes it once that is over; if the other wrote the line meanwhile, the merge makes it a conflict. +`keys` are the keys of the lines of the files that the result holds, widened by +`BeamletOpticsProjects.compose` to the systems and groups that keep the tree of the result whole. """ -function _saved_state(gui::LiveView, project) +function _composed(gui::LiveView, project) s = gui.sync - (isempty(s.conflicts) || isnothing(s.base)) && return project - return BMOP.compose(s.base, project, s.base, s.conflicts).project + held = isnothing(s.base) ? String[] : _held_keys(gui) + isempty(held) && return (; project, keys = String[]) + return BMOP.compose(s.base, project, s.base, held) +end + +""" + _saved_state(gui, project) -> Project + +What a save of the `gui` writes of its setup `project`, see `_composed`. +""" +_saved_state(gui::LiveView, project) = _composed(gui, project).project + +""" + _kept_back(gui, mine) -> Vector{String} + +The keys of the lines of the setup `mine` of the `gui` (see `_project_parts`) whose state of the +window a save does not write, see `_composed`: those that differ from the state of the files there. +""" +function _kept_back(gui::LiveView, mine) + c = _composed(gui, mine) + isempty(c.keys) && return String[] + d = BMOP.diff_projects(c.project, mine) + changed = Set{String}(_change_key(x) for x in Iterators.flatten((d.entries, d.systems, d.groups))) + return String[k for k in c.keys if k in changed] +end + +# What the text `text` of `system.jl` changes in the text `base`: the lines that only one of them +# has, sorted. A merge of the files changes both alike, so this stays while the files change. +function _own_change(base::AbstractString, text::AbstractString) + a, b = Set(split(base, '\n')), Set(split(text, '\n')) + return join(sort!(String[symdiff(a, b)...]), '\n') end """ _autosave!(gui; now = time(), force = false) -> Symbol One look of the autosave at the setup of the `gui`, after `_check_files!`: once the setup differs -from the files (`base`) and has not changed for `_AUTOSAVE_DELAY` seconds, it is saved (`:saved`) -and the changes of the person are added to the journal of `.bmo/session.md` (`_journal!`); files -changed meanwhile are merged first. An open conflict holds only its own objects: the save writes -everything else, see `_saved_state`. `:pending` while it waits, `:none` if the setup is as in the -files. `:paused` while the mouse drags or places an object (also in a linked view, see -`open_system`), an object glides to a pose of the files, or the files changed and are not merged -yet. While the files do not load (e.g. an agent is half through an edit), the setup is written to -the backup of the person (see `_write_backup!`) instead (`:paused`) and saved once they load again. -`:failed` if the save failed, the reason in the status line; tried again after `_AUTOSAVE_RETRY` -seconds. `force` saves without waiting and while the files changed or objects glide (when the -window closes or `Ctrl+S` saves a version). +from the files (`base`) and its change has not changed for `_AUTOSAVE_DELAY` seconds (`_own_change`: +a merge of the files does not count), it is saved (`:saved`) and the changes of the person are added +to the journal of `.bmo/session.md` (`_journal!`); files changed meanwhile are merged first. An open +conflict and the claims of others hold only their own objects: the save writes everything else, see +`_composed`. `:pending` while it waits, `:none` if the setup is as in the files. `:paused` while +the mouse drags or places an object (also in a linked view, see `open_system`) or an object glides +to a pose of the files, and while the files changed and are not merged yet, there for at most +`_AUTOSAVE_WAIT` seconds since the change of the person: then it is saved anyway, while other +writers keep changing the files. While the files do not load (e.g. an agent is half through an +edit), the setup is written to the backup of the person (see `_write_backup!`) instead (`:paused`) +and saved once they load again. `:busy` if another participant held the write lock of the files, +tried again after `_AUTOSAVE_DELAY` seconds; `:failed` if the save failed, the reason in the status +line, tried again after `_AUTOSAVE_RETRY` seconds. `force` saves without waiting and while the +files changed or objects glide (when the window closes or `Ctrl+S` saves a version). A window that +closes with objects whose state of the window is not written (see `_kept_back`) writes its setup to +the backup of the person, which `.bmo/session.md` names. """ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) s = gui.sync @@ -143,22 +210,30 @@ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) if force _finish_glides!(gui) else - (_busy(gui) || !isnothing(s.changed_at) || !isempty(s.gliding)) && return :paused + (_busy(gui) || !isempty(s.gliding)) && return :paused end # The name of the files that the view agrees with, not read from `Project.toml` at every look parts = _project_parts(gui, dir; name = s.base.name) + # The person's version of the objects that the files keep is lost otherwise + force && s.closed && !isempty(_kept_back(gui, parts.project)) && _write_backup!(gui, parts.project) text = _uidless_code(_saved_state(gui, parts.project)) - if text == _uidless_code(s.base) - s.text, s.edited_at = text, nothing + base = _uidless_code(s.base) + if text == base + s.text, s.edited_at = nothing, nothing return :none end - if text != s.text - s.text, s.edited_at = text, now + change = _own_change(base, text) + if change != s.text + s.text, s.edited_at = change, now force || return :pending end # Written to the backup already, the files still do not load isnothing(s.edited_at) && s.failed && !force && return :paused - !force && !isnothing(s.edited_at) && now - s.edited_at < _AUTOSAVE_DELAY && return :pending + if !force && !isnothing(s.edited_at) + now - s.edited_at < _AUTOSAVE_DELAY && return :pending + # The watcher merges files that changed (gliding) before the save, unless they keep changing + !isnothing(s.changed_at) && now - s.edited_at < _AUTOSAVE_WAIT && return :paused + end s.edited_at = nothing if s.failed _write_backup!(gui, parts.project) @@ -179,9 +254,11 @@ end """ Saves the setup (`parts`, see `_project_parts`) of the `gui` for `_autosave!`. Files that changed since the last look are merged first, without gliding, such that the save sees the poses of the -files; files that do not load then write the backup, a conflict holds only its objects (see -`_saved_state`). A new list of objects without a recipe stays in the status line and is warned -once; otherwise the status line stays as it was. +files; files that do not load then write the backup, a conflict and the claims of others hold only +their objects (see `_composed`). While another participant holds the write lock of the files, it is +tried again after `_AUTOSAVE_DELAY` seconds (`:busy`), without waiting here. A new list of objects +without a recipe stays in the status line and is warned once; otherwise the status line stays as +it was. """ function _autosave_write!(gui::LiveView, parts, now::Real) s = gui.sync @@ -197,10 +274,14 @@ function _autosave_write!(gui::LiveView, parts, now::Real) end changes = BMOP.diff_projects(s.base, _saved_state(gui, parts.project)) status = gui.status.text[] - saved = Base.CoreLogging.with_logger(Base.CoreLogging.SimpleLogger(stderr, Base.CoreLogging.Error)) do + result = Base.CoreLogging.with_logger(Base.CoreLogging.SimpleLogger(stderr, Base.CoreLogging.Error)) do _save_to!(gui, dir) end - if !saved + if result === :busy + s.edited_at = now + return :busy + end + if result !== :saved # Files that do not load (found by a merge of the save), or an error in the status line if s.failed _write_backup!(gui, _collect_project(gui, dir)) @@ -209,6 +290,9 @@ function _autosave_write!(gui::LiveView, parts, now::Real) s.edited_at = now + _AUTOSAVE_RETRY return :failed end + # Not the changes of the lines that the save did not write: claimed since the last read + held = _held_keys(gui) + isempty(held) || (changes = map(cs -> filter(c -> !(_change_key(c) in held), cs), changes)) _journal!(gui, changes) if !isempty(parts.skipped) && parts.skipped != s.skipped @warn "save_project: $(length(parts.skipped)) objects without a recipe are not saved: " * diff --git a/test/TestLiveProjectConflicts.jl b/test/TestLiveProjectConflicts.jl index 3d2b09f..a1a01e2 100644 --- a/test/TestLiveProjectConflicts.jl +++ b/test/TestLiveProjectConflicts.jl @@ -103,4 +103,145 @@ end close(b) end +const BMOP = BeamletOpticsProjects + +"""Two looks of the watcher: the files changed, then settled.""" +function tick!(gui) + t = time() + first = GUI._check_files!(gui; now = t) + return first === :pending ? GUI._check_files!(gui; now = t + 1) : first +end + +"""Replaces `a` by `b` in `system.jl` of `dir`, as an agent.""" +edit!(dir, a, b) = write(system(dir), replace(read(system(dir), String), a => b)) + +"""The window as if shown: closing it saves and ends its session, see `_stop_watching!`.""" +function watched!(gui) + gui.sync.timer = Timer(3600) + GUI._connect_session!(gui) + return gui +end + +"""A conflict on L1: B moved it by 0.02 and saved, A moved it by 0.01 and merged.""" +function conflict_on_l1() + a, b, dir = two_windows() + translate3d!(b, obj(b, :L1), [0.0, 0.0, 0.02]) + @test autosave!(b) === :saved + close(b) + translate3d!(a, obj(a, :L1), [0.0, 0.0, 0.01]) + @test sync!(a) === :conflict + return a, dir +end + +@testset "a line claimed by another keeps the files' state, the rest is saved" begin + a, b, dir = two_windows() + close(b) + l1, m1 = obj(a, :L1), obj(a, :M1) + key = a.sync.ids[l1][2] + # w1 claims L1 after the last read of the claims of the window, which moved it already + @test BMOP.claim!(dir; by = "w1", ids = [:L1]) + translate3d!(a, l1, [0.0, 0.0, 0.01]) + translate3d!(a, m1, [0.0, 0.0, 0.01]) + @test isempty(GUI._claims(a).others) + # the save refuses L1: the claims are read again and the rest is saved + @test autosave!(a) === :saved + @test haskey(GUI._claims(a).others, key) + @test occursin("pos = (0.0, 0.1, 0.0)", pos_line(dir, "L1")) + @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) + @test !any(l -> occursin("L1", l[2]), a.sync.journal) + # with the claim known, the next change is saved at once; the window keeps its L1 + translate3d!(a, obj(a, :D1), [0.0, 0.0, 0.01]) + @test autosave!(a) === :saved + @test occursin("pos = (0.0, 0.3, 0.01)", pos_line(dir, "D1")) + @test occursin("pos = (0.0, 0.1, 0.0)", pos_line(dir, "L1")) + @test BeamletOptics.position(l1) == [0.0, 0.1, 0.01] + @test GUI._autosave!(a; now = time() + 10) === :none + # released: the window writes its L1 + BMOP.release!(dir; by = "w1") + @test !GUI._refresh_claims!(a, a.project) && isempty(GUI._claims(a).others) + @test autosave!(a) === :saved + @test occursin("pos = (0.0, 0.1, 0.01)", pos_line(dir, "L1")) + + @testset "the holder writes the line meanwhile: a conflict" begin + @test BMOP.claim!(dir; by = "w1", ids = [:L1]) + @test GUI._refresh_claims!(a, a.project) + translate3d!(a, l1, [0.0, 0.0, 0.01]) + @test GUI._autosave!(a; now = time() + 10) === :none + edit!(dir, "pos = (0.0, 0.1, 0.01)", "pos = (0.0, 0.15, 0.01)") + BMOP.release!(dir; by = "w1") + GUI._refresh_claims!(a, a.project) + @test tick!(a) === :conflict + @test a.sync.conflicts == [key] + @test BeamletOptics.position(l1) == [0.0, 0.1, 0.02] + end + close(a) +end + +@testset "a system and a group made during a conflict keep their ids" begin + a, dir = conflict_on_l1() + m1, d1 = obj(a, :M1), obj(a, :D1) + g = GUI._group!(a, GUI._MultiSelection(Any[m1, d1])) + sys = add_system!(a; label = "Bench 2", select = false) + @test autosave!(a) === :saved + @test haskey(a.sync.ids, g) && haskey(a.sync.ids, sys) + ids = (a.sync.ids[g], a.sync.ids[sys]) + text = read(system(dir), String) + @test occursin(ids[1][2], text) && occursin(ids[2][2], text) + # the next save writes the same ones + translate3d!(a, obj(a, :B1), [0.0, 0.0, 0.01]) + @test autosave!(a) === :saved + @test (a.sync.ids[g], a.sync.ids[sys]) == ids + text = read(system(dir), String) + @test occursin(ids[1][2], text) && occursin(ids[2][2], text) + close(a) +end + +@testset "the id of the files at a conflict stays theirs" begin + a, b, dir = two_windows() + close(b) + l1 = obj(a, :L1) + translate3d!(a, l1, [0.0, 0.0, 0.01]) + # an agent renames L1 to L2 and moves it: a conflict on its line + edit!(dir, "@component L1 = ", "@component L2 = ") + edit!(dir, "[L1, M1, D1]", "[L2, M1, D1]") + edit!(dir, "pos = (0.0, 0.1, 0.0)", "pos = (0.0, 0.15, 0.0)") + @test sync!(a) === :conflict + # a new lens of the window does not take L2 + lens = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) + add_component!(a, lens; system = only(GUI._systems(a)), select = false, code = LENS) + translate3d!(a, lens, [0.0, 0.6, 0.0]) + @test autosave!(a) === :saved + @test a.sync.ids[lens][1] !== :L2 + @test occursin("pos = (0.0, 0.15, 0.0)", pos_line(dir, "L2")) + @test occursin("pos = (0.0, 0.6, 0.0)", pos_line(dir, String(a.sync.ids[lens][1]))) + close(a) +end + +@testset "closed with an open conflict: the person's version in the backup" begin + a, dir = conflict_on_l1() + watched!(a) + close(a) + # the files keep their L1, the backup of the person has the window's + @test occursin("pos = (0.0, 0.1, 0.02)", pos_line(dir, "L1")) + backup = joinpath(dir, ".bmo", "window-backup-$(GUI._person().id).jl") + @test isfile(backup) + line = only(l for l in split(read(backup, String), "\n") if startswith(l, "@component L1 ")) + @test occursin("pos = (0.0, 0.1, 0.01)", line) + # the session file stays and says so + file = GUI._session_file(dir) + @test isfile(file) + text = read(file, String) + @test occursin("The person's version of L1 is not in system.jl but in .bmo/window-backup-", text) + + @testset "without a conflict the session file goes" begin + c = open_project(dir; display = false, const_kw...) + watched!(c) + translate3d!(c, obj(c, :M1), [0.0, 0.0, 0.01]) + rm(backup) + close(c) + @test !isfile(file) && !isfile(backup) + @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) + end +end + end diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 3f5976c..36ee733 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -256,8 +256,10 @@ pos_line(dir, id) = only(l for l in split(read(system(dir), String), "\n") gui.controls.dragging = true @test GUI._autosave!(gui) === :paused gui.controls.dragging = false - gui.sync.changed_at = time() - @test GUI._autosave!(gui) === :paused + # files that changed are merged first, for at most `_AUTOSAVE_WAIT` seconds + gui.sync.changed_at = t = time() + @test GUI._autosave!(gui; now = t) === :pending + @test GUI._autosave!(gui; now = t + 1) === :paused gui.sync.changed_at = nothing @test autosave!(gui) === :saved @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) @@ -625,6 +627,69 @@ end @test_logs GUI._autosave!(gui; now = t + 1) close(gui) end + + @testset "saved while other writers keep changing the files" begin + gui, dir = opened() + GUI._autosave!(gui) + translate3d!(gui, obj(gui, :M1), [0.0, 0.0, 0.01]) + t = time() + @test GUI._autosave!(gui; now = t) === :pending + # an agent writes L1 every few 0.1 s: the files never settle + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.11, 0.0)"), text, "L1"), dir) + @test GUI._check_files!(gui; now = t + 0.2) === :pending + @test GUI._autosave!(gui; now = t + 1) === :paused + # merged at the latest after `_MERGE_EVERY`, and the agent writes again + @test GUI._check_files!(gui; now = t + 2.3) === :applied + @test GUI._autosave!(gui; now = t + 2.3) === :saved + @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) + translate3d!(gui, obj(gui, :D1), [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui; now = t + 3) === :pending + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.11, 0.0)" => "pos = (0.0, 0.122, 0.0)"), text, "L1"), dir) + @test GUI._check_files!(gui; now = t + 3.2) === :pending + @test GUI._autosave!(gui; now = t + 4) === :paused + # the change of the person waited `_AUTOSAVE_WAIT`: saved anyway, the files merged first + @test GUI._autosave!(gui; now = t + 5.1) === :saved + @test occursin("pos = (0.0, 0.3, 0.01)", pos_line(dir, "D1")) + @test occursin("pos = (0.0, 0.122, 0.0)", pos_line(dir, "L1")) + @test !any(l -> occursin("L1", l[2]), gui.sync.journal) + close(gui) + end + + @testset "save_project lands the glides first" begin + gui, dir = opened() + l1 = obj(gui, :L1) + edit!(text -> line!(l -> replace(l, "pos = (0.0, 0.1, 0.0)" => "pos = (0.0, 0.15, 0.0)"), text, "L1"), dir) + # the glide starts after the next yield; the save comes first + @test GUI._adopting(() -> GUI._sync!(gui; animate = true), gui) === :applied + @test !isempty(gui.sync.gliding) + save_project(gui) + @test isempty(gui.sync.gliding) + @test BeamletOptics.position(l1) == [0.0, 0.15, 0.0] + @test occursin("pos = (0.0, 0.15, 0.0)", pos_line(dir, "L1")) + sleep(0.5) + @test BeamletOptics.position(l1) == [0.0, 0.15, 0.0] + @test GUI._autosave!(gui; now = time() + 10) === :none + close(gui) + end + + # The bounded wait for the write lock of BeamletOpticsProjects (`ProjectChanged` while busy) + isdefined(BeamletOpticsProjects, :_with_write_lock) && @testset "the write lock of another: tried again soon" begin + gui, dir = opened() + GUI._autosave!(gui) + translate3d!(gui, obj(gui, :M1), [0.0, 0.0, 0.01]) + status = gui.status.text[] + lock = joinpath(dir, ".bmo", "write.lock") + write(lock, "PC9 1 another writer") + t = time() + @test GUI._autosave!(gui; now = t) === :pending + @test @test_logs(GUI._autosave!(gui; now = t + 1)) === :busy + @test gui.status.text[] == status + @test GUI._autosave!(gui; now = t + 1.2) === :pending + rm(lock) + @test GUI._autosave!(gui; now = t + 1.6) === :saved + @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) + close(gui) + end end @testset "Time of a merge and an autosave of a larger setup" begin From a6f7e2127401a4c34ddba62dbd37d77dd82ecddf Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 22:33:53 +0100 Subject: [PATCH 22/47] Glides land before they leave gliding, take their tokens from the merge, wait_solve waits for a merge F21: _land_all! removes an object from gliding only after its landing change, so a tick during the wait of that change for a cancelled solve does not end the merge and start its solve while the object still moves; a glide frame does not move an object that landed meanwhile. F27: the tokens of _glide_all! are the entries that its merge put into gliding, taken from the glides and not when the task first runs; objects that a newer merge took over neither glide nor land there. F23: wait_solve waits while a merge of the project files is applied (adopting, glides) and for a job whose result another task shows in _run!. Co-Authored-By: Claude Opus 5.5 --- src/LiveProjectGlide.jl | 47 +++++++++++++------ src/LiveScripting.jl | 38 +++++++++++++-- test/TestLiveProjectGlide.jl | 91 ++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 18 deletions(-) diff --git a/src/LiveProjectGlide.jl b/src/LiveProjectGlide.jl index b36b782..b2babff 100644 --- a/src/LiveProjectGlide.jl +++ b/src/LiveProjectGlide.jl @@ -151,15 +151,23 @@ then they land (`_land!`). A window group whose objects all moved by one rigid m that the motion takes to the pivot and orientation of the files, which `_adopt_groups!` gave it already, back to these. A glide stops when its object lands meanwhile (`_finish_glides!`) or glides anew, that of a group with each of its objects. + +`_adopt!` calls it in a task of its own, which may first run after a newer merge took over some of +the objects. So the tokens of the glides are what this merge put into `gliding` (see +`_glide_token`), taken from `glides` and not from `gliding` when the task runs: the objects that a +newer merge took over neither glide nor land here, that merge glides and lands them. """ function _glide_all!(gui::LiveView, glides; animate::Bool) gliding = gui.sync.gliding ctrl = gui.controls + # What `gliding` holds for each object of this merge as long as no newer one took it over + tokens = IdDict{Any, Any}(x[1] => _glide_token(x) for x in glides) + ours(@nospecialize(o)) = get(gliding, o, nothing) === tokens[o] objs = Any[first(x) for x in glides] if animate targets = IdDict{Any, Any}() for (obj, (P, R), _) in glides - haskey(gliding, obj) && (targets[obj] = (Point3{Float64}(P), Matrix{Float64}(R))) + ours(obj) && (targets[obj] = (Point3{Float64}(P), Matrix{Float64}(R))) end items = _Glide[] claimed = IdDict{Any, Nothing}() @@ -175,35 +183,39 @@ function _glide_all!(gui::LiveView, glides; animate::Bool) end token = _GroupGlide(g, poses) gliding[g] = token + tokens[g] = token P0, R0 = _pose(g) push!(items, _Glide(g, token, P0, R0, poses[1][2], poses[1][3]; - members = leaves, tokens = Any[gliding[m] for m in leaves])) + members = leaves, tokens = Any[tokens[m] for m in leaves])) push!(objs, g) foreach(m -> (claimed[m] = nothing), leaves) end for (obj, (P, R), _) in glides - (haskey(claimed, obj) || !haskey(gliding, obj)) && continue + (haskey(claimed, obj) || !ours(obj)) && continue P0, R0 = _pose(obj) - push!(items, _Glide(obj, gliding[obj], P0, R0, P, R)) + push!(items, _Glide(obj, tokens[obj], P0, R0, P, R)) end - # What `gliding` holds for each of them now: a newer merge lands the ones of this one first - tokens = IdDict{Any, Any}(o => gliding[o] for o in objs if haskey(gliding, o)) n = max(1, round(Int, _GLIDE * _GLIDE_FPS)) for i in 1:(n - 1) filter!(x -> _gliding(gliding, x), items) isempty(items) && break f = i / n f = f * f * (3 - 2f) - _change!(() -> foreach(x -> _glide_to!(x, f), items), ctrl, nothing) + # Not over a landing during the wait of the change for a solve that it cancels + _change!(() -> foreach(x -> _gliding(gliding, x) && _glide_to!(x, f), items), ctrl, nothing) _update_glided!(gui, Any[x.obj for x in items]) sleep(1 / _GLIDE_FPS) end - filter!(o -> haskey(tokens, o) && get(gliding, o, nothing) === tokens[o], objs) end - _land_all!(gui, objs) + # Not the ones that landed meanwhile or that a newer merge took over, which lands them + _land_all!(gui, objs; tokens) return nothing end +# The token of a glide `(obj, (P, R), entry)` of `_glide_all!`: what `_adopt!` puts into `gliding` +# for `obj`, a newer merge another one (another `R`) +_glide_token((_, (P, R), entry)) = (P, R, entry) + """ Puts `obj` of `gliding` of the `gui` exactly into the pose of its entry of the files and records that pose; a window group that glides as one (`_GroupGlide`) into the pivots and orientations of @@ -214,9 +226,15 @@ _land!(gui::LiveView, @nospecialize(obj)) = _land_all!(gui, Any[obj]) """ Lands those of the objects `objs` that are in `gliding` of the `gui` (see `_land!`): first the objects, then the window groups. The records of the window groups that hold one of them or are one -of them are made anew (`_current_record`), then the view is updated once. +of them are made anew (`_current_record`), then the view is updated once. With `tokens` +(`obj => token`), only the objects for which `gliding` holds their token, see `_glide_all!`. + +An object leaves `gliding` only after its landing change: the change waits for a solve that it +cancels (see `_change!`), and meanwhile the merge must not end (`_finish_adopt!`), whose solve +would run while the object still moves. An object that landed or glides anew meanwhile is left to +that landing or to the newer merge. """ -function _land_all!(gui::LiveView, objs) +function _land_all!(gui::LiveView, objs; tokens = nothing) gliding = gui.sync.gliding ctrl = gui.controls landed = Any[] @@ -224,22 +242,25 @@ function _land_all!(gui::LiveView, objs) for obj in objs haskey(gliding, obj) || continue v = gliding[obj] + isnothing(tokens) || get(tokens, obj, nothing) === v || continue v isa _GroupGlide && (push!(groups, v); continue) - P, R, e = pop!(gliding, obj) + P, R, e = v _change!(() -> _place_exactly!(obj, P, R), ctrl, obj) + get(gliding, obj, nothing) === v || continue + delete!(gliding, obj) _update_record!(gui, obj; pos = e.pos, rot = e.rot, pose = _pose(obj)) push!(landed, obj) end changed = IdDict{Any, Nothing}() foreach(o -> (changed[o] = nothing), landed) for gg in groups - delete!(gliding, gg.group) _change!(ctrl, nothing) do for (h, P, R) in gg.poses BMO.set_pivot3d!(h, Vector{Float64}(P)) BMO.orientation!(h, R) end end + get(gliding, gg.group, nothing) === gg && delete!(gliding, gg.group) foreach(x -> (changed[first(x)] = nothing), gg.poses) end isempty(changed) && return nothing diff --git a/src/LiveScripting.jl b/src/LiveScripting.jl index c457640..86a362c 100644 --- a/src/LiveScripting.jl +++ b/src/LiveScripting.jl @@ -232,15 +232,18 @@ outdated, see [`retrace!`](@ref). Call it after moves from code before reading d saving an image. The windows that show a system of the `gui` (see [`open_system`](@ref)) are waited for as well: a -solve of one of them traces the beams of the `gui`, which follows when it is done. +solve of one of them traces the beams of the `gui`, which follows when it is done. So is a change +of the files of the project of the `gui` (see [`open_project`](@ref)) that is being applied: its +objects glide to their new poses, then it is solved. """ function wait_solve(gui::LiveView; timeout::Real = Inf) t0 = time() views = _solve_views(gui) unsettled(v) = v.trace.pending || v.trace.preview || v.trace.coarse - settled() = all(v -> isnothing(v.trace.job), views) + settled() = all(v -> isnothing(v.trace.job) && !_merging(v), views) # A deferred solve may start a solve in the background in turn for _ in 1:4 + all(v -> _await_merge!(v, t0, timeout), views) || return false all(v -> _await_job!(v, t0, timeout), views) || return false any(unsettled, views) || return settled() for v in views @@ -253,6 +256,29 @@ function wait_solve(gui::LiveView; timeout::Real = Inf) return settled() end +# Whether a merge of the files of the project is being applied to the `gui`, see `_adopting`, or +# its objects still glide, see `_glide_all!` +_merging(gui::LiveView) = gui.sync.adopting || !isempty(gui.sync.gliding) + +""" + _await_merge!(gui, t0, timeout) -> Bool + +Waits until the merge of the files of the project that is being applied to the `gui`, if any, ended +(see `_merging`): its objects landed and its solve started, see `_finish_adopt!`, which it calls as +the tick of the window does. Glides of no merge land at once (`_finish_glides!`). Returns `false` +if it still runs `timeout` [s] after the time `t0`. +""" +function _await_merge!(gui::LiveView, t0::Real, timeout::Real) + while _merging(gui) + gui.sync.adopting ? _finish_adopt!(gui) : _finish_glides!(gui) + _merging(gui) || break + time() - t0 < timeout || return false + # The glides run in a task of their own + sleep(1 / _GLIDE_FPS) + end + return true +end + # The `gui` and the views that are linked with it and show one of its systems, see `_tracing` _solve_views(gui::LiveView) = LiveView[gui; [v for v in gui.links.views if v !== gui && _shares_system(v, gui)]] @@ -261,14 +287,16 @@ _solve_views(gui::LiveView) = _await_job!(gui, t0, timeout) -> Bool Waits for the job of the `gui` that runs in the background, if any, and shows its result, see -`_poll_job!`. Returns `false` if it still runs `timeout` [s] after the time `t0`. +`_poll_job!`; the result of a job that another task waits for in `_run!` (e.g. the tick that ended +a merge of the files of the project, see `_finish_adopt!`) is shown by that task. Returns `false` +if it still runs `timeout` [s] after the time `t0`. """ function _await_job!(gui::LiveView, t0::Real, timeout::Real) job = gui.trace.job isnothing(job) && return true _wait(job.done, timeout - (time() - t0)) - # `done` is notified by the task before it ends - while !istaskdone(job.task) + # `done` is notified by the task before it ends, the task in `_run!` resumes after it + while !istaskdone(job.task) || (gui.trace.awaited && gui.trace.job === job) time() - t0 < timeout || return false sleep(1e-3) end diff --git a/test/TestLiveProjectGlide.jl b/test/TestLiveProjectGlide.jl index 94217a6..f9887e7 100644 --- a/test/TestLiveProjectGlide.jl +++ b/test/TestLiveProjectGlide.jl @@ -231,4 +231,95 @@ end close(b) end +"""Moves `D1` in the window `a` by `dx`, saves and merges in `b` as the watcher does (one solve +after the merge, see `_adopting`), with the glides, which still run.""" +function merge_d1!(a, b, dx) + translate3d!(a, obj(a, :D1), [dx, 0.0, 0.0]) + @test autosave!(a) === :saved + @test GUI._adopting(() -> GUI._sync!(b; animate = true), b) === :applied + return obj(b, :D1) +end + +@testset "An object leaves the glides only after its landing change" begin + a, b, dir = two_windows() + warm_up!(a, b, (:D1,)) + @test wait_solve(b; timeout = 60) + d = merge_d1!(a, b, 0.003) + @test b.sync.adopting + @test haskey(b.sync.gliding, d) + # A tick while the landing change waits for a solve that it cancels must not end the merge, + # whose solve would run while the object still moves + ended = Bool[] + before = b.controls.before_change + b.controls.before_change = () -> (before(); push!(ended, GUI._finish_adopt!(b)); nothing) + try + GUI._finish_glides!(b) + finally + b.controls.before_change = before + end + @test !isempty(ended) + @test !any(ended) + @test isempty(b.sync.gliding) + @test same_pose(pose(d), pose(obj(a, :D1))) + # The merge ends on the next tick + @test b.sync.adopting + @test GUI._finish_adopt!(b) + @test !b.sync.adopting + @test wait_solve(b; timeout = 60) + close(a) + close(b) +end + +@testset "A glide of an older merge leaves the objects of a newer one" begin + a, b, dir = two_windows() + warm_up!(a, b, (:D1,)) + @test wait_solve(b; timeout = 60) + d = merge_d1!(a, b, 0.003) + P, R, e = b.sync.gliding[d] + GUI._finish_glides!(b) + @test wait_solve(b; timeout = 60) + # the task of the older merge has ended + sleep(2 * GUI._GLIDE) + @test same_pose(pose(d), pose(obj(a, :D1))) + # A newer merge takes `d` over before the task that glides the older one first runs: that + # task neither glides `d` back to the older pose nor lands it early + P2 = Point3{Float64}(Vector{Float64}(P) .+ [0.0, 0.0, 0.004]) + R2 = Matrix{Float64}(R) + newer = (P2, R2, e) + b.sync.gliding[d] = newer + GUI._glide_all!(b, [(d, (P, R), e)]; animate = true) + @test get(b.sync.gliding, d, nothing) === newer + @test same_pose(pose(d), pose(obj(a, :D1))) + GUI._finish_glides!(b) + @test isempty(b.sync.gliding) + @test same_pose(pose(d), (Vector{Float64}(P2), R2)) + @test wait_solve(b; timeout = 60) + close(a) + close(b) +end + +@testset "wait_solve waits for a merge of the files" begin + a, b, dir = two_windows() + warm_up!(a, b, (:D1,)) + @test wait_solve(b; timeout = 60) + @test !b.sync.adopting + d = merge_d1!(a, b, 0.003) + @test b.sync.adopting + @test !isempty(b.sync.gliding) + @test wait_solve(b; timeout = 60) + # the objects landed and the merge is solved + @test !b.sync.adopting + @test isempty(b.sync.gliding) + @test isnothing(b.trace.job) + @test !b.trace.stale + @test same_pose(pose(d), pose(obj(a, :D1))) + # with a timeout before the glides landed + merge_d1!(a, b, 0.002) + @test !wait_solve(b; timeout = 0.0) + @test wait_solve(b; timeout = 60) + @test !b.sync.adopting + close(a) + close(b) +end + end From d7e54b78a6ee353f7ceffe9eb3222778e108183f Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 22:40:56 +0100 Subject: [PATCH 23/47] Docs of the fixes: the pending change of the autosave, claimed lines held, wait_solve and merges Co-Authored-By: Claude Opus 5.5 --- skills/beamletopticsgui/API.md | 2 +- skills/beamletopticsgui/SKILL.md | 4 +++- src/LiveView.jl | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/skills/beamletopticsgui/API.md b/skills/beamletopticsgui/API.md index 25c8624..42ac4e8 100644 --- a/skills/beamletopticsgui/API.md +++ b/skills/beamletopticsgui/API.md @@ -174,7 +174,7 @@ rotate3d!(gui, mirror, [0, 0, 1], deg2rad(2)) # or rotate3d!(gui, mirror, R) select!(gui, lens); select!(gui, nothing) # like a click select!(gui, [lens, mirror]) # like Ctrl+click on each: moved, rotated, hidden, removed as one spectator!(gui, true) # like the key v -wait_solve(gui) # solve in the background shown, deferred solves done, also of linked windows +wait_solve(gui) # solve in the background shown, deferred solves done, also of linked windows and of a merge of the project files ``` Screenshots and videos: `spectator!(gui; help = false, background = :black, cards = false, view_cube = false)` diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index d9bd0d3..14b5784 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -109,7 +109,9 @@ When this Skill is active: or 30 s pass; a gesture on an object that another participant claims is refused, the status line names the holder ("Bob is editing M1"), and the object shows a lock and "editing". A conflict (an object changed here and in the files) holds only that object: everything else is saved, the - object keeps the files' state until the person picks "theirs" or "mine" on the card. The objects + object keeps the files' state until the person picks "theirs" or "mine" on the card. Lines that + another participant claims are held the same way: the window saves everything else and writes + its change of such an object once the claim is gone. The objects of one change of the files glide together; a group moved as a whole glides as one. The table fits the setup again after a drag, a removal or a change of the files. How to work on the files is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). diff --git a/src/LiveView.jl b/src/LiveView.jl index 3a7098e..2c3a071 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -609,8 +609,8 @@ What the view knows of the files of its project, which an agent may change while both in the view and in the files, and `theirs` the project of the files of that conflict; - `written`: per machine id the last states of an entry that the view itself wrote, each after the state it overwrote, `nothing` for a removal (the guard against an agent that writes from an older - read, see `_stale`), `text` the `system.jl` text of the view at the last look of the autosave and - `edited_at` the time it last changed, `skipped` the objects without a recipe that the last save + read, see `_stale`), `text` the person's own pending change at the last look of the autosave (see + `_own_change`) and `edited_at` the time it last changed, `skipped` the objects without a recipe that the last save left out (warned once); - `journal`: the recent changes of the person in the view, `(time, line)`, newest last, and `session` the listeners that write them to `.bmo/session.md`, see `LiveProjectSession.jl`; From 3d26f4b15d2306b88a38ecd37929c90dfba7923e Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 28 Oct 2026 23:43:27 +0100 Subject: [PATCH 24/47] The table fits the setup again shortly after every change; the drag of a pivot is a gesture that asks the claims; the view lock test expects the keys without Ctrl Co-Authored-By: Claude Opus 5.5 --- skills/beamletopticsgui/SKILL.md | 2 +- src/LivePivot.jl | 10 +++++-- src/LiveTable.jl | 49 +++++++++++++++++++++++++++----- test/TestLiveProjectClaims.jl | 18 ++++++++++++ test/TestLiveTableFit.jl | 36 +++++++++++++++++++++++ test/TestViewLock.jl | 8 ++++-- 6 files changed, 109 insertions(+), 14 deletions(-) diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index 2074108..ca6abf5 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -113,7 +113,7 @@ When this Skill is active: another participant claims are held the same way: the window saves everything else and writes its change of such an object once the claim is gone. The objects of one change of the files glide together; a group moved as a whole glides as one. The table - fits the setup again after a drag, a removal or a change of the files. How to work on the files + fits the setup again shortly after every change (a drag, a key step, undo, a removal, a change of the files). How to work on the files is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). - A group or a selection of several rotates about its pivot, a dot (dashed line to the gizmo) while it is selected: the mean of the objects for one made in the window or a selection, the pivot of the diff --git a/src/LivePivot.jl b/src/LivePivot.jl index a19c0d8..24d95f2 100644 --- a/src/LivePivot.jl +++ b/src/LivePivot.jl @@ -431,22 +431,24 @@ function _pivot_hit(gui::LiveView) end """ - _start_pivot_drag!(gui) + _start_pivot_drag!(gui) -> Bool A press on the dot of the pivot of the `gui`: the dot follows the mouse in the plane of the view through the pivot, see `_pivot_drag_step!`, and the release records the shift as one action of the -undo history, see `_end_pivot_drag!`. +undo history, see `_end_pivot_drag!`. A gesture like a drag of the selection: refused (`false`) if +another participant of the project claims one of its objects, see `_gesture!`. """ function _start_pivot_drag!(gui::LiveView) scene = gui.ax.scene sel = gui.controls.selected[] + _gesture!(gui, sel) || return false P = Vector{Float64}(position(sel)) n = _view_direction(scene) origin, dir = _cursor_ray(scene) hit = _ray_plane_intersect(origin, dir, P, n) grab = isnothing(hit) ? zeros(3) : P .- hit gui.components.pivot_marker.drag = (; sel, start = P, normal = n, grab) - return nothing + return true end function _pivot_drag_step!(gui::LiveView) @@ -468,6 +470,8 @@ function _end_pivot_drag!(gui::LiveView) sel = drag.sel δ = Matrix{Float64}(BMO.orientation(sel))' * (Vector{Float64}(position(sel)) .- drag.start) norm(δ) > 1e-15 && _record_pivot!(gui, sel, δ) + # the claims are held from the end of the drag on, see `_claim!` + _claim!(gui, sel) return nothing end diff --git a/src/LiveTable.jl b/src/LiveTable.jl index 02010bc..1ee9a45 100644 --- a/src/LiveTable.jl +++ b/src/LiveTable.jl @@ -14,6 +14,8 @@ const _TABLE_HOLE = 0.24 const _TABLE_MAX_HOLES = 20_000 # Distance of the table below the sources of a view without components [holes] const _TABLE_DROP = 2 +# The table fits the setup again this long [s] after the last change that wants it, see `_table_tick!` +const _TABLE_FIT_DELAY = 0.3 """ _Table @@ -24,7 +26,8 @@ the distance `pitch` [m] in the plane with the `normal` (the rotation axis of th time, then the lowest point of the components, see `_table_height`. The rows of the holes run along `e1` and `e2`, through the origin, see `_table_axes`. `range` are the holes that the table has, `(i0, i1, j0, j1)` along `e1` and `e2`, which grow with the setup while it is shown, see -`_table_include!`, and fit it again after a gesture, see `_table_fit!`. While it is `shown`, components and sources `snap` onto its holes, see +`_table_include!`, and fit it again after a gesture, see `_table_fit!`; `fit_at` is the time [s] +of the last change after which it fits the setup again (`NaN` if none), see `_table_tick!`. While it is `shown`, components and sources `snap` onto its holes, see `_table_snap`. Its `plots` are an outline and the `holes`, of the diameter `hole_size` [m], created when it is shown for the first time, see `_draw_table!`; `toggle` is its tool. """ @@ -37,6 +40,7 @@ mutable struct _Table height::Union{Nothing, Float64} range::Union{Nothing, NTuple{4, Int}} shown::Bool + fit_at::Float64 const holes::Observable{Vector{Point3f}} const outline::Observable{Vector{Point3f}} const hole_size::Observable{Float32} @@ -89,7 +93,7 @@ function _Table(normal, spec) n = normalize(Vector{Float64}(normal)) e1, e2 = _table_axes(n) return _Table(Float64(spec.pitch), n, e1, e2, spec.snap, - isnothing(spec.height) ? nothing : Float64(spec.height), nothing, false, + isnothing(spec.height) ? nothing : Float64(spec.height), nothing, false, NaN, Observable(Point3f[]), Observable(Point3f[]), Observable(0.0f0), AbstractPlot[], nothing) end @@ -229,7 +233,8 @@ end _table_include!(gui, obj) Extends the table of the `gui` to the position of the component or source `obj`, which was moved or -added, with `_TABLE_MARGIN` holes around it, while the table is shown. +added, with `_TABLE_MARGIN` holes around it, while the table is shown. It fits the setup again +`_TABLE_FIT_DELAY` seconds after the last of these changes, see `_table_tick!`. """ _table_include!(::LiveView, _) = nothing # A selection of several objects: each of them @@ -242,6 +247,7 @@ end function _table_include!(gui::LiveView, @nospecialize(obj::Union{BMO.ObjectOrGroup, _Source})) t = gui.components.table (isnothing(t) || !t.shown) && return nothing + t.fit_at = time() range = _union_range(t.range, _table_range(t, [Vector{Float64}(position(obj))]; min_holes = (1, 1))) range == t.range && return nothing @@ -256,14 +262,19 @@ end Makes the table of the `gui` fit the setup as it is again, i.e. its holes below the visible components, extras and sources with `_TABLE_MARGIN` holes around them (see `_table_range`), also if that is fewer than it has: to be called when a gesture ends (the end of a drag, a removal, a merge -of the files). Does nothing, and returns `false`, while the table is hidden, while the mouse drags -(`gui.controls.dragging`) or a component is placed (so it does not flicker), and if the range does -not change. The drawn holes stay where they are, see `_table_holes`. +of the files), and after any other change by `_table_tick!`. Does nothing, and returns `false`, +while the table is hidden, while the mouse drags (`gui.controls.dragging`) or a component is placed +(so it does not flicker; `_table_tick!` fits it after that), and if the range does not change. The +drawn holes stay where they are, see `_table_holes`. """ function _table_fit!(gui::LiveView) t = gui.components.table (isnothing(t) || !t.shown || isnothing(t.range) || isnothing(t.height)) && return false - (gui.controls.dragging || _placing(gui)) && return false + if gui.controls.dragging || _placing(gui) + t.fit_at = time() + return false + end + t.fit_at = NaN range = _table_range(t, _table_points(gui)) range == t.range && return false t.range = range @@ -271,6 +282,21 @@ function _table_fit!(gui::LiveView) return true end +""" + _table_tick!(gui; now = time()) -> Bool + +A frame of the `gui`: the table fits the setup again (see `_table_fit!`) once `_TABLE_FIT_DELAY` +seconds have passed since the last change that wants it (`fit_at` of `_Table`): a move of any +kind (also undo, redo, a key step, an input on a card, a verb from a script), an addition, or a +fit that was refused while the mouse dragged or a component was placed. Waiting for the last +change keeps held keys from fitting it at every step. Returns whether it changed. +""" +function _table_tick!(gui::LiveView; now::Real = time()) + t = gui.components.table + (isnothing(t) || isnan(t.fit_at) || now - t.fit_at < _TABLE_FIT_DELAY) && return false + return _table_fit!(gui) +end + # Components and sources snap onto the holes of the table, clip planes do not _grid_snaps(::Union{BMO.ObjectOrGroup, _Source}) = true _grid_snaps(_) = false @@ -308,6 +334,15 @@ function _build_table!(gui::LiveView, spec) @nospecialize obj return _table_snap(gui, obj, point) end + push!(gui.controls.listeners, on(events(gui.ax.scene).tick) do _ + try + _table_tick!(gui) + catch e + t.fit_at = NaN + gui.last_error = _log_once(gui, e, gui.last_error, "the table") + end + return nothing + end) if spec.shown status = gui.status.text[] t.toggle.active[] = true diff --git a/test/TestLiveProjectClaims.jl b/test/TestLiveProjectClaims.jl index 2e375e2..cc076f9 100644 --- a/test/TestLiveProjectClaims.jl +++ b/test/TestLiveProjectClaims.jl @@ -222,6 +222,24 @@ mine(dir, me) = sort!([String(c.id) for c in values(BMOP.claims(dir)) if c.by == ctrl.selected[] = nothing look!(gui) @test isempty(mine(dir, me)) + # the dot of its pivot is dragged like the group + @test BMOP.claim!(dir; by = "bob@PC9", ids = [:M1]) + look!(gui) + ctrl.selected[] = g + P = Vector{Float64}(position(g)) + @test !GUI._start_pivot_drag!(gui) + @test isnothing(gui.components.pivot_marker.drag) + @test gui.status.text[] == "Bob is editing M1" + @test Vector{Float64}(position(g)) == P + BMOP.release!(dir; by = "bob@PC9") + look!(gui) + @test GUI._start_pivot_drag!(gui) + GUI._end_pivot_drag!(gui) + look!(gui) + @test mine(dir, me) == ["M1", "M2"] + ctrl.selected[] = nothing + look!(gui) + @test isempty(mine(dir, me)) end @testset "claimed first by another" begin diff --git a/test/TestLiveTableFit.jl b/test/TestLiveTableFit.jl index b44659f..f691695 100644 --- a/test/TestLiveTableFit.jl +++ b/test/TestLiveTableFit.jl @@ -50,6 +50,42 @@ const GUI = BeamletOpticsGUI @test t.range == GUI._table_range(t, GUI._table_points(gui)) && t.range[4] < grown end + @testset "after undo, a key step or a card input" begin + gui, sys, m, lens = _fixture() + t = gui.components.table + r0 = t.range + @test !GUI._table_tick!(gui; now = time() + 1) + # a drag far away grows the table and fits it at its end + P0, R0 = GUI._pose(lens) + translate3d!(gui, lens, [1.0, 0.0, 0.0]) + @test t.range[2] > r0[2] + # the undo moves it back: the table fits after the delay, not at once + @test GUI._undo!(gui.controls) + @test GUI._pose(lens)[1] ≈ P0 + @test t.range[2] > r0[2] + @test !GUI._table_tick!(gui; now = t.fit_at + GUI._TABLE_FIT_DELAY / 2) + @test GUI._table_tick!(gui; now = t.fit_at + 2 * GUI._TABLE_FIT_DELAY) + @test t.range == r0 + @test isnan(t.fit_at) + @test !GUI._table_tick!(gui; now = time() + 1) + end + + @testset "a fit refused while placing is made up" begin + gui, sys, m, lens = _fixture() + t = gui.components.table + r0 = t.range + translate_to3d!(lens, [0.0, 1.0, 0.0]) + GUI._table_include!(gui, lens) + translate_to3d!(lens, [0.0, 0.1, 0.0]) + gui.controls.dragging = true + @test !GUI._table_fit!(gui) + @test !isnan(t.fit_at) + @test !GUI._table_tick!(gui; now = time() + 1) + gui.controls.dragging = false + @test GUI._table_tick!(gui; now = time() + 1) + @test t.range == r0 + end + @testset "hidden table is left alone" begin gui, sys, m, lens = _fixture() t = gui.components.table diff --git a/test/TestViewLock.jl b/test/TestViewLock.jl index 8bf2a70..231c008 100644 --- a/test/TestViewLock.jl +++ b/test/TestViewLock.jl @@ -107,9 +107,11 @@ const GUI = BeamletOpticsGUI @test gui.status.text[] == "view unlocked" c = cam(gui).controls @test c.rotation_button[] == Mouse.left - @test c.pan_left_key[] == Keyboard.j && c.pan_right_key[] == Keyboard.l - @test c.tilt_up_key[] == Keyboard.i && c.tilt_down_key[] == Keyboard.k - @test c.roll_clockwise_key[] == Keyboard.e && c.roll_counterclockwise_key[] == Keyboard.q + # the keys act without Ctrl or Cmd only, see `_without_ctrl!` + key(k) = k & !reduce(|, GUI._CTRL_OR_CMD_KEYS) + @test c.pan_left_key[] == key(Keyboard.j) && c.pan_right_key[] == key(Keyboard.l) + @test c.tilt_up_key[] == key(Keyboard.i) && c.tilt_down_key[] == key(Keyboard.k) + @test c.roll_clockwise_key[] == key(Keyboard.e) && c.roll_counterclockwise_key[] == key(Keyboard.q) @test !any(==(false), rotations(gui)) drag!(gui, free(gui), free(gui) .+ (60.0, 40.0)) @test norm(dir(gui) - d0) > 1e-3 From 99fc212aacc0eadc81bdbf0afa70685f275d6ed7 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 29 Oct 2026 00:32:01 +0100 Subject: [PATCH 25/47] A conflict holds the places of its object: theirs takes them from the files, an object removed here under the same id gives way; agents ask whether to keep a report on the tools Co-Authored-By: Claude Opus 5.5 --- skills/beamletopticsgui/SKILL.md | 10 +++++- src/LiveProjectSync.jl | 47 +++++++++++++++++++++++++-- test/TestLiveProjectConflicts.jl | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index ca6abf5..a08b39e 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -36,6 +36,13 @@ When this Skill is active: its card (`card_rows`), parameters without an object in `add_controls!`, plots in `add_panel!`. - Card functions are extended as `BeamletOpticsGUI.card_rows(x::MyType)`, in a package extension of the component package (weak dependency on BeamletOpticsGUI) or directly in a script. +- **Ask once, at the start of the work, whether you should keep a report on the tools**: where + BeamletOptics, BeamletOpticsProjects and BeamletOpticsGUI got in your way and what you would have + needed. It is how these packages are made easier to use for agents. If the person says yes, write + each point when it happens: what you wanted, what you tried (call, first line of the error, the + docs you followed), what it cost, what would have helped, and the package. In a project: as + "Report on the tools" in the skill `beamletopticsprojects` says; otherwise in + `bmo-report--.md` in the working folder. Tell the person the path when done. ## Default workflow @@ -109,7 +116,8 @@ When this Skill is active: or 30 s pass; a gesture on an object that another participant claims is refused, the status line names the holder ("Bob is editing M1"), and the object shows a lock and "editing". A conflict (an object changed here and in the files) holds only that object: everything else is saved, the - object keeps the files' state until the person picks "theirs" or "mine" on the card. Lines that + object keeps the files' state (also its places in the systems and groups) until the person picks + "theirs" or "mine" on the card. Lines that another participant claims are held the same way: the window saves everything else and writes its change of such an object once the claim is gone. The objects of one change of the files glide together; a group moved as a whole glides as one. The table diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 922b2db..bbc1f12 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -535,13 +535,23 @@ function _name!(gui::LiveView, @nospecialize(x), e) return nothing end -"""Adds the object or source of the entry `e` of the files to the `gui`, without a system yet.""" +""" +Adds the object or source of the entry `e` of the files to the `gui`, without a system yet. An +object that the person removed from the view under the same id (kept for its undo, see +`_project_ids`) is forgotten, with its undo history: the files' object takes its place, e.g. when +the person takes "theirs" of a conflict on an object they removed. +""" function _add_entry!(gui::LiveView, e) obj = e.object origin = _project_origin(gui, e) _unrecorded(gui) do add_component!(gui, obj; system = :none, select = false, label = _shown_label(e), origin) end + stale = Any[o for (o, (name, uid)) in gui.sync.ids + if o !== obj && (isnothing(e.uid) ? name === e.name : uid == e.uid) && + !(_shown_top(gui, o) || _has(_sources(gui), o))] + foreach(o -> delete!(gui.sync.ids, o), stale) + _drop_history!(gui.controls, stale) _name!(gui, obj, e) return obj end @@ -1105,6 +1115,38 @@ function _show_conflict!(gui::LiveView, keys::Vector{String}) return nothing end +""" + _taken_places(gui, mine, disk, keys) -> (; keys, merged) + +What "theirs" of a conflict at the `keys` takes from the files `disk` into the `gui`, whose state is +`mine`: the lines of the `keys` with their places in the systems and groups as in the files, the +other members of those systems and groups as in the window (see `BeamletOpticsProjects.compose`, +which holds the same in the files while the conflict is open). `keys` widened as by `compose`, and +`merged` the systems and groups whose members change, `key => (; members, sources)`, for `_adopt!`. +""" +function _taken_places(gui::LiveView, mine, disk, keys) + r = BMOP.compose(something(gui.sync.base, disk), mine, disk, keys) + p = r.project + ks(q, names) = Set(_group_member_keys(q, (; members = names))) + merged = Dict{String, Any}() + for se in p.systems + k = _key(se) + ms = _by_key(mine.systems, k) + (k in r.keys || isnothing(ms)) && continue + members, sources = ks(p, se.members), ks(p, se.sources) + (members == ks(mine, ms.members) && sources == ks(mine, ms.sources)) || + (merged[k] = (; members, sources)) + end + for ge in p.groups + k = _key(ge) + mg = _by_key(mine.groups, k) + (k in r.keys || isnothing(mg)) && continue + members = ks(p, ge.members) + members == ks(mine, mg.members) || (merged[k] = (; members, sources = Set{String}())) + end + return (; keys = Set(r.keys), merged) +end + """ _resolve_conflict!(gui, how, keys = all open ones) @@ -1125,7 +1167,8 @@ function _resolve_conflict!(gui::LiveView, how::Symbol, keys = copy(gui.sync.con names = String[_conflict_name(gui, k) for k in keys] if how === :theirs && !isnothing(s.theirs) mine = _collect_project(gui, gui.project) - done = _adopting(() -> _adopt!(gui, mine, s.theirs, Set(keys)), gui) + taken = _taken_places(gui, mine, s.theirs, keys) + done = _adopting(() -> _adopt!(gui, mine, s.theirs, taken.keys; taken.merged), gui) gui.status.text[] = "taken from the files: " * join(isempty(done) ? names : done, ", ") elseif how === :mine gui.status.text[] = "kept as here, written to the files: " * join(names, ", ") diff --git a/test/TestLiveProjectConflicts.jl b/test/TestLiveProjectConflicts.jl index a1a01e2..5b13bac 100644 --- a/test/TestLiveProjectConflicts.jl +++ b/test/TestLiveProjectConflicts.jl @@ -244,4 +244,60 @@ end end end +# The members of S1 in the files and in the window +members(dir) = Set(only(load_project(dir).systems).members) +members(gui::GUI.LiveView) = Set(only(GUI._collect_project(gui, gui.project).systems).members) +has_line(dir, id) = any(l -> startswith(l, "@component $id "), split(read(system(dir), String), "\n")) + +@testset "removed here, moved in the files: L1 keeps its place in S1 (F18)" begin + for how in (:theirs, :mine) + a, b, dir = two_windows() + remove_component!(a, obj(a, :L1)) + translate3d!(b, obj(b, :L1), [0.0, 0.0, 0.02]) + @test autosave!(b) === :saved + @test sync!(a) === :conflict + # the files keep the moved L1 in S1, the window has neither + @test autosave!(a) === :saved + @test occursin("pos = (0.0, 0.1, 0.02)", pos_line(dir, "L1")) + @test members(dir) == Set([:L1, :M1, :D1]) + @test members(a) == Set([:M1, :D1]) + GUI._resolve_conflict!(a, how) + @test isempty(a.sync.conflicts) + autosave!(a) + if how === :theirs + @test members(a) == Set([:L1, :M1, :D1]) + @test BeamletOptics.position(obj(a, :L1)) == [0.0, 0.1, 0.02] + @test members(dir) == Set([:L1, :M1, :D1]) + else + @test !has_line(dir, "L1") && members(dir) == Set([:M1, :D1]) + end + close(a); close(b) + end +end + +@testset "removed in the files, moved here: L1 keeps its place in S1 (F18)" begin + for how in (:mine, :theirs) + a, b, dir = two_windows() + remove_component!(b, obj(b, :L1)) + @test autosave!(b) === :saved + translate3d!(a, obj(a, :L1), [0.0, 0.0, 0.01]) + @test sync!(a) === :conflict + # the window keeps L1 in S1, the files have neither: nothing else to save + @test members(a) == Set([:L1, :M1, :D1]) + @test autosave!(a) === :none + @test !has_line(dir, "L1") && members(dir) == Set([:M1, :D1]) + GUI._resolve_conflict!(a, how) + @test isempty(a.sync.conflicts) + autosave!(a) + if how === :mine + @test occursin("pos = (0.0, 0.1, 0.01)", pos_line(dir, "L1")) + @test members(dir) == Set([:L1, :M1, :D1]) + else + @test members(a) == Set([:M1, :D1]) + @test !has_line(dir, "L1") + end + close(a); close(b) + end +end + end From ba63dada5ab34a9745165fd5ad66262a21e9380e Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 29 Oct 2026 11:25:49 +0100 Subject: [PATCH 26/47] A presence file that can not be written stops nothing: the look reads the files and writes it with the next one; the presence files of the others are read only when changed Co-Authored-By: Claude Opus 5.5 --- src/LiveProjectPresence.jl | 26 +++++++++++++++++++------- src/LiveProjectWatch.jl | 31 ++++++++++++++++++++----------- src/LiveView.jl | 4 +++- test/TestLiveProjectPresence.jl | 30 ++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 19 deletions(-) diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index ee361b5..1fbc8e2 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -69,13 +69,13 @@ Writes the presence file of the person of the `gui` (see `_person`, `_presence_f its name and host, `kind = "person"`, `ttl = _ALIVE`, the `beat` one higher than in the file, the selected object and `written`, the stamp of the files after the last save of the window (see `_own_written`). Sets `gui.sync.beat_at` to `now`. Does nothing without a project. An error of -writing propagates; the watcher logs it. The watcher writes it in the background instead, see -`_presence_due!`. +writing is no error: the file is written again with the next look. The watcher writes it in the +background instead, see `_presence_due!`. """ function _write_presence!(gui::LiveView; now::Real = time()) dir = gui.project isnothing(dir) && return nothing - _write_presence_file(dir, _presence_data(gui)) + _write_presence_file(dir, _presence_data(gui)) || (gui.sync.presence_due = true) gui.sync.beat_at = Float64(now) return nothing end @@ -103,12 +103,24 @@ function _claimed_keys(gui::LiveView) return c isa _ClaimState ? sort!(collect(keys(c.held))) : String[] end -"""Writes the presence file of `_presence_data` `p` into the project folder `dir`.""" +""" + _write_presence_file(dir, p) -> Bool + +Writes the presence file of `_presence_data` `p` into the project folder `dir`. Returns whether it +was written: a write that fails on the file system (e.g. the file of a shared drive is busy, see +`BeamletOpticsProjects.write_presence`) is no error, the caller writes it again later. +""" function _write_presence_file(dir::AbstractString, p) data = copy(p.data) - data["written"] = something(p.own, _file_written(_presence_file(dir, p.id))) - BMOP.write_presence(dir, p.id, data) - return nothing + try + data["written"] = something(p.own, _file_written(_presence_file(dir, p.id))) + BMOP.write_presence(dir, p.id, data) + catch e + (e isa Base.IOError || e isa SystemError) || rethrow() + @debug "live_view: the presence file was not written" exception = e + return false + end + return true end """ diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl index e937a68..147e52a 100644 --- a/src/LiveProjectWatch.jl +++ b/src/LiveProjectWatch.jl @@ -51,24 +51,27 @@ end _src_marks(marks) = filter(m -> m[1] != "system.jl", marks) """ - _files_look(dir; stamp = true, except = nothing, known = String[], presence = nothing) -> (; dir, marks, stamp, presences, known, present) + _files_look(dir; stamp = true, except = nothing, known = String[], presence = nothing, + cache = nothing) -> (; dir, marks, stamp, presences, known, present, presence_written) What one look of the watcher reads of the project folder `dir`: the times and sizes of its files (`_file_marks`), the stamp of their contents if `stamp` (else `nothing`), the presence files of the -others without the id `except` (`BeamletOpticsProjects.read_presences`) and of the ids `known` those -whose presence file is still there (`present`), also if it does not parse. Writes the own presence -file first if `presence` (see `_presence_data`). Touches no state of a window, so that it can run -in the background. +others without the id `except` (`BeamletOpticsProjects.read_presences`, with the `cache` of the +files read before) and of the ids `known` those whose presence file is still there (`present`), +also if it does not parse. Writes the own presence file first if `presence` (see +`_presence_data`); `presence_written` is `false` if that failed (e.g. the file of a shared drive is +busy), the look goes on and the window writes it with its next look. Touches no state of a +window, so that it can run in the background. """ function _files_look(dir::AbstractString; stamp::Bool = true, except = nothing, known = String[], - presence = nothing) - isnothing(presence) || _write_presence_file(dir, presence) + presence = nothing, cache = nothing) + presence_written = isnothing(presence) || _write_presence_file(dir, presence) marks = _file_marks(dir) st = stamp ? BMOP.project_stamp(dir) : nothing - presences = BMOP.read_presences(dir; except) + presences = BMOP.read_presences(dir; except, cache) known = Set{String}(known) present = Set{String}(id for id in known if isfile(_presence_file(dir, id))) - return (; dir = String(dir), marks, stamp = st, presences, known, present) + return (; dir = String(dir), marks, stamp = st, presences, known, present, presence_written) end """ @@ -97,13 +100,17 @@ function _take_look!(gui::LiveView; now::Real = time()) s.gen += 1 # Two writes of the own presence file at a time would share its temporary file presence = run.presence ? nothing : _presence_due!(gui, now) - return _files_look(dir; stamp = false, except = _person().id, known = collect(keys(s.others)), presence) + look = _files_look(dir; stamp = false, except = _person().id, known = collect(keys(s.others)), presence) + look.presence_written || (s.presence_due = true) + return look end s.look = nothing look = failed = nothing if !isnothing(run) try look = fetch(run.task) + # The presence file that the look could not write is written by the next one + look.presence_written || (s.presence_due = true) catch e e isa TaskFailedException || rethrow() failed = e.task.exception @@ -117,7 +124,9 @@ function _take_look!(gui::LiveView; now::Real = time()) except = _person().id known = collect(keys(s.others)) presence = _presence_due!(gui, now) - task = Threads.@spawn _files_look(dir; stamp, except, known, presence) + # Only the look in the background reads the presence files with the cache, one at a time + cache = s.presence_cache + task = Threads.@spawn _files_look(dir; stamp, except, known, presence, cache) s.look = (; task, gen = s.gen, presence = !isnothing(presence)) s.look_at = Float64(now) isnothing(failed) || throw(failed) diff --git a/src/LiveView.jl b/src/LiveView.jl index 8ad955e..e828869 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -629,7 +629,8 @@ What the view knows of the files of its project, which an agent may change while - `others`: the other people who have the project open (by the id of their presence file, see `LiveProjectPresence.jl`), `presence` the plots that show their selection, `beat_at` the time the view last wrote its own presence file (or gave it to a look to write) and `presence_due` - whether it is to be written again, see `_presence_due!`; + whether it is to be written again, see `_presence_due!`; `presence_cache` the presence files of + the others as the looks read them last (see `BeamletOpticsProjects.read_presences`); - `timer`: the watcher, and `closed` whether the view was closed (no watcher starts again); `look` the look of the watcher that reads the files in the background, `(; task, gen, presence)` (see `_take_look!`), `look_at` the time it started, `gen` a counter that drops the @@ -664,6 +665,7 @@ Base.@kwdef mutable struct _ProjectSync timer::Union{Nothing, Timer} = nothing closed::Bool = false presence_due::Bool = false + presence_cache::Dict{String, Any} = Dict{String, Any}() look::Union{Nothing, @NamedTuple{task::Task, gen::Int, presence::Bool}} = nothing look_at::Float64 = 0.0 gen::Int = 0 diff --git a/test/TestLiveProjectPresence.jl b/test/TestLiveProjectPresence.jl index 3ac82cc..e9197fd 100644 --- a/test/TestLiveProjectPresence.jl +++ b/test/TestLiveProjectPresence.jl @@ -384,6 +384,36 @@ labels(gui) = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text end end +@testset "A presence file that can not be written stops nothing" begin + gui, dir = opened() + try + # e.g. Windows can not replace it while another participant reads it: here it is a folder + own = GUI._presence_file(dir, GUI._person().id) + rm(own; force = true) + mkpath(own) + look = GUI._files_look(dir; presence = GUI._presence_data(gui), cache = Dict{String, Any}()) + @test !look.presence_written + @test any(m -> m[1] == "system.jl", look.marks) && !isnothing(look.stamp) + # the window writes it again with its next look, the watcher goes on + gui.sync.presence_due = false + GUI._write_presence!(gui; now = 100.0) + @test gui.sync.presence_due && gui.sync.beat_at == 100.0 + gui.sync.presence_due = true + @test isnothing(GUI._take_look!(gui; now = 200.0)) + wait(gui.sync.look.task) + # the failed look gives the write to the next look, which it starts at once + GUI._take_look!(gui; now = 201.0) + @test gui.sync.look.presence + wait(gui.sync.look.task) + # once it can be written again, it is + rm(own; recursive = true) + GUI._write_presence!(gui; now = 300.0) + @test isfile(own) + finally + close(gui) + end +end + @testset "Presence without a project" begin gui = live_view(BeamletOptics.System(); const_kw...) @test isnothing(GUI._write_presence!(gui)) From 49ea0e3fc5ba3827509365570bce7b99855d7016 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 29 Oct 2026 14:26:01 +0100 Subject: [PATCH 27/47] Saving does not race the window's own look of the files; a write that the file system refuses is busy and tried again, without a log entry per try Co-Authored-By: Claude Opus 5.5 --- src/LiveProject.jl | 37 ++++++++++++++++++++++++++++++++++--- src/LiveProjectWatch.jl | 5 +++++ src/LiveView.jl | 6 ++++-- test/TestLiveProjectSync.jl | 31 +++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 49aabbd..f3994d1 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -738,7 +738,9 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) end new = !isfile(joinpath(dir, "Project.toml")) # Refuses a line that another participant claimed (see `BeamletOpticsProjects.claim!`) - path = save_project(project; packages = _project_packages(), force, by = _person().id) + path = _without_looks(gui) do + save_project(project; packages = _project_packages(), force, by = _person().id) + end # A new project gets the skills of BeamletOptics and BeamletOpticsProjects from its scaffold, # and the one of the GUI here; derived copies, not part of the versions new && Base.CoreLogging.with_logger(Base.CoreLogging.NullLogger()) do @@ -798,7 +800,9 @@ Saves the `gui` to `dir` (`:saved`); if the files changed meanwhile, they are me `_composed`). A line that another participant claimed since the last read of the claims (the save refuses it) keeps the files' state too: the claims are read again and it is saved again without it. `:busy` while another participant holds the write lock of the files (they did not change, or -changed again right after the merge): nothing is written, the status line stays. `:failed` after +changed again right after the merge), or while the file system refuses the write (an `IOError` or +`SystemError` of the project of the `gui`, e.g. a file of a shared drive that is open elsewhere): +nothing is written, the status line stays. `:failed` after any other error, also of the second save, which is logged and shown in the status line, or after files that do not load. """ @@ -819,6 +823,11 @@ function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) gui.sync.failed && return :failed elseif e isa ArgumentError && !refreshed && _refresh_claims!(gui, dir) refreshed = true + elseif e isa Union{Base.IOError, SystemError} && dir == gui.project + # The file system refused the write for now (e.g. a file of a shared drive that + # another one reads): tried again soon, like a busy write lock + @debug "save_project: the files are busy" exception = e + return :busy else return _save_failed!(gui, e) end @@ -826,6 +835,28 @@ function _save_to!(gui::LiveView, dir::AbstractString; force::Bool = false) end end +""" + _without_looks(f, gui) + +Calls `f()`, e.g. the write of `system.jl`, while no look of the watcher of the `gui` reads the +files (see `_take_look!`): it waits up to `_LOOK_DRAIN` seconds for the look that runs and starts +none meanwhile. Windows can not replace a file that is open, also not for the window's own read +in the background. A look that takes longer (e.g. behind a solve on the only thread of the +background) is not waited for: the write then tries again or overwrites the file in place, see +`BeamletOpticsProjects.save_project`. +""" +function _without_looks(f, gui::LiveView) + s = gui.sync + s.saving = true + try + run = s.look + isnothing(run) || timedwait(() -> istaskdone(run.task), _LOOK_DRAIN; pollint = 0.01) + return f() + finally + s.saving = false + end +end + """ Whether a `ProjectChanged` of a save of the `gui` to `dir` came from the write lock of another participant, not from a change of the files: of a folder other than that of the project of the @@ -897,7 +928,7 @@ function _save_target(path::AbstractString) end # The status line of a save while another participant writes the files, see `_save_to!` -const _BUSY_TEXT = "project not saved: another participant is writing the files, try again" +const _BUSY_TEXT = "project not saved: the files are busy (another participant writes them, or the drive), try again" # Runs `f` after the key callback returned, e.g. a dialog, which blocks _run_later(f) = (_DIALOG_TASK[] = @async f()) diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl index 147e52a..3c70197 100644 --- a/src/LiveProjectWatch.jl +++ b/src/LiveProjectWatch.jl @@ -19,6 +19,8 @@ const _POLL_WITH_MONITOR = 0.2 const _STAMP_EVERY = 2.0 # How long a look may wait in the background before the window reads the files itself [s] const _LOOK_WAIT = 1.0 +# How long a save of the window waits for the look that runs, see `_without_looks` [s] +const _LOOK_DRAIN = 0.5 # How long the files must stay unchanged before they are loaded [s], an agent writes in steps const _SETTLE = 0.3 # How long the files may keep changing before they are loaded anyway [s], e.g. while several @@ -89,9 +91,12 @@ to a file waits for the network, often longer than a frame: the window never wai the background (a solve does not yield), the files are read here instead, without the stamp, at most every `_LOOK_WAIT` seconds, so that changes of the files and the own presence go on. - An error of a look is thrown here once, after the next look started. +- While the window writes the files (`saving`, see `_without_looks`) no look starts. """ function _take_look!(gui::LiveView; now::Real = time()) s = gui.sync + # The window writes the files: a look would keep them open, see `_without_looks` + s.saving && return nothing dir = gui.project run = s.look if !isnothing(run) && !istaskdone(run.task) diff --git a/src/LiveView.jl b/src/LiveView.jl index e828869..dbbcf4b 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -636,8 +636,9 @@ What the view knows of the files of its project, which an agent may change while presence)` (see `_take_look!`), `look_at` the time it started, `gen` a counter that drops the looks started before it (`gen` of a look), and `stamp_at` the time a look last read the stamp of the contents of the files; `first_change_at` the time the files began to change since the last - merge, `adopting` whether a merge is being applied (its solves wait for its end) and `monitor` - the notifications of changes of the files, see `LiveProjectWatch.jl`; + merge, `adopting` whether a merge is being applied (its solves wait for its end), `saving` + whether the view writes the files (no look reads them meanwhile, see `_without_looks`) and + `monitor` the notifications of changes of the files, see `LiveProjectWatch.jl`; - `claim`: the claims of the person of the view and of the others, see `LiveProjectClaims.jl`. """ Base.@kwdef mutable struct _ProjectSync @@ -672,6 +673,7 @@ Base.@kwdef mutable struct _ProjectSync stamp_at::Float64 = 0.0 first_change_at::Union{Nothing, Float64} = nothing adopting::Bool = false + saving::Bool = false monitor::Any = nothing claim::Any = nothing end diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 36ee733..897a45e 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -797,6 +797,37 @@ end wait(s.look.task) s.look = nothing end + @testset "no look while the window writes the files" begin + # Windows can not replace system.jl while the window's own look reads it + slow = Threads.@spawn (sleep(0.2); GUI._files_look(dir)) + s.look = (; task = slow, gen = s.gen, presence = false) + started = Ref(true) + GUI._without_looks(gui) do + # the look that ran is done, and none starts meanwhile + @test istaskdone(slow) + started[] = !isnothing(GUI._take_look!(gui; now = t + 50)) || s.look.task !== slow + end + @test !started[] && !s.saving + # a look that takes longer is not waited for + stuck = Threads.@spawn sleep(5.0) + s.look = (; task = stuck, gen = s.gen, presence = false) + @test @elapsed(GUI._without_looks(() -> nothing, gui)) < 2.0 + wait(stuck) + s.look = nothing + end + @testset "a write that the file system refuses is busy and tried again" begin + file = system(dir) + translate3d!(gui, obj(gui, :M1), [0.0, 0.0, 0.01]) + # read-only: neither the replace nor the overwrite in place works + chmod(file, 0o444) + try + @test GUI._save_to!(gui, dir) === :busy + finally + chmod(file, 0o644) + end + @test GUI._save_to!(gui, dir) === :saved + @test occursin("pos = (0.0, 0.2, 0.01)", pos_line(dir, "M1")) + end @testset "the own presence file, written by the looks" begin withenv("BMO_PERSON" => "anna") do own = GUI._presence_file(dir, GUI._person().id) From 1a94841d355be7a2624fca90f43315f4736e833f Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 29 Oct 2026 23:31:49 +0100 Subject: [PATCH 28/47] A selection of several reaches the others: presence and session files list every object and the windows box each; session files date their actions and say how to tell a window that was not closed; skill: docstrings with REPL Co-Authored-By: Claude Opus 5.5 --- skills/beamletopticsgui/API.md | 2 +- skills/beamletopticsgui/SKILL.md | 2 +- skills/beamletopticsgui/WIDGETS.md | 2 +- src/LiveProjectPresence.jl | 73 ++++++++++++++++++++++-------- src/LiveProjectSession.jl | 47 +++++++++++-------- test/TestLiveProjectPresence.jl | 10 ++++ test/TestLiveProjectSession.jl | 13 +++++- 7 files changed, 106 insertions(+), 43 deletions(-) diff --git a/skills/beamletopticsgui/API.md b/skills/beamletopticsgui/API.md index 01c3b02..2b69982 100644 --- a/skills/beamletopticsgui/API.md +++ b/skills/beamletopticsgui/API.md @@ -16,7 +16,7 @@ script = export_script(gui) # the whole setup as a script (constructors of catal ``` Look up the docstring of any name before use, e.g. -`julia --project= -e 'using BeamletOpticsGUI; display(@doc live_view)'`. Do not invent keywords. +`julia --project= -e 'using REPL, BeamletOpticsGUI; display(@doc live_view)'`. Do not invent keywords. ## Exported names (by category) diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index a08b39e..170aa77 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -22,7 +22,7 @@ When this Skill is active: confirm them with the docstrings and tell the user that `using BeamletOpticsGUI; BeamletOpticsGUI.install_agent_skill()` installs the matching skill. - Use only documented API. **Do not invent functions or keywords.** Print the docstring before use: - `julia --project= -e 'using BeamletOpticsGUI; display(@doc live_view)'`, or list the exports with + `julia --project= -e 'using REPL, BeamletOpticsGUI; display(@doc live_view)'`, or list the exports with `names(BeamletOpticsGUI)`. - Every script starts with `using BeamletOptics, BeamletOpticsGUI, GLMakie`. The order makes a difference: GLMakie is loaded last. The window works in any order, but with GLMakie loaded before diff --git a/skills/beamletopticsgui/WIDGETS.md b/skills/beamletopticsgui/WIDGETS.md index 0aba386..5811ded 100644 --- a/skills/beamletopticsgui/WIDGETS.md +++ b/skills/beamletopticsgui/WIDGETS.md @@ -4,7 +4,7 @@ Recipes for `gui = live_view(...)` of BeamletOpticsGUI (`using BeamletOptics, Be needs a window, not for headless scripts). Use them when adding a component type whose parameters should be editable in the window, or a GUI extension. Everything works identically in `layout = :compact` and `layout = :app`. Look up any signature before use with -`julia --project= -e 'using BeamletOpticsGUI; display(@doc BeamletOpticsGUI.CardRow)'`. +`julia --project= -e 'using REPL, BeamletOpticsGUI; display(@doc BeamletOpticsGUI.CardRow)'`. All card functions (`card_rows`, `card_actions`, `card_input`, `card_show!`) belong to BeamletOpticsGUI: extend them as `BeamletOpticsGUI.card_rows(x::MyType)`, never as diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index 1fbc8e2..e8e458a 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -8,7 +8,9 @@ The file holds the name and host of the participant, its `kind` (`"person"` for for a coding agent; default `"person"`), a counter `beat` that grows with every write, `ttl` (the seconds that a beat counts; windows write `_ALIVE`, agents e.g. 600, since they write only when they act), the object it works on (`selected`: its uid, `selected_id`: its readable id; `""` if -nothing; the readable id counts when the uid is missing or unknown, an agent writes only that), +nothing; of a selection of several all of them in `selected_uids` and `selected_ids`, the first also +in `selected` and `selected_id`; the readable id counts when the uid is missing or unknown, an agent +writes only that), of a window the keys of the lines that its person claims (`claimed`, see `LiveProjectClaims.jl`), optionally `doing` (a few words, shown under the name) and the stamp of the files after its last write (`written`; of a window: its last save, never files it merged). A window writes its file when it saves, when its selection changes and every @@ -44,22 +46,31 @@ _presence_file(dir::AbstractString, id::AbstractString) = BMOP.presence_file(dir """ The machine id and the readable id of the selected object of the `gui`, `("", "")` if none: of the innermost item with an id that holds the selection, e.g. a group (`@group`), a member of a group, or -the component of a selected part of it. +the component of a selected part of it; of a selection of several the first object, see +`_presence_selection`. """ function _presence_selected(gui::LiveView) + uids, ids = _presence_selection(gui) + return isempty(ids) ? ("", "") : (first(uids), first(ids)) +end + +""" +The machine ids and readable ids of the selected objects of the `gui`, `(uids, ids)`: of each +object of a selection of several, else of the one selected (see `_presence_selected`); empty if +nothing with an id is selected. +""" +function _presence_selection(gui::LiveView) sel = gui.controls.selected[] - isnothing(sel) && return ("", "") - chain = try - _chain(gui.controls, sel) - catch - Any[sel] - end - for x in chain - haskey(gui.sync.ids, x) || continue + objs = sel isa _MultiSelection ? sel.objects : isnothing(sel) ? Any[] : Any[sel] + uids, ids = String[], String[] + for o in objs + x = _id_holder(gui, o) + isnothing(x) && continue id, uid = gui.sync.ids[x] - return (uid, String(id)) + push!(uids, uid) + push!(ids, String(id)) end - return ("", "") + return uids, ids end """ @@ -90,9 +101,11 @@ window, written by `_write_presence_file`, also in the background. """ function _presence_data(gui::LiveView) me = _person() - uid, id = _presence_selected(gui) + uids, ids = _presence_selection(gui) + uid, id = isempty(ids) ? ("", "") : (first(uids), first(ids)) data = Dict{String, Any}("name" => me.name, "host" => me.host, "kind" => "person", "ttl" => _ALIVE, - "selected" => uid, "selected_id" => id, "claimed" => _claimed_keys(gui)) + "selected" => uid, "selected_id" => id, "selected_uids" => uids, "selected_ids" => ids, + "claimed" => _claimed_keys(gui)) own = get(_OWN_SAVE, gui, nothing) return (; id = me.id, data, own = !isnothing(own) && own == gui.sync.stamp ? own : nothing) end @@ -210,8 +223,9 @@ function _check_presence!(gui::LiveView; now::Real = time(), look = nothing) old = get(s.others, id, nothing) seen_at = isnothing(old) || old.beat != beat ? Float64(now) : old.seen_at s.others[id] = (; name = data["name"], kind = data["kind"], selected_uid = data["selected"], - selected_id = data["selected_id"], doing = first(data["doing"], _DOING_CHARS), written = data["written"], - ttl = data["ttl"], seen_at, beat) + selected_id = data["selected_id"], selected_uids = _string_list(get(data, "selected_uids", nothing)), + selected_ids = _string_list(get(data, "selected_ids", nothing)), + doing = first(data["doing"], _DOING_CHARS), written = data["written"], ttl = data["ttl"], seen_at, beat) end # A file that is skipped (it does not parse) keeps its participant as it was; a file that is gone drops it for id in collect(keys(s.others)) @@ -248,6 +262,23 @@ function _object_of_other(gui::LiveView, o) return isnothing(obj) ? _object_of_id(gui, o.selected_id) : obj end +# The objects of the view that the other `o` has selected: those of a selection of several +# (`selected_uids`, `selected_ids` of its file, each by uid, else by readable id), else the one of +# `_object_of_other` +function _objects_of_other(gui::LiveView, o) + isempty(o.selected_ids) && return (obj = _object_of_other(gui, o); isnothing(obj) ? Any[] : Any[obj]) + objs = Any[] + for (i, id) in enumerate(o.selected_ids) + uid = get(o.selected_uids, i, "") + obj = something(_object_of_uid(gui, uid), _object_of_id(gui, id), Some(nothing)) + isnothing(obj) || _has(objs, obj) || push!(objs, obj) + end + return objs +end + +# The strings of a list field of a presence file, `String[]` if it is missing or not a list +_string_list(x) = x isa AbstractVector ? String[s for s in x if s isa AbstractString] : String[] + # The label of the other `o`: its name, with what it is doing in a second line _other_label(o) = isempty(o.doing) ? o.name : o.name * "\n" * o.doing @@ -262,10 +293,14 @@ function _presence_shown(gui::LiveView, now::Real) o = get(gui.sync.others, id, nothing) objs = get(claimed, id, Any[]) if !isnothing(o) && _alive(o, now) - obj = _object_of_other(gui, o) - if !isnothing(obj) && !_has(objs, obj) + # the name at the first object of a selection of several + label = _other_label(o) + for obj in _objects_of_other(gui, o) + _has(objs, obj) && continue bb = _part_box(gui, obj) - isnothing(bb) || push!(shown, (_bbox_wireframe(bb), _other_label(o), false)) + isnothing(bb) && continue + push!(shown, (_bbox_wireframe(bb), label, false)) + label = "" end end for obj in objs diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl index 5a7c00a..e5142a3 100644 --- a/src/LiveProjectSession.jl +++ b/src/LiveProjectSession.jl @@ -97,33 +97,37 @@ end """ The id, label, type and position of the selected object of the `gui`, or `nothing`: of the -innermost item with an id that holds the selection, as `_presence_selected`. +innermost item with an id that holds the selection, as `_presence_selected`; of a selection of +several, the number of objects and each of them. """ function _session_selected(gui::LiveView) sel = gui.controls.selected[] - isnothing(sel) && return nothing - chain = try - _chain(gui.controls, sel) - catch - Any[sel] + objs = sel isa _MultiSelection ? sel.objects : isnothing(sel) ? Any[] : Any[sel] + items = String[] + for o in objs + x = _id_holder(gui, o) + isnothing(x) || push!(items, _session_item(gui, x)) end - for x in chain - haskey(gui.sync.ids, x) || continue - id = gui.sync.ids[x][1] - text = String(id) - label = get(gui.labels, x, nothing) - isnothing(label) || isempty(label) || label == text || (text *= " \"$label\"") - text *= " ($(nameof(typeof(x))))" - try - text *= " at " * _session_pos(BMO.position(x)) - catch - end - return text + isempty(items) && return nothing + length(items) == 1 && return only(items) + return "$(length(items)) objects: " * join(items, "; ") +end + +# The id, label, type and position of the item `x` with an id of the `gui` +function _session_item(gui::LiveView, @nospecialize(x)) + text = String(gui.sync.ids[x][1]) + label = get(gui.labels, x, nothing) + isnothing(label) || isempty(label) || label == text || (text *= " \"$label\"") + text *= " ($(nameof(typeof(x))))" + try + text *= " at " * _session_pos(BMO.position(x)) + catch end - return nothing + return text end -_session_clock(t) = Libc.strftime("%H:%M:%S", t) +# With the day: a session file of a window that was not closed may be read days later +_session_clock(t) = Libc.strftime("%Y-%m-%d %H:%M:%S", t) # The readable id of the entry, group or system of a conflict `key` (a uid, or "id:L1") function _conflict_id(gui::LiveView, key::String) @@ -164,6 +168,9 @@ function _session_text(gui::LiveView; kept = _closed_kept(gui)) io = IOBuffer() println(io, _session_header(gui.project)) println(io, "Written by the open BeamletOpticsGUI window: what the person sees and did there.") + println(io, "While the window is open, its presence file .bmo/presence-$(_person().id).toml changes at") + println(io, "least every $(round(Int, _BEAT)) s; if it is older than $(round(Int, _ALIVE)) s or gone, the window was not closed") + println(io, "properly and this file is out of date (`look` marks such a participant \"likely gone\").") println(io, "system.jl holds the state of the window (it saves every finished action), except the objects") println(io, "of an open conflict, which keep the state of the file until the person decides, those that") println(io, "another participant claims, until the claim is released, and while the files do not load:") diff --git a/test/TestLiveProjectPresence.jl b/test/TestLiveProjectPresence.jl index e9197fd..5d3af0b 100644 --- a/test/TestLiveProjectPresence.jl +++ b/test/TestLiveProjectPresence.jl @@ -175,6 +175,16 @@ labels(gui) = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text @test all(p -> p.visible[], gui.sync.presence) end + @testset "an other with a selection of several" begin + other!(dir, "bob@PC9"; name = "Bob", beat = 2, selected = uid(gui, :M1), selected_id = "M1", + selected_uids = [uid(gui, :M1), uid(gui, :L1)], selected_ids = ["M1", "L1"]) + GUI._check_presence!(gui; now = 503.0) + @test gui.sync.others["bob@PC9"].selected_ids == ["M1", "L1"] + # a box at each, the name at the first; Carl at L1 too + @test sort(labels(gui)) == ["", "Bob", "Carl"] + @test length(boxes(gui)) == 3 + end + @testset "writer" begin other!(dir, "bob@PC9"; name = "Bob", beat = 10, written = "stamp-b") other!(dir, "carl@PC8"; name = "Carl", beat = 10, written = "stamp-c") diff --git a/test/TestLiveProjectSession.jl b/test/TestLiveProjectSession.jl index ac77699..33f1c0f 100644 --- a/test/TestLiveProjectSession.jl +++ b/test/TestLiveProjectSession.jl @@ -61,6 +61,16 @@ session(dir) = read(GUI._session_file(dir), String) gui.controls.selected[] = obj(gui, :L1) @test occursin("Selected: L1 \"Kollimator\" (Lens)", session(dir)) @test !isfile(file * ".tmp") + # how to tell a file of a window that was not closed + @test occursin("presence-$(p.id).toml changes at", text) + # a selection of several: each object, in the session and the presence file + gui.controls.selected[] = GUI._MultiSelection(Any[obj(gui, :L1), obj(gui, :M1)]) + @test occursin(r"Selected: 2 objects: L1 \"Kollimator\" \(Lens\) at [^;]*; M1 \(", session(dir)) + data = GUI._presence_data(gui).data + @test data["selected_ids"] == ["L1", "M1"] && data["selected_id"] == "L1" + @test length(data["selected_uids"]) == 2 && data["selected"] == data["selected_uids"][1] + gui.controls.selected[] = nothing + @test GUI._presence_data(gui).data["selected_ids"] == String[] end @testset "conflict and failure" begin @@ -114,7 +124,8 @@ session(dir) = read(GUI._session_file(dir), String) @test any(l -> startswith(l, "added M4 = RoundPlanoMirror("), lines) text = session(dir) @test occursin("Recent changes by the person (newest first):", text) - @test occursin(r"- \d\d:\d\d:\d\d moved L1 to \(0\.0, 0\.1051, 0\.0\)", text) + # with the day: a file of a window that was not closed may be read days later + @test occursin(r"- \d{4}-\d\d-\d\d \d\d:\d\d:\d\d moved L1 to \(0\.0, 0\.1051, 0\.0\)", text) # newest first @test findfirst("added M4", text).start < findfirst("moved L1", text).start From cbff47ecd8323d7747259919236d5e835fcbe0ed Mon Sep 17 00:00:00 2001 From: Hugo Uittenbosch Date: Wed, 4 Nov 2026 11:10:05 +0100 Subject: [PATCH 29/47] The window says when the packages of its project change; people and agents run a project in .envs/@; AGENTS.md: Pkg reads [sources] of path-tracked dependencies Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- skills/beamletopticsgui/SKILL.md | 5 ++ src/BeamletOpticsGUI.jl | 1 + src/LiveProjectPackages.jl | 115 +++++++++++++++++++++++++++++++ src/LiveProjectWatch.jl | 16 +++-- test/TestLiveProjectPackages.jl | 91 ++++++++++++++++++++++++ test/runtests.jl | 1 + 7 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 src/LiveProjectPackages.jl create mode 100644 test/TestLiveProjectPackages.jl diff --git a/AGENTS.md b/AGENTS.md index 049e72f..81ebf4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,8 +169,9 @@ describe it as available in docs or the skill. ## Releases -The users of a registered version get BeamletOptics from the registry: Pkg reads `[sources]` only -in the active project, never in a dependency. The entries stay in the registered version; the +The users of a registered version get BeamletOptics from the registry: Pkg reads `[sources]` of +the active project and of dependencies tracked by path, never of a registered dependency (checked +on Julia 1.13). The entries stay in the registered version; the docs of a tag remove them and use the registered BMO. BMO and the GUI are released together: the latest registered GUI works with the latest registered BMO. diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index 170aa77..5a65345 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -123,6 +123,11 @@ When this Skill is active: of one change of the files glide together; a group moved as a whole glides as one. The table fits the setup again shortly after every change (a drag, a key step, undo, a removal, a change of the files). How to work on the files is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). + People and agents run a project in the package environment of their person and machine, + `julia --project=.envs/@` in its folder (`personal_environment!` of + BeamletOpticsProjects makes it), never with the folder itself as Julia's project. When the + packages of the project change (`[deps]`, `[compat]` or `[sources]` of its `Project.toml`), the + window says so; it keeps its loaded packages until it is started again. - A group or a selection of several rotates about its pivot, a dot (dashed line to the gizmo) while it is selected: the mean of the objects for one made in the window or a selection, the pivot of the script for a group of the script (BeamletOptics starts it at the origin). The row "pivot" of the diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 1dd2125..b98649b 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -114,6 +114,7 @@ include("LiveGroupKeys.jl") include("LiveProject.jl") include("LiveProjectSync.jl") include("LiveProjectWatch.jl") +include("LiveProjectPackages.jl") include("LiveProjectGlide.jl") include("LiveProjectSession.jl") include("LiveProjectPresence.jl") diff --git a/src/LiveProjectPackages.jl b/src/LiveProjectPackages.jl new file mode 100644 index 0000000..504c6c7 --- /dev/null +++ b/src/LiveProjectPackages.jl @@ -0,0 +1,115 @@ +#= +The packages of the project of a window: each person and machine runs a project in a package +environment of its own, `.envs/@/` (see `BeamletOpticsProjects.personal_environment!`), +which has the packages that the project's `Project.toml` names. When someone changes those (e.g. +`] add` of a package that the project needs), the window says so: the person updates the +environment and starts again. Nothing here loads or reloads a package. +=# + +""" + _package_marks(dir) -> Vector{Tuple{String, Float64, Int}} + +The time and size of the `Project.toml` of the project in `dir` (if it exists), like `_file_marks`; +read by each look of the watcher, see `_files_look`. +""" +function _package_marks(dir::AbstractString) + file = joinpath(dir, "Project.toml") + return isfile(file) ? [("Project.toml", mtime(file), filesize(file))] : Tuple{String, Float64, Int}[] +end + +""" + _packages_key(dir) -> Union{Nothing, Dict} + +What the `Project.toml` of the project in `dir` says about its packages: its `[deps]`, `[compat]` +and `[sources]`; `nothing` if it is missing or does not parse (e.g. while it is written). +""" +function _packages_key(dir::AbstractString) + file = joinpath(dir, "Project.toml") + isfile(file) || return nothing + toml = try + TOML.parsefile(file) + catch e + e isa InterruptException && rethrow() + return nothing + end + return Dict{String, Any}(k => get(toml, k, nothing) for k in ("deps", "compat", "sources")) +end + +""" + _PackageState + +What a window knows about the packages of its project: the folder `dir`, the packages at its start +(`key`, see `_packages_key`), the `marks` of the last look and the time `changed_at` they changed +(`nothing` once settled), and the packages it noticed last (`noticed`), so that a change is shown +once. +""" +mutable struct _PackageState + dir::String + key::Union{Nothing, Dict{String, Any}} + marks::Vector{Tuple{String, Float64, Int}} + changed_at::Union{Nothing, Float64} + noticed::Union{Nothing, Dict{String, Any}} +end + +const _PACKAGES = WeakKeyDict{LiveView, _PackageState}() + +""" + _package_state(gui) -> Union{Nothing, _PackageState} + +The packages of the project of the `gui` as it started with them (noted at the first call, e.g. +when its watcher starts); `nothing` without a project. +""" +function _package_state(gui::LiveView) + dir = gui.project + isnothing(dir) && return nothing + st = get(_PACKAGES, gui, nothing) + (isnothing(st) || st.dir != dir) || return st + st = _PackageState(String(dir), _packages_key(dir), _package_marks(dir), nothing, nothing) + _PACKAGES[gui] = st + return st +end + +"""The info card that the packages of the project changed, see `_check_packages!`.""" +struct _PackageNotice <: _InfoItem + point::Point3f +end + +_points(x::_PackageNotice) = [x.point] +_title(::_PackageNotice) = "Packages changed" +_tree_kind(::_PackageNotice) = :warning +_keep_plots!(::LiveView, ::_PackageNotice) = nothing +_keepable(::_PackageNotice) = false +card_rows(::_PackageNotice) = ( + _text_row("what", :packages_what, (_, _) -> "the packages of the project changed"; width = 48), + _text_row("take", :packages_take, (_, _) -> "personal_environment!(\".\"), then start again"; width = 48)) + +const _PACKAGE_NOTICE = "packages of the project changed: personal_environment!(\".\") and start again to take them" + +""" + _check_packages!(gui, marks; now = time()) -> Symbol + +One look of the watcher at the `Project.toml` of the project of the `gui` with its `marks` (see +`_package_marks`): `:pending` while it changes or has not stayed unchanged for `_SETTLE` seconds, +then `:noticed` once per new state of its packages (`[deps]`, `[compat]`, `[sources]`) that +differs from the one the window started with, shown in the status line and on an info card (see +`_PackageNotice`); `:none` otherwise, e.g. for an edit of another part of it. The window keeps +its loaded packages: nothing is loaded. +""" +function _check_packages!(gui::LiveView, marks; now::Real = time()) + st = _package_state(gui) + isnothing(st) && return :none + if marks != st.marks + st.marks = marks + st.changed_at = Float64(now) + return :pending + end + isnothing(st.changed_at) && return :none + now - st.changed_at >= _SETTLE || return :pending + st.changed_at = nothing + key = _packages_key(st.dir) + (isnothing(key) || key == st.key || key == st.noticed) && return :none + st.noticed = key + gui.status.text[] = _PACKAGE_NOTICE + _show_info!(gui, _PackageNotice(_error_anchor(gui))) + return :noticed +end diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl index 3c70197..3adc031 100644 --- a/src/LiveProjectWatch.jl +++ b/src/LiveProjectWatch.jl @@ -3,7 +3,8 @@ The watcher of the files of a project of a shown window: it looks at the files i (see `_take_look!`), merges a change of them once they settled, or at the latest every `_MERGE_EVERY` seconds while they keep changing (`_check_files!`, `_sync!`), and calls the autosave and the presence; see `LiveProjectSync.jl`. Notifications of the file system (`_FileNotes`) start a -look at once; the polling stays as a safety net +look at once; the polling stays as a safety net. The looks also mark the `Project.toml`, whose +changed packages the window notices, see `LiveProjectPackages.jl` =# import FileWatching @@ -54,10 +55,11 @@ _src_marks(marks) = filter(m -> m[1] != "system.jl", marks) """ _files_look(dir; stamp = true, except = nothing, known = String[], presence = nothing, - cache = nothing) -> (; dir, marks, stamp, presences, known, present, presence_written) + cache = nothing) -> (; dir, marks, packages, stamp, presences, known, present, presence_written) What one look of the watcher reads of the project folder `dir`: the times and sizes of its files -(`_file_marks`), the stamp of their contents if `stamp` (else `nothing`), the presence files of the +(`_file_marks`) and of its `Project.toml` (`_package_marks`), the stamp of their contents if +`stamp` (else `nothing`), the presence files of the others without the id `except` (`BeamletOpticsProjects.read_presences`, with the `cache` of the files read before) and of the ids `known` those whose presence file is still there (`present`), also if it does not parse. Writes the own presence file first if `presence` (see @@ -69,11 +71,12 @@ function _files_look(dir::AbstractString; stamp::Bool = true, except = nothing, presence = nothing, cache = nothing) presence_written = isnothing(presence) || _write_presence_file(dir, presence) marks = _file_marks(dir) + packages = _package_marks(dir) st = stamp ? BMOP.project_stamp(dir) : nothing presences = BMOP.read_presences(dir; except, cache) known = Set{String}(known) present = Set{String}(id for id in known if isfile(_presence_file(dir, id))) - return (; dir = String(dir), marks, stamp = st, presences, known, present, presence_written) + return (; dir = String(dir), marks, packages, stamp = st, presences, known, present, presence_written) end """ @@ -241,6 +244,8 @@ or of a script) is not watched and writes neither; the functions can be called i """ function _start_watching!(gui::LiveView; notes::Bool = true, poll::Real = _POLL_WITH_MONITOR) s = gui.sync + # The packages of the project as the window starts with them, see `LiveProjectPackages.jl` + _package_state(gui) (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing _connect_session!(gui) # The presence file is written by the looks of the watcher, see `_presence_due!` @@ -382,6 +387,9 @@ function _check_files!(gui::LiveView; now::Real = time(), look = nothing) dir = gui.project (isnothing(dir) || isnothing(s.base)) && return :none isnothing(look) && (look = _files_look(dir)) + # A change of the packages of the project is noticed apart from the setup + packages = get(look, :packages, nothing) + isnothing(packages) || _check_packages!(gui, packages; now) marks = look.marks if marks != s.marks s.marks = marks diff --git a/test/TestLiveProjectPackages.jl b/test/TestLiveProjectPackages.jl new file mode 100644 index 0000000..82cde01 --- /dev/null +++ b/test/TestLiveProjectPackages.jl @@ -0,0 +1,91 @@ +module TestLiveProjectPackages + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const TOML = GUI.TOML # the TOML of the package, not a dependency of the tests +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const START = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, 1.5) pos = (0.0, 0.1, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1] sources = [B1] + """ + +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + return gui, dir +end + +# Edits the Project.toml of the project in `dir` with `f(toml)` +function edit_project!(f, dir) + file = joinpath(dir, "Project.toml") + toml = TOML.parsefile(file) + f(toml) + open(io -> TOML.print(io, toml; sorted = true), file, "w") + # a new time of change also on file systems with coarse times + t = time() + 5 + Base.Filesystem.futime(Base.Filesystem.open(file, Base.Filesystem.JL_O_WRONLY), t, t) |> _ -> nothing +end + +look(gui, dir, now) = GUI._check_packages!(gui, GUI._package_marks(dir); now) + +@testset "The packages of the project of a window" begin + gui, dir = opened() + try + st = GUI._package_state(gui) + @test st.dir == dir && st.key == GUI._packages_key(dir) + @test look(gui, dir, 100.0) === :none + + @testset "another part of Project.toml: no notice" begin + edit_project!(t -> (t["version"] = "0.2.0"), dir) + @test look(gui, dir, 200.0) === :pending + @test look(gui, dir, 200.0 + GUI._SETTLE) === :none + @test isnothing(GUI._info_card(gui)) + end + + @testset "a package added to the project: noticed once" begin + edit_project!(t -> (t["deps"]["Dates"] = "ade2ca70-3891-5945-98fb-dc099432e06a"), dir) + loaded = length(Base.loaded_modules) + @test look(gui, dir, 300.0) === :pending + @test look(gui, dir, 300.0 + GUI._SETTLE / 2) === :pending + @test look(gui, dir, 300.0 + GUI._SETTLE) === :noticed + @test gui.status.text[] == GUI._PACKAGE_NOTICE + @test GUI._info_card(gui).obj isa GUI._PackageNotice + # nothing is loaded, and the same state is not shown again + @test length(Base.loaded_modules) == loaded + GUI._release_info!(gui, GUI._PackageNotice) + edit_project!(t -> (t["version"] = "0.3.0"), dir) + @test look(gui, dir, 400.0) === :pending + @test look(gui, dir, 400.0 + GUI._SETTLE) === :none + end + + @testset "through the looks of the watcher, apart from the setup" begin + stamp = gui.sync.stamp + edit_project!(t -> (t["compat"]["Dates"] = "1"), dir) + l = GUI._files_look(dir) + @test l.packages == GUI._package_marks(dir) + GUI._check_files!(gui; now = 500.0, look = l) + GUI._check_files!(gui; now = 500.0 + GUI._SETTLE, look = l) + @test gui.status.text[] == GUI._PACKAGE_NOTICE + @test gui.sync.stamp == stamp + end + finally + close(gui) + end +end + +@testset "No packages without a project" begin + gui = live_view(BeamletOptics.System(); const_kw...) + @test isnothing(GUI._package_state(gui)) + @test GUI._check_packages!(gui, Tuple{String, Float64, Int}[]) === :none +end + +end diff --git a/test/runtests.jl b/test/runtests.jl index a8bbfa8..8414417 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -49,6 +49,7 @@ include(joinpath(@__DIR__, "TestLiveProjectConflicts.jl")) include(joinpath(@__DIR__, "TestLiveProjectGlide.jl")) include(joinpath(@__DIR__, "TestLiveProjectLoad.jl")) include(joinpath(@__DIR__, "TestLiveProjectClaims.jl")) +include(joinpath(@__DIR__, "TestLiveProjectPackages.jl")) include(joinpath(@__DIR__, "TestLiveExportScript.jl")) include(joinpath(@__DIR__, "TestLiveScroll.jl")) include(joinpath(@__DIR__, "TestLiveSplitter.jl")) From 7ec83ace97f26931db2fa22c9e6ce51ba08daa3d Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 5 Nov 2026 00:09:00 +0100 Subject: [PATCH 30/47] Projects open from any environment: the window checks the versions of the project file when it opens it (a card for older or missing packages, a notice for a newer major version), Ctrl+N and the tool "New project" make an empty project, the presence file carries the versions of each participant; the packages of a project and their notice are gone Co-Authored-By: Claude Opus 5.5 --- ext/BeamletOpticsGUIGLMakieExt/session.jl | 61 +++++- skills/beamletopticsgui/SKILL.md | 11 +- src/BeamletOpticsGUI.jl | 2 +- src/LiveProject.jl | 146 +++++++++---- src/LiveProjectPackages.jl | 115 ---------- src/LiveProjectPresence.jl | 11 +- src/LiveProjectSync.jl | 2 +- src/LiveProjectVersions.jl | 245 ++++++++++++++++++++++ src/LiveProjectWatch.jl | 16 +- test/TestLiveHelp.jl | 2 +- test/TestLiveProjectNew.jl | 150 +++++++++++++ test/TestLiveProjectPackages.jl | 91 -------- test/TestLiveProjectVersions.jl | 214 +++++++++++++++++++ test/TestPrecompileSession.jl | 2 +- 14 files changed, 797 insertions(+), 271 deletions(-) delete mode 100644 src/LiveProjectPackages.jl create mode 100644 src/LiveProjectVersions.jl create mode 100644 test/TestLiveProjectNew.jl delete mode 100644 test/TestLiveProjectPackages.jl create mode 100644 test/TestLiveProjectVersions.jl diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index 0cf95f3..3466863 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -1148,9 +1148,12 @@ Opens a new project in the empty folder `dir`, with a repository of its own, in and works on it like a person with an agent: drags the lens, which the autosave writes to the files; changes the files like an agent, which the window merges and glides; `Ctrl+S` with the card of the version, closed by a typed message and `Enter`, by `Esc`, by the button "Cancel" and by the -button "Save version"; another participant (a presence file) works on the mirror, shown with its -name, and leaves; `Ctrl+G` groups the lens and the mirror, which the autosave writes as a line, and -an agent ungroups them in the file. The steps call the autosave and the watcher themselves, with times that do +button "Save version"; another participant (a presence file) with another version of BeamletOptics +works on the mirror, shown with its name, and leaves; `Ctrl+G` groups the lens and the mirror, which +the autosave writes as a line, and an agent ungroups them in the file; a gesture on an object that +another participant claims; a conflict decided on its card; a project saved with newer versions, +whose card of the versions is closed by "Cancel"; `Ctrl+N` in another window, which is not shown, +makes a new project, whose window the step draws and closes. The steps call the autosave and the watcher themselves, with times that do not depend on how long a step takes: the watcher of the window never starts, and the step of the agent waits until the mirror has glided. Returns the live view and its screen, which the caller closes. @@ -1248,8 +1251,13 @@ function _project_session(step; dir = mktempdir()) selected = "$(gui.sync.ids[mirror][2])" selected_id = "$(gui.sync.ids[mirror][1])" written = "" + + [versions] + BeamletOptics = "0.0.1" """) GUI._check_presence!(gui) + _check(startswith(gui.status.text[], "Anna: BeamletOptics 0.0.1, you: "), + "the other version of the other participant is not noted") frame!() _check(!isempty(gui.sync.presence), "the mirror of the other participant is not shown") rm(file) @@ -1335,6 +1343,53 @@ function _project_session(step; dir = mktempdir()) frame!() _check(isempty(gui.sync.conflicts), "\"mine\" did not end the conflict") end + step("project: open a project saved with newer versions, Cancel on the card") do + newer = mktempdir() + close(GUI.BMOP.LibGit2.init(newer)) + GUI.BMOP.new_project(newer; name = "Newer") + marker = GUI.BMOP.project_marker(newer) + data = GUI.TOML.parsefile(marker) + data["versions"]["BeamletOptics"] = "99.0.0" + open(io -> GUI.TOML.print(io, data), marker, "w") + GUI._open_checked!(gui, newer) + frame!() + card = GUI._versions_card(gui) + _check(!isnothing(card), "the card of the versions is not shown") + _click!(gui, _rect_center(GUI._card_widget(card, :versions_cancel))) + frame!() + _check(isnothing(GUI._versions_card(gui)), "Cancel did not close the card of the versions") + _check(!gui.sync.closed, "Cancel opened the project") + end + step("project: Ctrl+N, a new project") do + parent = mktempdir() + # The versions go into a repository of their own, see the step "project: open" + close(GUI.BMOP.LibGit2.init(parent)) + target = joinpath(parent, "New") + dialog = GUI._SAVE_DIALOG[] + GUI._SAVE_DIALOG[] = _ -> target + # A window that is not shown, whose new window is not shown either: a shown one would open a + # new window on the screen, here the invisible one shows it + old = live_view(BMO.System()) + GUI._DIALOG_TASK[] = nothing + new = try + _key!(old, Keyboard.n, Keyboard.left_control) + task = GUI._DIALOG_TASK[] + isnothing(task) ? nothing : fetch(task) + finally + GUI._SAVE_DIALOG[] = dialog + old.sync.closed || close(old) + end + _check(new isa GUI.LiveView && new.project == target && old.sync.closed, + "Ctrl+N did not open the new project") + s = _screen() + try + display(s, new.fig) + _frame!(new, s) + finally + close(new) + close(s) + end + end return gui, screen end diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index 3799066..6f5dcca 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -123,11 +123,12 @@ When this Skill is active: of one change of the files glide together; a group moved as a whole glides as one. The table fits the setup again shortly after every change (a drag, a key step, undo, a removal, a change of the files). How to work on the files is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). - People and agents run a project in the package environment of their person and machine, - `julia --project=.envs/@` in its folder (`personal_environment!` of - BeamletOpticsProjects makes it), never with the folder itself as Julia's project. When the - packages of the project change (`[deps]`, `[compat]` or `[sources]` of its `Project.toml`), the - window says so; it keeps its loaded packages until it is started again. + A project is a folder with a `system.jl`, not a Julia environment: open it from any environment + that has BeamletOptics, BeamletOpticsProjects and the GUI (`open_project(path)`, or `Ctrl+O` in + any window, e.g. of `live_view(System())`). `Ctrl+N` or the tool "New project" makes an empty project in a new or + empty folder (the dialog of "Save as") and opens it in place of the window, after writing what + is pending of the old project; from code: `BeamletOpticsProjects.new_project(dir)`, then + `open_project(dir)`. - A group or a selection of several rotates about its pivot, a dot (dashed line to the gizmo) while it is selected: the mean of the objects for one made in the window or a selection, the pivot of the script for a group of the script (BeamletOptics starts it at the origin). The row "pivot" of the diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 48df738..0158e9a 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -115,11 +115,11 @@ include("LiveGroupKeys.jl") include("LiveProject.jl") include("LiveProjectSync.jl") include("LiveProjectWatch.jl") -include("LiveProjectPackages.jl") include("LiveProjectGlide.jl") include("LiveProjectSession.jl") include("LiveProjectPresence.jl") include("LiveProjectClaims.jl") +include("LiveProjectVersions.jl") include("LivePivot.jl") include("LiveDebug.jl") include("LiveDebugHUD.jl") diff --git a/src/LiveProject.jl b/src/LiveProject.jl index f3994d1..0a50265 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -15,6 +15,7 @@ const _VIEW_TOML = "view.toml" const _PROJECT_HELP = _HelpSection["Edit" => [ _HelpEntry([_ctrl_cap(), "S"], "save a version"; combo = true), _HelpEntry([_ctrl_cap(), "Shift", "S"], "save as a new project with a name"; combo = true), + _HelpEntry([_ctrl_cap(), "N"], "new project with a name"; combo = true), _HelpEntry([_ctrl_cap(), "O"], "open a project (.bmo)"; combo = true), _HelpEntry([_ctrl_cap(), "R"], "reload the project from its files"; combo = true)]] @@ -33,7 +34,11 @@ cancelled. The native dialog of NativeFileDialog, replaced by the tests. """ const _OPEN_DIALOG = Ref{Any}(dir -> NativeFileDialog.pick_file(dir; filterlist = _BMO_FILTER)) -# The task of the last dialog of `Ctrl+S` or `Ctrl+O`, which runs after the key callback returned +# The icon of the tool "New project": `create_new_folder` of Material Symbols, see `LiveIcons.jl` +const _NEW_PROJECT_ICON = _svg_path("M560-320h80v-80h80v-80h-80v-80h-80v80h-80v80h80v80ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z") + +# The task of the last dialog of `Ctrl+S`, `Ctrl+N` or `Ctrl+O`, which runs after the key callback +# returned const _DIALOG_TASK = Ref{Any}(nothing) #= @@ -307,14 +312,11 @@ ignored. """ _collect_project(gui::LiveView, dir::AbstractString) = _project_parts(gui, dir).project -"""The packages whose versions a new project pins: BeamletOptics, the projects, the GUI, GLMakie.""" -function _project_packages() - mods = Module[BMO, BMOP, @__MODULE__] - glmakie = Base.PkgId(Base.UUID("e9467ef8-e4e7-5192-8a1a-b1aee30e663a"), "GLMakie") - m = get(Base.loaded_modules, glmakie, nothing) - isnothing(m) || push!(mods, m) - return mods -end +"""The packages whose versions a project records: BeamletOptics, the projects and the GUI.""" +_project_packages() = Module[BMO, BMOP, @__MODULE__] + +"""Whether the folder `dir` is a project: it has a `system.jl`, see `BeamletOpticsProjects`.""" +_is_project(dir::AbstractString) = isfile(joinpath(dir, "system.jl")) """ _apply_project_names!(gui, project; skip = String[], window = nothing) @@ -649,9 +651,14 @@ to them (an agent or the window of another person), notes the selection and the of the person in `.bmo/session-.md` and shows where the others work (see `LiveProjectPresence.jl`). `Ctrl+S` saves a version of the project (a git commit with a message), see [`save_project`](@ref). The components and sources from the catalog can be edited and copied. -`Ctrl+O` or the tool "Open" of a window opens a project chosen in a dialog and closes the window. +`Ctrl+O` or the tool "Open" of a window opens a project chosen in a dialog and closes the window; +`Ctrl+N` or the tool "New project" makes an empty project and opens it the same way. +Warns when the project was saved with newer or incompatible versions of the packages, or its own +code needs a package that is not installed (see `BeamletOpticsProjects.check_versions`); the +dialog of "Open" asks instead, see `_open_checked!`. """ function open_project(path::AbstractString; display::Bool = true, kwargs...) + _warn_versions(path) project = BMOP.load_project(path) gui = _open_from(project; kwargs...) if display @@ -687,8 +694,8 @@ _set_screen_title!(@nospecialize(screen), ::String) = nothing Saves the setup of the live view `gui` as the project in the folder `dir` and returns the path of its `system.jl`: each system with its members and sources, the objects and sources without a system, each with its constructor call and its pose, see `BeamletOpticsProjects`. A folder that is -not a project yet becomes one, with the versions of BeamletOptics, BeamletOpticsProjects, -BeamletOpticsGUI and GLMakie. How the view looks is written to `.bmo/view-.toml` (per person; `view.toml` only as the default of new people). An object whose +not a project yet (it has no `system.jl`) becomes one, with the versions of BeamletOptics, +BeamletOpticsProjects and BeamletOpticsGUI. How the view looks is written to `.bmo/view-.toml` (per person; `view.toml` only as the default of new people). An object whose constructor is not known, e.g. one that the script gave to `live_view`, is not saved: a warning and the status line name it. Each object, source and system keeps its readable id (e.g. `L1`) and its machine id (uid) across saves; a new one gets the next free id of its kind and a new uid, and its @@ -720,7 +727,7 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) from = gui.project save_as = !isnothing(from) && dir != from # A new folder of "save as" gets the own code and the module of the project - name = save_as && !isfile(joinpath(dir, "Project.toml")) ? BMOP.project_name(from) : + name = save_as && !_is_project(dir) ? BMOP.project_name(from) : BMOP.project_name(dir) # The objects that glide to a pose of the files land first: the save writes that pose _finish_glides!(gui) @@ -736,7 +743,7 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) # Before checking whether the folder is a project: the copy must not miss its module BMOP.copy_code(from, dir) end - new = !isfile(joinpath(dir, "Project.toml")) + new = !_is_project(dir) # Refuses a line that another participant claimed (see `BeamletOpticsProjects.claim!`) path = _without_looks(gui) do save_project(project; packages = _project_packages(), force, by = _person().id) @@ -906,25 +913,31 @@ function _ask_path(gui::LiveView, dialog::Ref) end """ - _save_target(path) -> (; dir, message) + _save_target(path; new = false) -> (; dir, message) The folder that "Save as" saves to, from the `path` of its dialog, a folder and a typed name (`.bmo` or not): the folder of the project whose project file the user picked (the dialog asked whether to overwrite it), else a new or empty folder with the name next to it. A `dir` of `nothing` and the reason in `message` for any other path, e.g. a folder of that name that exists already. +For "New project" (`new`, see `_new_key!`) only a new or empty folder: a picked project file is +refused too. """ -function _save_target(path::AbstractString) +function _save_target(path::AbstractString; new::Bool = false) path = abspath(path) file = basename(path) name = endswith(lowercase(file), BMOP.EXTENSION) ? file[1:(end - length(BMOP.EXTENSION))] : file parent = dirname(path) - isempty(strip(name)) && return (; dir = nothing, message = "project not saved: no name given") - isfile(joinpath(parent, name * BMOP.EXTENSION)) && isfile(joinpath(parent, "Project.toml")) && - return (; dir = parent, message = "") + refused = new ? "project not created" : "project not saved" + isempty(strip(name)) && return (; dir = nothing, message = "$refused: no name given") + # The project file of a project, whose folder is the project; a new project does not overwrite it + if isfile(joinpath(parent, name * BMOP.EXTENSION)) + new || return (; dir = parent, message = "") + return (; dir = nothing, message = "$refused: \"$parent\" is a project already") + end dir = joinpath(parent, name) (!ispath(dir) || (isdir(dir) && isempty(readdir(dir)))) && return (; dir, message = "") - return (; dir = nothing, - message = "project not saved: \"$dir\" exists already; to overwrite a project, pick its .bmo file") + hint = new ? "" : "; to overwrite a project, pick its .bmo file" + return (; dir = nothing, message = "$refused: \"$dir\" exists already$hint") end # The status line of a save while another participant writes the files, see `_save_to!` @@ -953,6 +966,69 @@ function _save_as_key!(gui::LiveView) return nothing end +""" +`Ctrl+N` and the tool "New project" of the `gui`: the dialog of "Save as" asks for a folder and a +name, and an empty project is made in a new or empty folder of that name there (see `_save_target` +with `new`, `BeamletOpticsProjects.new_project`, which saves its first version). What the autosave +has not written yet of the project of the `gui` is written first, without waiting (see +`_autosave!` with `force`); nothing is made while a conflict waits for a decision on its card, or +when that write fails or another participant writes the files. Then the new project opens in a new +window with the layout and the theme of the `gui` (if the `gui` is shown), and the `gui` and its +window close, like `_open_key!`. A cancelled dialog does nothing; a folder that exists and is not +empty, the project file of a project and an error keep the `gui`, the reason in the status line. +The task of the dialog (`_DIALOG_TASK`) returns the view of the new project, else `nothing`. +From code: `BeamletOpticsProjects.new_project(dir)`, then [`open_project`](@ref). +""" +function _new_key!(gui::LiveView) + _run_later() do + path = _ask_path(gui, _SAVE_DIALOG) + isnothing(path) && return nothing + target = _save_target(path; new = true) + dir = target.dir + if isnothing(dir) + gui.status.text[] = target.message + return nothing + end + # The changes of the person in the old project are not lost + if !isnothing(gui.project) + _conflict_open!(gui) && return nothing + r = _autosave!(gui; force = true) + if r === :busy + gui.status.text[] = "project not created: the files of the project are busy " * + "(another participant writes them, or the drive), try again" + return nothing + end + r === :failed && return nothing + # A merge of the write found a conflict: decided on its card first + _conflict_open!(gui) && return nothing + end + try + BMOP.new_project(dir; packages = _project_packages()) + # The skills of BeamletOptics and BeamletOpticsProjects come with the scaffold, the + # one of the GUI here, as for a project of "Save as" (see `save_project`) + Base.CoreLogging.with_logger(Base.CoreLogging.NullLogger()) do + install_agent_skill(joinpath(dir, ".claude", "skills")) + end + catch e + e isa InterruptException && rethrow() + @error "new_project failed" exception = (e, catch_backtrace()) + gui.status.text[] = "project not created: $(_message_line(e))" + return nothing + end + new = try + open_project(dir; display = _shown(gui), _window_kwargs(gui)...) + catch e + e isa InterruptException && rethrow() + @error "open_project failed" exception = (e, catch_backtrace()) + gui.status.text[] = "project created in $dir, not opened: $(_message_line(e))" + return nothing + end + _close_window!(gui) + return new + end + return nothing +end + """ `Ctrl+S` and the tool "Save" of the `gui`: saves a version of its project. What the autosave has not written yet is written to the files first, without waiting, and noted in the session file (see @@ -1106,31 +1182,23 @@ end `Ctrl+O` and the tool "Open" of the `gui`: opens the project of the project file (`*.bmo`) that the dialog picks in a new window with the layout and the theme of the `gui`, then closes the `gui` and its window. A cancelled dialog does nothing, an error shows its message in the status line and -keeps the `gui`. +keeps the `gui`. The versions of the project are checked first, see `_open_checked!`. """ function _open_key!(gui::LiveView) _run_later() do path = _ask_path(gui, _OPEN_DIALOG) isnothing(path) && return nothing - try - open_project(path; _window_kwargs(gui)...) - catch e - e isa InterruptException && rethrow() - @error "open_project failed" exception = (e, catch_backtrace()) - gui.status.text[] = "project not opened: $(_message_line(e))" - return nothing - end - _close_window!(gui) - return nothing + return _open_checked!(gui, path) end return nothing end """ -Connects `Ctrl+S`, `Ctrl+Shift+S` and `Ctrl+O` (`Cmd` on macOS) of the `gui` and the tools "Save", -"Save as" and "Open", see `_save_key!`, `_save_as_key!` and `_open_key!`, like `_connect_copy!`: by -the letters of the keys in the keyboard layout, before the camera, which takes `s` and `o`; left to -a focused text box, nothing in the spectator mode. `Esc` closes the card of a version. +Connects `Ctrl+S`, `Ctrl+Shift+S`, `Ctrl+N`, `Ctrl+O` and `Ctrl+R` (`Cmd` on macOS) of the `gui` +and the tools "Save", "Save as", "New project" and "Open", see `_save_key!`, `_save_as_key!`, +`_new_key!`, `_open_key!` and `_reload_key!`, like `_connect_copy!`: by the letters of the keys in +the keyboard layout, before the camera, which takes `s` and `o`; left to a focused text box, +nothing in the spectator mode. `Esc` closes the card of a version. """ function _connect_project!(gui::LiveView) scene = gui.ax.scene @@ -1141,9 +1209,11 @@ function _connect_project!(gui::LiveView) key = _layout_key(event.key) ctrl.ignore_keys() && return Consume(false) ctrl.spectator[] && return Consume(true) - key in (Keyboard.s, Keyboard.o, Keyboard.r) || return Consume(false) + key in (Keyboard.s, Keyboard.o, Keyboard.r, Keyboard.n) || return Consume(false) if key == Keyboard.o _open_key!(gui) + elseif key == Keyboard.n + _new_key!(gui) elseif key == Keyboard.r _reload_key!(gui) else @@ -1165,6 +1235,8 @@ function _connect_project!(gui::LiveView) tooltip = "Save a version ($(_ctrl_cap())+S)") add_tool!(_save_as_key!, gui, "Save as"; icon = :save_as, tooltip = "Save as a new project with a name ($(_ctrl_cap())+Shift+S)") + add_tool!(_new_key!, gui, "New project"; icon = _NEW_PROJECT_ICON, + tooltip = "New empty project with a name ($(_ctrl_cap())+N)") add_tool!(_open_key!, gui, "Open"; icon = :open, tooltip = "Open a project, its .bmo file ($(_ctrl_cap())+O)") return nothing diff --git a/src/LiveProjectPackages.jl b/src/LiveProjectPackages.jl deleted file mode 100644 index 504c6c7..0000000 --- a/src/LiveProjectPackages.jl +++ /dev/null @@ -1,115 +0,0 @@ -#= -The packages of the project of a window: each person and machine runs a project in a package -environment of its own, `.envs/@/` (see `BeamletOpticsProjects.personal_environment!`), -which has the packages that the project's `Project.toml` names. When someone changes those (e.g. -`] add` of a package that the project needs), the window says so: the person updates the -environment and starts again. Nothing here loads or reloads a package. -=# - -""" - _package_marks(dir) -> Vector{Tuple{String, Float64, Int}} - -The time and size of the `Project.toml` of the project in `dir` (if it exists), like `_file_marks`; -read by each look of the watcher, see `_files_look`. -""" -function _package_marks(dir::AbstractString) - file = joinpath(dir, "Project.toml") - return isfile(file) ? [("Project.toml", mtime(file), filesize(file))] : Tuple{String, Float64, Int}[] -end - -""" - _packages_key(dir) -> Union{Nothing, Dict} - -What the `Project.toml` of the project in `dir` says about its packages: its `[deps]`, `[compat]` -and `[sources]`; `nothing` if it is missing or does not parse (e.g. while it is written). -""" -function _packages_key(dir::AbstractString) - file = joinpath(dir, "Project.toml") - isfile(file) || return nothing - toml = try - TOML.parsefile(file) - catch e - e isa InterruptException && rethrow() - return nothing - end - return Dict{String, Any}(k => get(toml, k, nothing) for k in ("deps", "compat", "sources")) -end - -""" - _PackageState - -What a window knows about the packages of its project: the folder `dir`, the packages at its start -(`key`, see `_packages_key`), the `marks` of the last look and the time `changed_at` they changed -(`nothing` once settled), and the packages it noticed last (`noticed`), so that a change is shown -once. -""" -mutable struct _PackageState - dir::String - key::Union{Nothing, Dict{String, Any}} - marks::Vector{Tuple{String, Float64, Int}} - changed_at::Union{Nothing, Float64} - noticed::Union{Nothing, Dict{String, Any}} -end - -const _PACKAGES = WeakKeyDict{LiveView, _PackageState}() - -""" - _package_state(gui) -> Union{Nothing, _PackageState} - -The packages of the project of the `gui` as it started with them (noted at the first call, e.g. -when its watcher starts); `nothing` without a project. -""" -function _package_state(gui::LiveView) - dir = gui.project - isnothing(dir) && return nothing - st = get(_PACKAGES, gui, nothing) - (isnothing(st) || st.dir != dir) || return st - st = _PackageState(String(dir), _packages_key(dir), _package_marks(dir), nothing, nothing) - _PACKAGES[gui] = st - return st -end - -"""The info card that the packages of the project changed, see `_check_packages!`.""" -struct _PackageNotice <: _InfoItem - point::Point3f -end - -_points(x::_PackageNotice) = [x.point] -_title(::_PackageNotice) = "Packages changed" -_tree_kind(::_PackageNotice) = :warning -_keep_plots!(::LiveView, ::_PackageNotice) = nothing -_keepable(::_PackageNotice) = false -card_rows(::_PackageNotice) = ( - _text_row("what", :packages_what, (_, _) -> "the packages of the project changed"; width = 48), - _text_row("take", :packages_take, (_, _) -> "personal_environment!(\".\"), then start again"; width = 48)) - -const _PACKAGE_NOTICE = "packages of the project changed: personal_environment!(\".\") and start again to take them" - -""" - _check_packages!(gui, marks; now = time()) -> Symbol - -One look of the watcher at the `Project.toml` of the project of the `gui` with its `marks` (see -`_package_marks`): `:pending` while it changes or has not stayed unchanged for `_SETTLE` seconds, -then `:noticed` once per new state of its packages (`[deps]`, `[compat]`, `[sources]`) that -differs from the one the window started with, shown in the status line and on an info card (see -`_PackageNotice`); `:none` otherwise, e.g. for an edit of another part of it. The window keeps -its loaded packages: nothing is loaded. -""" -function _check_packages!(gui::LiveView, marks; now::Real = time()) - st = _package_state(gui) - isnothing(st) && return :none - if marks != st.marks - st.marks = marks - st.changed_at = Float64(now) - return :pending - end - isnothing(st.changed_at) && return :none - now - st.changed_at >= _SETTLE || return :pending - st.changed_at = nothing - key = _packages_key(st.dir) - (isnothing(key) || key == st.key || key == st.noticed) && return :none - st.noticed = key - gui.status.text[] = _PACKAGE_NOTICE - _show_info!(gui, _PackageNotice(_error_anchor(gui))) - return :noticed -end diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index e8e458a..6a09403 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -12,7 +12,8 @@ nothing; of a selection of several all of them in `selected_uids` and `selected_ in `selected` and `selected_id`; the readable id counts when the uid is missing or unknown, an agent writes only that), of a window the keys of the lines that its person claims (`claimed`, see `LiveProjectClaims.jl`), -optionally `doing` (a few words, shown under the name) and the stamp of the files after its last +the versions of BeamletOptics, BeamletOpticsProjects and the GUI (`versions`, see +`LiveProjectVersions.jl`), optionally `doing` (a few words, shown under the name) and the stamp of the files after its last write (`written`; of a window: its last save, never files it merged). A window writes its file when it saves, when its selection changes and every `_BEAT` seconds. It reads the files of the others at every look of the watcher: an other is there while its `beat` changed within its `ttl` seconds of the clock of this machine, so the clocks of @@ -78,7 +79,7 @@ end Writes the presence file of the person of the `gui` (see `_person`, `_presence_file`) atomically: its name and host, `kind = "person"`, `ttl = _ALIVE`, the `beat` one higher than in the file, the -selected object and `written`, the stamp of the files after the last save of the window (see +selected object, the versions of this session (see `_own_versions`) and `written`, the stamp of the files after the last save of the window (see `_own_written`). Sets `gui.sync.beat_at` to `now`. Does nothing without a project. An error of writing is no error: the file is written again with the next look. The watcher writes it in the background instead, see `_presence_due!`. @@ -105,7 +106,7 @@ function _presence_data(gui::LiveView) uid, id = isempty(ids) ? ("", "") : (first(uids), first(ids)) data = Dict{String, Any}("name" => me.name, "host" => me.host, "kind" => "person", "ttl" => _ALIVE, "selected" => uid, "selected_id" => id, "selected_uids" => uids, "selected_ids" => ids, - "claimed" => _claimed_keys(gui)) + "claimed" => _claimed_keys(gui), "versions" => _own_versions()) own = get(_OWN_SAVE, gui, nothing) return (; id = me.id, data, own = !isnothing(own) && own == gui.sync.stamp ? own : nothing) end @@ -204,7 +205,8 @@ updates `gui.sync.others`, people and agents alike. An other whose `beat` change one whose file is gone is dropped. One whose `beat` did not change for its `ttl` seconds stays in `others` but is not shown, so that the file of a window that crashed or of an agent that did not delete it does not come back. A merge whose status line says "agent" gets the name of the other -that published its stamp meanwhile (see `_name_writer!`). The claims go one step on (see +that published its stamp meanwhile (see `_name_writer!`). Another participant with other versions +is noted once in the status line (see `_versions_notice!`). The claims go one step on (see `_claims_tick!`: the own ones are written and those of the others read in the background). Then the boxes and names of the selections of the others are drawn again if what they show changed (see `_draw_presence!`). Does @@ -233,6 +235,7 @@ function _check_presence!(gui::LiveView; now::Real = time(), look = nothing) id in look.known && !(id in look.present) && delete!(s.others, id) end _name_writer!(gui, now) + _versions_notice!(gui, look, now) _claims_tick!(gui; now) _draw_presence!(gui, now) return nothing diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index bbc1f12..bbac275 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -212,7 +212,7 @@ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) else (_busy(gui) || !isempty(s.gliding)) && return :paused end - # The name of the files that the view agrees with, not read from `Project.toml` at every look + # The name of the files that the view agrees with, not read from the `.bmo` file at every look parts = _project_parts(gui, dir; name = s.base.name) # The person's version of the objects that the files keep is lost otherwise force && s.closed && !isempty(_kept_back(gui, parts.project)) && _write_backup!(gui, parts.project) diff --git a/src/LiveProjectVersions.jl b/src/LiveProjectVersions.jl new file mode 100644 index 0000000..e11be40 --- /dev/null +++ b/src/LiveProjectVersions.jl @@ -0,0 +1,245 @@ +#= +The versions of a project: its project file (`.bmo`) records the newest versions of +BeamletOptics, BeamletOpticsProjects and the GUI that it was saved with and the packages that its +code uses (see `BeamletOpticsProjects.check_versions`). The tool "Open" checks them against this +session before it opens a project (`_open_checked!`): the same or newer compatible versions open +silently, newer versions that may break it open with a notice in the status line, and older +versions or a package that is not installed show a card that asks first (`_VersionsCard`). +`open_project` from code warns instead (`_warn_versions`). The presence file of a window carries +its versions (`_own_versions`), and the window says once per participant and difference when +another participant works with other versions (`_versions_notice!`). +=# + +""" + _own_versions() -> Dict{String, String} + +The versions of BeamletOptics, BeamletOpticsProjects and the GUI in this session by name (see +`_project_packages`), for the presence file of a window: the others compare them with theirs, see +`_versions_notice!`. A package without a version is left out. +""" +function _own_versions() + out = Dict{String, String}() + for m in _project_packages() + v = pkgversion(m) + isnothing(v) || (out[string(nameof(m))] = string(v)) + end + return out +end + +""" + _version_checks(path) -> Vector{BeamletOpticsProjects.VersionCheck} + +The versions of the project of the folder or project file `path` compared with this session, see +`BeamletOpticsProjects.check_versions` (with the GUI). Empty if they can not be read, e.g. a project +file that does not parse: opening it reports that. +""" +function _version_checks(path::AbstractString) + try + return BMOP.check_versions(path; packages = Module[@__MODULE__]) + catch e + e isa InterruptException && rethrow() + @debug "live_view: the versions of the project were not checked" exception = e + return BMOP.VersionCheck[] + end +end + +# The checks that ask before the project opens (an older version, a package that is not +# installed) and those that only note it (a newer version that may break it) +const _ASKING = (:older, :missing) +const _NOTED = (:older, :missing, :breaking) + +# The versions in a text: `"?"` for an unknown one +_version_string(v) = isnothing(v) ? "?" : string(v) + +""" +The text of the check `c` of a version (see `BeamletOpticsProjects.VersionCheck`) on the card and in +the warnings, with the command that installs what the project needs: `] up X` for an older version, +`] add X` for a package that is not installed. +""" +function _check_text(c) + s = c.status + s === :older && return "saved with $(_version_string(c.recorded)), here $(_version_string(c.installed)): ] up $(c.package)" + s === :missing && return isnothing(c.recorded) ? "not installed here: ] add $(c.package)" : + "saved with $(c.recorded), not installed here: ] add $(c.package)" + s === :breaking && return "saved with $(_version_string(c.recorded)), here $(_version_string(c.installed)), which may break it" + return "saved with $(_version_string(c.recorded)), here $(_version_string(c.installed))" +end + +""" + _VersionsCard + +The card of `_open_checked!` before a project opens that was saved with newer versions than this +session has, or whose code uses a package that is not installed here (see +`BeamletOpticsProjects.check_versions`): an info card at `point` with a row per such package +(`checks`, of the status `:older`, `:missing` or `:breaking`) with the version that the project +recorded, the one here and the command that installs what it needs, and the buttons "Open anyway" +(see `_open_now!`) and "Cancel" (see `_cancel_open!`). The project of the folder or project file +`path` opens only by "Open anyway". +""" +struct _VersionsCard <: _InfoItem + point::Point3f + path::String + checks::Vector{Any} +end + +_points(x::_VersionsCard) = [x.point] +_title(::_VersionsCard) = "Saved with other versions" +_tree_kind(::_VersionsCard) = :warning +_keep_plots!(::LiveView, ::_VersionsCard) = nothing +_keepable(::_VersionsCard) = false +function card_rows(x::_VersionsCard) + # the names in a column as wide as the longest one + width = 8 * maximum(c -> length(c.package), x.checks; init = 5) + rows = Any[_text_row(c.package, Symbol(:versions_, i), (_, _) -> _check_text(c); width) + for (i, c) in enumerate(x.checks)] + push!(rows, CardRow( + CardWidget(Button; name = :versions_open, label = "Open anyway", + on = (gui, _, _) -> _open_anyway!(gui)), + CardWidget(Button; name = :versions_cancel, label = "Cancel", + on = (gui, _, _) -> _cancel_open!(gui)))) + return Tuple(rows) +end + +"""The card of the versions of the `gui` if it is shown (see `_VersionsCard`), else `nothing`.""" +function _versions_card(gui::LiveView) + c = _info_card(gui) + return (isnothing(c) || !(c.obj isa _VersionsCard)) ? nothing : c +end + +""" + _open_checked!(gui, path; checks = _version_checks(path)) -> Union{LiveView, Nothing} + +Opens the project of the folder or project file `path` from the window of the `gui`, e.g. by the +tool "Open" (`Ctrl+O`), after its versions were compared with this session (`checks`, see +`_version_checks`): the same or newer compatible versions open it at once, newer versions that may +break it (`:breaking`) open it with a notice in the status line of the new window (see +`_open_now!`). An older version or a package that is not installed (`:older`, `:missing`) show the +card of the versions (`_VersionsCard`) instead, which opens it by "Open anyway"; the status line +says so. Returns the view of the opened project, else `nothing`. +""" +function _open_checked!(gui::LiveView, path::AbstractString; checks = _version_checks(path)) + noted = Any[c for c in checks if c.status in _NOTED] + if any(c -> c.status in _ASKING, noted) + _show_info!(gui, _VersionsCard(_error_anchor(gui), String(path), noted)) + gui.status.text[] = "project not opened yet: it needs other versions, see the card" + return nothing + end + return _open_now!(gui, path, noted) +end + +# Whether `open_project` warns about the versions of the project: not when the window checked them +const _VERSIONS_SHOWN = Base.ScopedValues.ScopedValue(false) + +""" + _open_now!(gui, path, noted) -> Union{LiveView, Nothing} + +Opens the project of the folder or project file `path` in a new window with the layout and the +theme of the `gui` (see `open_project`, which does not warn about its versions then), notes the +checks `noted` of its versions in the status line of the new window (see `_check_text`), and closes +the `gui` and its window. An error shows its message in the status line and keeps the `gui`. +Returns the new view, else `nothing`. Never called inside an event callback of the window of the +`gui`, which it closes (see `_open_anyway!`). +""" +function _open_now!(gui::LiveView, path::AbstractString, noted) + new = try + Base.ScopedValues.with(_VERSIONS_SHOWN => true) do + open_project(path; _window_kwargs(gui)...) + end + catch e + e isa InterruptException && rethrow() + @error "open_project failed" exception = (e, catch_backtrace()) + gui.status.text[] = "project not opened: $(_message_line(e))" + return nothing + end + isempty(noted) || (new.status.text[] = new.status.text[] * "; " * _noted_text(noted)) + _close_window!(gui) + return new +end + +# The checks `noted` of the versions of a project in the status line, see `_open_now!` +_noted_text(noted) = "other versions: " * join(("$(c.package) $(_check_text(c))" for c in noted), "; ") + +""" +"Open anyway" on the card of the versions of the `gui` (see `_VersionsCard`): closes the card and +opens its project after the callback of the button returned, which the task `_DIALOG_TASK` returns. +""" +function _open_anyway!(gui::LiveView) + c = _versions_card(gui) + isnothing(c) && return nothing + x = c.obj + _release_info!(gui, _VersionsCard) + _run_later(() -> _open_now!(gui, x.path, x.checks)) + return nothing +end + +""" +"Cancel" on the card of the versions of the `gui` (see `_VersionsCard`): closes the card, the project +is not opened. +""" +function _cancel_open!(gui::LiveView) + c = _versions_card(gui) + isnothing(c) && return nothing + path = c.obj.path + _release_info!(gui, _VersionsCard) + gui.status.text[] = "project not opened: $path" + return nothing +end + +""" + _warn_versions(path) + +Warns for `open_project` from code when the project of the folder or project file `path` was saved +with newer versions than this session has, needs newer ones or a package that is not installed +(see `_version_checks`), with the command that installs it; one warning for all. Nothing when the +window checked them already (see `_open_checked!`). +""" +function _warn_versions(path::AbstractString) + _VERSIONS_SHOWN[] && return nothing + noted = [c for c in _version_checks(path) if c.status in _NOTED] + isempty(noted) && return nothing + @warn "the project was saved with other versions:\n" * + join(("$(c.package): $(_check_text(c))" for c in noted), "\n") + return nothing +end + +#= +The versions of the others +=# + +# Per view, the differences of the versions of the others that its status line noted: +# (id of the participant, package, its version) +const _VERSIONS_NOTED = WeakKeyDict{LiveView, Set{Tuple{String, String, String}}}() + +""" + _versions_notice!(gui, look, now) + +Notes in the status line of the `gui` once per participant and difference when another +participant that is there at `now` works with other versions of a package than this session (see +`_own_versions`), from the `versions` of the presence files of the `look` of the watcher (see +`_check_presence!`), e.g. `"Oliver: BeamletOptics 0.14.1, you: 0.14.0"`. A package that one of +both does not name is no difference. +""" +function _versions_notice!(gui::LiveView, look, now::Real) + mine = _own_versions() + noted = get!(Set{Tuple{String, String, String}}, _VERSIONS_NOTED, gui) + texts = String[] + for id in sort!(collect(keys(look.presences))) + o = get(gui.sync.others, id, nothing) + (isnothing(o) || !_alive(o, now)) && continue + theirs = get(look.presences[id], "versions", nothing) + theirs isa AbstractDict || continue + diffs = String[] + for name in sort!(collect(keys(theirs))) + v = theirs[name] + m = get(mine, name, nothing) + (v isa AbstractString && !isnothing(m) && v != m) || continue + key = (String(id), String(name), String(v)) + key in noted && continue + push!(noted, key) + push!(diffs, "$name $v, you: $m") + end + isempty(diffs) || push!(texts, "$(o.name): " * join(diffs, "; ")) + end + isempty(texts) || (gui.status.text[] = join(texts, " · ")) + return nothing +end diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl index 3adc031..d038976 100644 --- a/src/LiveProjectWatch.jl +++ b/src/LiveProjectWatch.jl @@ -3,8 +3,7 @@ The watcher of the files of a project of a shown window: it looks at the files i (see `_take_look!`), merges a change of them once they settled, or at the latest every `_MERGE_EVERY` seconds while they keep changing (`_check_files!`, `_sync!`), and calls the autosave and the presence; see `LiveProjectSync.jl`. Notifications of the file system (`_FileNotes`) start a -look at once; the polling stays as a safety net. The looks also mark the `Project.toml`, whose -changed packages the window notices, see `LiveProjectPackages.jl` +look at once; the polling stays as a safety net. =# import FileWatching @@ -55,11 +54,10 @@ _src_marks(marks) = filter(m -> m[1] != "system.jl", marks) """ _files_look(dir; stamp = true, except = nothing, known = String[], presence = nothing, - cache = nothing) -> (; dir, marks, packages, stamp, presences, known, present, presence_written) + cache = nothing) -> (; dir, marks, stamp, presences, known, present, presence_written) What one look of the watcher reads of the project folder `dir`: the times and sizes of its files -(`_file_marks`) and of its `Project.toml` (`_package_marks`), the stamp of their contents if -`stamp` (else `nothing`), the presence files of the +(`_file_marks`), the stamp of their contents if `stamp` (else `nothing`), the presence files of the others without the id `except` (`BeamletOpticsProjects.read_presences`, with the `cache` of the files read before) and of the ids `known` those whose presence file is still there (`present`), also if it does not parse. Writes the own presence file first if `presence` (see @@ -71,12 +69,11 @@ function _files_look(dir::AbstractString; stamp::Bool = true, except = nothing, presence = nothing, cache = nothing) presence_written = isnothing(presence) || _write_presence_file(dir, presence) marks = _file_marks(dir) - packages = _package_marks(dir) st = stamp ? BMOP.project_stamp(dir) : nothing presences = BMOP.read_presences(dir; except, cache) known = Set{String}(known) present = Set{String}(id for id in known if isfile(_presence_file(dir, id))) - return (; dir = String(dir), marks, packages, stamp = st, presences, known, present, presence_written) + return (; dir = String(dir), marks, stamp = st, presences, known, present, presence_written) end """ @@ -244,8 +241,6 @@ or of a script) is not watched and writes neither; the functions can be called i """ function _start_watching!(gui::LiveView; notes::Bool = true, poll::Real = _POLL_WITH_MONITOR) s = gui.sync - # The packages of the project as the window starts with them, see `LiveProjectPackages.jl` - _package_state(gui) (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing _connect_session!(gui) # The presence file is written by the looks of the watcher, see `_presence_due!` @@ -387,9 +382,6 @@ function _check_files!(gui::LiveView; now::Real = time(), look = nothing) dir = gui.project (isnothing(dir) || isnothing(s.base)) && return :none isnothing(look) && (look = _files_look(dir)) - # A change of the packages of the project is noticed apart from the setup - packages = get(look, :packages, nothing) - isnothing(packages) || _check_packages!(gui, packages; now) marks = look.marks if marks != s.marks s.marks = marks diff --git a/test/TestLiveHelp.jl b/test/TestLiveHelp.jl index 72c8132..db053b7 100644 --- a/test/TestLiveHelp.jl +++ b/test/TestLiveHelp.jl @@ -108,7 +108,7 @@ const GUI = BeamletOpticsGUI # the card ctrl = GUI._ctrl_cap() @test Set(_caps(sections)) == Set(["Esc", "Enter", "G", "M", "↑", "↓", "←", "→", "PgUp", "PgDn", "+", - "−", "Shift", "Bksp", ctrl, "Ctrl", "Z", "Y", "V", "1", "P", "Del", "C", "T", "Ins", "Tab", "S", "O", "R"]) + "−", "Shift", "Bksp", ctrl, "Ctrl", "Z", "Y", "V", "1", "P", "Del", "C", "T", "Ins", "Tab", "S", "O", "R", "N"]) # no key is listed twice; Esc is the key of the groups, a trace is cancelled by a button keys = [(e.keys, e.combo) for (_, entries) in sections for e in entries if !(e.keys[1] isa Pair)] @test allunique(keys) diff --git a/test/TestLiveProjectNew.jl b/test/TestLiveProjectNew.jl new file mode 100644 index 0000000..5f2954f --- /dev/null +++ b/test/TestLiveProjectNew.jl @@ -0,0 +1,150 @@ +module TestLiveProjectNew + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const BMOP = BeamletOpticsProjects +const GUI = BeamletOpticsGUI + +@testset "New project" begin + + # Each change is solved at once, see `TestLiveView.jl`; no card of a detector is pinned at start + const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + + _entry(name) = only(e for e in component_catalog() if e.name == name) + _defaults(entry) = String[GUI._catalog_string(p) for p in entry.params] + _built(name, strings = _defaults(_entry(name))) = GUI._catalog_component(_entry(name), strings) + + # The key that types the letter of `key` in the keyboard layout of this machine, see `_layout_key` + _typed(key) = first(k for k in Keyboard.Button.(Int(Keyboard.a):Int(Keyboard.z)) if GUI._layout_key(k) == key) + function _ctrl!(gui, key) + state = events(gui.ax.scene).keyboardstate + push!(state, Keyboard.left_control) + events(gui.ax.scene).keyboardbutton[] = Makie.KeyEvent(_typed(key), Keyboard.press) + delete!(state, Keyboard.left_control) + return nothing + end + + # A view with a mirror in a system and a source without one, from the catalog + function _setup() + sys = System() + gui = live_view(sys; const_kw...) + m = _built("Round mirror") + translate3d!(m.obj, [0.0, 0.1, 0.0]) + add_component!(gui, m.obj; system = sys, origin = m.origin, label = "M1", select = false) + b = _built("Beam") + add_component!(gui, b.obj; system = :none, origin = b.origin, label = "B1", select = false) + return (; gui, mirror = m.obj, beam = b.obj) + end + + path = Ref{Any}("") + calls = Ref(0) + dialog = GUI._SAVE_DIALOG[] + GUI._SAVE_DIALOG[] = dir -> (calls[] += 1; path[]) + # The view of the new project that the task of the dialog returns, else `nothing` + _new!(gui) = (_ctrl!(gui, Keyboard.n); fetch(GUI._DIALOG_TASK[])) + try + @testset "with a project: its pending changes first, then the new one" begin + parent = mktempdir() + t = _setup() + gui = t.gui + old = joinpath(parent, "bench") + save_project(gui, old) + # a change that the autosave has not written yet (the view is not shown) + translate3d!(gui, t.beam, [0.0, 0.0, 0.01]) + id = first(gui.sync.ids[t.beam]) + @test position(load_project(old)[id]) ≉ position(t.beam) + + # cancelled: nothing happens + path[] = "" + @test isnothing(_new!(gui)) + @test calls[] == 1 && gui.project == old && !gui.sync.closed + + # a folder that exists and is not empty is refused + full = joinpath(parent, "full") + mkpath(full) + write(joinpath(full, "notes.txt"), "x") + path[] = joinpath(parent, "full.bmo") + @test isnothing(_new!(gui)) + @test calls[] == 2 && gui.project == old && !gui.sync.closed + @test gui.status.text[] == "project not created: \"$full\" exists already" + @test readdir(full) == ["notes.txt"] + # ... and so is the project file of a project + path[] = joinpath(old, "bench.bmo") + @test isnothing(_new!(gui)) + @test gui.status.text[] == "project not created: \"$old\" is a project already" + # no name + path[] = joinpath(parent, ".bmo") + @test isnothing(_new!(gui)) + @test gui.status.text[] == "project not created: no name given" + @test gui.project == old && !gui.sync.closed + # the changes are still pending + @test position(load_project(old)[id]) ≉ position(t.beam) + + # a new name: the pending change is written, the empty project made and opened + path[] = joinpath(parent, "New bench") + new = _new!(gui) + dir = joinpath(parent, "New bench") + @test calls[] == 5 + @test position(load_project(old)[id]) ≈ position(t.beam) + @test isfile(joinpath(dir, "system.jl")) && isfile(joinpath(dir, "New bench.bmo")) + @test isfile(joinpath(dir, "src", "New_bench.jl")) + @test isdir(joinpath(dir, ".claude", "skills", "beamletopticsgui")) + @test [v.message for v in versions(dir)] == ["New project"] + p = load_project(dir) + @test p.name == "New_bench" && isempty(p.entries) && isempty(p.systems) + # the window shows the empty project, the old one is closed + @test new isa GUI.LiveView && new !== gui + @test new.project == dir && new.sync.base.path == dir + @test isempty(new.sync.ids) && isempty(new.beams.unassigned) + @test all(s -> isempty(s.objects), GUI._systems(new)) + @test new.status.text[] == "project New_bench opened from $dir" + @test gui.sync.closed + # the new project saves like any other + c = _built("Round mirror") + add_component!(new, c.obj; origin = c.origin, label = "M1", select = false) + save_project(new) + @test length(load_project(dir).entries) == 1 + close(new) + end + + @testset "without a project: opens the new one" begin + parent = mktempdir() + t = _setup() + gui = t.gui + # an empty folder is taken + mkpath(joinpath(parent, "empty")) + path[] = joinpath(parent, "empty.bmo") + new = _new!(gui) + dir = joinpath(parent, "empty") + @test new isa GUI.LiveView && new.project == dir && gui.sync.closed + @test isfile(joinpath(dir, "system.jl")) && isfile(joinpath(dir, "empty.bmo")) + close(new) + end + + @testset "tool, help and spectator mode" begin + parent = mktempdir() + t = _setup() + gui = t.gui + @test any(e -> occursin("new project", e.text), only(GUI._PROJECT_HELP).second) + # the spectator mode ignores the key + n = calls[] + gui.controls.spectator[] = true + _ctrl!(gui, Keyboard.n) + @test calls[] == n + gui.controls.spectator[] = false + # the tool does the same as the key + path[] = "" + GUI._new_key!(gui) + wait(GUI._DIALOG_TASK[]) + @test calls[] == n + 1 && !gui.sync.closed + close(gui) + end + finally + GUI._SAVE_DIALOG[] = dialog + end +end + +end diff --git a/test/TestLiveProjectPackages.jl b/test/TestLiveProjectPackages.jl deleted file mode 100644 index 92ef40a..0000000 --- a/test/TestLiveProjectPackages.jl +++ /dev/null @@ -1,91 +0,0 @@ -module TestLiveProjectPackages - -using GLMakie, BeamletOptics, BeamletOpticsGUI -using BeamletOpticsProjects -using Makie -using Test - -const GUI = BeamletOpticsGUI -const TOML = GUI.TOML # the TOML of the package, not a dependency of the tests -const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) - -const START = """ - @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, ConstantRefractiveIndex(1.5)) pos = (0.0, 0.1, 0.0) - @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) - @system S1 = [L1] sources = [B1] - """ - -function opened() - dir = mktempdir() - new_project(dir; name = "Bench") - write(joinpath(dir, "system.jl"), START) - gui = open_project(dir; display = false, const_kw...) - save_project(gui) - return gui, dir -end - -# Edits the Project.toml of the project in `dir` with `f(toml)` -function edit_project!(f, dir) - file = joinpath(dir, "Project.toml") - toml = TOML.parsefile(file) - f(toml) - open(io -> TOML.print(io, toml; sorted = true), file, "w") - # a new time of change also on file systems with coarse times - t = time() + 5 - Base.Filesystem.futime(Base.Filesystem.open(file, Base.Filesystem.JL_O_WRONLY), t, t) |> _ -> nothing -end - -look(gui, dir, now) = GUI._check_packages!(gui, GUI._package_marks(dir); now) - -@testset "The packages of the project of a window" begin - gui, dir = opened() - try - st = GUI._package_state(gui) - @test st.dir == dir && st.key == GUI._packages_key(dir) - @test look(gui, dir, 100.0) === :none - - @testset "another part of Project.toml: no notice" begin - edit_project!(t -> (t["version"] = "0.2.0"), dir) - @test look(gui, dir, 200.0) === :pending - @test look(gui, dir, 200.0 + GUI._SETTLE) === :none - @test isnothing(GUI._info_card(gui)) - end - - @testset "a package added to the project: noticed once" begin - edit_project!(t -> (t["deps"]["Dates"] = "ade2ca70-3891-5945-98fb-dc099432e06a"), dir) - loaded = length(Base.loaded_modules) - @test look(gui, dir, 300.0) === :pending - @test look(gui, dir, 300.0 + GUI._SETTLE / 2) === :pending - @test look(gui, dir, 300.0 + GUI._SETTLE) === :noticed - @test gui.status.text[] == GUI._PACKAGE_NOTICE - @test GUI._info_card(gui).obj isa GUI._PackageNotice - # nothing is loaded, and the same state is not shown again - @test length(Base.loaded_modules) == loaded - GUI._release_info!(gui, GUI._PackageNotice) - edit_project!(t -> (t["version"] = "0.3.0"), dir) - @test look(gui, dir, 400.0) === :pending - @test look(gui, dir, 400.0 + GUI._SETTLE) === :none - end - - @testset "through the looks of the watcher, apart from the setup" begin - stamp = gui.sync.stamp - edit_project!(t -> (t["compat"]["Dates"] = "1"), dir) - l = GUI._files_look(dir) - @test l.packages == GUI._package_marks(dir) - GUI._check_files!(gui; now = 500.0, look = l) - GUI._check_files!(gui; now = 500.0 + GUI._SETTLE, look = l) - @test gui.status.text[] == GUI._PACKAGE_NOTICE - @test gui.sync.stamp == stamp - end - finally - close(gui) - end -end - -@testset "No packages without a project" begin - gui = live_view(BeamletOptics.System(); const_kw...) - @test isnothing(GUI._package_state(gui)) - @test GUI._check_packages!(gui, Tuple{String, Float64, Int}[]) === :none -end - -end diff --git a/test/TestLiveProjectVersions.jl b/test/TestLiveProjectVersions.jl new file mode 100644 index 0000000..c2e0e7a --- /dev/null +++ b/test/TestLiveProjectVersions.jl @@ -0,0 +1,214 @@ +module TestLiveProjectVersions + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const BMOP = BeamletOpticsProjects +const TOML = GUI.TOML # the TOML of the package, not a dependency of the tests +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const START = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, ConstantRefractiveIndex(1.5)) pos = (0.0, 0.1, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1] sources = [B1] + """ + +const BMO_VERSION = pkgversion(BeamletOptics) + +""" +A project with `START` whose project file records the `versions` and `packages` in addition to +those of `new_project`. +""" +function project(; versions = Dict{String, String}(), packages = Dict{String, String}()) + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + file = BMOP.project_marker(dir) + data = TOML.parsefile(file) + merge!(get!(data, "versions", Dict{String, Any}()), versions) + merge!(get!(data, "packages", Dict{String, Any}()), packages) + open(io -> TOML.print(io, data), file, "w") + return dir +end + +# A version of BeamletOptics before the one here that is not compatible with it +breaking_below(v) = v.major >= 1 ? VersionNumber(v.major - 1, 0, 0) : VersionNumber(0, v.minor - 1, 0) + +# A window without a project, from which a project is opened (not shown: neither is the new one) +window() = live_view(System(); const_kw...) + +press!(gui, name) = (GUI._card_widget(GUI._versions_card(gui), name).clicks[] += 1; nothing) +card_texts(gui) = [GUI._card_widget(GUI._versions_card(gui), Symbol(:versions_, i)).text[] + for i in eachindex(GUI._versions_card(gui).obj.checks)] + +@testset "Versions of a project" begin + @test BMO_VERSION isa VersionNumber && (BMO_VERSION.major >= 1 || BMO_VERSION.minor >= 1) + + @testset "the same versions open silently" begin + dir = project() + @test all(c -> c.status === :same, GUI._version_checks(dir)) + gui = window() + new = @test_logs min_level = Base.CoreLogging.Warn GUI._open_checked!(gui, dir) + @test new isa GUI.LiveView && new.project == dir && gui.sync.closed + @test isnothing(GUI._versions_card(gui)) + @test new.status.text[] == "project Bench opened from $dir" + close(new) + end + + @testset "newer compatible versions open silently" begin + dir = project() + gui = window() + checks = [BMOP.VersionCheck("BeamletOptics", v"0.14.0", v"0.14.3", :newer)] + new = GUI._open_checked!(gui, dir; checks) + @test new isa GUI.LiveView && gui.sync.closed + @test new.status.text[] == "project Bench opened from $dir" + close(new) + end + + @testset "newer versions that may break it open with a notice" begin + old = breaking_below(BMO_VERSION) + dir = project(; versions = Dict("BeamletOptics" => string(old))) + @test first(GUI._version_checks(dir)).status === :breaking + gui = window() + # the window notes it, `open_project` does not warn again + new = @test_logs min_level = Base.CoreLogging.Warn GUI._open_checked!(gui, dir) + @test new isa GUI.LiveView && new.project == dir && gui.sync.closed + @test isnothing(GUI._versions_card(gui)) + @test new.status.text[] == "project Bench opened from $dir; other versions: BeamletOptics " * + "saved with $old, here $BMO_VERSION, which may break it" + close(new) + end + + @testset "an older version shows the card: Cancel" begin + dir = project(; versions = Dict("BeamletOptics" => "99.0.0")) + gui = window() + @test isnothing(GUI._open_checked!(gui, dir)) + c = GUI._versions_card(gui) + @test !isnothing(c) && !gui.sync.closed && isnothing(gui.project) + @test only(c.obj.checks).status === :older && c.obj.path == dir + @test card_texts(gui) == ["saved with 99.0.0, here $BMO_VERSION: ] up BeamletOptics"] + @test gui.status.text[] == "project not opened yet: it needs other versions, see the card" + press!(gui, :versions_cancel) + @test isnothing(GUI._versions_card(gui)) + @test !gui.sync.closed && isnothing(gui.project) + @test gui.status.text[] == "project not opened: $dir" + close(gui) + end + + @testset "an older version shows the card: Open anyway" begin + dir = project(; versions = Dict("BeamletOptics" => "99.0.0")) + gui = window() + @test isnothing(GUI._open_checked!(gui, dir)) + @test !gui.sync.closed + # opened in a task after the callback of the button, without a warning of `open_project` + press!(gui, :versions_open) + new = @test_logs min_level = Base.CoreLogging.Warn fetch(GUI._DIALOG_TASK[]) + @test new isa GUI.LiveView && new.project == dir && gui.sync.closed + @test new.status.text[] == "project Bench opened from $dir; other versions: BeamletOptics " * + "saved with 99.0.0, here $BMO_VERSION: ] up BeamletOptics" + close(new) + end + + @testset "a package that is not installed shows the card" begin + dir = project(; packages = Dict("NotInstalledAnywhere" => "1.2.0")) + checks = GUI._version_checks(dir) + @test last(checks).package == "NotInstalledAnywhere" && last(checks).status === :missing + gui = window() + @test isnothing(GUI._open_checked!(gui, dir)) + @test card_texts(gui) == ["saved with 1.2.0, not installed here: ] add NotInstalledAnywhere"] + press!(gui, :versions_cancel) + @test isnothing(GUI._versions_card(gui)) && !gui.sync.closed + close(gui) + end + + @testset "a path that is no project: the error of opening it" begin + dir = mktempdir() + # nothing recorded: nothing to compare + @test all(c -> c.status === :same, GUI._version_checks(dir)) + gui = window() + @test isnothing(GUI._open_checked!(gui, dir)) + @test isnothing(GUI._versions_card(gui)) && !gui.sync.closed + @test startswith(gui.status.text[], "project not opened: ") + close(gui) + end + + @testset "Ctrl+O opens through the check" begin + dir = project(; versions = Dict("BeamletOptics" => "99.0.0")) + dialog = GUI._OPEN_DIALOG[] + GUI._OPEN_DIALOG[] = _ -> joinpath(dir, "Bench.bmo") + try + gui = window() + GUI._open_key!(gui) + @test isnothing(fetch(GUI._DIALOG_TASK[])) + @test !isnothing(GUI._versions_card(gui)) && !gui.sync.closed + close(gui) + finally + GUI._OPEN_DIALOG[] = dialog + end + end + + @testset "open_project warns" begin + dir = project(; versions = Dict("BeamletOptics" => "99.0.0"), + packages = Dict("NotInstalledAnywhere" => "1.2.0")) + gui = @test_logs (:warn, r"saved with other versions:\nBeamletOptics: saved with 99\.0\.0, here .*: \] up BeamletOptics\nNotInstalledAnywhere: saved with 1\.2\.0, not installed here: \] add NotInstalledAnywhere") open_project(dir; display = false, const_kw...) + @test gui.project == dir + close(gui) + same = project() + gui = @test_logs min_level = Base.CoreLogging.Warn open_project(same; display = false, const_kw...) + close(gui) + end + + @testset "the versions of the others" begin + dir = project() + gui = open_project(dir; display = false, const_kw...) + # the own presence file carries the versions of this session + GUI._write_presence!(gui) + mine = TOML.parsefile(GUI._presence_file(dir, GUI._person().id))["versions"] + @test mine == Dict("BeamletOptics" => string(BMO_VERSION), + "BeamletOpticsProjects" => string(pkgversion(BeamletOpticsProjects)), + "BeamletOpticsGUI" => string(pkgversion(BeamletOpticsGUI))) + @test mine == GUI._own_versions() + + function other!(id, name, beat, versions) + file = GUI._presence_file(dir, id) + mkpath(dirname(file)) + data = Dict{String, Any}("name" => name, "host" => "PC9", "beat" => beat, "selected" => "", + "selected_id" => "", "written" => "", "versions" => versions) + open(io -> TOML.print(io, data), file, "w") + return file + end + v = string(VersionNumber(BMO_VERSION.major, BMO_VERSION.minor, BMO_VERSION.patch + 1)) + gui.status.text[] = "-" + # the same versions, a package that this session does not have: no notice + other!("Anna@pc2", "Anna", 1, Dict("BeamletOptics" => string(BMO_VERSION), "Unitful" => "1.0.0")) + GUI._check_presence!(gui) + @test gui.status.text[] == "-" + # another version: one notice + other!("Oliver@pc3", "Oliver", 1, Dict("BeamletOptics" => v)) + GUI._check_presence!(gui) + @test gui.status.text[] == "Oliver: BeamletOptics $v, you: $BMO_VERSION" + # ... once + gui.status.text[] = "-" + other!("Oliver@pc3", "Oliver", 2, Dict("BeamletOptics" => v)) + GUI._check_presence!(gui) + @test gui.status.text[] == "-" + # a participant that is not there any more (its `beat` did not change for its `ttl`) gives none + t = time() + other!("Bob@pc4", "Bob", 1, Dict("BeamletOptics" => string(BMO_VERSION))) + GUI._check_presence!(gui; now = t) + other!("Bob@pc4", "Bob", 1, Dict("BeamletOptics" => v)) + GUI._check_presence!(gui; now = t + 3600) + @test gui.status.text[] == "-" + # another difference of the same participant: a new notice, several in one line + other!("Oliver@pc3", "Oliver", 3, Dict("BeamletOptics" => "0.0.1", "BeamletOpticsGUI" => "9.9.9")) + GUI._check_presence!(gui; now = t + 3600) + @test gui.status.text[] == "Oliver: BeamletOptics 0.0.1, you: $BMO_VERSION; " * + "BeamletOpticsGUI 9.9.9, you: $(pkgversion(BeamletOpticsGUI))" + close(gui) + end +end + +end diff --git a/test/TestPrecompileSession.jl b/test/TestPrecompileSession.jl index 495839f..14b1198 100644 --- a/test/TestPrecompileSession.jl +++ b/test/TestPrecompileSession.jl @@ -57,7 +57,7 @@ using Test dir = joinpath(outer, "project") gui, screen = ext._project_session(step; dir) @test isempty(failed) - @test length(names) == 11 + @test length(names) == 13 @test LibGit2.with(LibGit2.isorphan, LibGit2.GitRepo(outer)) @test LibGit2.with(r -> realpath(LibGit2.path(r)) == realpath(dir), LibGit2.GitRepoExt(dir)) # the steps call the watcher themselves: none runs alongside them From c286f32cd88591b1d2ceaf964d7604914bcd4db2 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Fri, 6 Nov 2026 13:35:21 +0100 Subject: [PATCH 31/47] Version 0.3.1; the project tests do not depend on the speed of the machine (a shown window sees a change of the files within 5 s instead of 0.5 s, the first look within 60 s), and the write that the file system refuses makes the folder read-only too where a rename depends on the folder only (Linux) Co-Authored-By: Claude Opus 5.5 --- Project.toml | 2 +- test/TestLiveProjectLoad.jl | 13 +++++++------ test/TestLiveProjectSync.jl | 6 +++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Project.toml b/Project.toml index dcd88a4..cc286c9 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "BeamletOpticsGUI" uuid = "d62ba671-5e61-47cc-8a7f-19d21d414e61" -version = "0.3.0" +version = "0.3.1" authors = ["Hugo Uittenbosch , Oliver Kliebisch and Thomas Dekorsy "] [workspace] diff --git a/test/TestLiveProjectLoad.jl b/test/TestLiveProjectLoad.jl index de985ca..cdfeaf4 100644 --- a/test/TestLiveProjectLoad.jl +++ b/test/TestLiveProjectLoad.jl @@ -203,9 +203,10 @@ end end @testset "a shown window sees a change of the files" begin - # Within 0.5 s with notifications, although it polls only every 10 s; within two looks - # without (the look that runs at the change read the files before it) - for (notes, poll, within) in ((true, 10.0, 0.5), (false, 10.0, 2 * GUI._WATCH_INTERVAL + 0.1)) + # With notifications and without (by the looks, every `_WATCH_INTERVAL`), well before the + # poll of the folder every 10 s: typically within 0.5 s (see the logged times), but the + # bound is generous, since the CI workers share their CPUs and compile each first call + for (notes, poll, within) in ((true, 10.0, 5.0), (false, 10.0, 5.0)) gui, dir, _ = opened() screen = GLMakie.Screen(visible = false) display(screen, gui.fig) @@ -214,17 +215,17 @@ end @test n isa GUI._FileNotes && isempty(n.monitors) == !notes # Its own presence file is written first, by a look @test timedwait(() -> !gui.sync.presence_due && !isnothing(gui.sync.look) && - istaskdone(gui.sync.look.task), 2.0; pollint = 0.01) === :ok + istaskdone(gui.sync.look.task), 60.0; pollint = 0.01) === :ok sleep(0.5) marks = gui.sync.marks t = time() move!(dir, "L1", (0.0, 0.15, 0.0)) - @test timedwait(() -> gui.sync.marks != marks, 2.0; pollint = 0.005) === :ok + @test timedwait(() -> gui.sync.marks != marks, poll - 1; pollint = 0.005) === :ok seen = time() - t @info "a change of the files was seen after $(round(1000 * seen; digits = 1)) ms" notes @test seen <= within # and merged once it settled - @test timedwait(() -> BeamletOptics.position(obj(gui, :L1)) ≈ [0.0, 0.15, 0.0], 3.0; + @test timedwait(() -> BeamletOptics.position(obj(gui, :L1)) ≈ [0.0, 0.15, 0.0], 30.0; pollint = 0.01) === :ok close(screen) close(gui) diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 877ff4d..94d1806 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -818,11 +818,15 @@ end @testset "a write that the file system refuses is busy and tried again" begin file = system(dir) translate3d!(gui, obj(gui, :M1), [0.0, 0.0, 0.01]) - # read-only: neither the replace nor the overwrite in place works + # read-only: neither the replace nor the overwrite in place works. Windows refuses to + # replace a read-only file; elsewhere a rename depends on the folder only, so the folder + # is made read-only too (not for root, which the CI runners are not) chmod(file, 0o444) + Sys.iswindows() || chmod(dir, 0o555) try @test GUI._save_to!(gui, dir) === :busy finally + Sys.iswindows() || chmod(dir, 0o755) chmod(file, 0o644) end @test GUI._save_to!(gui, dir) === :saved From 837ac93c9f821baaf4c183bde9f365577b7eaa3f Mon Sep 17 00:00:00 2001 From: Hugo Uittenbosch Date: Wed, 11 Nov 2026 10:16:31 +0100 Subject: [PATCH 32/47] Links follow the move of BeamletOptics to StackEnjoyer/BeamletOptics.jl: the [sources] entries, README, AGENTS.md and docs; the Michelson tutorial on the new docs site Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- Project.toml | 2 +- README.md | 2 +- docs/Project.toml | 2 +- docs/src/api.md | 2 +- docs/src/examples/live_michelson.md | 2 +- docs/src/index.md | 2 +- test/Project.toml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92ce1fe..84b4db1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # BeamletOpticsGUI.jl: developer instructions for coding agents -The interactive GUI of [BeamletOptics.jl](https://github.com/JuliaPhysics/BeamletOptics.jl) (BMO): +The interactive GUI of [BeamletOptics.jl](https://github.com/StackEnjoyer/BeamletOptics.jl) (BMO): `live_view` and the tools around it (kinematic controls, view cube, cards, custom panels, controls and tools), built on Makie. BMO does the physics and draws the components; this package arranges them into an interactive window. diff --git a/Project.toml b/Project.toml index cc286c9..219ca67 100644 --- a/Project.toml +++ b/Project.toml @@ -27,7 +27,7 @@ GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" BeamletOpticsGUIGLMakieExt = "GLMakie" [sources] -BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} +BeamletOptics = {url = "https://github.com/StackEnjoyer/BeamletOptics.jl", rev = "master"} BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} [compat] diff --git a/README.md b/README.md index fd3b64b..7788204 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![codecov](https://codecov.io/gh/StackEnjoyer/BeamletOpticsGUI.jl/graph/badge.svg)](https://codecov.io/gh/StackEnjoyer/BeamletOpticsGUI.jl) [![Aqua QA](https://raw.githubusercontent.com/JuliaTesting/Aqua.jl/master/badge.svg)](https://github.com/JuliaTesting/Aqua.jl) -Interactive GUI for [BeamletOptics.jl](https://github.com/JuliaPhysics/BeamletOptics.jl): move the +Interactive GUI for [BeamletOptics.jl](https://github.com/StackEnjoyer/BeamletOptics.jl): move the components of an optical setup with the mouse and keyboard, and see the beams and detector signals update live. BeamletOptics computes the physics and draws the components; this package arranges them into an interactive window. diff --git a/docs/Project.toml b/docs/Project.toml index 9845d06..855980a 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -6,7 +6,7 @@ Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" [sources] -BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} +BeamletOptics = {url = "https://github.com/StackEnjoyer/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} diff --git a/docs/src/api.md b/docs/src/api.md index 64d1266..57278d8 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -3,7 +3,7 @@ The exported names of BeamletOpticsGUI. The functions and types of BeamletOptics that the GUI builds on (`render!`, `live_render!`, `update_render!`, `remove_render!`, `pick_object` and the render handle protocol) are documented on the -[BeamletOptics site](https://github.com/JuliaPhysics/BeamletOptics.jl). +[BeamletOptics site](https://github.com/StackEnjoyer/BeamletOptics.jl). ```@docs BeamletOpticsGUI diff --git a/docs/src/examples/live_michelson.md b/docs/src/examples/live_michelson.md index d198112..ee7bd92 100644 --- a/docs/src/examples/live_michelson.md +++ b/docs/src/examples/live_michelson.md @@ -7,7 +7,7 @@ include(joinpath(dir, "live_michelson_showcase.jl")) # Interactive Michelson interferometer -This example shows how to build an interactive application with [`live_view`](@ref), which is based on the live rendering functions of BeamletOptics (`live_render!`, `update_render!`). The Michelson interferometer of the [Michelson interferometer tutorial](https://juliaphysics.github.io/BeamletOptics.jl/dev/tutorials/michelson) is rendered into a `GLMakie` window, in which the components can be moved and rotated with the mouse and keyboard. After each change, the system is solved again and the beam path, the fringe pattern on the detector and the optical power are updated live. +This example shows how to build an interactive application with [`live_view`](@ref), which is based on the live rendering functions of BeamletOptics (`live_render!`, `update_render!`). The Michelson interferometer of the [Michelson interferometer tutorial](https://stackenjoyer.github.io/BeamletOptics.jl/dev/tutorials/michelson) is rendered into a `GLMakie` window, in which the components can be moved and rotated with the mouse and keyboard. After each change, the system is solved again and the beam path, the fringe pattern on the detector and the optical power are updated live. ![Interactive Michelson interferometer](live_michelson_fringes.png) diff --git a/docs/src/index.md b/docs/src/index.md index d1c2bf8..d50c42d 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,6 +1,6 @@ # BeamletOpticsGUI.jl -BeamletOpticsGUI is the interactive GUI of [BeamletOptics.jl](https://github.com/JuliaPhysics/BeamletOptics.jl) +BeamletOpticsGUI is the interactive GUI of [BeamletOptics.jl](https://github.com/StackEnjoyer/BeamletOptics.jl) (BMO), a package for non-sequential 3D ray and Gaussian beamlet tracing. It opens a window in which a BMO system and its beams are drawn, the components are moved and rotated with the mouse and keyboard, and the beams and the detector views on the cards of the detectors are updated after each change: diff --git a/test/Project.toml b/test/Project.toml index 40c43e4..36c1f77 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -12,7 +12,7 @@ ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [sources] -BeamletOptics = {url = "https://github.com/JuliaPhysics/BeamletOptics.jl", rev = "master"} +BeamletOptics = {url = "https://github.com/StackEnjoyer/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} From e1b0027744fd0e9323e502c114751b87a4345776 Mon Sep 17 00:00:00 2001 From: Hugo Uittenbosch Date: Wed, 11 Nov 2026 10:52:26 +0100 Subject: [PATCH 33/47] CompatHelper, as in BeamletOptics: daily, for the package, test and docs; its pull requests run CI (DOCUMENTER_KEY) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/CompatHelper.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/CompatHelper.yml diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml new file mode 100644 index 0000000..97d5a2c --- /dev/null +++ b/.github/workflows/CompatHelper.yml @@ -0,0 +1,20 @@ +name: CompatHelper +on: + schedule: + - cron: 0 0 * * * + workflow_dispatch: +jobs: + CompatHelper: + runs-on: ubuntu-latest + steps: + # The runner image ships no Julia + - uses: julia-actions/setup-julia@v3 + with: + version: '1' + - name: Pkg.add("CompatHelper") + run: julia -e 'using Pkg; Pkg.add("CompatHelper")' + - name: CompatHelper.main() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }} + run: julia -e 'using CompatHelper; CompatHelper.main(; subdirs = ["", "test", "docs"])' From 7ea477d3f3d30530378f5c4b44ec1f00d25f37bf Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 11 Nov 2026 22:24:39 +0100 Subject: [PATCH 34/47] Docs on projects: a page of its own (the folder, autosave, changes of the others, versions, Ctrl+R, groups, conflicts, presence, claims, session file, keys and tools), open_project and save_project on the API page, a short section in the live view page that points to it Co-Authored-By: Claude Opus 5.5 --- docs/make.jl | 1 + docs/src/api.md | 7 ++++ docs/src/live_view.md | 5 +++ docs/src/projects.md | 97 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 docs/src/projects.md diff --git a/docs/make.jl b/docs/make.jl index 772d3a5..1205dad 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -30,6 +30,7 @@ makedocs(; pages=[ "Home" => "index.md", "Live view" => "live_view.md", + "Projects" => "projects.md", "Adding and removing components" => "components.md", "Kinematic controls" => "kinematic_controls.md", "View cube" => "view_cube.md", diff --git a/docs/src/api.md b/docs/src/api.md index 57278d8..ad1f696 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -36,6 +36,13 @@ wait_solve Makie.record(::Any, ::BeamletOpticsGUI.LiveView, ::AbstractString, ::Any) ``` +## Projects + +```@docs +open_project +save_project(::BeamletOpticsGUI.LiveView, ::AbstractString) +``` + ## Adding and removing components ```@docs diff --git a/docs/src/live_view.md b/docs/src/live_view.md index de6eb4c..526cf96 100644 --- a/docs/src/live_view.md +++ b/docs/src/live_view.md @@ -871,6 +871,11 @@ added and removed with [`add_system!`](@ref) and [`remove_system!`](@ref). See [Adding and removing components](@ref components_page) and the section of the same name in the docstring of [`live_view`](@ref). +## Projects + +A view is saved as a project folder that the window keeps up to date, and that people in other +windows and coding agents change at the same time, see [Projects](@ref projects_page) and [`open_project`](@ref). + ## Exporting the changes The "Export" button of the tool rail (or the toolbar) prints the changed poses as Julia code and copies it diff --git a/docs/src/projects.md b/docs/src/projects.md new file mode 100644 index 0000000..f00d198 --- /dev/null +++ b/docs/src/projects.md @@ -0,0 +1,97 @@ +# [Projects](@id projects_page) + +A live view can be saved as a project of +[BeamletOpticsProjects](https://stackenjoyer.github.io/BeamletOpticsProjects.jl/dev/): a folder with +the setup as readable Julia code, which the window opens again, and which people in other windows +and coding agents change at the same time. The window writes every finished action to the files and +takes in what the others write there, without a reload. + +!!! note + A prototype: the format of the files may still change. + +```julia +using BeamletOptics, BeamletOpticsGUI, GLMakie + +gui = live_view(system, beam) +save_project(gui, "Bench") # the view becomes the project in the folder Bench +gui = open_project("Bench") # or "Bench/Bench.bmo" +``` + +## The folder + +```text +Bench/ +├── Bench.bmo the project file, which people open: the versions it was saved with +├── system.jl the setup: one line per component, source, group and system +├── src/Bench.jl own code of the project (functions, components) +├── view.toml how the view looks for a person without an own one +├── AGENTS.md instructions for coding agents (CLAUDE.md points to it) +└── .bmo/ the files of the open windows (not versioned): + view-.toml, session-.md, presence-.toml, claims/ +``` + +Each line of `system.jl` has a readable id (`L1`, `D1`, `S1`), the label that the view shows and a +machine id that stays when the object is renamed. A project is data, not a Julia environment: it +opens from any environment that has BeamletOptics, BeamletOpticsProjects and the GUI. When the +project was saved with newer or other versions of these packages, or its own code needs a package +that is not installed, [`open_project`](@ref) warns, and the dialog of "Open" asks first. + +How the view looks (camera, saved views, clip planes, table, the colors of the beams) is saved per +person in `.bmo/view-.toml`. Objects whose constructor the GUI does not know, e.g. those a +script gave to [`live_view`](@ref), are not saved; a warning names them. The components and sources +from the catalog are saved with their constructors and can be edited on their page "Edit". + +## Working in a project + +- **Autosave.** Every finished action (a drag, a key step, an input on a card, adding, removing, + grouping, undo) is written to `system.jl` shortly after it, without a key. +- **Changes of the others.** When the files change, e.g. by an agent, the window applies the change + once the files settle: moved objects glide to their new poses (the objects of one change + together, a group moved as a whole as one), new objects appear, changed ones are built again and + removed ones go. The window is never replaced. +- **Versions.** `Ctrl+S` writes what is still pending and saves a version of the project (a git + commit, without a git install). A card asks for its message, filled with what changed since the + last version. +- **Taking the files.** `Ctrl+R` makes the window as in the files: everything that differs is + taken from them, changes in the window that were not saved are lost. +- **Groups** made in the window are `@group` lines of the project. + +## Conflicts + +An object that was changed both in the window and in the files, differently, is a conflict. Only +that object waits: everything else is applied and saved, and the files keep their state of the +object until the person decides. A card lists the objects of the conflict, each with "theirs" (take +the state of the files) and "mine" (keep the window's), and offers "Save as…" to keep the window's +state in another folder. `Ctrl+S` saves no version while a conflict is open. + +## Several people and agents + +Several windows and agents can work on one project at once, also from a shared drive. + +- **Presence.** Each window shows where the others work: a box at the object they selected, with + their name and what they are doing, people and agents alike. +- **Claims.** The first gesture on an object claims it for the person, until it is deselected or + 30 s pass without a gesture. A gesture on an object that another participant claims is refused, + the status line names the holder ("Bob is editing M1"), and the object shows a lock. A change + that lost the race to a claim is held like a conflict and written once the claim is gone. +- **Session file.** The window notes the selection and the recent changes of its person in + `.bmo/session-.md`, for the agents that work on the same project. +- **Versions of the packages.** The window says when another participant works with other versions + of BeamletOptics, BeamletOpticsProjects or the GUI. + +An agent needs no GUI: it works with BeamletOptics and BeamletOpticsProjects only. How it reads and +changes a project is in the agent skill of BeamletOpticsProjects +(`BeamletOpticsProjects.install_agent_skill()`). + +## Keys and tools + +| Key (`Cmd` on macOS) | Tool | Action | +|---|---|---| +| `Ctrl+S` | Save | write what is pending, save a version (without a project: like "Save as") | +| `Ctrl+Shift+S` | Save as | save the view as a project in a new folder, which becomes the project of the window | +| `Ctrl+O` | Open | open a project chosen in a dialog in place of the window | +| `Ctrl+N` | New project | make an empty project in a new or empty folder and open it in place of the window | +| `Ctrl+R` | | take the files as they are | + +"New project" first writes what is pending of the current project. From code, see +[`open_project`](@ref) and [`save_project`](@ref save_project(::BeamletOpticsGUI.LiveView, ::AbstractString)). From c6d8ed01c4b350575c5b476df8c6c0fa8121c958 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 11 Nov 2026 23:13:36 +0100 Subject: [PATCH 35/47] benchmark/two_machines.jl: two machines on one project in a shared folder, each window driven by simulated mouse and key events (presence, drags seen by the other, claims refused and expiring, edits at the same moment, a race that ends on the conflict card, an agent's edit, a group, versions of both, leaving); the sides wait for each other through files of the folder and log each step Co-Authored-By: Claude Opus 5.5 --- benchmark/two_machines.jl | 741 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 benchmark/two_machines.jl diff --git a/benchmark/two_machines.jl b/benchmark/two_machines.jl new file mode 100644 index 0000000..0d025c3 --- /dev/null +++ b/benchmark/two_machines.jl @@ -0,0 +1,741 @@ +# Two machines on one project in a shared folder: step 3 of the roadmap, without two people. +# +# Machine A and machine B each run this script with a visible window of the same project, which +# lies in the shared folder. The script acts like a person in the window: simulated mouse and key +# events (the same as GLFW sends them, as in the precompile session of the GLMakie extension), so +# that the claims and the autosave go the way of a real gesture. Between the steps, the two sides +# wait for each other through small files in `/coord`. Each side logs its steps to +# `/log-.md` and saves a picture of its window after each step to `/screens`. +# +# A> julia --project=test benchmark/two_machines.jl A "H:\BMOP test env" +# B> julia --project=test benchmark/two_machines.jl B "H:\BMOP test env" +# +# `--no-notify` after the folder skips the measurement of the notifications (e.g. for a trial run of +# both roles on one machine, in a folder of `tempdir()`). +# +# A starts a run: it removes what an earlier run left (`coord`, `project`, `screens`, `results`, +# its log) and writes `coord/run.txt`. B joins the run of A, also when it started first. Both +# windows must stay visible and the mouse must stay off them while the run lasts. +# +# The script touches nothing outside the folder. If the folder can not be reached, or the other +# side halts, the script halts (exit code 2) with a line that starts with "HALT". +# +# The steps: preflight (versions, clock against the drive), the notifications of the drive both +# ways (`watch_notify.jl`), presence, a drag of each side seen by the other, the claims (refused +# gesture, lock, release, expiry after 30 s), edits of both at the same time on different objects +# and on the same object, an agent's edit through BeamletOpticsProjects, a group, versions of both, +# leaving, and the final comparison of both windows and the files. +const ROLE = uppercase(get(ARGS, 1, "")) +ROLE in ("A", "B") || error("usage: julia --project=test benchmark/two_machines.jl A|B ") +const ROOT = abspath(ARGS[2]) +const OTHER = ROLE == "A" ? "B" : "A" +const NAMES = Dict("A" => "Alice", "B" => "Bob") +const ME, THEM = NAMES[ROLE], NAMES[OTHER] +# The person of this window for the claims and the presence: both machines may run under the same +# user name, which would make them one person +ENV["BMO_PERSON"] = ME + +using Dates, Printf, Pkg +using BeamletOptics, BeamletOpticsGUI, BeamletOpticsProjects, GLMakie +using Makie + +const GUI = BeamletOpticsGUI +const BMOP = BeamletOpticsProjects +const LibGit2 = BMOP.LibGit2 + +const COORD = joinpath(ROOT, "coord") +const PROJ = joinpath(ROOT, "project", "Bench") +const SHOTS = joinpath(ROOT, "screens") +const RESULTS = joinpath(ROOT, "results") +const LOGF = joinpath(ROOT, "log-$ROLE.md") +const STEP = Ref(0) +const RUN = Ref("") +const FAILS = String[] +const WINDOW = Ref{Any}(nothing) + +#= +Halting, logging +=# + +struct Halt <: Exception + msg::String +end + +reachable() = try + isdir(ROOT) +catch + false +end + +function reach() + reachable() || throw(Halt("the shared folder $ROOT can not be reached")) + return nothing +end + +stamp() = Dates.format(now(), "HH:MM:SS.sss") + +function logline(step, result, details = "") + line = "| $(stamp()) | $step | $result | $(replace(string(details), '|' => '/', '\n' => ' ')) |" + println(line) + reach() + open(io -> println(io, line), LOGF, "a") + return nothing +end + +step!(name) = (STEP[] += 1; logline("**$(STEP[]) $name**", "start")) + +"""Records a check: `ok` or `FAIL`, and continues the run.""" +function check(ok::Bool, what, details = "") + ok || push!(FAILS, "step $(STEP[]): $what") + logline(" $what", ok ? "ok" : "FAIL", details) + return ok +end + +info(what, details = "") = logline(" $what", "info", details) + +#= +Files of the coordination: `coord/--`, written once, read by the other side +=# + +cfile(role, name) = joinpath(COORD, "$(RUN[])-$role-$name") + +function signal(name; data...) + reach() + text = join(["$k=$v" for (k, v) in pairs(data)], "\n") + tmp = cfile(ROLE, name) * ".tmp" + write(tmp, "t=$(time())\n" * text * "\n") + mv(tmp, cfile(ROLE, name); force = true) + return nothing +end + +function readsignal(file) + d = Dict{String, String}() + for l in eachline(file) + occursin('=', l) || continue + k, v = split(l, '='; limit = 2) + d[k] = v + end + return d +end + +"""Waits for the signal `name` of the other side; halts when it halts or the folder is gone.""" +function await(name; timeout = 600.0) + t0 = time() + file = cfile(OTHER, name) + last_touch = time() + while true + reach() + isfile(file) && return readsignal(file) + isfile(cfile(OTHER, "HALT")) && throw(Halt("machine $OTHER halted: " * read(cfile(OTHER, "HALT"), String))) + run_now = try + strip(read(joinpath(COORD, "run.txt"), String)) + catch + "" + end + run_now == RUN[] || throw(Halt("the run $(RUN[]) is gone (another run started: \"$run_now\")")) + # A keeps its run fresh for a B that starts later + if ROLE == "A" && time() - last_touch > 20 + touch(joinpath(COORD, "run.txt")) + last_touch = time() + end + time() - t0 > timeout && throw(Halt("machine $OTHER did not reach \"$name\" within $(round(Int, timeout)) s")) + sleep(0.2) + end +end + +barrier(name; timeout = 600.0) = (signal(name); await(name; timeout)) + +"""Waits until `f()` is true, at most `timeout` s; returns the time it took or `nothing`.""" +function until(f; timeout = 30.0, every = 0.05) + t0 = time() + while time() - t0 < timeout + ok = try + f() + catch e + e isa InterruptException && rethrow() + false + end + ok && return time() - t0 + sleep(every) + end + return nothing +end + +fmt(x::Nothing) = "never" +fmt(x::Real) = @sprintf("%.2f s", x) + +#= +The window: objects, poses, simulated events +=# + +function obj(gui, id::Symbol) + for (o, (i, _)) in gui.sync.ids + i === id && return o + end + error("no object $id in the window") +end + +wpos(o) = Vector{Float64}(BeamletOptics.position(o)) +wpos(gui, id::Symbol) = wpos(obj(gui, id)) + +"""The position of `id` in the files, `nothing` while they can not be read.""" +function fpos(id::Symbol) + try + return collect(Float64, BMOP.pose(BMOP.load_project(PROJ), id).pos) + catch e + e isa InterruptException && rethrow() + return nothing + end +end + +near(a, b; atol = 1e-6) = !isnothing(a) && !isnothing(b) && isapprox(a, b; atol) +vec3(s) = parse.(Float64, split(strip(s, ['[', ']']), ",")) +show3(v) = isnothing(v) ? "nothing" : "[" * join((@sprintf("%.5f", x) for x in v), ", ") * "]" + +ev(gui) = events(gui.ax.scene) + +function px(gui, p) + scene = gui.ax.scene + vp = scene.viewport[] + q = Makie.project(scene, :data, :pixel, Point3f(p...)) + return (Float64(vp.origin[1] + q[1]), Float64(vp.origin[2] + q[2])) +end + +function free_px(gui) + vp = gui.ax.scene.viewport[] + o, w = minimum(vp), widths(vp) + for fy in (0.5, 0.3, 0.7, 0.15, 0.85), fx in (0.2, 0.8, 0.35, 0.65, 0.1, 0.9) + p = (Float64(o[1] + fx * w[1]), Float64(o[2] + fy * w[2])) + isnothing(first(Makie.pick(gui.fig.scene, Point2f(p), 40))) && return p + end + error("the 3D view has no free pixel") +end + +mouse!(gui, xy) = (ev(gui).mouseposition[] = (Float64(xy[1]), Float64(xy[2])); sleep(0.03)) +press!(gui) = (ev(gui).mousebutton[] = Makie.MouseButtonEvent(Mouse.left, Mouse.press); sleep(0.03)) +release!(gui) = (ev(gui).mousebutton[] = Makie.MouseButtonEvent(Mouse.left, Mouse.release); sleep(0.03)) +click!(gui, xy) = (mouse!(gui, xy); press!(gui); release!(gui); sleep(0.6)) + +function key!(gui, key, modifiers...) + e = ev(gui) + foreach(m -> e.keyboardbutton[] = Makie.KeyEvent(m, Keyboard.press), modifiers) + e.keyboardbutton[] = Makie.KeyEvent(key, Keyboard.press) + e.keyboardbutton[] = Makie.KeyEvent(key, Keyboard.release) + foreach(m -> e.keyboardbutton[] = Makie.KeyEvent(m, Keyboard.release), modifiers) + sleep(0.3) + return nothing +end + +# The pixel in the middle of a block of the layout, e.g. of a button of a card +rect_center(block) = (r = block.layoutobservables.computedbbox[]; Tuple(Float64.(minimum(r) .+ widths(r) ./ 2))) +rect_center(b::Union{GUI._IconButton, GUI._IconToggle}) = rect_center(b.box) + +type!(gui, text) =foreach(c -> (ev(gui).unicode_input[] = c; sleep(0.02)), text) + +"""Selects the object `id` with a click on it; `true` if it is selected then.""" +function select_obj!(gui, id::Symbol) + o = obj(gui, id) + click!(gui, px(gui, wpos(o))) + return gui.controls.selected[] === o +end + +"""A click on the background, which deselects (and releases the claims).""" +deselect!(gui) = (click!(gui, free_px(gui)); isnothing(gui.controls.selected[])) + +"""Drags the selected object `id` by `d` pixels, as a press, moves and a release.""" +function drag_obj!(gui, id::Symbol, d) + a = px(gui, wpos(gui, id)) + mouse!(gui, a) + press!(gui) + for t in range(0, 1; length = 12) + mouse!(gui, a .+ t .* d) + end + release!(gui) + wait_solve(gui) + sleep(0.2) + return nothing +end + +"""Waits until the files have the position of `id` in the window (the autosave wrote it).""" +written(gui, id; timeout = 20.0) = until(() -> near(fpos(id), wpos(gui, id); atol = 1e-10); timeout) + +function shot(gui, name) + try + reach() + mkpath(SHOTS) + img = Makie.colorbuffer(Makie.getscreen(gui.fig.scene)) + Makie.save(joinpath(SHOTS, "$ROLE-$(lpad(STEP[], 2, '0'))-$name.png"), img) + catch e + e isa Halt && rethrow() + info("picture $name not saved", sprint(showerror, e)) + end + return nothing +end + +others(gui) = [o.name for o in values(gui.sync.others)] +claimed(gui, id) = GUI._claimed_by(gui, obj(gui, id)) +status(gui) = gui.status.text[] + +#= +The steps +=# + +const START = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, ConstantRefractiveIndex(1.5)) pos = (0.0, 0.08, 0.0) + @component L2 = SphericalLens(0.1, -0.1, 0.005, 0.0254, ConstantRefractiveIndex(1.5)) pos = (0.0, 0.14, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) rot = (0.0, 0.0, 45.0) + @component M2 = RoundPlanoMirror(0.0254, 0.006) pos = (-0.1, 0.2, 0.0) rot = (0.0, 0.0, 45.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, L2, M1, M2] sources = [B1] + """ + +"""The offset of the clock of the drive against this machine [s]: the time of a written file.""" +function clock_offset() + f = joinpath(COORD, "clock-$ROLE.txt") + offs = Float64[] + for _ in 1:5 + t0 = time() + write(f, "$t0") + t1 = time() + push!(offs, mtime(f) - (t0 + t1) / 2) + sleep(0.2) + end + rm(f; force = true) + return sort(offs)[3] +end + +function start_run() + mkpath(ROOT) + reach() + if ROLE == "A" + for d in (COORD, joinpath(ROOT, "project"), SHOTS, RESULTS) + rm(d; recursive = true, force = true) + end + mkpath(COORD) + RUN[] = Dates.format(now(), "yyyymmdd-HHMMSS") + write(joinpath(COORD, "run.txt"), RUN[]) + else + # The run of A, written or touched within the last 90 s, which A did neither halt nor end + function fresh() + f = joinpath(COORD, "run.txt") + isfile(f) && abs(time() - mtime(f)) < 90 || return false + r = strip(read(f, String)) + return !isfile(joinpath(COORD, "$r-A-HALT")) && !isfile(joinpath(COORD, "$r-A-end")) + end + println("waiting for machine A to start a run ...") + t = until(fresh; timeout = 1800.0, every = 0.5) + isnothing(t) && throw(Halt("machine A started no run within 30 min")) + RUN[] = strip(read(joinpath(COORD, "run.txt"), String)) + end + write(LOGF, "# Machine $ROLE ($ME), run $(RUN[])\n\n| time | step | result | details |\n|---|---|---|---|\n") + mkpath(RESULTS) + return nothing +end + +"""The git tree of the code of the package `m` (as Pkg has it, also for a package tracked by path).""" +function tree(m::Module) + d = Pkg.dependencies()[Base.PkgId(m).uuid] + t = d.is_tracking_path ? readchomp(`git -C $(d.source) rev-parse "HEAD^{tree}"`) : string(d.tree_hash) + return first(t, 10) +end + +function preflight() + step!("preflight") + info("host", gethostname()) + info("versions", "Julia $(VERSION), BeamletOptics $(pkgversion(BeamletOptics)), " * + "BeamletOpticsProjects $(pkgversion(BMOP)), BeamletOpticsGUI $(pkgversion(GUI))") + commit = try + readchomp(`git -C $(dirname(@__DIR__)) rev-parse --short HEAD`) + catch + "unknown" + end + info("commit of the GUI", commit) + info("person", "$(BMOP.person())") + off = clock_offset() + info("clock of the drive minus this clock", @sprintf("%.3f s", off)) + versions = "BeamletOptics $(pkgversion(BeamletOptics)) ($(tree(BeamletOptics))), " * + "BeamletOpticsProjects $(pkgversion(BMOP)) ($(tree(BMOP))), BeamletOpticsGUI $(pkgversion(GUI))" + info("trees of the packages", versions) + signal("ready"; offset = off, commit, versions) + other = await("ready"; timeout = 1800.0) + check(other["versions"] == versions, "same packages on both machines (versions and git trees)", + "$ROLE: $versions; $OTHER: $(other["versions"])") + check(other["commit"] == commit, "same commit of the GUI", "$ROLE: $commit, $OTHER: $(other["commit"])") + # (drive - me) - (drive - other) = other - me + skew = parse(Float64, other["offset"]) - off + check(abs(skew) < 5, "clocks of the machines agree within 5 s", @sprintf("clock of %s minus clock of %s: %.3f s", OTHER, ROLE, skew)) + return skew +end + +"""`watch_notify.jl` both ways: B watches while A writes, then A watches while B writes.""" +function notifications() + step!("notifications of the drive (watch_notify.jl, 60 writes each way)") + script = joinpath(@__DIR__, "watch_notify.jl") + julia = Base.julia_cmd() + for (watcher, n) in (("B", 1), ("A", 2)) + dir = joinpath(ROOT, "notify-$n") + out = joinpath(RESULTS, "notify-$watcher-watches.txt") + if ROLE == watcher + rm(dir; recursive = true, force = true) + mkpath(dir) + p = run(pipeline(`$julia --startup-file=no $script watch $dir 300`; stdout = out, stderr = out); wait = false) + sleep(8) + signal("watching-$n") + ok = until(() -> !process_running(p); timeout = 400.0, every = 0.5) + isnothing(ok) && kill(p) + text = read(out, String) + summary = join(filter(l -> occursin(r"^(writes seen|notification after|no write|no end)", l), split(text, '\n')), " / ") + info("notifications, $OTHER writes, $ROLE watches", summary) + else + await("watching-$n"; timeout = 120.0) + run(pipeline(`$julia --startup-file=no $script write $dir 60`; stdout = devnull)) + info("wrote 60 probes for the watcher on $watcher") + end + barrier("notify-$n-done"; timeout = 600.0) + end + return nothing +end + +function open_window() + step!("open the project") + if ROLE == "A" + mkpath(PROJ) + close(LibGit2.init(PROJ)) + BMOP.new_project(PROJ; name = "Bench") + write(joinpath(PROJ, "system.jl"), START) + gui = open_project(PROJ) + WINDOW[] = gui + save_project(gui) + signal("project") + else + await("project"; timeout = 300.0) + # the files of A settle + sleep(2) + gui = open_project(PROJ) + WINDOW[] = gui + end + gui = WINDOW[] + sleep(3) + check(length(gui.sync.ids) >= 5, "the window has the objects of the project", "$(length(gui.sync.ids)) ids") + signal("opened") + await("opened"; timeout = 300.0) + t = until(() -> THEM in others(gui); timeout = 90.0) + check(!isnothing(t), "$THEM is shown as present", "after $(fmt(t)) (beat every 20 s); others: $(others(gui))") + shot(gui, "opened") + barrier("presence") + return gui +end + +"""One side drags `id`, the other sees it glide to the same place.""" +function drag_seen(gui, actor, id, d) + step!("$(NAMES[actor]) drags $id, $(actor == "A" ? NAMES["B"] : NAMES["A"]) sees it") + if ROLE == actor + check(select_obj!(gui, id), "the click selects $id") + before = wpos(gui, id) + drag_obj!(gui, id, d) + after = wpos(gui, id) + check(!near(before, after), "the drag moved $id", "$(show3(before)) -> $(show3(after))") + t = written(gui, id) + check(!isnothing(t), "the autosave wrote $id", "after $(fmt(t))") + shot(gui, "dragged-$id") + check(deselect!(gui), "a click on the background deselects") + signal("moved-$id"; pos = after) + else + s = await("moved-$id"; timeout = 120.0) + target = vec3(s["pos"]) + sent = parse(Float64, s["t"]) + t = until(() -> near(wpos(gui, id), target); timeout = 60.0) + check(!isnothing(t), "$id glides to the place of $(NAMES[actor])", + "$(fmt(t)) after the signal was seen, $(fmt(isnothing(t) ? nothing : time() - sent)) after it was written (clock of $actor)") + shot(gui, "seen-$id") + end + barrier("drag-$id") + return nothing +end + +"""Claims: B holds M1, A is refused and sees the lock; B lets go, A moves M1; a claim expires.""" +function claims(gui) + step!("claims: $(NAMES["B"]) holds M1, $(NAMES["A"]) is refused") + if ROLE == "B" + check(select_obj!(gui, :M1), "the click selects M1") + key!(gui, Keyboard.up) + t = until(() -> any(c -> c.id === :M1, values(BMOP.claims(PROJ))); timeout = 10.0) + check(!isnothing(t), "the key step on M1 claims it (a file in .bmo/claims)", "after $(fmt(t))") + # the key step is in the files before A looks, such that A sees no move of M1 later + written(gui, :M1) + signal("holds-M1") + await("refused") + shot(gui, "holding-M1") + check(deselect!(gui), "a click on the background deselects M1") + t = until(() -> !any(c -> c.id === :M1, values(BMOP.claims(PROJ))); timeout = 15.0) + check(!isnothing(t), "the claim file of M1 is removed", "after $(fmt(t))") + signal("released") + s = await("moved-M1") + target = vec3(s["pos"]) + t = until(() -> near(wpos(gui, :M1), target); timeout = 60.0) + check(!isnothing(t), "M1 glides to the place of $(NAMES["A"])", "after $(fmt(t))") + else + await("holds-M1"; timeout = 120.0) + t = until(() -> claimed(gui, :M1) == THEM; timeout = 30.0) + check(!isnothing(t), "M1 is shown as claimed by $THEM", "after $(fmt(t)) from the signal") + check(!isempty(GUI._claims(gui).locks), "the lock of M1 is drawn") + # the key step of B has arrived + # (a key step is 10 nm: compared to 0.1 nm) + until(() -> near(wpos(gui, :M1), fpos(:M1); atol = 1e-10) && isempty(gui.sync.gliding); timeout = 30.0) + before = wpos(gui, :M1) + select_obj!(gui, :M1) + gui.status.text[] = "" + drag_obj!(gui, :M1, (40, 0)) + drag_status = status(gui) + gui.status.text[] = "" + key!(gui, Keyboard.up) + key_status = status(gui) + check(near(wpos(gui, :M1), before; atol = 1e-12), "the drag and the key step do not move M1") + check(occursin("$THEM is editing M1", drag_status * " " * key_status), "the status line names $THEM", + "after the drag: \"$drag_status\", after the key: \"$key_status\"") + shot(gui, "refused-M1") + deselect!(gui) + signal("refused") + await("released") + t = until(() -> isnothing(claimed(gui, :M1)); timeout = 30.0) + check(!isnothing(t), "the lock of M1 goes when $THEM lets go", "after $(fmt(t)) from the signal") + check(select_obj!(gui, :M1), "the click selects M1") + drag_obj!(gui, :M1, (40, 0)) + after = wpos(gui, :M1) + check(!near(before, after), "now the drag moves M1") + t = written(gui, :M1) + check(!isnothing(t), "the autosave wrote M1", "after $(fmt(t))") + check(deselect!(gui), "a click on the background deselects") + signal("moved-M1"; pos = after) + end + barrier("claims") + + step!("claims: a claim without a gesture for 30 s expires") + if ROLE == "B" + check(select_obj!(gui, :L2), "the click selects L2") + key!(gui, Keyboard.up) + written(gui, :L2) + signal("holds-L2") + # L2 stays selected, without a gesture + await("L2-free"; timeout = 120.0) + check(deselect!(gui), "a click on the background deselects L2") + else + s = await("holds-L2"; timeout = 120.0) + t1 = until(() -> claimed(gui, :L2) == THEM; timeout = 30.0) + check(!isnothing(t1), "L2 is shown as claimed by $THEM", "after $(fmt(t1))") + t2 = until(() -> isnothing(claimed(gui, :L2)); timeout = 90.0) + check(!isnothing(t2) && 20 < (isnothing(t1) ? 0 : t1) + t2 < 60, "the claim of L2 expires after about 30 s", + "free $(fmt(isnothing(t1) || isnothing(t2) ? nothing : t1 + t2)) after the signal") + signal("L2-free") + end + barrier("expiry") + return nothing +end + +"""The time `t` of the clock of A on this machine, with the skew of the clocks (other - me).""" +local_time(tA, skew) = ROLE == "A" ? tA : tA - skew + +"""Both sides drag at the same moment: A `idA`, B `idB` (the same object: a race).""" +function at_once(gui, skew, idA, idB, tag, name) + step!(name) + id = ROLE == "A" ? idA : idB + d = ROLE == "A" ? (35, 0) : (-35, 0) + if ROLE == "A" + signal("go-$tag"; at = time() + 15) + tA = time() + 15 + else + tA = parse(Float64, await("go-$tag"; timeout = 120.0)["at"]) + end + t0 = local_time(tA, skew) + sleep(max(0.0, t0 - 2 - time())) + check(select_obj!(gui, id), "the click selects $id") + sleep(max(0.0, t0 - time())) + gui.status.text[] = "" + drag_obj!(gui, id, d) + info("drag of $id at", @sprintf("%+.2f s against the agreed time", time() - t0)) + mine = wpos(gui, id) + st = status(gui) + info("status line after the drag", "\"$st\"") + sleep(3) + shot(gui, "at-once") + deselect!(gui) + signal("dragged-$tag"; pos = mine) + await("dragged-$tag"; timeout = 120.0) + if idA === idB + # The same object: the window whose write came second finds the object changed in the + # files and here, a conflict that the person decides on its card (unless the claim of the + # other was seen first, which refuses the drag). The script takes "theirs" + sleep(8) + conflict = !isempty(gui.sync.conflicts) + info("outcome of the race here", (conflict ? "conflict" : "no conflict") * ", status \"$(status(gui))\"") + signal("outcome-$tag"; conflict) + o = await("outcome-$tag"; timeout = 120.0) + n = conflict + (o["conflict"] == "true") + check(n <= 1, "at most one window has a conflict", "conflicts in $n windows") + if conflict + shot(gui, "conflict") + card = GUI._info_card(gui) + check(!isnothing(card) && card.obj isa GUI._ProjectConflict, "the card of the conflict is shown") + isnothing(card) || click!(gui, rect_center(GUI._card_widget(card, :conflict_theirs))) + t = until(() -> isempty(gui.sync.conflicts); timeout = 20.0) + check(!isnothing(t), "\"theirs\" on the card ends the conflict", "after $(fmt(t))") + end + end + # Both windows and the files agree on both objects + agree() = all(i -> near(wpos(gui, i), fpos(i)), unique([idA, idB])) && isempty(gui.sync.conflicts) + t = until(agree; timeout = 60.0) + check(!isnothing(t), "the window agrees with the files on $(join(unique([idA, idB]), " and ")), no conflict open", + "after $(fmt(t)); conflicts: $(gui.sync.conflicts); status \"$(status(gui))\"") + finals = Dict(i => wpos(gui, i) for i in unique([idA, idB])) + signal("final-$tag"; (Symbol(i) => finals[i] for i in keys(finals))...) + other = await("final-$tag"; timeout = 180.0) + for i in keys(finals) + check(near(finals[i], vec3(other[string(i)])), "both windows have the same $i", + "$ROLE: $(show3(finals[i])), $OTHER: $(other[string(i)])") + end + shot(gui, "after") + barrier("done-$tag") + return nothing +end + +"""B changes the files like an agent, with BeamletOpticsProjects; both windows follow.""" +function agent_edit(gui) + step!("an agent (on B) moves M2 by 10 mm in z through BeamletOpticsProjects") + before = wpos(gui, :M2) + if ROLE == "B" + barrier("before-agent") + BMOP.move!(PROJ, [:M2]; offset = (0.0, 0.0, 0.01), by = "agent-B") + signal("agent-moved") + else + barrier("before-agent") + await("agent-moved"; timeout = 120.0) + end + target = before .+ [0.0, 0.0, 0.01] + t = until(() -> near(wpos(gui, :M2), target); timeout = 60.0) + check(!isnothing(t), "M2 glides 10 mm up", "after $(fmt(t)) from the signal") + shot(gui, "agent") + barrier("agent") + return nothing +end + +"""A groups L1 and L2 (Ctrl+click, Ctrl+G); B sees the group.""" +function group(gui) + step!("$(NAMES["A"]) groups L1 and L2 (select!, Ctrl+G), $(NAMES["B"]) sees the group") + if ROLE == "A" + # Both lenses as by Ctrl+click: a click may land on the card of the first one, which covers + # the second one depending on where the race left them + select!(gui, [obj(gui, :L1), obj(gui, :L2)]) + sleep(0.5) + key!(gui, Keyboard.g, Keyboard.left_control) + wait_solve(gui) + check(!isempty(gui.components.groups), "Ctrl+G makes a group") + t = until(() -> occursin("@group", read(joinpath(PROJ, "system.jl"), String)); timeout = 20.0) + check(!isnothing(t), "the autosave writes the @group line", "after $(fmt(t))") + shot(gui, "group") + deselect!(gui) + signal("grouped") + else + await("grouped"; timeout = 120.0) + t = until(() -> !isempty(gui.components.groups); timeout = 60.0) + check(!isnothing(t), "the group appears", "after $(fmt(t)) from the signal") + shot(gui, "group") + end + barrier("group") + return nothing +end + +"""Each side saves a version with Ctrl+S, one after the other.""" +function versions(gui) + step!("versions: Ctrl+S on both machines") + for who in ("A", "B") + if ROLE == who + # A change of this side since the last version, otherwise Ctrl+S says "no changes" + id = who == "A" ? :M1 : :M2 + check(select_obj!(gui, id), "the click selects $id") + key!(gui, Keyboard.page_up, Keyboard.left_shift) + check(!isnothing(written(gui, id)), "the key step on $id is written") + deselect!(gui) + n = length(BMOP.versions(PROJ)) + key!(gui, Keyboard.s, Keyboard.left_control) + shown = !isnothing(until(() -> !isnothing(GUI._version_box(gui)); timeout = 20.0)) + check(shown, "Ctrl+S shows the card of the version") + type!(gui, " (two machines, $ME)") + key!(gui, Keyboard.enter) + t = until(() -> length(BMOP.versions(PROJ)) == n + 1; timeout = 60.0) + check(!isnothing(t), "Enter saves a version", "after $(fmt(t)); $(n + 1) versions") + shot(gui, "version") + signal("version-$who"; n = n + 1) + else + s = await("version-$who"; timeout = 180.0) + n = parse(Int, s["n"]) + check(length(BMOP.versions(PROJ)) == n, "the version of $(NAMES[who]) is in the history", "$(length(BMOP.versions(PROJ))) versions") + end + barrier("versions-$who") + end + msgs = [v.message for v in BMOP.versions(PROJ)] + info("messages of the versions", join(msgs, " / ")) + return nothing +end + +"""B closes its window; A sees B leave. Both compare the window with the files.""" +function finish(gui) + step!("compare and leave") + ids = [:L1, :L2, :M1, :M2] + mine = Dict(i => wpos(gui, i) for i in ids) + for i in ids + check(near(mine[i], fpos(i)), "$i: the window agrees with the files", show3(mine[i])) + end + signal("final"; (Symbol(i) => mine[i] for i in ids)...) + other = await("final"; timeout = 120.0) + for i in ids + haskey(other, string(i)) || continue + check(near(mine[i], vec3(other[string(i)])), "$i: both windows agree") + end + if ROLE == "B" + close(gui) + WINDOW[] = nothing + signal("left") + else + await("left"; timeout = 120.0) + t = until(() -> !(THEM in others(gui)); timeout = 90.0) + check(!isnothing(t), "$THEM is no longer shown after leaving", "after $(fmt(t))") + shot(gui, "left") + end + return nothing +end + +function main() + start_run() + skew = preflight() + "--no-notify" in ARGS || notifications() + gui = open_window() + drag_seen(gui, "A", :L1, (40, 10)) + drag_seen(gui, "B", :M2, (-30, 20)) + claims(gui) + at_once(gui, skew, :L2, :M2, "pair", "both drag at once, different objects") + at_once(gui, skew, :L1, :L1, "race", "both drag L1 at once (a race)") + agent_edit(gui) + group(gui) + versions(gui) + finish(gui) + signal("end") + logline("**end**", isempty(FAILS) ? "all ok" : "$(length(FAILS)) FAIL", join(FAILS, "; ")) + return isempty(FAILS) ? 0 : 1 +end + +code = try + main() +catch e + msg = e isa Halt ? e.msg : sprint(showerror, e, catch_backtrace()) + drive = !reachable() + println(drive ? "HALT: the shared folder can not be reached: $msg" : "HALT: $msg") + try + reachable() && (signal("HALT"; reason = replace(first(msg, 300), '\n' => ' ')); logline("**HALT**", "HALT", first(msg, 2000))) + catch + end + 2 +finally + w = WINDOW[] + isnothing(w) || try close(w) catch end +end +exit(code) From 6c2797246d05899e4a99a8d2564393b9b3a21124 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Wed, 11 Nov 2026 23:39:24 +0100 Subject: [PATCH 36/47] Benchmarks for a slow shared drive: drive_io.jl times the single file accesses of a project in a folder, save_io.jl the parts of an autosave also in a folder of a drive; two_machines.jl reads a signal file again while the other side replaces it, and compares the windows after edits at the same moment only once the other side's change arrived Co-Authored-By: Claude Opus 5.5 --- benchmark/drive_io.jl | 47 ++++++++++++++++++++++++++++ benchmark/save_io.jl | 66 +++++++++++++++++++++++++-------------- benchmark/two_machines.jl | 33 +++++++++++++++----- 3 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 benchmark/drive_io.jl diff --git a/benchmark/drive_io.jl b/benchmark/drive_io.jl new file mode 100644 index 0000000..9f0da6a --- /dev/null +++ b/benchmark/drive_io.jl @@ -0,0 +1,47 @@ +# How long the file accesses of a project take on a drive: the accesses that the window does on its +# own thread (the autosave, the look of the watcher, the presence), one by one, without the GUI. +# Run it on each machine with a folder on the shared drive (and once with a folder on the local +# disk to compare); it writes only into `/drive-io-`, which it removes at the end. +# +# julia benchmark/drive_io.jl [n = 40] +# +# Prints per access the median and the maximum in ms. Only the standard library is needed. +using Printf + +const DIR = joinpath(abspath(ARGS[1]), "drive-io-" * gethostname()) +const N = length(ARGS) >= 2 ? parse(Int, ARGS[2]) : 40 + +median(x) = (s = sort(x); n = length(s); isodd(n) ? s[(n + 1) ÷ 2] : (s[n ÷ 2] + s[n ÷ 2 + 1]) / 2) + +function measure() + rm(DIR; recursive = true, force = true) + mkpath(joinpath(DIR, "sub")) + text = repeat("@component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, ConstantRefractiveIndex(1.5)) pos = (0.0, 0.1, 0.0)\n", 30) + file = joinpath(DIR, "system.jl") + write(file, text) + for i in 1:10 + write(joinpath(DIR, "sub", "f$i.toml"), "x = $i\n") + end + ops = [ + "isfile" => () -> isfile(file), + "mtime" => () -> mtime(file), + "read 3 KB" => () -> read(file, String), + "readdir (10 files)" => () -> readdir(joinpath(DIR, "sub")), + "write in place" => () -> write(file, text), + "write tmp + rename" => () -> (tmp = file * ".tmp"; write(tmp, text); mv(tmp, file; force = true)), + "create + remove" => () -> (f = joinpath(DIR, "lock"); write(f, "x"); rm(f)), + "mkdir + rmdir" => () -> (d = joinpath(DIR, "d"); mkdir(d); rm(d)), + ] + println("folder: ", DIR, ", ", N, " times each") + for (name, f) in ops + f() + t = Float64[] + for _ in 1:N + push!(t, @elapsed f()) + end + @printf("%-20s median %8.2f ms max %8.2f ms\n", name, 1000 * median(t), 1000 * maximum(t)) + end + rm(DIR; recursive = true, force = true) +end + +measure() diff --git a/benchmark/save_io.jl b/benchmark/save_io.jl index a855e55..4e31109 100644 --- a/benchmark/save_io.jl +++ b/benchmark/save_io.jl @@ -1,7 +1,11 @@ -# The parts of an autosave after a change over SMB (\\localhost\C$\...), to find the file accesses -# that cost the thread of the window. +# The parts of an autosave after a change, on the local disk and over SMB, to find the file +# accesses that cost the thread of the window. # -# julia --project=test benchmark/save_io.jl +# julia --project=test benchmark/save_io.jl [] +# +# Without the second folder, the copy of the project is measured on the local disk and through +# \\localhost\C$\...; with it, on the local disk and in a copy inside that folder (e.g. a folder on a +# shared drive), which is removed at the end. using GLMakie, BeamletOptics, BeamletOpticsGUI, BeamletOpticsProjects const G = BeamletOpticsGUI const P = BeamletOpticsProjects @@ -11,26 +15,40 @@ ms(t) = round(1000t; digits = 1) copy_dir = joinpath(mktempdir(), basename(ARGS[1])) cp(ARGS[1], copy_dir) rm(joinpath(copy_dir, ".bmo"); force = true, recursive = true) -for dir in (copy_dir, raw"\\localhost\C$" * copy_dir[3:end]) - gui = open_project(dir; display = false, trace_budget = Inf, throttle = false, detectors = []) - obj = first(x for x in keys(gui.sync.ids) if haskey(gui.components.origin, x)) - times = Dict{String, Float64}() - for i in 1:4 - G.translate3d!(gui, obj, [0.0, 0.0, 1e-4]) - parts = G._project_parts(gui, dir; name = gui.sync.base.name) - t = Dict( - "project_stamp" => @elapsed(P.project_stamp(dir)), - "file_marks" => @elapsed(G._file_marks(dir)), - "project_parts" => @elapsed(G._project_parts(gui, dir; name = gui.sync.base.name)), - "save_project" => @elapsed(save_project(gui, dir)), - "write_presence" => @elapsed(G._write_presence!(gui)), - "write_session" => @elapsed(G._write_session!(gui)), - ) - i > 1 && mergewith!(+, times, t) - end - println(dir) - for (k, v) in sort(collect(times); by = last, rev = true) - println(" ", rpad(k, 16), ms(v / 3), " ms") +remote = if length(ARGS) >= 2 + d = joinpath(abspath(ARGS[2]), "save-io-" * gethostname(), basename(ARGS[1])) + rm(dirname(d); recursive = true, force = true) + mkpath(dirname(d)) + cp(copy_dir, d) + d +else + raw"\\localhost\C$" * copy_dir[3:end] +end +try + for dir in (copy_dir, remote) + t_open = @elapsed(gui = open_project(dir; display = false, trace_budget = Inf, throttle = false, detectors = [])) + obj = first(x for x in keys(gui.sync.ids) if haskey(gui.components.origin, x)) + times = Dict{String, Float64}() + for i in 1:4 + G.translate3d!(gui, obj, [0.0, 0.0, 1e-4]) + t = Dict( + "project_stamp" => @elapsed(P.project_stamp(dir)), + "file_marks" => @elapsed(G._file_marks(dir)), + "project_parts" => @elapsed(G._project_parts(gui, dir; name = gui.sync.base.name)), + "save_project" => @elapsed(save_project(gui, dir)), + "write_presence" => @elapsed(G._write_presence!(gui)), + "write_session" => @elapsed(G._write_session!(gui)), + "files_look" => @elapsed(G._files_look(dir; stamp = true, except = G._person().id, known = String[])), + "claims" => @elapsed(P.claims(dir)), + ) + i > 1 && mergewith!(+, times, t) + end + println(dir, " (open_project ", ms(t_open), " ms, the first time compiles)") + for (k, v) in sort(collect(times); by = last, rev = true) + println(" ", rpad(k, 16), ms(v / 3), " ms") + end + close(gui) end - close(gui) +finally + length(ARGS) >= 2 && rm(dirname(remote); recursive = true, force = true) end diff --git a/benchmark/two_machines.jl b/benchmark/two_machines.jl index 0d025c3..75f2fdc 100644 --- a/benchmark/two_machines.jl +++ b/benchmark/two_machines.jl @@ -108,14 +108,27 @@ function signal(name; data...) return nothing end +""" +The data of the signal file `file`. A file that the other side is just replacing can not be opened +for a moment ("Permission denied" on a shared drive): read again for up to 10 s. +""" function readsignal(file) - d = Dict{String, String}() - for l in eachline(file) - occursin('=', l) || continue - k, v = split(l, '='; limit = 2) - d[k] = v + t0 = time() + while true + try + d = Dict{String, String}() + for l in eachline(file) + occursin('=', l) || continue + k, v = split(l, '='; limit = 2) + d[k] = v + end + return d + catch e + (e isa Union{Base.IOError, SystemError} && time() - t0 < 10) || rethrow() + reach() + sleep(0.2) + end end - return d end """Waits for the signal `name` of the other side; halts when it halts or the folder is gone.""" @@ -560,7 +573,13 @@ function at_once(gui, skew, idA, idB, tag, name) shot(gui, "at-once") deselect!(gui) signal("dragged-$tag"; pos = mine) - await("dragged-$tag"; timeout = 120.0) + theirs = await("dragged-$tag"; timeout = 120.0) + if idA !== idB + # The other object arrives here before the windows are compared + other_id = ROLE == "A" ? idB : idA + t = until(() -> near(wpos(gui, other_id), vec3(theirs["pos"])); timeout = 90.0) + check(!isnothing(t), "$other_id glides to the place of $THEM", "after $(fmt(t)) from the signal") + end if idA === idB # The same object: the window whose write came second finds the object changed in the # files and here, a conflict that the person decides on its card (unless the claim of the From e3912d0fdac8668bdc061496db7ea1125530da9d Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:10:31 +0100 Subject: [PATCH 37/47] A watched window writes the project files in the background: the autosave prepares the save on the window's thread and writes it on a thread of the background, its result taken at a later step (merge after files changed, claims read again, busy, failed, as before); the session file is written by one writer in the background that keeps the latest text; a slow look is no longer read again on the window's thread unless a solve holds the background. Closing, Ctrl+S, Ctrl+R and save_project wait for a running write. On a shared drive 89 ms away an autosave had stopped the window for 4-16 s and every click for about 1 s Co-Authored-By: Claude Opus 5.5 --- src/LiveProject.jl | 66 ++++++++-- src/LiveProjectSession.jl | 88 +++++++++++++- src/LiveProjectSync.jl | 154 +++++++++++++++++++++++- src/LiveProjectWatch.jl | 14 ++- src/LiveView.jl | 8 +- test/TestLiveProjectBackground.jl | 193 ++++++++++++++++++++++++++++++ test/TestLiveProjectSync.jl | 16 ++- 7 files changed, 515 insertions(+), 24 deletions(-) create mode 100644 test/TestLiveProjectBackground.jl diff --git a/src/LiveProject.jl b/src/LiveProject.jl index 0a50265..df7e071 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -436,9 +436,13 @@ Writes the view of the `gui` to `.bmo/view-.toml` of the person, in the fold with the `names` of its sources. `view.toml` in the folder is written only if it does not exist: it is the default for a person who has no view file yet. """ -function _write_view_toml(gui::LiveView, project, names) +_write_view_toml(gui::LiveView, project, names) = + _put_view_toml(project, sprint(io -> TOML.print(io, _view_dict(gui, names); sorted = true))) + +# Writes the `text` of a view file (see `_write_view_toml`) into the folder of the `project`; touches +# no window, so that it can run in the background +function _put_view_toml(project, text::AbstractString) path = _view_file(project.path) - text = sprint(io -> TOML.print(io, _view_dict(gui, names); sorted = true)) _put_atomic(path, text) default = BMOP.project_file(project, _VIEW_TOML) isfile(default) || _put_atomic(default, text) @@ -723,6 +727,24 @@ files in their own project; the window keeps its own and writes it once the conf is over. Objects that glide to a pose of the files land first. """ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) + # A write of the autosave in the background ends first, see `_start_save!` + _settle_save!(gui) + prep = _save_prepare(gui, dir; force) + # Refuses a line that another participant claimed (see `BeamletOpticsProjects.claim!`) + written = _without_looks(() -> _save_write(prep), gui) + return _save_finish!(gui, prep, written) +end + +""" + _save_prepare(gui, dir; force = false) -> NamedTuple + +The first part of `save_project(gui, dir)`, on the thread of the window: what the save writes, taken +from the `gui` (the project of its setup with the lines that keep the state of the files, see +`_composed`, the text of its view file), such that `_save_write` needs nothing of the `gui` and can +run in the background. Objects that glide land first. "Save as" copies the own code of the project +to the new folder here. +""" +function _save_prepare(gui::LiveView, dir::AbstractString; force::Bool = false) dir = abspath(dir) from = gui.project save_as = !isnothing(from) && dir != from @@ -744,16 +766,40 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) BMOP.copy_code(from, dir) end new = !_is_project(dir) - # Refuses a line that another participant claimed (see `BeamletOpticsProjects.claim!`) - path = _without_looks(gui) do - save_project(project; packages = _project_packages(), force, by = _person().id) - end + view = sprint(io -> TOML.print(io, _view_dict(gui, parts.names); sorted = true)) + return (; dir, from, save_as, parts, keep, composed, project, new, force, view, + packages = _project_packages(), by = _person().id) +end + +""" + _save_write(prep) -> NamedTuple + +The second part of `save_project(gui, dir)`: writes the files of the prepared save `prep` (see +`_save_prepare`) and returns their `path` and `marks` (see `_file_marks`). Touches nothing of the +window: it runs on the thread of the window or in the background (see `_start_save!`). Throws as +`BeamletOpticsProjects.save_project`. +""" +function _save_write(prep) + path = save_project(prep.project; prep.packages, prep.force, prep.by) # A new project gets the skills of BeamletOptics and BeamletOpticsProjects from its scaffold, # and the one of the GUI here; derived copies, not part of the versions - new && Base.CoreLogging.with_logger(Base.CoreLogging.NullLogger()) do - install_agent_skill(joinpath(dir, ".claude", "skills")) + prep.new && Base.CoreLogging.with_logger(Base.CoreLogging.NullLogger()) do + install_agent_skill(joinpath(prep.dir, ".claude", "skills")) end - _write_view_toml(gui, parts.project, parts.names) + _put_view_toml(prep.project, prep.view) + return (; path, marks = _file_marks(prep.dir)) +end + +""" + _save_finish!(gui, prep, written) -> String + +The last part of `save_project(gui, dir)`, on the thread of the window, after `_save_write` wrote +the prepared save `prep`: the window takes the machine ids of the save, its new base and stamp, +the folder of the project and the status line. Returns the path of `system.jl`. +""" +function _save_finish!(gui::LiveView, prep, written) + (; dir, from, save_as, parts, keep, composed, project) = prep + path = written.path # The uids that the save gave are those of `project`; at the lines that keep the files' state # it holds those of the files, not of the window _apply_project_names!(gui, project; skip = composed.keys, window = parts.project) @@ -773,7 +819,7 @@ function save_project(gui::LiveView, dir::AbstractString; force::Bool = false) gui.project = dir # The files of this stamp are the window's own write, see `_own_written` _own_save!(gui, project.stamp) - _saved!(gui, project; keep) + _saved!(gui, project; keep, marks = written.marks) connected && _connect_session!(gui) dir == from || _set_title!(gui, basename(dir)) msg = save_as ? "project saved as $dir" : "project saved to $dir" diff --git a/src/LiveProjectSession.jl b/src/LiveProjectSession.jl index e5142a3..2464d62 100644 --- a/src/LiveProjectSession.jl +++ b/src/LiveProjectSession.jl @@ -220,9 +220,22 @@ and the journal. Does nothing without a project. An error of writing never propa logged once per distinct message. """ function _write_session!(gui::LiveView) - isnothing(gui.project) && return nothing + dir = gui.project + isnothing(dir) && return nothing + text = _session_text(gui) + # A watched window writes it in the background, see `_write_later!` + if gui.sync.background + _write_later!(gui, :session, () -> _put_session_logged(dir, text)) + else + _put_session_logged(dir, text) + end + return nothing +end + +# Writes the session file `text` of the project `dir`; an error is logged once per distinct message +function _put_session_logged(dir, text) try - _put_session(gui.project, _session_text(gui)) + _put_session(dir, text) catch e e isa InterruptException && rethrow() msg = sprint(showerror, e) @@ -237,6 +250,75 @@ end # The last message of a failed write that was logged const _SESSION_WARNED = Ref("") +""" +The writes of a window in the background (`gui.sync.writer`, see `_write_later!`): the latest +write per file (`jobs`, by a key, in the order of their first request, `order`), done by one task +on a thread of the background while `running`. +""" +mutable struct _Writer + jobs::Dict{Symbol, Function} + order::Vector{Symbol} + running::Bool + idle::Threads.Condition +end +_Writer() = _Writer(Dict{Symbol, Function}(), Symbol[], false, Threads.Condition()) + +_file_writer(gui::LiveView) = (w = gui.sync.writer; w isa _Writer ? w : (gui.sync.writer = _Writer())) + +""" + _write_later!(gui, key, f) + +Writes a file of the `gui` in the background: `f()` (which must not touch the window, only write) +runs on a thread of the background, after the writes that were requested before. A request of the +same `key` that has not run yet is replaced: only the latest text of a file is written. See +`_drain_writes!`. +""" +function _write_later!(gui::LiveView, key::Symbol, f::Function) + w = _file_writer(gui) + start = lock(w.idle) do + haskey(w.jobs, key) || push!(w.order, key) + w.jobs[key] = f + w.running && return false + w.running = true + return true + end + start && Threads.@spawn _run_writes!(w) + return nothing +end + +# The task of the writer `w`: the requested writes, one after the other, until there are none +function _run_writes!(w::_Writer) + while true + f = lock(w.idle) do + if isempty(w.order) + w.running = false + notify(w.idle) + return nothing + end + return pop!(w.jobs, popfirst!(w.order)) + end + isnothing(f) && return nothing + try + f() + catch e + e isa InterruptException && rethrow() + @warn "a write of the window in the background failed" exception = e + end + end +end + +"""Waits until the writes of the `gui` in the background are done (see `_write_later!`).""" +function _drain_writes!(gui::LiveView) + w = gui.sync.writer + w isa _Writer || return nothing + lock(w.idle) do + while w.running + wait(w.idle) + end + end + return nothing +end + """ _connect_session!(gui) @@ -266,6 +348,8 @@ function _session_closed!(gui::LiveView) off(l) end empty!(gui.sync.session) + # A write in the background would bring the file back after it was removed + _drain_writes!(gui) isnothing(gui.project) && return nothing try kept = _closed_kept(gui) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index bbac275..6ef8fd4 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -31,9 +31,10 @@ function _shown(gui::LiveView) end """ - _saved!(gui, project; written = true, keep = String[]) + _saved!(gui, project; written = true, keep = String[], marks = nothing) -Records that the `gui` and the files of the `project` agree, after it was opened, saved or the +`marks` are the `_file_marks` of the files if a write in the background read them (else they are +read here). Records that the `gui` and the files of the `project` agree, after it was opened, saved or the files were merged: `project` becomes the `base` of the comparisons (see `_ProjectSync`), the conflicts are over except those of `keep` (a save of the window writes the files' state of the objects in conflict and keeps them open, see `_saved_state`), the states that the window wrote are @@ -42,12 +43,13 @@ remembered (unless `written` is `false`, e.g. for files that `Ctrl+R` took in; s watched view publishes the stamp of the files in its presence file (the others name it as the writer, see `_writer`). """ -function _saved!(gui::LiveView, project; written::Bool = true, keep = String[]) +function _saved!(gui::LiveView, project; written::Bool = true, keep = String[], marks = nothing) s = gui.sync before = s.base s.base = _align_uids!(gui, project) s.stamp = project.stamp - s.marks = _file_marks(project.path) + # read by a write in the background already, see `_save_write` + s.marks = something(marks, _file_marks(project.path)) s.src = _src_marks(s.marks) s.changed_at = s.handled = nothing s.failed = false @@ -202,10 +204,21 @@ line, tried again after `_AUTOSAVE_RETRY` seconds. `force` saves without waiting files changed or objects glide (when the window closes or `Ctrl+S` saves a version). A window that closes with objects whose state of the window is not written (see `_kept_back`) writes its setup to the backup of the person, which `.bmo/session.md` names. + +A watched window (`background`, see `_start_watching!`) writes in the background instead, such +that a slow drive does not stop the window (see `_start_save!`): `:saving` while that write runs; +its result is taken at a later look (see `_take_save!`), which returns the result above. `force` +waits for a write that runs and then saves on the thread of the window. """ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) s = gui.sync dir = gui.project + if force + _settle_save!(gui) + elseif !isnothing(s.save) + # `:saving` also when it wrote again after a merge + return _take_save!(gui) + end (isnothing(dir) || isnothing(s.base)) && return :none if force _finish_glides!(gui) @@ -239,9 +252,129 @@ function _autosave!(gui::LiveView; now::Real = time(), force::Bool = false) _write_backup!(gui, parts.project) return :paused end + # The files that changed meanwhile are found by the save itself (`ProjectChanged`) and merged + # then, see `_take_save!`: reading their stamp here would cost the window a round trip of the drive + s.background && !force && return _start_save!(gui, parts, now) return _autosave_write!(gui, parts, now) end +""" + _start_save!(gui, parts, now; merged = false, refreshed = false) -> Symbol + +Starts the write of the autosave of the setup `parts` (see `_project_parts`) of the `gui` in the +background: `_save_prepare` here, `_save_write` on a thread of the background (after the look that +runs, see `_without_looks`; no look starts meanwhile), and returns `:saving`. `_take_save!` takes its +result. `merged` and `refreshed` are the retries that `_save_to!` makes: after a merge of files +that changed, after reading the claims of the others again. +""" +function _start_save!(gui::LiveView, parts, now::Real; merged::Bool = false, refreshed::Bool = false) + s = gui.sync + dir = gui.project + changes = BMOP.diff_projects(s.base, _saved_state(gui, parts.project)) + status = gui.status.text[] + prep = _save_prepare(gui, dir) + run = s.look + look = isnothing(run) ? nothing : run.task + s.saving = true + task = Threads.@spawn begin + isnothing(look) || timedwait(() -> istaskdone(look), _LOOK_DRAIN; pollint = 0.01) + try + _SAVE_HOOK[]() + (; ok = true, written = _save_write(prep), busy = false, error = nothing) + catch e + e isa InterruptException && rethrow() + # Another writer holds the lock, if the files are still those that the window knows + busy = e isa BMOP.ProjectChanged && try + BMOP.project_stamp(prep.dir) == prep.project.stamp + catch + false + end + (; ok = false, written = nothing, busy, error = e) + end + end + s.save = (; task, prep, parts, changes, status, now = Float64(now), merged, refreshed) + return :saving +end + +""" +Waits for the write of the autosave of the `gui` in the background and takes its result (see +`_take_save!`), also a write that starts again after a merge; nothing if none runs. +""" +function _settle_save!(gui::LiveView) + while !isnothing(gui.sync.save) + _take_save!(gui; wait = true) + end + return nothing +end + +# Called by the write of the autosave in the background before it writes (a test slows it down) +const _SAVE_HOOK = Ref{Function}(() -> nothing) + +""" + _take_save!(gui; wait = false) -> Symbol + +Takes the result of the write of the autosave of the `gui` in the background (see `_start_save!`): +`:saving` while it runs (`wait` waits for it), else as `_autosave!` after a save: `:saved` (the +window takes the machine ids and the new base, the journal of the session), `:busy` (another +writer holds the lock, tried again later), `:failed`, `:paused` (the files do not load) or +`:saving` again when the files changed or the claims of others did meanwhile: the window merged +them or read them again and writes again at once. As the retries of `_save_to!`. +""" +function _take_save!(gui::LiveView; wait::Bool = false) + s = gui.sync + sv = s.save + isnothing(sv) && return :none + wait ? Base.wait(sv.task) : (istaskdone(sv.task) || return :saving) + s.save = nothing + s.saving = false + r = fetch(sv.task) + dir = gui.project + if r.ok + _save_finish!(gui, sv.prep, r.written) + return _autosaved!(gui, sv.parts, sv.changes, sv.status) + end + e = r.error + if e isa BMOP.ProjectChanged + if sv.merged || r.busy + s.edited_at = sv.now + return :busy + end + # Without gliding: the save right after must see the poses of the files + s.changed_at = nothing + if _adopting(() -> _sync!(gui; animate = false), gui) === :failed || s.failed + _write_backup!(gui, _collect_project(gui, dir)) + return :paused + end + return _restart_save!(gui, sv; merged = true) + elseif e isa ArgumentError && !sv.refreshed && _refresh_claims!(gui, dir) + return _restart_save!(gui, sv; refreshed = true) + elseif e isa Union{Base.IOError, SystemError} + # The file system refused the write for now, see `_save_to!` + @debug "save_project: the files are busy" exception = e + s.edited_at = sv.now + return :busy + end + Base.CoreLogging.with_logger(Base.CoreLogging.SimpleLogger(stderr, Base.CoreLogging.Error)) do + _save_failed!(gui, e) + end + if s.failed + _write_backup!(gui, _collect_project(gui, dir)) + return :paused + end + s.edited_at = sv.now + _AUTOSAVE_RETRY + return :failed +end + +# Starts the write of `sv` (see `_start_save!`) again with the setup as it is now, after a merge or a +# new read of the claims; `:none` if the setup is as in the files then +function _restart_save!(gui::LiveView, sv; merged::Bool = sv.merged, refreshed::Bool = sv.refreshed) + s = gui.sync + parts = _project_parts(gui, gui.project) + _uidless_code(_saved_state(gui, parts.project)) == _uidless_code(s.base) && return :none + _start_save!(gui, parts, sv.now; merged, refreshed) + return :saving +end + """Whether the mouse drags or places an object in the `gui` or in a view linked to it.""" _busy(gui::LiveView) = any(v -> v.controls.dragging || _placing(v), Any[gui, gui.links.views...]) @@ -290,6 +423,17 @@ function _autosave_write!(gui::LiveView, parts, now::Real) s.edited_at = now + _AUTOSAVE_RETRY return :failed end + return _autosaved!(gui, parts, changes, status) +end + +""" +After a save of the autosave (on the thread of the window or in the background): the `changes` of +the person go into the journal of the session, except those of lines that others claim; a new list +of objects without a recipe of `parts` is warned once, otherwise the status line before the save, +`status`, stays. Returns `:saved`. +""" +function _autosaved!(gui::LiveView, parts, changes, status) + s = gui.sync # Not the changes of the lines that the save did not write: claimed since the last read held = _held_keys(gui) isempty(held) || (changes = map(cs -> filter(c -> !(_change_key(c) in held), cs), changes)) @@ -1033,6 +1177,8 @@ end """Takes all of the files of the project into the open `gui`, see `_reload_key!`.""" function _load_from_disk!(gui::LiveView) + # A write of the autosave that runs in the background ends first: it would write afterwards + _settle_save!(gui) disk = try BMOP.load_project(gui.project) catch e diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl index d038976..27b30f6 100644 --- a/src/LiveProjectWatch.jl +++ b/src/LiveProjectWatch.jl @@ -87,9 +87,11 @@ to a file waits for the network, often longer than a frame: the window never wai files are settled, and writes the own presence file when it is due (see `_presence_due!`). - A look that started before the last save of the window (see `_saved!`) or before a look here is dropped; one of another folder too. -- While a look waits longer than `_LOOK_WAIT` seconds, e.g. behind a solve on the only thread of - the background (a solve does not yield), the files are read here instead, without the stamp, at - most every `_LOOK_WAIT` seconds, so that changes of the files and the own presence go on. +- While a look waits longer than `_LOOK_WAIT` seconds behind a solve on the only thread of the + background (a solve does not yield, see `_running`), the files are read here instead, without + the stamp, at most every `_LOOK_WAIT` seconds, so that changes of the files and the own presence + go on. Without a solve the look is waited for: on a slow drive it takes that long by itself, and + a read here would stop the window. - An error of a look is thrown here once, after the next look started. - While the window writes the files (`saving`, see `_without_looks`) no look starts. """ @@ -100,7 +102,9 @@ function _take_look!(gui::LiveView; now::Real = time()) dir = gui.project run = s.look if !isnothing(run) && !istaskdone(run.task) - (isnothing(dir) || now - s.look_at < _LOOK_WAIT) && return nothing + # Only a solve holds the thread of the background that long; a look on a slow drive takes + # longer than `_LOOK_WAIT` by itself and is waited for, not read here again + (isnothing(dir) || now - s.look_at < _LOOK_WAIT || !_running(gui)) && return nothing s.look_at = Float64(now) s.gen += 1 # Two writes of the own presence file at a time would share its temporary file @@ -242,6 +246,8 @@ or of a script) is not watched and writes neither; the functions can be called i function _start_watching!(gui::LiveView; notes::Bool = true, poll::Real = _POLL_WITH_MONITOR) s = gui.sync (isnothing(s.timer) && !s.closed && !isnothing(gui.project) && _shown(gui)) || return nothing + # The autosave and the session file are written in the background, see `_start_save!` + s.background = true _connect_session!(gui) # The presence file is written by the looks of the watcher, see `_presence_due!` s.presence_due = true diff --git a/src/LiveView.jl b/src/LiveView.jl index 0815161..bc19cf0 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -658,7 +658,10 @@ What the view knows of the files of its project, which an agent may change while merge, `adopting` whether a merge is being applied (its solves wait for its end), `saving` whether the view writes the files (no look reads them meanwhile, see `_without_looks`) and `monitor` the notifications of changes of the files, see `LiveProjectWatch.jl`; -- `claim`: the claims of the person of the view and of the others, see `LiveProjectClaims.jl`. +- `claim`: the claims of the person of the view and of the others, see `LiveProjectClaims.jl`; +- `background`: whether the view writes its files in the background (a watched window, see + `_start_watching!`), `save` the write of the autosave that runs there (see `_start_save!`) and + `writer` the writes of its session file (see `_write_later!`). """ Base.@kwdef mutable struct _ProjectSync base::Any = nothing @@ -695,6 +698,9 @@ Base.@kwdef mutable struct _ProjectSync saving::Bool = false monitor::Any = nothing claim::Any = nothing + background::Bool = false + save::Any = nothing + writer::Any = nothing end """ diff --git a/test/TestLiveProjectBackground.jl b/test/TestLiveProjectBackground.jl new file mode 100644 index 0000000..fa4da37 --- /dev/null +++ b/test/TestLiveProjectBackground.jl @@ -0,0 +1,193 @@ +module TestLiveProjectBackground + +# A watched window writes its files in the background, such that a slow drive does not stop it +# (see `_start_save!`, `_write_later!`): here the writes are slowed down by a hook instead + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const BMOP = BeamletOpticsProjects +const TOML = GUI.TOML +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const START = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, ConstantRefractiveIndex(1.5)) pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component M2 = RoundPlanoMirror(0.0254, 0.006) pos = (0.05, 0.2, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, M2] sources = [B1] + """ + +"""A project with `START`, opened (not shown) and saved once; it writes in the background as a watched window.""" +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + gui.sync.background = true + return gui, dir +end + +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +file_pos(dir, id) = collect(BMOP.pose(BMOP.load_project(dir), id).pos) +pos(x) = Vector{Float64}(position(x)) + +"""Calls the autosave until it is no longer `:saving` (the write in the background ended), returns its last result.""" +function settle!(gui, now) + r = :saving + for _ in 1:400 + r = GUI._autosave!(gui; now) + r === :saving || return r + sleep(0.01) + end + return r +end + +"""Changes the setup and lets `_AUTOSAVE_DELAY` pass: the next autosave writes. Returns the time to call it with.""" +function changed!(gui, id, d) + GUI.translate3d!(gui, obj(gui, id), d) + t = time() + @test GUI._autosave!(gui; now = t) === :pending + return t + 1.0 +end + +hook!(f) = (GUI._SAVE_HOOK[] = f) +unhook!() = (GUI._SAVE_HOOK[] = () -> nothing) + +@testset "the autosave of a watched window writes in the background" begin + gui, dir = opened() + s = gui.sync + try + @testset "the window does not wait for a slow write" begin + hook!(() -> sleep(1.0)) + t = changed!(gui, :L1, [0.0, 0.0, 0.01]) + took = @elapsed(r = GUI._autosave!(gui; now = t)) + @test r === :saving && took < 0.5 + @test s.saving && !isnothing(s.save) + # no look starts while it writes + @test GUI._take_look!(gui; now = t) === nothing + # the next steps do not wait either + @test @elapsed(GUI._autosave!(gui; now = t + 0.1)) < 0.1 + @test settle!(gui, t + 2) === :saved + @test !s.saving && isnothing(s.save) + @test file_pos(dir, :L1) ≈ pos(obj(gui, :L1)) + @test GUI._autosave!(gui; now = t + 3) === :none + end + @testset "a change during the write is saved by the next write" begin + hook!(() -> sleep(0.5)) + t = changed!(gui, :M1, [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui; now = t) === :saving + GUI.translate3d!(gui, obj(gui, :M2), [0.0, 0.0, 0.02]) + @test settle!(gui, t + 0.1) === :saved + unhook!() + # the change of M2 came after the write started: not written yet + @test !(file_pos(dir, :M2) ≈ pos(obj(gui, :M2))) + @test GUI._autosave!(gui; now = t + 1) === :pending + @test GUI._autosave!(gui; now = t + 2) === :saving + @test settle!(gui, t + 2) === :saved + @test file_pos(dir, :M1) ≈ pos(obj(gui, :M1)) && file_pos(dir, :M2) ≈ pos(obj(gui, :M2)) + end + @testset "files changed by another before the write: merged, written again" begin + n = Ref(0) + hook!(() -> (n[] += 1; n[] == 1 && BMOP.move!(dir, [:M2]; offset = (0.0, 0.03, 0.0), by = "agent"))) + m2 = file_pos(dir, :M2) + t = changed!(gui, :L1, [0.01, 0.0, 0.0]) + @test GUI._autosave!(gui; now = t) === :saving + @test settle!(gui, t) === :saved + @test n[] == 2 + # both: the change of the window and the one of the agent + @test file_pos(dir, :L1) ≈ pos(obj(gui, :L1)) + @test file_pos(dir, :M2) ≈ m2 .+ [0.0, 0.03, 0.0] + @test pos(obj(gui, :M2)) ≈ m2 .+ [0.0, 0.03, 0.0] + unhook!() + end + @testset "the file system refuses the write: tried again" begin + n = Ref(0) + hook!(() -> (n[] += 1; n[] == 1 && throw(Base.IOError("the file is in use", -4082)))) + t = changed!(gui, :M1, [0.0, 0.01, 0.0]) + @test GUI._autosave!(gui; now = t) === :saving + @test settle!(gui, t) === :busy + @test !(file_pos(dir, :M1) ≈ pos(obj(gui, :M1))) + @test GUI._autosave!(gui; now = t + 1) === :saving + @test settle!(gui, t + 1) === :saved + @test file_pos(dir, :M1) ≈ pos(obj(gui, :M1)) + unhook!() + end + @testset "a closing window and Ctrl+S wait for the write" begin + hook!(() -> sleep(0.5)) + t = changed!(gui, :L1, [0.0, 0.0, -0.01]) + @test GUI._autosave!(gui; now = t) === :saving + GUI.translate3d!(gui, obj(gui, :M1), [0.0, 0.0, -0.01]) + # force: waits for the write, then writes the rest on this thread + @test GUI._autosave!(gui; now = t, force = true) === :saved + @test isnothing(s.save) && !s.saving + @test file_pos(dir, :L1) ≈ pos(obj(gui, :L1)) && file_pos(dir, :M1) ≈ pos(obj(gui, :M1)) + unhook!() + end + @testset "save_project and Ctrl+R wait for the write" begin + hook!(() -> sleep(0.5)) + t = changed!(gui, :M2, [0.0, 0.0, -0.01]) + @test GUI._autosave!(gui; now = t) === :saving + save_project(gui) + @test isnothing(s.save) + @test file_pos(dir, :M2) ≈ pos(obj(gui, :M2)) + t = changed!(gui, :M2, [0.0, 0.0, -0.01]) + @test GUI._autosave!(gui; now = t) === :saving + GUI._reload_key!(gui) + @test isnothing(s.save) + # the write was done before the window took the files + @test file_pos(dir, :M2) ≈ pos(obj(gui, :M2)) + unhook!() + end + finally + unhook!() + close(gui) + end +end + +@testset "a watched window writes its session file in the background" begin + gui, dir = opened() + try + file = GUI._session_file(dir) + GUI._write_session!(gui) + GUI._drain_writes!(gui) + @test isfile(file) + # Many writes in a row: the latest text is written last + for id in (:L1, :M1, :M2) + gui.controls.selected[] = obj(gui, id) + GUI._write_session!(gui) + end + GUI._drain_writes!(gui) + @test read(file, String) == GUI._session_text(gui) + @test occursin("M2", read(file, String)) + # A write that is slow does not hold the window + slow = Ref(false) + GUI._write_later!(gui, :slow, () -> (sleep(0.5); slow[] = true)) + @test !slow[] + GUI._drain_writes!(gui) + @test slow[] + # A failed write is logged and the next ones run + @test_logs (:warn, r"in the background failed") begin + GUI._write_later!(gui, :bad, () -> error("disk full")) + GUI._drain_writes!(gui) + end + ok = Ref(false) + GUI._write_later!(gui, :next, () -> (ok[] = true)) + GUI._drain_writes!(gui) + @test ok[] + finally + close(gui) + end +end + +end diff --git a/test/TestLiveProjectSync.jl b/test/TestLiveProjectSync.jl index 94d1806..03ba7ec 100644 --- a/test/TestLiveProjectSync.jl +++ b/test/TestLiveProjectSync.jl @@ -748,11 +748,21 @@ end wait(s.look.task) slow = Threads.@spawn (sleep(1.0); GUI._files_look(dir)) s.look = (; task = slow, gen = s.gen, presence = false) - # While it runs, nothing, until `_LOOK_WAIT` passed: then read here, without the stamp + # Without a solve, a slow look (e.g. on a slow drive) is waited for, never read here @test GUI._take_look!(gui; now = t2 + 0.1) === nothing - look = GUI._take_look!(gui; now = t2 + GUI._LOOK_WAIT) + @test GUI._take_look!(gui; now = t2 + GUI._LOOK_WAIT) === nothing + # Behind a solve: nothing, until `_LOOK_WAIT` passed; then read here, without the stamp + done = Base.Event() + solve = Threads.@spawn (wait(done); nothing) + gui.trace.job = GUI._SolveJob(solve, done, [BeamletOptics.ProgressSink()], [Point3f(0)], + r -> nothing, nothing, :solve_time, time(), (; k = 0, t0 = NaN, t = NaN, count = 0)) + @test GUI._take_look!(gui; now = t2 + 0.2) === nothing + look = GUI._take_look!(gui; now = t2 + 0.2 + GUI._LOOK_WAIT) @test look.marks == GUI._file_marks(dir) && look.stamp === nothing - @test GUI._take_look!(gui; now = t2 + GUI._LOOK_WAIT + 0.1) === nothing + @test GUI._take_look!(gui; now = t2 + 0.3 + GUI._LOOK_WAIT) === nothing + gui.trace.job = nothing + notify(done) + wait(solve) # The waiting one is dropped once it is done: it started before wait(slow) @test GUI._take_look!(gui; now = t2 + 3) === nothing From 63c3c2d7f0bbda46365c9e7e5d359ad990e48782 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:13:10 +0100 Subject: [PATCH 38/47] A card row can fill the width of the card (#64): CardWidget(...; fill = true) takes the rest of the width of the card (a given width wins), CardRow(...; aligned = true) gives the first texts of its rows one width, so that their widgets start in one column; WIDGETS.md shows both Co-Authored-By: Claude Opus 5.5 --- skills/beamletopticsgui/WIDGETS.md | 34 +++++- src/API.jl | 28 ++++- src/LiveBackground.jl | 5 +- src/LiveCard.jl | 57 ++++++++- src/LiveCards.jl | 25 +++- test/TestLiveCardRowWidth.jl | 183 +++++++++++++++++++++++++++++ 6 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 test/TestLiveCardRowWidth.jl diff --git a/skills/beamletopticsgui/WIDGETS.md b/skills/beamletopticsgui/WIDGETS.md index c121a0b..3c6bd61 100644 --- a/skills/beamletopticsgui/WIDGETS.md +++ b/skills/beamletopticsgui/WIDGETS.md @@ -35,7 +35,7 @@ control on its page "Pose" (the rows of `card_rows`), and every card, also a pin own beam types add `beam_card_rows(x)...` (toggle `:beam_on`, `:show_beams` for Gaussian beamlets, `:polarization` and sliders `:pol_wavelength`, `:pol_amplitude` for polarized beams, the box `:flen` for the drawn length of rays that hit nothing). -2. One `CardRow` per line; cells are strings or `CardWidget(T; name, value, on, solve, attributes...)` +2. One `CardRow` per line; cells are strings or `CardWidget(T; name, value, on, solve, fill, attributes...)` with `T` a Makie block (`Label`, `Slider`, `Toggle`, `Textbox`, `Button`, `Menu`) or an own widget type. 3. `value(gui, obj)` returns what the widget shows, in display units (e.g. mm, %); it must be cheap, it runs after every move, solve and input. @@ -85,6 +85,38 @@ BeamletOpticsGUI.card_actions(a::MyAttenuator) = ( on = (gui, a, _) -> (a.transmission = 0.0), solve = true)) ``` +### Widths of rows: a widget to the right edge, labels in one column + +Each row is laid out on its own, as wide as its cells; the widest row (or the head) sets the width of +the card. Do not measure pixels to line rows up: + +- `CardWidget(T; fill = true, ...)`: the widget takes the rest of the width of the card, e.g. a + `Slider` that ends at the right edge. Its row then does not make the card wider (only as wide as + its other cells and a short widget need). At most one widget per row (`ArgumentError` otherwise); + a `width` among the attributes wins, and `fill` is ignored. +- `CardRow(cells...; aligned = true)`: the first cells (texts) of all `aligned` rows of the card get + the width of the widest of them, so their second cells start at the same x. Rows without + `aligned` keep the width of their own first cell. + +```julia +# A wheel with two neutral density filters: the labels in one column, the sliders to the right edge +mutable struct MyFilterWheel{T} <: BeamletOptics.AbstractObject{T} + shape::BeamletOptics.PlanoSurfaceSDF{T} + od1::Float64 + od2::Float64 +end + +BeamletOpticsGUI.card_rows(w::MyFilterWheel) = (pose_card_rows(w)..., + CardRow("OD 1", + CardWidget(Slider; name = :od1, range = 0:0.1:4, fill = true, + value = (gui, w) -> w.od1, on = (gui, w, v) -> (w.od1 = v)); + aligned = true), + CardRow("OD 2 (back)", + CardWidget(Slider; name = :od2, range = 0:0.1:4, fill = true, + value = (gui, w) -> w.od2, on = (gui, w, v) -> (w.od2 = v)); + aligned = true)) +``` + ## Recipe: a card for your system type A system (subtype of `AbstractSystem`) has a card too: shown, without gizmo and without a selection diff --git a/src/API.jl b/src/API.jl index 691253f..a26de2f 100644 --- a/src/API.jl +++ b/src/API.jl @@ -229,6 +229,11 @@ textboxes take the keyboard. `T` may be an own widget type with methods of [`car - `solve = true`: the input changes the optics, e.g. a parameter of `obj`: a running solve is cancelled before `on`, and the systems are solved again afterwards (or the beams are marked as outdated without auto tracing), like after a move, via the `on_change` of the controls. +- `fill = true`: the widget takes the rest of the width of the card, e.g. a `Slider` that ends at + the right edge of the card, however wide its other rows or its head make it. Its row spans the + width of the card and makes the card only as wide as its other cells and a short widget need. At + most one widget of a [`CardRow`](@ref) fills. A `width` among the `attributes` wins: the widget + keeps that width, and `fill` is ignored. - `name`: finds the widget on a card, e.g. in tests. `gui` is the `LiveView`, e.g. for the beams of the last solve or the status line. @@ -240,22 +245,37 @@ struct CardWidget on::Any solve::Bool name::Union{Nothing, Symbol} + fill::Bool end -CardWidget(type::Type; name = nothing, value = nothing, on = nothing, solve::Bool = false, attributes...) = - CardWidget(type, NamedTuple(attributes), value, on, solve, name) +CardWidget(type::Type; name = nothing, value = nothing, on = nothing, solve::Bool = false, fill::Bool = false, + attributes...) = CardWidget(type, NamedTuple(attributes), value, on, solve, name, fill) """ - CardRow(cells...) + CardRow(cells...; aligned = false) A line of a card of [`live_view`](@ref), see [`card_rows`](@ref): its `cells` from left to right, each a text or a [`CardWidget`](@ref). + +- `aligned = true`: the first cell, if it is a text, is as wide as the widest first text of the + `aligned` rows of the card, such that their second cells start at the same x, e.g. the labels of + a column of sliders. The first cell of a row without `aligned` keeps its own width. + +At most one widget of a row has `fill = true`, else an `ArgumentError` is thrown: it takes the rest +of the width of the card, see [`CardWidget`](@ref). """ struct CardRow cells::Vector{Union{String, CardWidget}} + aligned::Bool + function CardRow(cells::Vector{Union{String, CardWidget}}, aligned::Bool) + count(c -> c isa CardWidget && c.fill, cells) <= 1 || + throw(ArgumentError("a CardRow has at most one CardWidget with `fill = true`")) + return new(cells, aligned) + end end -CardRow(cells::Union{AbstractString, CardWidget}...) = CardRow(Union{String, CardWidget}[_card_cell(c) for c in cells]) +CardRow(cells::Union{AbstractString, CardWidget}...; aligned::Bool = false) = + CardRow(Union{String, CardWidget}[_card_cell(c) for c in cells], aligned) _card_cell(s::AbstractString) = String(s) _card_cell(w::CardWidget) = w diff --git a/src/LiveBackground.jl b/src/LiveBackground.jl index d3a4f1e..caeb9c2 100644 --- a/src/LiveBackground.jl +++ b/src/LiveBackground.jl @@ -34,10 +34,11 @@ The `row` of the card of `obj` on the card of the `_BackgroundItem` of `obj`: it instead of the item in `value(gui, obj)` and `on(gui, obj, v)`. An input with `solve = true` is applied like on the card of `obj` itself (see `_apply_on!`), i.e. `on_change` gets `obj`. """ -_for_object(r::CardRow, @nospecialize(obj)) = CardRow(Union{String, CardWidget}[_for_object(c, obj) for c in r.cells]) +_for_object(r::CardRow, @nospecialize(obj)) = + CardRow(Union{String, CardWidget}[_for_object(c, obj) for c in r.cells], r.aligned) _for_object(s::String, _) = s _for_object(w::CardWidget, @nospecialize(obj)) = CardWidget(w.type, w.attributes, _value_of(w.value, obj), - _on_of(w.on, obj, w.solve), false, w.name) + _on_of(w.on, obj, w.solve), false, w.name, w.fill) _value_of(::Nothing, _) = nothing _value_of(value, @nospecialize(obj)) = (gui, _) -> value(gui, obj) diff --git a/src/LiveCard.jl b/src/LiveCard.jl index 0823826..7d288fc 100644 --- a/src/LiveCard.jl +++ b/src/LiveCard.jl @@ -541,10 +541,18 @@ _card_value(c::_AbstractCard, a::_ThemeColor) = getproperty(c.theme, a.name) Attributes of the block of the declared widget `w` on the card `c`: the style of the card for the type of the block (see `_card_style`), then the attributes of `w` with their values on the card -(see `_card_value`), which the host may adapt, see `_host_attributes`. +(see `_card_value`) and, if it fills the rest of its row (see `_fills`), the width of its cell +without telling it, which the host may adapt, see `_host_attributes`. """ _cell_attributes(c::_AbstractCard, w::CardWidget) = _host_attributes(c, w.type, - (; _card_style(c, w.type)..., map(v -> _card_value(c, v), w.attributes)...)) + (; _card_style(c, w.type)..., map(v -> _card_value(c, v), w.attributes)..., + (_fills(w) ? (; width = Makie.Relative(1), tellwidth = false) : (;))...)) + +# Whether the widget `w` takes the rest of the width of its row: `fill`, unless it has a `width` +_fills(w::CardWidget) = w.fill && !haskey(w.attributes, :width) +_fills(::String) = false +# Whether the row `r` spans the width of the card, see `_build_content!` +_fills(r::CardRow) = any(_fills, r.cells) # The attributes of a block of the type `T` on the card, as declared by default _host_attributes(::_AbstractCard, T::Type, attributes::NamedTuple) = attributes @@ -608,9 +616,9 @@ _show_value!(b::Textbox, v, force::Bool) = card_show!(b, v; force) # The layout of declarations, independent of the functions `value` and `on`, see `_build_content!` _layout_key(rows) = map(_layout_key, rows) -_layout_key(r::CardRow) = map(_layout_key, r.cells) +_layout_key(r::CardRow) = (map(_layout_key, r.cells), r.aligned) _layout_key(s::String) = s -_layout_key(w::CardWidget) = (w.type, w.attributes, w.name) +_layout_key(w::CardWidget) = (w.type, w.attributes, w.name, w.fill) # The declared widgets of rows or actions, in the order of their blocks _declared_widgets(rows) = CardWidget[w for r in rows for w in _declared_widgets(r)] @@ -760,6 +768,47 @@ function _fit_properties!(c::_ComponentCard) return nothing end +# Width of a widget that fills the rest of its row at least [px], see `_fit_rows!` +const _CARD_FILL_MIN = 60.0f0 + +""" + _fit_rows!(c::_ComponentCard) + +Sets the width of the rows of the card `c`, if they are shown and one of them has a widget that +fills the rest of its row (see `_fills`), to the width of the card: of its head with the actions and +tools, of its other parts and of its other rows, at least the width that the other cells of the rows +with such a widget need with `_CARD_FILL_MIN` for it, such that these rows end at the right edge of +the card. These rows do not tell their width (see `_build_content!`), hence the width of the card +does not depend on them. Only a changed width updates the layout. +""" +function _fit_rows!(c::_ComponentCard) + lower = _lower_parts(c) + any(p -> p === c.rows, lower) || return nothing + grids = GridLayout[x.content for x in c.rows.content if x.content isa GridLayout] + any(g -> !g.tellwidth[], grids) || return nothing + h, a, t = _head_size(c), _actions_size(c), _card_size(c.tools) + w = h[1] + _CARD_PADDING + a[1] + _CARD_PADDING + t[1] + for part in lower + part === c.rows || (w = max(w, _part_size(c, part)[1])) + end + for g in grids + w = max(w, g.tellwidth[] ? something(g.layoutobservables.autosize[][1], 0.0f0) : _fill_row_width(g)) + end + size = Makie.Fixed(w) + c.rows.colsizes[1] == size || Makie.colsize!(c.rows, 1, size) + return nothing +end + +# The width [px] that the row layout `g` with a widget that fills needs: its other columns, its gaps +# and `_CARD_FILL_MIN` for the column of the widget, whose width is not determined +function _fill_row_width(g::GridLayout) + GLB = Makie.GridLayoutBase + n = GLB.ncols(g) + w = sum(j -> something(GLB.determinedirsize(GLB.offset(g, j, GLB.Col()), g, GLB.Col()), _CARD_FILL_MIN), 1:n) + gaps = sum(gap -> gap isa Makie.Fixed ? gap.x : 0.0f0, g.addedcolgaps; init = 0.0f0) + return Float32(w + gaps) +end + """ _arrange_card!(c::_ComponentCard, p::Point2f) diff --git a/src/LiveCards.jl b/src/LiveCards.jl index da9fe57..8d4d0d2 100644 --- a/src/LiveCards.jl +++ b/src/LiveCards.jl @@ -502,6 +502,7 @@ function _update_card!(gui::LiveView, c::_ComponentCard, @nospecialize(obj), obs sel = _screen_rect(scene, corners, obj) c.auto_collapsed = false c.step_shown = _shows_step(gui, c, obj) + _fit_rows!(c) _fit_properties!(c) _fit_view!(c, view) size = _card_size(c) @@ -714,18 +715,40 @@ function _build_content!(gui::LiveView, c::_AbstractCard, @nospecialize(obj)) end grids = GridLayout[] for (i, row) in enumerate(rows) - layout = GridLayout(c.rows[i, 1]; _row_attributes(c)...) + # a row with a widget that fills spans the width of the card without telling its own + layout = GridLayout(c.rows[i, 1]; _row_attributes(c)..., (_fills(row) ? (; tellwidth = false) : (;))...) for (j, cell) in enumerate(row.cells) _add_cell!(gui, c, layout[1, j], cell) end push!(grids, layout) end + _align_first_cells!(rows, grids) c.content_key = key push!(gui.cards.settle, (gui.cards.ticks, GridLayout[c.actions; grids])) _on_content_built!(gui, c) return nothing end +""" + _align_first_cells!(rows, grids) + +Gives the first cells of the `aligned` rows among the declared `rows` (see [`CardRow`](@ref)), if +they are texts, the width of the widest of them, such that their second cells start at the same x: +the first column of each of their layouts `grids` gets that width. The texts are part of the layout +of the declarations (see `_layout_key`), hence widths that are set once stay right. +""" +function _align_first_cells!(rows, grids::Vector{GridLayout}) + aligned = GridLayout[g for (r, g) in zip(rows, grids) if r.aligned && !isempty(r.cells) && first(r.cells) isa String] + isempty(aligned) && return nothing + w = maximum(_first_cell_width, aligned) + foreach(g -> Makie.colsize!(g, 1, Makie.Fixed(w)), aligned) + return nothing +end + +# The width that the text in the first column of the row layout `g` needs [px] +_first_cell_width(g::GridLayout) = + maximum(l -> something(l.layoutobservables.autosize[][1], 0.0f0), Makie.contents(g[1, 1])) + # Ticks from the growth of the viewports of new widgets until their size is restored, see # `_settle_cards!`: about 0.1 s; a restore in the next frame is missed now and then const _RESTORE_TICKS = 6 diff --git a/test/TestLiveCardRowWidth.jl b/test/TestLiveCardRowWidth.jl new file mode 100644 index 0000000..aa6a199 --- /dev/null +++ b/test/TestLiveCardRowWidth.jl @@ -0,0 +1,183 @@ +module TestLiveCardRowWidth + +#= +Rows of a card that fill its width (`CardWidget(...; fill = true)`) and rows whose first texts line +up in one column (`CardRow(...; aligned = true)`), on the floating card (compact layout) and on the +docked card (app layout) +=# + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using Makie +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI + +# A component whose card has the rows `rows(o)`, see `card_rows`; no actions in the head +struct RowTestObject{T, S <: BMO.AbstractShape{T}} <: BMO.AbstractObject{T} + shape::S + rows::Any +end +RowTestObject(rows) = RowTestObject(BMO.PlanoSurfaceSDF(1e-3, 10e-3), rows) +BMO.intersect3d(::RowTestObject, ::BMO.AbstractRay) = nothing +BMO.interact3d(::BMO.AbstractSystem, ::RowTestObject, ::BMO.AbstractBeam, ::BMO.AbstractRay) = nothing +BeamletOpticsGUI.card_rows(o::RowTestObject) = o.rows() +BeamletOpticsGUI.card_actions(::RowTestObject) = () + +# The example of `skills/beamletopticsgui/WIDGETS.md`: the labels in one column, the sliders to the +# right edge of the card +mutable struct MyFilterWheel{T} <: BMO.AbstractObject{T} + shape::BMO.PlanoSurfaceSDF{T} + od1::Float64 + od2::Float64 +end +MyFilterWheel() = MyFilterWheel(BMO.PlanoSurfaceSDF(1e-3, 25e-3), 0.0, 0.0) +BMO.intersect3d(::MyFilterWheel, ::BMO.AbstractRay) = nothing +BMO.interact3d(::BMO.AbstractSystem, ::MyFilterWheel, ::BMO.AbstractBeam, ::BMO.AbstractRay) = nothing +BeamletOpticsGUI.card_rows(w::MyFilterWheel) = (pose_card_rows(w)..., + CardRow("OD 1", + CardWidget(Slider; name = :od1, range = 0:0.1:4, fill = true, + value = (gui, w) -> w.od1, on = (gui, w, v) -> (w.od1 = v)); + aligned = true), + CardRow("OD 2 (back)", + CardWidget(Slider; name = :od2, range = 0:0.1:4, fill = true, + value = (gui, w) -> w.od2, on = (gui, w, v) -> (w.od2 = v)); + aligned = true)) + +@testset "Live card row width" begin + + _live_view(args...; kwargs...) = live_view(args...; merge((; trace_budget = Inf, detectors = []), kwargs)...) + _card(gui::GUI.LiveView{GUI.AppLayout}) = gui.layout.inspector.card + _card(gui) = gui.cards.selection + function _select!(gui, obj) + gui.controls.selected[] = obj + GUI._update_selection_box!(gui.controls) + GUI._update_cards!(gui) + gui isa GUI.LiveView{GUI.AppLayout} && GUI._update_inspector!(gui; force = true) + return nothing + end + _w(gui, name) = GUI._card_widget(_card(gui), name) + _rect(x) = x.layoutobservables.computedbbox[] + _left(x) = minimum(_rect(x))[1] + _right(x) = maximum(_rect(x))[1] + _width(x) = Makie.widths(_rect(x))[1] + # The layouts of the rows of the card, from top to bottom + _rows(gui) = GridLayout[x.content for x in _card(gui).rows.content] + # The right edge of the widest row that has no widget that fills + _widest(gui) = maximum(g -> _right(g), filter(g -> g.tellwidth[], _rows(gui)); init = -Inf32) + # The first cell (a label) of the row layout `g` + _first(g) = only(Makie.contents(g[1, 1])) + _second(g) = only(Makie.contents(g[1, 2])) + + _beam() = Beam([0.0, 0, 0], [0.0, 1, 0]) + + @testset "declarations" begin + s = CardWidget(Slider; fill = true) + @test s.fill && !CardWidget(Slider).fill + @test CardRow("a", s; aligned = true).aligned && !CardRow("a", s).aligned + # at most one widget of a row fills + @test_throws ArgumentError CardRow("a", s, CardWidget(Slider; fill = true)) + @test_throws ArgumentError CardRow(Union{String, CardWidget}[s, s], false) + # a given width wins + @test GUI._fills(s) && !GUI._fills(CardWidget(Slider; fill = true, width = 80)) + @test GUI._fills(CardRow("a", s)) && !GUI._fills(CardRow("a", CardWidget(Slider))) + # another layout rebuilds the widgets + @test GUI._layout_key(CardRow("a", s)) != GUI._layout_key(CardRow("a", CardWidget(Slider))) + @test GUI._layout_key(CardRow("a"; aligned = true)) != GUI._layout_key(CardRow("a")) + end + + @testset "fill ($layout)" for layout in (:compact, :app) + # a row with a long or a short label above a row with a slider that fills + rows(label) = () -> (CardRow(label, CardWidget(Label; name = :value, value = (gui, o) -> "1 mm")), + CardRow("level", CardWidget(Slider; name = :level, range = 0:10, fill = true), "%")) + long = RowTestObject(rows("a rather long label of the first row of this card")) + short = RowTestObject(rows("short")) + translate3d!(short, [0, 0.05, 0]) + gui = _live_view(System([long, short]), _beam(); layout) + widths = Float32[] + for obj in (long, short) + _select!(gui, obj) + slider = _w(gui, :level) + # the slider and the "%" behind it end at the right edge of the widest row + pct = _second(_rows(gui)[2]) === slider ? only(Makie.contents(_rows(gui)[2][1, 3])) : nothing + @test !isnothing(pct) + @test abs(_right(pct) - max(_widest(gui), _right(_rows(gui)[2]))) <= 2 + @test _width(slider) > 0 + if layout === :compact + c = _card(gui) + @test abs(_right(pct) - _widest(gui)) <= 2 + # ... which is the right edge of the card + @test abs(_right(pct) - (_right(c.background) - GUI._CARD_PADDING)) <= 2 + else + # the docked rows span the sidebar + @test abs(_right(pct) - _right(_card(gui).rows)) <= 2 + end + push!(widths, _width(slider)) + end + # the floating card follows the other row, the docked one has the width of the sidebar + layout === :compact && @test widths[1] > widths[2] + 50 + close(gui) + end + + @testset "only a row that fills ($layout)" for layout in (:compact, :app) + obj = RowTestObject(() -> (CardRow(CardWidget(Slider; name = :level, range = 0:10, fill = true)),)) + gui = _live_view(System([obj]), _beam(); layout) + _select!(gui, obj) + slider, c = _w(gui, :level), _card(gui) + # the card keeps a width: at least that of its head, and the slider ends at its right edge + @test _width(slider) >= GUI._CARD_FILL_MIN - 1 + if layout === :compact + @test _width(c.background) >= GUI._head_size(c)[1] + 2 * GUI._CARD_PADDING - 1 + @test abs(_right(slider) - (_right(c.background) - GUI._CARD_PADDING)) <= 2 + else + @test abs(_right(slider) - _right(c.rows)) <= 2 + end + close(gui) + end + + @testset "a given width wins over fill" begin + obj = RowTestObject(() -> (CardRow("a label that makes the card wide enough", CardWidget(Label)), + CardRow("level", CardWidget(Slider; name = :level, range = 0:10, fill = true, width = 80)))) + gui = _live_view(System([obj]), _beam()) + _select!(gui, obj) + @test abs(_width(_w(gui, :level)) - 80) <= 1 + @test all(g -> g.tellwidth[], _rows(gui)) + close(gui) + end + + @testset "aligned ($layout)" for layout in (:compact, :app) + rows = () -> (CardRow("x", CardWidget(Slider; name = :a, range = 0:10, width = 90); aligned = true), + CardRow("a longer label", CardWidget(Slider; name = :b, range = 0:10, width = 90); aligned = true), + CardRow("mid", CardWidget(Slider; name = :c, range = 0:10, width = 90)), + CardRow("y", CardWidget(Slider; name = :d, range = 0:10, width = 90); aligned = true)) + obj = RowTestObject(rows) + gui = _live_view(System([obj]), _beam(); layout) + _select!(gui, obj) + g = _rows(gui) + # the second cells of the aligned rows start at the same x, behind the widest first text + xs = [_left(_second(g[i])) for i in (1, 2, 4)] + @test maximum(xs) - minimum(xs) <= 1 + # their first columns have the width of the widest first text + @test all(i -> g[i].colsizes[1] == g[2].colsizes[1], (1, 4)) && g[2].colsizes[1] isa Makie.Fixed + @test abs(g[2].colsizes[1].x - _width(_first(g[2]))) <= 1 + @test _left(_second(g[2])) >= _right(_first(g[2])) - 1 + # a row without `aligned` keeps the width of its first text + @test _left(_second(g[3])) < minimum(xs) - 5 + @test g[3].colsizes[1] == Auto() + close(gui) + end + + @testset "the example of WIDGETS.md ($layout)" for layout in (:compact, :app) + w = MyFilterWheel() + gui = _live_view(System([w]), _beam(); layout) + _select!(gui, w) + od1, od2 = _w(gui, :od1), _w(gui, :od2) + @test abs(_left(od1) - _left(od2)) <= 1 + @test abs(_right(od1) - _right(od2)) <= 2 + right = layout === :compact ? _right(_card(gui).background) - GUI._CARD_PADDING : _right(_card(gui).rows) + @test abs(_right(od1) - right) <= 2 + close(gui) + end +end + +end From 6a25a4c201394f4d88eb5493581f94671342e8a6 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:16:55 +0100 Subject: [PATCH 39/47] Skeleton of the activity of the others (plans/activity-cues.md): the types of episodes and of the activity of a window, the interfaces of the model, the cues and the timeline, stubs to be filled in by their workstreams Co-Authored-By: Claude Opus 5.5 --- src/BeamletOpticsGUI.jl | 3 + src/LiveActivity.jl | 173 ++++++++++++++++++++++++++++++++++++++++ src/LiveActivityCues.jl | 16 ++++ src/LiveTimeline.jl | 13 +++ src/LiveView.jl | 2 + 5 files changed, 207 insertions(+) create mode 100644 src/LiveActivity.jl create mode 100644 src/LiveActivityCues.jl create mode 100644 src/LiveTimeline.jl diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 0158e9a..7bcc7d9 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -119,6 +119,9 @@ include("LiveProjectGlide.jl") include("LiveProjectSession.jl") include("LiveProjectPresence.jl") include("LiveProjectClaims.jl") +include("LiveActivity.jl") +include("LiveActivityCues.jl") +include("LiveTimeline.jl") include("LiveProjectVersions.jl") include("LivePivot.jl") include("LiveDebug.jl") diff --git a/src/LiveActivity.jl b/src/LiveActivity.jl new file mode 100644 index 0000000..67defcf --- /dev/null +++ b/src/LiveActivity.jl @@ -0,0 +1,173 @@ +#= +The activity of the others in a project: who changed what and when (episodes), the colour of each +participant, the pacing of the cues in the 3D view, the history of the poses and following a +participant with the camera. The model only: the cues are drawn in `LiveActivityCues.jl`, the +timeline in `LiveTimeline.jl`. See `plans/activity-cues.md`. +=# + +# The calm colours of the participants on a light theme; on a dark theme mixed towards white, see +# `_activity_color` +const _ACTIVITY_PALETTE = RGBf[ + Makie.to_color("#5B7DB1"), # slate blue + Makie.to_color("#3E9C8F"), # teal + Makie.to_color("#C8963E"), # amber + Makie.to_color("#9B6B9E"), # plum + Makie.to_color("#C46A55"), # terracotta + Makie.to_color("#7A9A4E"), # moss + Makie.to_color("#5F8FB0"), # steel + Makie.to_color("#C27A8E"), # rose +] + +# The life of a cue [s]: drawn in, held, faded out +const _CUE_IN = 0.6 +const _CUE_HOLD = 1.4 +const _CUE_OUT = 1.2 +# Pacing [s]: a cue starts at least `_CUE_SLOT` after the one before; waiting cues more than +# `_CUE_LAG` behind become one summary +const _CUE_SLOT = 0.9 +const _CUE_LAG = 3.0 +# Episodes kept per window +const _ACTIVITY_MAX = 500 +# The camera follows a participant at most every `_FOLLOW_EVERY` seconds +const _FOLLOW_EVERY = 2.0 + +""" + _ActivityItem + +One object of an episode (see `_Episode`): the `key` of its line (uid, or `"id:L1"`), its readable +`id`, the kind of `change` (`:moved`, `:rotated`, `:added`, `:removed`, `:changed`, `:grouped`, +`:ungrouped`, `:system`), its pose (position, orientation) `before` and `after` (`nothing` where it +did not exist), and `frame`: the 24 points of the wireframe of its box at `after` (at `before` for +`:removed`), see `_bbox_wireframe`. +""" +struct _ActivityItem + key::String + id::Symbol + change::Symbol + before::Union{Nothing, Tuple{Vector{Float64}, Matrix{Float64}}} + after::Union{Nothing, Tuple{Vector{Float64}, Matrix{Float64}}} + frame::Vector{Point3f} +end + +""" + _Episode + +A change of the files by an other participant as the window saw it, or a message of it (`doing`): +its number `n` (1, 2, … per window; 0 for a summary), the participant `who` (its presence id, or +`"agent"` if unknown), its shown `name`, whether it is an `agent`, the time `t` it was seen (clock +of this machine), its `items`, a short `text` ("moved M2 by 12 mm, L1"), the `message` ("" for a +change) and `count`, the episodes in it (more than 1 for a summary). +""" +struct _Episode + n::Int + who::String + name::String + agent::Bool + t::Float64 + items::Vector{_ActivityItem} + text::String + message::String + count::Int +end + +""" + _Activity + +The activity of the others in a window (`gui.activity`, see `_activity`): the `episodes` (oldest +first, at most `_ACTIVITY_MAX`), the `queue` of the numbers of episodes waiting for their cue, the +`cues` shown (episode or summary, start time), the start of the last cue `last_start`, the `colors` +of the participants (id => index into `_ACTIVITY_PALETTE`), the participant to `follow` and the +time `follow_at` the camera last followed, whether an other was there once (`seen`: the timeline +is shown), whether the timeline is `expanded`, the time of its scrub `cursor`, the episode under +the mouse `hover`, and the plots of the cues (`cue_plots`) and the timeline (`strip`). +""" +Base.@kwdef mutable struct _Activity + episodes::Vector{_Episode} = _Episode[] + queue::Vector{Int} = Int[] + cues::Vector{Tuple{_Episode, Float64}} = Tuple{_Episode, Float64}[] + last_start::Float64 = -Inf + colors::Dict{String, Int} = Dict{String, Int}() + follow::Union{Nothing, String} = nothing + follow_at::Float64 = -Inf + seen::Bool = false + expanded::Bool = false + cursor::Union{Nothing, Float64} = nothing + hover::Union{Nothing, Int} = nothing + cue_plots::Any = nothing + strip::Any = nothing +end + +"""The activity of the others in the `gui`, see `_Activity`; created at the first use.""" +function _activity(gui::LiveView) + a = gui.activity + a isa _Activity && return a + a = _Activity() + gui.activity = a + return a +end + +"""Whether the theme of the `gui` is dark: the luminance of the background of its 3D view.""" +_is_dark(gui::LiveView) = false + +""" + _activity_color(gui, who; alpha = 1.0) -> RGBAf + +The colour of the participant `who` in the `gui`: stable within the window, the first 8 +participants differ; lighter on a dark theme. +""" +_activity_color(gui::LiveView, who::AbstractString; alpha::Real = 1.0) = + RGBAf(_ACTIVITY_PALETTE[1], alpha) + +""" + _record_episode!(gui, who, name, agent, items; text, message = "", now = time()) -> _Episode + +Records a change (or with `message` a message) of the participant `who` and queues its cue. +""" +function _record_episode!(gui::LiveView, who::AbstractString, name::AbstractString, agent::Bool, + items::Vector{_ActivityItem}; text::AbstractString, message::AbstractString = "", + now::Real = time()) + a = _activity(gui) + ep = _Episode(length(a.episodes) + 1, String(who), String(name), agent, Float64(now), items, + String(text), String(message), 1) + push!(a.episodes, ep) + a.seen = true + return ep +end + +""" + _activity_step!(gui, now) -> Vector{Tuple{_Episode, Float64}} + +The cues to draw at the time `now`, each with its age [s]: starts waiting cues at the pace of +`_CUE_SLOT`, makes one summary of the waiting cues that are more than `_CUE_LAG` behind, and drops +the cues whose life is over. +""" +_activity_step!(gui::LiveView, now::Real) = Tuple{_Episode, Float64}[] + +"""Shows the episode of number `n` of the `gui` as a cue again, at once.""" +_replay!(gui::LiveView, n::Integer; now::Real = time()) = nothing + +""" + _poses_at(gui, t) -> Dict{String, Tuple{Vector{Float64}, Matrix{Float64}}} + +The pose at the time `t` of every line (by key) that an episode changed after `t`. +""" +_poses_at(gui::LiveView, t::Real) = Dict{String, Tuple{Vector{Float64}, Matrix{Float64}}}() + +"""Follows the participant `who` with the camera (`nothing`: none), see `_follow_target`.""" +_set_follow!(gui::LiveView, who::Union{Nothing, AbstractString}) = nothing + +""" + _follow_target(gui, now) -> Union{Nothing, Vector{Point3f}} + +The points that the camera should frame now to follow the participant of `_set_follow!`: the +frames of its newest cue, at most every `_FOLLOW_EVERY` seconds; else `nothing`. +""" +_follow_target(gui::LiveView, now::Real) = nothing + +""" + _cue_alpha(age) -> Float64 + +The opacity of a cue of the age `age` [s]: eased in over `_CUE_IN`, held for `_CUE_HOLD`, eased out +over `_CUE_OUT`; 0 before and after. +""" +_cue_alpha(age::Real) = 0.0 diff --git a/src/LiveActivityCues.jl b/src/LiveActivityCues.jl new file mode 100644 index 0000000..d24009e --- /dev/null +++ b/src/LiveActivityCues.jl @@ -0,0 +1,16 @@ +#= +The cues of the activity of the others in the 3D view, see `LiveActivity.jl` and +`plans/activity-cues.md` (W2). +=# + +"""Draws the `cues` (episode, age) of the `gui` at the time `now`, see `_activity_step!`.""" +_draw_cues!(gui::LiveView, cues, now::Real) = nothing + +"""Draws ghost frames of the objects at the `poses` (see `_poses_at`); `nothing` clears them.""" +_draw_scrub!(gui::LiveView, poses) = nothing + +"""Deletes the plots of the cues of the `gui`.""" +_clear_cues!(gui::LiveView) = nothing + +"""The colour of the presence (box and name) of the participant `who` in the `gui`.""" +_presence_color(gui::LiveView, who::AbstractString) = _activity_color(gui, who) diff --git a/src/LiveTimeline.jl b/src/LiveTimeline.jl new file mode 100644 index 0000000..2ee8fb5 --- /dev/null +++ b/src/LiveTimeline.jl @@ -0,0 +1,13 @@ +#= +The timeline of the activity of the others at the bottom of the 3D view, see `LiveActivity.jl` and +`plans/activity-cues.md` (W3). +=# + +"""Builds the timeline strip of the `gui`, hidden until an other participant was there.""" +_timeline!(gui::LiveView) = nothing + +"""Updates the timeline strip of the `gui` at the time `now`: lanes, marks, ages, tooltip.""" +_update_timeline!(gui::LiveView, now::Real) = nothing + +"""Expands (`true`) or collapses the timeline strip of the `gui`.""" +_set_expanded!(gui::LiveView, on::Bool) = (_activity(gui).expanded = on; nothing) diff --git a/src/LiveView.jl b/src/LiveView.jl index bc19cf0..d58b125 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -767,6 +767,8 @@ Base.@kwdef mutable struct LiveView{L <: AbstractLiveLayout} project::Union{Nothing, String} = nothing # the files of the project when it was opened or saved, see `_ProjectSync` sync::_ProjectSync = _ProjectSync() + # what the others did in the project, see `_Activity` (`LiveActivity.jl`), created at first use + activity::Any = nothing widgets::_LayoutWidgets # state of the layout, e.g. the slots of the app layout, see `AbstractLiveLayout` layout::L From 8f84124a024a9615a21df6bbbe695555963a11f9 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:31:00 +0100 Subject: [PATCH 40/47] =?UTF-8?q?Picking=20the=20members=20of=20a=20system?= =?UTF-8?q?=20asks=20the=20claims=20of=20the=20system:=20a=20click=20of=20?= =?UTF-8?q?a=20pick=20("+",=20"=E2=88=92")=20that=20started=20before=20ano?= =?UTF-8?q?ther=20participant=20claimed=20the=20system,=20and=20"=C3=97"?= =?UTF-8?q?=20of=20a=20row=20of=20its=20members,=20are=20refused=20and=20t?= =?UTF-8?q?he=20status=20line=20names=20the=20holder;=20otherwise=20they?= =?UTF-8?q?=20claim=20the=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/LivePick.jl | 5 ++++- src/LiveSystemCard.jl | 6 +++++- test/TestLiveProjectClaims.jl | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/LivePick.jl b/src/LivePick.jl index 15a9501..c8ed89a 100644 --- a/src/LivePick.jl +++ b/src/LivePick.jl @@ -104,11 +104,14 @@ _pick_candidate(@nospecialize(obj)) = obj isa BMO.ObjectOrGroup || _is_source(ob Applies the pick of the `gui` to `obj`, a component or a source that was clicked: it is added to the system of the pick or taken out of it, see `_add_member!`, `_remove_member!` and `_set_source_system!`. What can not be done, e.g. an object that is in the system already, only -shows a message in the status line. +shows a message in the status line. A gesture on the system (its line changes): refused while +another participant of the project claims it, see `_gesture!`. """ function _pick_member!(gui::LiveView, @nospecialize(obj)) pick = _member_pick(gui) isnothing(pick) && return nothing + # claimed by another participant: the status line names the holder + _gesture!(gui, pick.sys) || return nothing top = _is_pair_source(gui, obj) ? obj : _component_top(gui, obj) try _apply_member_pick!(gui, top, pick.sys, pick.add) diff --git a/src/LiveSystemCard.jl b/src/LiveSystemCard.jl index d5124c0..ad48544 100644 --- a/src/LiveSystemCard.jl +++ b/src/LiveSystemCard.jl @@ -481,11 +481,15 @@ end "×" of the member `i` of the list of the card of the system `sys` (see `_members`): a source has no system afterwards (see `_set_source_system!`), an object is taken out of `sys` and stays in the -view (see `_remove_member!`). What can not be done only shows a message in the status line. +view (see `_remove_member!`). What can not be done only shows a message in the status line. A +gesture on `sys` (its line changes): refused while another participant of the project claims it, +see `_gesture!`. """ function _take_out_member!(gui::LiveView, sys::BMO.AbstractSystem, i::Int) members = _members(gui, sys) i <= length(members) || return nothing + # claimed by another participant: the status line names the holder + _gesture!(gui, sys) || return nothing try _take_out!(gui, members[i], sys) catch e diff --git a/test/TestLiveProjectClaims.jl b/test/TestLiveProjectClaims.jl index 1f4e70a..0aee45a 100644 --- a/test/TestLiveProjectClaims.jl +++ b/test/TestLiveProjectClaims.jl @@ -328,6 +328,40 @@ mine(dir, me) = sort!([String(c.id) for c in values(BMOP.claims(dir)) if c.by == @test isempty(mine(dir, me)) end + @testset "picking members and taking them out ask the claim of the system" begin + S1 = obj(gui, :S1) + members = copy(GUI._members(gui, S1)) + # "−" started before another claimed the system: the clicks of the pick are refused + GUI._set_member_pick!(gui, S1, false) + @test !isnothing(GUI._member_pick(gui)) + @test BMOP.claim!(dir; by = "bob@PC9", ids = [:S1]) + look!(gui) + gui.status.text[] = "" + GUI._pick_member!(gui, L1) + @test GUI._has(GUI._members(gui, S1), L1) + @test gui.status.text[] == "Bob is editing S1" + # "×" of a row of the list of members + gui.status.text[] = "" + GUI._take_out_member!(gui, S1, 1) + @test GUI._members(gui, S1) == members + @test gui.status.text[] == "Bob is editing S1" + # released: the pick works and claims the system + BMOP.release!(dir; by = "bob@PC9") + look!(gui) + GUI._pick_member!(gui, L1) + @test !GUI._has(GUI._members(gui, S1), L1) + @test haskey(GUI._claims(gui).held, uid(gui, :S1)) + GUI._set_member_pick!(gui, S1, false) + GUI._pick_member!(gui, L1) + GUI._set_member_pick!(gui, S1, true) + GUI._pick_member!(gui, L1) + GUI._set_member_pick!(gui, S1, true) + @test isnothing(GUI._member_pick(gui)) + GUI._release!(gui) + ctrl.selected[] = nothing + look!(gui) + end + @testset "undo and redo of an edit ask the claims" begin entry = only(e for e in component_catalog() if e.name == "Thin lens") c = GUI._catalog_component(entry, String[GUI._catalog_string(p) for p in entry.params]) From de91b75a33aad906ada731d86d5bd40c1752c1e9 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:31:00 +0100 Subject: [PATCH 41/47] The activity model of the others (plans/activity-cues.md, W1): a calm colour per participant (lighter on a dark theme), episodes of their changes and messages, cues paced one after the other and summarised when more than 3 s behind, the poses at a time for the scrub of the timeline, following a participant with the camera Co-Authored-By: Claude Opus 5.5 --- src/LiveActivity.jl | 161 +++++++++++++++++++++-- test/TestLiveActivity.jl | 272 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+), 10 deletions(-) create mode 100644 test/TestLiveActivity.jl diff --git a/src/LiveActivity.jl b/src/LiveActivity.jl index 67defcf..60579ac 100644 --- a/src/LiveActivity.jl +++ b/src/LiveActivity.jl @@ -106,8 +106,29 @@ function _activity(gui::LiveView) return a end +# The relative luminance of the colour `c` (WCAG, linear sRGB), 0 (black) … 1 (white) +function _rel_luminance(c) + lin(x) = x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055)^2.4 + rgb = RGBf(c) + return 0.2126 * lin(Float64(rgb.r)) + 0.7152 * lin(Float64(rgb.g)) + + 0.0722 * lin(Float64(rgb.b)) +end + """Whether the theme of the `gui` is dark: the luminance of the background of its 3D view.""" -_is_dark(gui::LiveView) = false +_is_dark(gui::LiveView) = _rel_luminance(gui.layout.theme.view) < 0.4 + +# The index into `_ACTIVITY_PALETTE` of the participant `who`, assigned at its first use: the +# smallest index not used yet (cyclic once all are used) +function _color_index!(a::_Activity, who::AbstractString) + i = get(a.colors, who, 0) + i > 0 && return i + n = length(_ACTIVITY_PALETTE) + used = Set(values(a.colors)) + free = findfirst(j -> !(j in used), 1:n) + i = free === nothing ? mod(length(a.colors), n) + 1 : free + a.colors[String(who)] = i + return i +end """ _activity_color(gui, who; alpha = 1.0) -> RGBAf @@ -115,8 +136,21 @@ _is_dark(gui::LiveView) = false The colour of the participant `who` in the `gui`: stable within the window, the first 8 participants differ; lighter on a dark theme. """ -_activity_color(gui::LiveView, who::AbstractString; alpha::Real = 1.0) = - RGBAf(_ACTIVITY_PALETTE[1], alpha) +function _activity_color(gui::LiveView, who::AbstractString; alpha::Real = 1.0) + c = _ACTIVITY_PALETTE[_color_index!(_activity(gui), who)] + if _is_dark(gui) + # mixed 30 % towards white + c = RGBf(c.r + 0.3f0 * (1 - c.r), c.g + 0.3f0 * (1 - c.g), c.b + 0.3f0 * (1 - c.b)) + end + return RGBAf(c, alpha) +end + +# The index in `a.episodes` of the episode of number `n`, `nothing` if it is not kept (any more) +function _episode_index(a::_Activity, n::Integer) + isempty(a.episodes) && return nothing + i = n - a.episodes[1].n + 1 + return 1 <= i <= length(a.episodes) && a.episodes[i].n == n ? i : nothing +end """ _record_episode!(gui, who, name, agent, items; text, message = "", now = time()) -> _Episode @@ -127,13 +161,46 @@ function _record_episode!(gui::LiveView, who::AbstractString, name::AbstractStri items::Vector{_ActivityItem}; text::AbstractString, message::AbstractString = "", now::Real = time()) a = _activity(gui) - ep = _Episode(length(a.episodes) + 1, String(who), String(name), agent, Float64(now), items, + n = isempty(a.episodes) ? 1 : a.episodes[end].n + 1 + ep = _Episode(n, String(who), String(name), agent, Float64(now), items, String(text), String(message), 1) + _color_index!(a, who) push!(a.episodes, ep) + push!(a.queue, n) + # the latest `_ACTIVITY_MAX` are kept; the waiting cues of the others go with them + if length(a.episodes) > _ACTIVITY_MAX + k = length(a.episodes) - _ACTIVITY_MAX + first_kept = a.episodes[k + 1].n + deleteat!(a.episodes, 1:k) + filter!(>=(first_kept), a.queue) + end a.seen = true return ep end +# One summary of the waiting episodes `eps` (oldest first), see `_activity_step!`: the latest item +# per key (in the order of their first change), with its pose `before` from the first and `after` +# from the last episode that changed it +function _summary(eps::Vector{_Episode}) + keys = String[] + items = Dict{String, _ActivityItem}() + for ep in eps, it in ep.items + prev = get(items, it.key, nothing) + if prev === nothing + push!(keys, it.key) + items[it.key] = it + else + items[it.key] = _ActivityItem(it.key, it.id, it.change, prev.before, it.after, it.frame) + end + end + count = sum(ep -> ep.count, eps; init = 0) + who = eps[1].who + one = all(ep -> ep.who == who, eps) + return _Episode(0, one ? who : "several", one ? eps[end].name : "several", + all(ep -> ep.agent, eps), eps[end].t, _ActivityItem[items[k] for k in keys], + "$(count) changes ($(length(keys)) objects)", "", count) +end + """ _activity_step!(gui, now) -> Vector{Tuple{_Episode, Float64}} @@ -141,20 +208,69 @@ The cues to draw at the time `now`, each with its age [s]: starts waiting cues a `_CUE_SLOT`, makes one summary of the waiting cues that are more than `_CUE_LAG` behind, and drops the cues whose life is over. """ -_activity_step!(gui::LiveView, now::Real) = Tuple{_Episode, Float64}[] +function _activity_step!(gui::LiveView, now::Real) + a = _activity(gui) + now = Float64(now) + life = _CUE_IN + _CUE_HOLD + _CUE_OUT + filter!(c -> now - c[2] < life, a.cues) + if !isempty(a.queue) + oldest = a.episodes[_episode_index(a, a.queue[1])] + if now - oldest.t > _CUE_LAG + # all waiting cues at once; a single one stays itself + eps = _Episode[a.episodes[_episode_index(a, n)] for n in a.queue] + empty!(a.queue) + push!(a.cues, (length(eps) == 1 ? eps[1] : _summary(eps), now)) + a.last_start = now + elseif now - a.last_start >= _CUE_SLOT + n = popfirst!(a.queue) + push!(a.cues, (a.episodes[_episode_index(a, n)], now)) + a.last_start = now + end + end + return Tuple{_Episode, Float64}[(ep, now - t0) for (ep, t0) in a.cues] +end """Shows the episode of number `n` of the `gui` as a cue again, at once.""" -_replay!(gui::LiveView, n::Integer; now::Real = time()) = nothing +function _replay!(gui::LiveView, n::Integer; now::Real = time()) + a = _activity(gui) + i = _episode_index(a, n) + i === nothing && return nothing + now = Float64(now) + # a cue of it that is still shown starts again instead of being shown twice + filter!(c -> c[1].n != n, a.cues) + push!(a.cues, (a.episodes[i], now)) + # the waiting cues keep their pace after it + a.last_start = max(a.last_start, now) + return nothing +end """ _poses_at(gui, t) -> Dict{String, Tuple{Vector{Float64}, Matrix{Float64}}} The pose at the time `t` of every line (by key) that an episode changed after `t`. """ -_poses_at(gui::LiveView, t::Real) = Dict{String, Tuple{Vector{Float64}, Matrix{Float64}}}() +function _poses_at(gui::LiveView, t::Real) + poses = Dict{String, Tuple{Vector{Float64}, Matrix{Float64}}}() + done = Set{String}() + for ep in _activity(gui).episodes + ep.t > t || continue + for it in ep.items + it.key in done && continue + push!(done, it.key) + # a line added after `t` had no pose at `t` + it.before === nothing || (poses[it.key] = it.before) + end + end + return poses +end """Follows the participant `who` with the camera (`nothing`: none), see `_follow_target`.""" -_set_follow!(gui::LiveView, who::Union{Nothing, AbstractString}) = nothing +function _set_follow!(gui::LiveView, who::Union{Nothing, AbstractString}) + a = _activity(gui) + a.follow = who === nothing ? nothing : String(who) + a.follow_at = -Inf + return nothing +end """ _follow_target(gui, now) -> Union{Nothing, Vector{Point3f}} @@ -162,7 +278,26 @@ _set_follow!(gui::LiveView, who::Union{Nothing, AbstractString}) = nothing The points that the camera should frame now to follow the participant of `_set_follow!`: the frames of its newest cue, at most every `_FOLLOW_EVERY` seconds; else `nothing`. """ -_follow_target(gui::LiveView, now::Real) = nothing +function _follow_target(gui::LiveView, now::Real) + a = _activity(gui) + who = a.follow + who === nothing && return nothing + now - a.follow_at >= _FOLLOW_EVERY || return nothing + # the cues are in the order of their start + for k in length(a.cues):-1:1 + ep, t0 = a.cues[k] + t0 > a.follow_at || break + ep.who == who || continue + pts = Point3f[p for it in ep.items for p in it.frame] + isempty(pts) && continue + a.follow_at = Float64(now) + return pts + end + return nothing +end + +# Eased in and out (smoothstep) on 0 … 1 +_ease(x::Float64) = (x = clamp(x, 0.0, 1.0); x * x * (3 - 2x)) """ _cue_alpha(age) -> Float64 @@ -170,4 +305,10 @@ _follow_target(gui::LiveView, now::Real) = nothing The opacity of a cue of the age `age` [s]: eased in over `_CUE_IN`, held for `_CUE_HOLD`, eased out over `_CUE_OUT`; 0 before and after. """ -_cue_alpha(age::Real) = 0.0 +function _cue_alpha(age::Real) + age = Float64(age) + age <= 0 && return 0.0 + age < _CUE_IN && return _ease(age / _CUE_IN) + age <= _CUE_IN + _CUE_HOLD && return 1.0 + return 1.0 - _ease((age - _CUE_IN - _CUE_HOLD) / _CUE_OUT) +end diff --git a/test/TestLiveActivity.jl b/test/TestLiveActivity.jl new file mode 100644 index 0000000..77712c9 --- /dev/null +++ b/test/TestLiveActivity.jl @@ -0,0 +1,272 @@ +module TestLiveActivity + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using Makie +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI +const R0 = [1.0 0 0; 0 1 0; 0 0 1] + +@testset "Live activity" begin + + # `live_view` does not display the window by itself + _gui(; theme = :light) = live_view(BMO.System(); theme) + + # an item of the line `key` moved from x = `a` to x = `b` (`nothing`: did not exist) + _item(key, a, b; change = :moved) = GUI._ActivityItem(key, Symbol(key), change, + a === nothing ? nothing : ([Float64(a), 0.0, 0.0], R0), + b === nothing ? nothing : ([Float64(b), 0.0, 0.0], R0), + fill(Point3f(something(b, a), 0, 0), 24)) + _rec!(gui, who, t, items = [_item("L1", 0, 1)]; agent = true, message = "") = + GUI._record_episode!(gui, who, who, agent, items; text = "moved", message, now = t) + _shown(cues) = [ep.n for (ep, _) in cues] + + @testset "pacing (the trace of the plan)" begin + gui = _gui() + a = GUI._activity(gui) + @test !a.seen + @test GUI._activity_step!(gui, 0.0) == Tuple{GUI._Episode, Float64}[] + + # t = 0: episode 1 starts at once + ep = _rec!(gui, "claude@agent", 0.0) + @test ep.n == 1 && ep.count == 1 + @test a.seen + @test a.queue == [1] + cues = GUI._activity_step!(gui, 0.0) + @test isempty(a.queue) + @test _shown(cues) == [1] + @test last(cues[1]) == 0.0 + + # t = 0.1: episodes 2, 3 wait, the slot is not over + _rec!(gui, "claude@agent", 0.1) + _rec!(gui, "claude@agent", 0.1) + cues = GUI._activity_step!(gui, 0.1) + @test a.queue == [2, 3] + @test _shown(cues) == [1] + @test last(cues[1]) ≈ 0.1 + + cues = GUI._activity_step!(gui, 0.9) + @test a.queue == [3] + @test _shown(cues) == [1, 2] + + cues = GUI._activity_step!(gui, 1.8) + @test isempty(a.queue) + @test _shown(cues) == [1, 2, 3] + @test last.(cues) ≈ [1.8, 0.9, 0.0] + + # t = 2.0 … 2.4: an agent, episodes 4 … 40; the episode k changes the line "L(k % 9)" from + # x = k to x = k + 1 + for (j, t) in enumerate(range(2.0, 2.4; length = 37)) + k = j + 3 + _rec!(gui, "claude@agent", t, [_item("L$(k % 9)", k, k + 1)]) + cues = GUI._activity_step!(gui, t) + @test _shown(cues) == [1, 2, 3] + end + @test a.queue == collect(4:40) + + # t = 2.7: 4 starts, the oldest waiting (5) is 0.7 s behind + cues = GUI._activity_step!(gui, 2.7) + @test a.queue == collect(5:40) + @test _shown(cues) == [1, 2, 3, 4] + + # The oldest waiting (5, seen at 2.011) is more than 3 s behind from 5.012 on (the plan's + # table says 5.0, where it is 2.99 s behind): one summary of all waiting, started at once. + # The cues 1, 2, 3 are over (life 3.2 s). + cues = GUI._activity_step!(gui, 5.1) + @test isempty(a.queue) + @test _shown(cues) == [4, 0] + s, age = cues[2] + @test age == 0.0 + @test a.last_start == 5.1 + @test s.count == 36 + @test s.who == "claude@agent" && s.name == "claude@agent" && s.agent + @test isempty(s.message) + # the latest item per key: 9 keys, in the order of their first change + @test length(s.items) == 9 + @test [it.key for it in s.items] == ["L$(k % 9)" for k in 5:13] + @test s.text == "36 changes (9 objects)" + for it in s.items + ks = [k for k in 5:40 if "L$(k % 9)" == it.key] + @test it.before[1][1] == first(ks) # before from the first episode + @test it.after[1][1] == last(ks) + 1 # after from the last + @test it.frame == fill(Point3f(last(ks) + 1, 0, 0), 24) + end + # the summary is not stored, the timeline keeps every episode + @test length(a.episodes) == 40 + @test all(ep -> ep.n != 0, a.episodes) + + # the summary is over after its life; nothing is waiting + @test _shown(GUI._activity_step!(gui, 5.1 + 3.2)) == Int[] + end + + @testset "summary of several participants, messages" begin + gui = _gui() + a = GUI._activity(gui) + _rec!(gui, "Bob@PC3", 0.0, [_item("A", nothing, 1; change = :added)]; agent = false) + GUI._activity_step!(gui, 0.0) + _rec!(gui, "Bob@PC3", 0.1, [_item("A", 1, 2)]; agent = false) + _rec!(gui, "claude@agent", 0.2, GUI._ActivityItem[]; message = "aligning M2") + _rec!(gui, "claude@agent", 0.3, [_item("A", 2, 5), _item("B", 0, 1)]) + @test a.queue == [2, 3, 4] # messages are queued too + cues = GUI._activity_step!(gui, 3.2) + s = first(cues[end]) + @test s.n == 0 + @test s.who == "several" && s.name == "several" + @test !s.agent + @test s.count == 3 + @test s.text == "3 changes (2 objects)" + @test [it.key for it in s.items] == ["A", "B"] + @test s.items[1].before[1][1] == 1 && s.items[1].after[1][1] == 5 + @test s.items[2].before[1][1] == 0 && s.items[2].after[1][1] == 1 + + # a single waiting cue that is late stays itself + gui = _gui() + _rec!(gui, "Bob@PC3", 0.0) + cues = GUI._activity_step!(gui, 3.5) + @test _shown(cues) == [1] + end + + @testset "colours" begin + gui = _gui() + ids = ["p$k" for k in 1:10] + cs = [GUI._activity_color(gui, id) for id in ids] + # the first 8 participants differ and get the palette in order + @test length(unique(cs[1:8])) == 8 + @test [RGBf(c) for c in cs[1:8]] == GUI._ACTIVITY_PALETTE + # stable within the window + @test [GUI._activity_color(gui, id) for id in ids] == cs + @test GUI._activity_color(gui, "p3") == cs[3] + # alpha passed through + @test GUI._activity_color(gui, "p2"; alpha = 0.3) == RGBAf(RGBf(cs[2]), 0.3) + @test GUI._activity_color(gui, "p2").alpha == 1 + # recording assigns the colour of a new participant + gui = _gui() + _rec!(gui, "Bob@PC3", 0.0) + _rec!(gui, "claude@agent", 0.0) + @test GUI._activity(gui).colors == Dict("Bob@PC3" => 1, "claude@agent" => 2) + @test RGBf(GUI._activity_color(gui, "claude@agent")) == GUI._ACTIVITY_PALETTE[2] + + # the dark theme: lighter, each mixed 30 % towards white + light = _gui() + dark = _gui(; theme = :dark) + @test !GUI._is_dark(light) + @test GUI._is_dark(dark) + for id in ids[1:8] + cl = GUI._activity_color(light, id) + cd = GUI._activity_color(dark, id; alpha = 0.5) + @test cd.alpha == 0.5f0 + @test GUI._rel_luminance(RGBf(cd)) > GUI._rel_luminance(RGBf(cl)) + @test cd.r ≈ cl.r + 0.3 * (1 - cl.r) atol = 1e-6 + end + end + + @testset "poses at a time" begin + gui = _gui() + _rec!(gui, "Bob@PC3", 1.0, [_item("A", 0, 1)]) + _rec!(gui, "Bob@PC3", 2.0, [_item("A", 1, 2), _item("B", 10, 11)]) + _rec!(gui, "Bob@PC3", 3.0, [_item("C", nothing, 7; change = :added)]) + _rec!(gui, "Bob@PC3", 4.0, [_item("B", 11, 12)]) + p = GUI._poses_at(gui, 0.5) + @test sort!(collect(keys(p))) == ["A", "B"] # C did not exist at 0.5 + @test p["A"][1] == [0.0, 0, 0] + @test p["B"][1] == [10.0, 0, 0] + @test p["A"][2] == R0 + p = GUI._poses_at(gui, 1.5) + @test p["A"][1] == [1.0, 0, 0] && p["B"][1] == [10.0, 0, 0] + p = GUI._poses_at(gui, 3.5) + @test collect(keys(p)) == ["B"] + @test p["B"][1] == [11.0, 0, 0] + @test isempty(GUI._poses_at(gui, 4.0)) + end + + @testset "cue alpha" begin + @test GUI._cue_alpha(-1) == 0 + @test GUI._cue_alpha(0) == 0 + @test all(GUI._cue_alpha(t) == 1 for t in 0.6:0.05:2.0) + @test all(GUI._cue_alpha(t) == 0 for t in 3.2:0.1:5.0) + @test GUI._CUE_IN + GUI._CUE_HOLD + GUI._CUE_OUT ≈ 3.2 + ts = range(0, 3.4; step = 1 / 60) + as = GUI._cue_alpha.(ts) + @test all(0 .<= as .<= 1) + d = diff(as) + @test maximum(abs, d) < 0.05 + @test all(d[ts[2:end] .<= 0.6] .>= 0) # monotone in + @test all(d[ts[1:(end - 1)] .>= 2.0] .<= 0) # monotone out + end + + @testset "at most 500 episodes" begin + gui = _gui() + a = GUI._activity(gui) + for k in 1:520 + _rec!(gui, "claude@agent", k * 0.001) + end + @test length(a.episodes) == GUI._ACTIVITY_MAX == 500 + @test first(a.episodes).n == 21 && last(a.episodes).n == 520 + @test a.queue == collect(21:520) + cues = GUI._activity_step!(gui, 0.6) + @test _shown(cues) == [21] + # the numbers stay unique + @test _rec!(gui, "claude@agent", 0.7).n == 521 + # replay of a kept episode, nothing for one that is gone + GUI._replay!(gui, 300; now = 0.8) + @test _shown(a.cues) == [21, 300] + GUI._replay!(gui, 3; now = 0.8) + @test _shown(a.cues) == [21, 300] + end + + @testset "replay" begin + gui = _gui() + a = GUI._activity(gui) + _rec!(gui, "Bob@PC3", 0.0) + _rec!(gui, "Bob@PC3", 0.1) + @test _shown(GUI._activity_step!(gui, 0.2)) == [1] + # at once, not queued, and once only + GUI._replay!(gui, 1; now = 0.5) + cues = GUI._activity_step!(gui, 0.5) + @test _shown(cues) == [1] + @test last(cues[1]) == 0.0 + @test a.queue == [2] + # the waiting cue keeps its pace after it + @test _shown(GUI._activity_step!(gui, 1.3)) == [1] + @test _shown(GUI._activity_step!(gui, 1.5)) == [1, 2] + end + + @testset "follow" begin + gui = _gui() + a = GUI._activity(gui) + @test GUI._follow_target(gui, 0.0) === nothing + GUI._set_follow!(gui, "Bob@PC3") + @test a.follow == "Bob@PC3" + _rec!(gui, "claude@agent", 0.0, [_item("A", 0, 1)]) + GUI._activity_step!(gui, 0.0) + @test GUI._follow_target(gui, 0.0) === nothing # an other participant + _rec!(gui, "Bob@PC3", 0.5, [_item("B", 0, 3)]) + GUI._activity_step!(gui, 1.0) + pts = GUI._follow_target(gui, 1.0) + @test pts == fill(Point3f(3, 0, 0), 24) + @test a.follow_at == 1.0 + # nothing new + @test GUI._follow_target(gui, 3.5) === nothing + # one target per 2 s + _rec!(gui, "Bob@PC3", 1.5, [_item("C", 0, 4)]) + GUI._activity_step!(gui, 2.0) + @test GUI._follow_target(gui, 2.0) === nothing + _rec!(gui, "Bob@PC3", 2.1, [_item("D", 0, 5)]) + @test _shown(GUI._activity_step!(gui, 2.95))[(end - 1):end] == [3, 4] + @test GUI._follow_target(gui, 2.99) === nothing + # the newest cue of the participant + @test GUI._follow_target(gui, 3.0) == fill(Point3f(5, 0, 0), 24) + @test a.follow_at == 3.0 + @test GUI._follow_target(gui, 3.1) === nothing + # a message has no frames + _rec!(gui, "Bob@PC3", 3.2, GUI._ActivityItem[]; message = "thinking") + @test last(_shown(GUI._activity_step!(gui, 3.9))) == 5 + @test GUI._follow_target(gui, 5.5) === nothing + GUI._set_follow!(gui, nothing) + @test a.follow === nothing + @test GUI._follow_target(gui, 10.0) === nothing + end +end + +end From adaf3072bdcf9a2c598544d6bc9d8df20f48b1e8 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:43:25 +0100 Subject: [PATCH 42/47] The undo of a key step on an object that glides to a pose of the files gives back the pose of the files, not the one halfway: the step notes the pose after its gesture, which lands the glide Co-Authored-By: Claude Opus 5.5 --- src/LiveInteraction.jl | 10 +++++++++- test/TestLiveProjectClaims.jl | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/LiveInteraction.jl b/src/LiveInteraction.jl index f78b87c..021d99e 100644 --- a/src/LiveInteraction.jl +++ b/src/LiveInteraction.jl @@ -495,6 +495,9 @@ mutable struct KinematicController{H <: AbstractSystemRenderHandle} undo_stack::Vector{_AnyHistoryEntry} redo_stack::Vector{_AnyHistoryEntry} last_key_step::Union{Nothing, _KeyStepInfo} + # pose of the object of the last key step before its motion, after its gesture (which may land + # a glide, see `gesture`), for the undo history, see `_key_step!` + step_from::Union{Nothing, Tuple{Point3{Float64}, Matrix{Float64}}} # selection box and gizmo of the keyboard controls box_obs::Observable{Vector{Point3f}} arrow_pos::Observable{Vector{Point3f}} @@ -1585,6 +1588,8 @@ function _key_step!(ctrl::KinematicController, @nospecialize(obj), key, factor) isnothing(axis) && return false _within_constraints(ctrl, obj, ctrl.mode[], axis) || return true # locked: consumed, no motion ctrl.gesture(obj) || return true # refused: consumed, no motion + # The undo of the step goes back to here: after the gesture, which may land a glide + ctrl.step_from = _pose(obj) _change!(ctrl, obj) do if ctrl.mode[] == :move translate3d!(obj, (sign * factor * ctrl.fine_step) .* axis) @@ -2243,7 +2248,7 @@ function kinematic_controls!( Observable(spectator), true, nothing, select_modifier, Float64(drag_threshold), constraints_dict, Float64(source_pick_radius), ignore_keys, false, false, zeros(3), zeros(3), (0.0, 0.0), nothing, nothing, :none, - nothing, _AnyHistoryEntry[], _AnyHistoryEntry[], nothing, + nothing, _AnyHistoryEntry[], _AnyHistoryEntry[], nothing, nothing, box_obs, arrow_pos, arrow_dir, label_pos, ring_pts, arrow_color, label_color, ring_color, gizmo_size, gizmo_visible, help_obs, show_help, _HelpSection[], nothing, plots, Any[], nothing, obj -> false, @@ -2501,8 +2506,11 @@ function kinematic_controls!( _push_history!(ctrl, obj, P0, R0, P1, R1) _request_update!(ctrl) else + ctrl.step_from = nothing P0, R0 = _pose(obj) if _key_step!(ctrl, obj, event.key, _shift_pressed(scene) ? 10 : 1) + # the pose before the motion, after a glide that the gesture landed + isnothing(ctrl.step_from) || ((P0, R0) = ctrl.step_from) P1, R1 = _pose(obj) _record_key_step!(ctrl, obj, event.key, P0, R0, P1, R1) _request_update!(ctrl) diff --git a/test/TestLiveProjectClaims.jl b/test/TestLiveProjectClaims.jl index 0aee45a..5008868 100644 --- a/test/TestLiveProjectClaims.jl +++ b/test/TestLiveProjectClaims.jl @@ -468,6 +468,16 @@ mine(dir, me) = sort!([String(c.id) for c in values(BMOP.claims(dir)) if c.by == events(scene).mousebutton[] = Makie.MouseButtonEvent(Mouse.left, Mouse.release) @test GUI._undo!(ctrl) @test pose(M1)[1] ≈ P + # a key step too: its undo gives back the pose of the files, not the one halfway + glide!() + ctrl.selected[] = M1 + kb = events(scene).keyboardbutton + kb[] = Makie.KeyEvent(Keyboard.up, Keyboard.press) + kb[] = Makie.KeyEvent(Keyboard.up, Keyboard.release) + @test isempty(gui.sync.gliding) + @test !(pose(M1)[1] ≈ P) + @test GUI._undo!(ctrl) + @test pose(M1)[1] ≈ P # a glide of another object goes on gui.sync.gliding[M2] = (Point3{Float64}(pose(M2)[1]), pose(M2)[2], (; rec.pos, rec.rot)) @test GUI._gesture!(gui, M1) From 294f51d7bd3f5049820fa45f290f507f9a560ac6 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:46:01 +0100 Subject: [PATCH 43/47] The cues of the activity of the others in the 3D view (plans/activity-cues.md, W2): a frame in the colour of the participant, a dashed ghost at the old place and a soft arc drawn out to the new one (a move), an arc around the object (a rotation), a settling frame (added), a ghost with a cross (removed), a chip with the name and what changed, bubbles of messages, ghosts of the scrub; overlays only, faded in and out, the chips and bubbles hidden in the spectator mode Co-Authored-By: Claude Opus 5.5 --- src/LiveActivityCues.jl | 714 ++++++++++++++++++++++++++++++++++- test/TestLiveActivityCues.jl | 325 ++++++++++++++++ 2 files changed, 1032 insertions(+), 7 deletions(-) create mode 100644 test/TestLiveActivityCues.jl diff --git a/src/LiveActivityCues.jl b/src/LiveActivityCues.jl index d24009e..4d39889 100644 --- a/src/LiveActivityCues.jl +++ b/src/LiveActivityCues.jl @@ -1,16 +1,716 @@ #= The cues of the activity of the others in the 3D view, see `LiveActivity.jl` and `plans/activity-cues.md` (W2). + +A cue shows an episode (or a summary of several) for the life of `_CUE_IN + _CUE_HOLD + _CUE_OUT` +seconds: frames (boxes) of the objects in the colour of the participant, a dashed ghost frame at +the old place of a moved, rotated or removed object, a soft arc from the old to the new place (or +around the object for a rotation) that is drawn out during `_CUE_IN`, and a chip with the name and +the text, or the bubble of a message. All are overlays: the GUI never draws a component here. The +3D parts are plots of `gui.ax`, the chips and bubbles plots of a child scene with a pixel camera +(`_CuePlots.hud`), placed every frame at the projection of their object, like the progress window +(see `_ProgressOverlay`). A cue creates its plots when it starts and deletes them when it ends; per +frame only the colours, the positions of the chips and, while it is drawn in, the arcs change. =# -"""Draws the `cues` (episode, age) of the `gui` at the time `now`, see `_activity_step!`.""" -_draw_cues!(gui::LiveView, cues, now::Real) = nothing +# The width of the lines of a frame, a ghost frame and an arc [px] +const _CUE_FRAME_WIDTH = 1.5f0 +const _CUE_GHOST_WIDTH = 1.25f0 +const _CUE_ARC_WIDTH = 2.0f0 +# The alpha of a ghost frame relative to the cue, and of all 3D parts of a summary +const _CUE_GHOST_ALPHA = 0.5 +const _CUE_SUMMARY_ALPHA = 0.6 +# The alpha of the ghost frames of a scrub +const _SCRUB_ALPHA = 0.85 +# Points of an arc; its lift towards the camera's up vector, relative to the distance, and at least [m] +const _CUE_ARC_POINTS = 32 +const _CUE_ARC_LIFT = 0.15 +const _CUE_ARC_MIN_LIFT = 0.002 +# A frame that is added settles from this size to 1 while the cue is drawn in +const _CUE_ADDED_SCALE = 1.1 +# Size of the arrowhead and of the × of a removed object [px] +const _CUE_HEAD_SIZE = 9.0f0 +const _CUE_CROSS_SIZE = 10.0f0 +# Chips and bubbles [px]: font size, line height, padding, corner radius, the dot of the colour +const _CHIP_FONTSIZE = 12.0f0 +const _CHIP_LINEHEIGHT = 1.3f0 +const _CHIP_PAD = Vec2f(6, 3) +const _CHIP_CORNER = 6.0f0 +const _CHIP_DOT = 6.0f0 +const _CHIP_DOT_GAP = 5.0f0 +# The alpha of the background of a chip (the theme's `view`) +const _CHIP_BACKGROUND_ALPHA = 0.92 +# Distance of a chip from the top of its object, between stacked chips and from the edges of the view +const _CHIP_GAP = 8.0f0 +const _CHIP_STACK = 4.0f0 +const _CHIP_MARGIN = 6.0f0 +# At most this many characters of the text of a chip +const _CHIP_CHARS = 56 +# A bubble: the bar of the colour at its left edge, its padding, the wrap of the message +const _BUBBLE_BAR = 3.0f0 +const _BUBBLE_PAD = Vec2f(8, 5) +const _BUBBLE_WRAP = 40 +const _BUBBLE_LINES = 3 +# z translation of the chips and bubbles: after the 3D scene and in front of it, below the cards +# (see `_CARD_Z0`) and the progress window; a newer chip over an older one +const _CHIP_Z = 9000.0f0 + +""" + _CueDrawn + +The plots of a cue that is shown: its `episode`, its `start` time, the `plots` of its parts by name +(`:frames`, `:ghosts`, `:arcs`, `:heads`, `:crosses` in `gui.ax`; `:background`, `:dot`, `:bar`, +`:name`, `:text` of the chip or bubble in the pixel scene), the full `arcs` (the points of each), the +frames that are `added` (centre and points at full size), the corners of the box of the object of +the chip `anchor` (empty: at the presence of the participant or the corner of the view), the size +of the chip `size`, its place in a stack of chips at one object `slot`, its position `pos` [px], +the `progress` of the arcs drawn last, and whether it is a `summary` or a `bubble`. +""" +mutable struct _CueDrawn + episode::_Episode + start::Float64 + plots::Dict{Symbol, Makie.AbstractPlot} + arcs::Vector{Vector{Point3f}} + added::Vector{Tuple{Point3f, Vector{Point3f}}} + static::Vector{Point3f} + anchor::Vector{Point3f} + size::Vec2f + slot::Int + pos::Observable{Point2f} + progress::Float64 + summary::Bool + bubble::Bool +end + +""" + _CuePlots + +The plots of the cues of a window (`gui.activity.cue_plots`): the child scene with a pixel camera of +the chips and bubbles `hud`, the cues `drawn`, the plot of the ghost frames of a scrub `scrub` +(`nothing` if none) with the keys it shows `scrub_keys`, the fonts and the caches of the widths of +texts, and a counter of the chips for their order (`z`). +""" +mutable struct _CuePlots + hud::Makie.Scene + drawn::Vector{_CueDrawn} + scrub::Union{Nothing, Makie.AbstractPlot} + scrub_keys::Vector{String} + regular::Any + bold::Any + widths::Dict{String, Float32} + bold_widths::Dict{String, Float32} + z::Int +end + +# The plots of the cues of the `gui`, created at the first use +function _cue_plots(gui::LiveView) + a = _activity(gui) + c = a.cue_plots + c isa _CuePlots && return c + hud = Makie.campixel(gui.ax.scene) + c = _CuePlots(hud, _CueDrawn[], nothing, String[], _tree_font(hud, :regular), _tree_font(hud, :bold), + Dict{String, Float32}(), Dict{String, Float32}(), 0) + a.cue_plots = c + return c +end + +# The life of a cue [s] +_cue_life() = _CUE_IN + _CUE_HOLD + _CUE_OUT + +# Eases `x` in 0..1 in and out +_cue_ease(x::Real) = (x = clamp(Float64(x), 0.0, 1.0); x * x * (3 - 2x)) -"""Draws ghost frames of the objects at the `poses` (see `_poses_at`); `nothing` clears them.""" -_draw_scrub!(gui::LiveView, poses) = nothing +# The common attributes of the overlays: never clipped, never picked +const _CUE_3D = (; clip_planes = Plane3f[], inspectable = false, transparency = true) +const _CUE_2D = (; clip_planes = Plane3f[], inspectable = false, transparency = false, markerspace = :pixel) -"""Deletes the plots of the cues of the `gui`.""" -_clear_cues!(gui::LiveView) = nothing +""" + _draw_cues!(gui, cues, now) -"""The colour of the presence (box and name) of the participant `who` in the `gui`.""" +Draws the `cues` (episode, age [s]) of the `gui` at the time `now`, see `_activity_step!`: creates +the plots of a cue that starts, updates those of the cues shown (their alpha follows `_cue_alpha`, +the arcs are drawn out during `_CUE_IN`, the chips follow their objects on the screen) and deletes +the plots of a cue that is not in `cues` any more or whose life is over. The chips and bubbles are +hidden in the spectator mode, the frames and arcs stay. +""" +function _draw_cues!(gui::LiveView, cues, now::Real) + c = _cue_plots(gui) + life = _cue_life() + keep = falses(length(c.drawn)) + for (ep, age) in cues + age = Float64(age) + start = Float64(now) - age + k = findfirst(i -> !keep[i] && c.drawn[i].episode === ep && abs(c.drawn[i].start - start) < 0.05, + eachindex(c.drawn)) + age >= life && continue + if isnothing(k) + push!(c.drawn, _new_cue(gui, c, ep, start)) + push!(keep, true) + k = length(c.drawn) + else + keep[k] = true + end + _update_cue!(gui, c, c.drawn[k], age) + end + for i in reverse(eachindex(c.drawn)) + keep[i] && continue + _delete_cue!(gui, c, c.drawn[i]) + deleteat!(c.drawn, i) + end + return nothing +end + +"""Deletes the plots of the cues of the `gui` and the ghost frames of a scrub.""" +function _clear_cues!(gui::LiveView) + a = gui.activity + a isa _Activity || return nothing + c = a.cue_plots + c isa _CuePlots || return nothing + foreach(d -> _delete_cue!(gui, c, d), c.drawn) + empty!(c.drawn) + _draw_scrub!(gui, nothing) + return nothing +end + +"""The colour of the presence (box and name) of the participant `who` in the `gui`, see `_activity_color`.""" _presence_color(gui::LiveView, who::AbstractString) = _activity_color(gui, who) + +# The colour of the participant of the episode `ep` with `alpha`: muted for a summary of several +function _cue_color(gui::LiveView, ep::_Episode, alpha::Real) + ep.who == "several" && return RGBAf(Makie.to_color(gui.layout.theme.muted), alpha) + return _activity_color(gui, ep.who; alpha) +end + +#= +Geometry +=# + +# The points `pts` of an object at the pose `from` (position, orientation) moved with it to the pose `to` +function _move_points(pts::AbstractVector{Point3f}, from, to) + (pa, Ra), (pb, Rb) = from, to + M = Rb * transpose(Ra) + return Point3f[Point3f(M * (Vector{Float64}(p) .- pa) .+ pb) for p in pts] +end + +# The corners of the box of the points `pts`, `(lo, hi)` +_frame_box(pts) = (reduce((a, b) -> min.(a, b), pts), reduce((a, b) -> max.(a, b), pts)) +_frame_center(pts) = (b = _frame_box(pts); (b[1] + b[2]) / 2) + +# The 8 corners of the box of the points `pts` +function _frame_corners(pts) + lo, hi = _frame_box(pts) + return Point3f[Point3f(x, y, z) for x in (lo[1], hi[1]), y in (lo[2], hi[2]), z in (lo[3], hi[3])][:] +end + +# The camera's up vector of the 3D view of the `gui` +_cue_up(gui::LiveView) = Vector{Float64}(Makie.cameracontrols(gui.ax.scene).upvector[]) + +""" + _move_arc(a, b, up) -> Vector{Point3f} + +The arc of a move from `a` to `b`: a quadratic Bézier curve of `_CUE_ARC_POINTS` points whose +middle is lifted by `_CUE_ARC_LIFT` of the distance (at least `_CUE_ARC_MIN_LIFT`) towards `up` +(the part of it across the move). Empty if `a` and `b` coincide. +""" +function _move_arc(a, b, up) + a, b = Vector{Float64}(a), Vector{Float64}(b) + d = norm(b - a) + d > 1e-9 || return Point3f[] + dir = (b - a) / d + n = up - dot(up, dir) * dir + if norm(n) < 1e-6 + n = cross(dir, abs(dir[3]) < 0.9 ? [0.0, 0.0, 1.0] : [1.0, 0.0, 0.0]) + end + n = normalize(n) + # the middle of a quadratic Bézier curve lies half way to its control point + c = (a + b) / 2 + 2 * max(_CUE_ARC_LIFT * d, _CUE_ARC_MIN_LIFT) * n + return Point3f[Point3f((1 - t)^2 * a + 2 * (1 - t) * t * c + t^2 * b) + for t in range(0, 1; length = _CUE_ARC_POINTS)] +end + +""" + _turn_arc(center, radius, from, to, up) -> Vector{Point3f} + +A short arc around `center` of the rotation from the orientation `from` to `to`: in the plane across +the axis of the rotation, of the angle of the rotation (at least 30°, at most 300°), starting at the +side towards `up`. Empty if the orientations coincide. +""" +function _turn_arc(center, radius::Real, from, to, up) + axis, angle = _rotation_axis_angle(Matrix{Float64}(to * transpose(from))) + abs(angle) > 1e-6 || return Point3f[] + u = up - dot(up, axis) * axis + norm(u) < 1e-6 && (u = cross(axis, abs(axis[3]) < 0.9 ? [0.0, 0.0, 1.0] : [1.0, 0.0, 0.0])) + u = normalize(u) + v = cross(axis, u) + φ = sign(angle) * clamp(abs(angle), deg2rad(30), deg2rad(300)) + c = Vector{Float64}(center) + return Point3f[Point3f(c + radius * (cos(t) * u + sin(t) * v)) for t in range(0, φ; length = _CUE_ARC_POINTS)] +end + +# The first `f` (0..1) of the polyline `pts`, the last point interpolated +function _partial_line(pts::Vector{Point3f}, f::Real) + n = length(pts) + n < 2 && return copy(pts) + s = clamp(Float64(f), 0.0, 1.0) * (n - 1) + k = floor(Int, s) + k >= n - 1 && return copy(pts) + out = pts[1:(k + 1)] + push!(out, pts[k + 1] + Float32(s - k) * (pts[k + 2] - pts[k + 1])) + return out +end + +# The frame `pts` scaled by `s` about `center` +_scale_frame(center::Point3f, pts::Vector{Point3f}, s::Real) = Point3f[center + Float32(s) * (p - center) for p in pts] + +#= +Plots of a cue +=# + +""" + _new_cue(gui, c, ep, start) -> _CueDrawn + +Creates the plots of the cue of the episode `ep` that started at `start`: its frames, ghosts, arcs, +arrowheads and crosses in `gui.ax` (only those it needs, all items in a few plots) and its chip, or +the bubble of a message. +""" +function _new_cue(gui::LiveView, c::_CuePlots, ep::_Episode, start::Float64) + up = _cue_up(gui) + static, ghosts, crosses = Point3f[], Point3f[], Point3f[] + arcs = Vector{Point3f}[] + added = Tuple{Point3f, Vector{Point3f}}[] + anchor = Point3f[] + for it in ep.items + isempty(it.frame) && continue + isempty(anchor) && (anchor = _frame_corners(it.frame)) + if it.change === :removed + append!(ghosts, it.frame) + push!(crosses, Point3f(_frame_center(it.frame))) + elseif it.change === :added + push!(added, (Point3f(_frame_center(it.frame)), it.frame)) + else + append!(static, it.frame) + if it.change in (:moved, :rotated) && !isnothing(it.before) && !isnothing(it.after) + old = _move_points(it.frame, it.after, it.before) + append!(ghosts, old) + if it.change === :moved + arc = _move_arc(_frame_center(old), _frame_center(it.frame), up) + else + lo, hi = _frame_box(it.frame) + arc = _turn_arc(_frame_center(it.frame), 0.6 * norm(hi - lo), it.before[2], it.after[2], up) + end + isempty(arc) || push!(arcs, arc) + end + end + end + plots = Dict{Symbol, Makie.AbstractPlot}() + color = _cue_color(gui, ep, 0) + if !isempty(static) || !isempty(added) + plots[:frames] = linesegments!(gui.ax, _frame_points(static, added, 0.0); _CUE_3D..., color, + linewidth = _CUE_FRAME_WIDTH) + end + isempty(ghosts) || (plots[:ghosts] = linesegments!(gui.ax, ghosts; _CUE_3D..., color, + linewidth = _CUE_GHOST_WIDTH, linestyle = :dash)) + if !isempty(arcs) + plots[:arcs] = lines!(gui.ax, Point3f[]; _CUE_3D..., color, linewidth = _CUE_ARC_WIDTH) + plots[:heads] = scatter!(gui.ax, Point3f[]; _CUE_3D..., color, marker = :utriangle, + markersize = _CUE_HEAD_SIZE, markerspace = :pixel, rotation = Float32[]) + end + isempty(crosses) || (plots[:crosses] = scatter!(gui.ax, crosses; _CUE_3D..., color, marker = :xcross, + markersize = _CUE_CROSS_SIZE, markerspace = :pixel)) + bubble = !isempty(ep.message) + if isempty(anchor) && bubble + anchor = _presence_anchor(gui, ep.who) + end + pos = Observable(Point2f(NaN)) + d = _CueDrawn(ep, start, plots, arcs, added, static, anchor, Vec2f(0), 0, pos, -1.0, ep.n == 0 || ep.count > 1, bubble) + bubble ? _new_bubble!(gui, c, d) : _new_chip!(gui, c, d) + d.slot = _free_slot(gui, c, d) + return d +end + +# The points of the frames: the `static` ones and the `added` ones scaled for the progress `f` (0..1) +function _frame_points(static::Vector{Point3f}, added, f::Real) + isempty(added) && return static + s = _CUE_ADDED_SCALE + (1 - _CUE_ADDED_SCALE) * _cue_ease(f) + pts = copy(static) + for (center, frame) in added + append!(pts, _scale_frame(center, frame, s)) + end + return pts +end + +# The corners of the box of the object that the participant `who` of the `gui` works on (its +# presence box), empty if none +function _presence_anchor(gui::LiveView, who::AbstractString) + o = get(gui.sync.others, who, nothing) + isnothing(o) && return Point3f[] + for obj in _objects_of_other(gui, o) + bb = _part_box(gui, obj) + isnothing(bb) || return _frame_corners(_bbox_wireframe(bb)) + end + return Point3f[] +end + +# The width of the text `s` [px] in the regular or `bold` font of the chips +_chip_width(c::_CuePlots, s::AbstractString; bold::Bool = false) = + bold ? _text_width(c.bold_widths, c.bold, _CHIP_FONTSIZE, s) : _text_width(c.widths, c.regular, _CHIP_FONTSIZE, s) + +# `s` with at most `n` characters, else cut with "…" +_clip_text(s::AbstractString, n::Integer) = length(s) <= n ? String(s) : rstrip(first(s, n - 1)) * "…" + +""" + _wrap_text(s, width = _BUBBLE_WRAP, lines = _BUBBLE_LINES) -> Vector{String} + +The text `s` in lines of at most `width` characters, broken between words (a longer word is cut), +at most `lines` lines; the last of them ends with "…" if text is left over. +""" +function _wrap_text(s::AbstractString, width::Integer = _BUBBLE_WRAP, lines::Integer = _BUBBLE_LINES) + rows = String[] + line = "" + for word in split(strip(s)) + w = String(word) + # a word longer than a line is cut + while length(w) > width + isempty(line) || (push!(rows, line); line = "") + push!(rows, first(w, width)) + w = w[nextind(w, 0, width + 1):end] + end + isempty(w) && continue + if isempty(line) + line = w + elseif length(line) + 1 + length(w) <= width + line *= " " * w + else + push!(rows, line) + line = w + end + end + isempty(line) || push!(rows, line) + length(rows) <= lines && return rows + out = rows[1:lines] + out[end] = (length(out[end]) < width ? out[end] : rstrip(first(out[end], width - 1))) * "…" + return out +end + +# Adds the plots of a chip of the cue `d` to the pixel scene: background, the dot of the colour, +# the name in bold and the text, e.g. "● Claude · moved M2" +function _new_chip!(gui::LiveView, c::_CuePlots, d::_CueDrawn) + ep = d.episode + t = gui.layout.theme + name = _clip_text(ep.name, 24) + text = " · " * _clip_text(ep.text, _CHIP_CHARS) + wn, wt = _chip_width(c, name; bold = true), _chip_width(c, text) + H = round(_CHIP_FONTSIZE * _CHIP_LINEHEIGHT + 2 * _CHIP_PAD[2]) + W = round(2 * _CHIP_PAD[1] + _CHIP_DOT + _CHIP_DOT_GAP + wn + wt) + d.size = Vec2f(W, H) + x0 = _CHIP_PAD[1] + _CHIP_DOT + _CHIP_DOT_GAP + _chip_plots!(gui, c, d, t) + d.plots[:dot] = scatter!(c.hud, d.pos; _CUE_2D..., marker = :circle, markersize = _CHIP_DOT, + color = _cue_color(gui, ep, 0), marker_offset = Vec2f(_CHIP_PAD[1] + _CHIP_DOT / 2, H / 2)) + d.plots[:name] = text!(c.hud, d.pos; _CUE_2D..., text = name, font = c.bold, fontsize = _CHIP_FONTSIZE, + color = RGBAf(Makie.to_color(t.text), 0), align = (:left, :center), offset = Vec2f(x0, H / 2)) + d.plots[:text] = text!(c.hud, d.pos; _CUE_2D..., text, font = c.regular, fontsize = _CHIP_FONTSIZE, + color = RGBAf(Makie.to_color(t.text), 0), align = (:left, :center), offset = Vec2f(x0 + wn, H / 2)) + _order_chip!(c, d) + return d +end + +# Adds the plots of the bubble of the message of the cue `d`: a rounded box with the colour of the +# participant as a bar at its left edge, the name in bold and the message below, wrapped +function _new_bubble!(gui::LiveView, c::_CuePlots, d::_CueDrawn) + ep = d.episode + t = gui.layout.theme + name = _clip_text(ep.name, 24) + lines = _wrap_text(ep.message) + lh = _CHIP_FONTSIZE * _CHIP_LINEHEIGHT + w = max(_chip_width(c, name; bold = true), maximum((_chip_width(c, l) for l in lines); init = 0.0f0)) + H = round(2 * _BUBBLE_PAD[2] + (1 + length(lines)) * lh) + W = round(2 * _BUBBLE_PAD[1] + _BUBBLE_BAR + w) + d.size = Vec2f(W, H) + x0 = _BUBBLE_PAD[1] + _BUBBLE_BAR + _chip_plots!(gui, c, d, t) + d.plots[:bar] = scatter!(c.hud, d.pos; _CUE_2D..., + marker = _rounded_rect_marker(1, (H - 8) / _BUBBLE_BAR, _BUBBLE_BAR / 2 / _BUBBLE_BAR), markersize = _BUBBLE_BAR, + color = _cue_color(gui, ep, 0), marker_offset = Vec2f(4 + _BUBBLE_BAR / 2, H / 2)) + d.plots[:name] = text!(c.hud, d.pos; _CUE_2D..., text = name, font = c.bold, fontsize = _CHIP_FONTSIZE, + color = RGBAf(Makie.to_color(t.text), 0), align = (:left, :center), offset = Vec2f(x0, H - _BUBBLE_PAD[2] - lh / 2)) + d.plots[:text] = text!(c.hud, d.pos; _CUE_2D..., text = join(lines, "\n"), font = c.regular, + fontsize = _CHIP_FONTSIZE, lineheight = _CHIP_LINEHEIGHT, color = RGBAf(Makie.to_color(t.text), 0), + align = (:left, :top), offset = Vec2f(x0, H - _BUBBLE_PAD[2] - lh - (lh - _CHIP_FONTSIZE) / 2)) + _order_chip!(c, d) + return d +end + +# The background of a chip or bubble of the size `d.size`: the rounded box in the theme's `view` +# with a border +function _chip_plots!(gui::LiveView, c::_CuePlots, d::_CueDrawn, t) + W, H = d.size + d.plots[:background] = scatter!(c.hud, d.pos; _CUE_2D..., + marker = _rounded_rect_marker(1, H / W, _CHIP_CORNER / W), markersize = W, + color = RGBAf(Makie.to_color(t.view), 0), strokecolor = RGBAf(Makie.to_color(t.border), 0), + strokewidth = 1, marker_offset = Vec2f(W / 2, H / 2)) + return nothing +end + +# The plots of the chip of `d` in front of the 3D scene and of older chips +function _order_chip!(c::_CuePlots, d::_CueDrawn) + c.z += 1 + z = _CHIP_Z + 5 * mod(c.z, 100) + for (k, s) in enumerate((:background, :bar, :dot, :name, :text)) + haskey(d.plots, s) && translate!(d.plots[s], 0, 0, z + k) + end + return nothing +end + +#= +Placing a chip +=# + +# The point [px of the 3D view] above the box of the anchor of the cue `d` (its top, centred), or +# `nothing` if it is behind the camera; without an anchor the upper left corner of the view +function _chip_anchor(gui::LiveView, d::_CueDrawn) + scene = gui.ax.scene + if isempty(d.anchor) + h = Makie.widths(Makie.viewport(scene)[])[2] + return Point2f(_CHIP_MARGIN + d.size[1] / 2, h - _CHIP_MARGIN - d.size[2] - _CHIP_GAP) + end + lo, hi = Vec2f(Inf), Vec2f(-Inf) + for p in d.anchor + _behind(scene, p) && continue + q = Makie.project(scene, :data, :pixel, p) + all(isfinite, q) || continue + lo, hi = min.(lo, Vec2f(q[1], q[2])), max.(hi, Vec2f(q[1], q[2])) + end + all(isfinite, lo) || return nothing + return Point2f((lo[1] + hi[1]) / 2, hi[2]) +end + +# The lower left corner of the chip of `d` at the `anchor` [px], within the view +function _chip_position(gui::LiveView, d::_CueDrawn, anchor::Point2f) + w, h = Makie.widths(Makie.viewport(gui.ax.scene)[]) + W, H = d.size + x = anchor[1] - W / 2 + y = anchor[2] + _CHIP_GAP + d.slot * (H + _CHIP_STACK) + x = clamp(x, _CHIP_MARGIN, max(_CHIP_MARGIN, w - W - _CHIP_MARGIN)) + y = clamp(y, _CHIP_MARGIN, max(_CHIP_MARGIN, h - H - _CHIP_MARGIN)) + return Point2f(x, y) +end + +# The lowest place of the chip of `d` above its object where it does not cover a chip shown (see +# `_chip_position`), kept while the cue is shown so that the chips do not jump +function _free_slot(gui::LiveView, c::_CuePlots, d::_CueDrawn) + a = _chip_anchor(gui, d) + isnothing(a) && return 0 + taken = Makie.Rect2f[] + for e in c.drawn + (e === d || !all(isfinite, e.pos[])) && continue + push!(taken, Makie.Rect2f(e.pos[], e.size)) + end + for s in 0:7 + d.slot = s + r = Makie.Rect2f(_chip_position(gui, d, a), d.size) + any(t -> _chips_overlap(r, t), taken) || return s + end + return 0 +end + +# Whether the rectangles `a` and `b` [px] overlap or are closer than `_CHIP_STACK` +function _chips_overlap(a, b) + alo, ahi, blo, bhi = minimum(a), maximum(a), minimum(b), maximum(b) + return all(alo .< bhi .+ _CHIP_STACK) && all(blo .< ahi .+ _CHIP_STACK) +end + +#= +Per frame +=# + +# Sets the observable `obs` to `value` if it differs +_set_if!(obs, value) = (obs[] == value || (obs[] = value); nothing) + +""" + _update_cue!(gui, c, d, age) + +Updates the plots of the cue `d` at its `age` [s]: the alpha of all parts follows `_cue_alpha` +(the ghosts at `_CUE_GHOST_ALPHA`, the 3D parts of a summary at `_CUE_SUMMARY_ALPHA`), the arcs and +the frames that are added are drawn out during `_CUE_IN`, the arrowheads point along the arcs on +the screen and the chip follows its object; the chip is hidden in the spectator mode. +""" +function _update_cue!(gui::LiveView, c::_CuePlots, d::_CueDrawn, age::Float64) + ep, p = d.episode, d.plots + t = gui.layout.theme + a = _cue_alpha(age) + k = d.summary ? _CUE_SUMMARY_ALPHA : 1.0 + color = _cue_color(gui, ep, a * k) + f = _cue_ease(age / _CUE_IN) + haskey(p, :frames) && _set_if!(p[:frames].color, color) + haskey(p, :ghosts) && _set_if!(p[:ghosts].color, _cue_color(gui, ep, a * k * _CUE_GHOST_ALPHA)) + haskey(p, :crosses) && _set_if!(p[:crosses].color, color) + if f != d.progress + haskey(p, :frames) && !isempty(d.added) && _set_if!(p[:frames][1], _frame_points(d.static, d.added, f)) + if haskey(p, :arcs) + parts = [_partial_line(arc, f) for arc in d.arcs] + pts = Point3f[] + for (i, q) in enumerate(parts) + i > 1 && push!(pts, Point3f(NaN)) + append!(pts, q) + end + p[:arcs][1] = pts + end + d.progress = f + end + if haskey(p, :arcs) + _set_if!(p[:arcs].color, color) + _update_heads!(gui, d, color) + end + # the chip or bubble + visible = !gui.controls.spectator[] + anchor = visible ? _chip_anchor(gui, d) : nothing + shown = visible && !isnothing(anchor) + shown && _set_if!(d.pos, _chip_position(gui, d, anchor)) + for s in (:background, :bar, :dot, :name, :text) + haskey(p, s) && _set_if!(p[s].visible, shown) + end + shown || return nothing + _set_if!(p[:background].color, RGBAf(Makie.to_color(t.view), _CHIP_BACKGROUND_ALPHA * a)) + _set_if!(p[:background].strokecolor, RGBAf(Makie.to_color(t.border), a)) + s = haskey(p, :dot) ? :dot : :bar + _set_if!(p[s].color, _cue_color(gui, ep, a)) + _set_if!(p[:name].color, RGBAf(Makie.to_color(t.text), a)) + _set_if!(p[:text].color, RGBAf(Makie.to_color(t.text), a)) + return nothing +end + +# The arrowheads at the ends of the arcs drawn so far of the cue `d`, turned along their direction +# on the screen +function _update_heads!(gui::LiveView, d::_CueDrawn, color) + scene = gui.ax.scene + tips, angles = Point3f[], Float32[] + for arc in d.arcs + q = _partial_line(arc, d.progress) + length(q) < 2 && continue + tip, prev = q[end], q[end - 1] + if norm(tip - prev) < 1e-12 + length(q) < 3 && continue + prev = q[end - 2] + end + a = Makie.project(scene, :data, :pixel, tip) + b = Makie.project(scene, :data, :pixel, prev) + dx, dy = a[1] - b[1], a[2] - b[2] + (isfinite(dx) && isfinite(dy)) || continue + push!(tips, tip) + # `:utriangle` points up (+y) without rotation + push!(angles, Float32(atan(dy, dx) - π / 2)) + end + h = d.plots[:heads] + (h[1][] == tips && h.rotation[] == angles) || Makie.update!(h; arg1 = tips, rotation = angles) + _set_if!(h.color, color) + return nothing +end + +function _delete_cue!(gui::LiveView, c::_CuePlots, d::_CueDrawn) + for (s, p) in d.plots + try + s in (:background, :bar, :dot, :name, :text) ? delete!(c.hud, p) : delete!(gui.ax, p) + catch + end + end + empty!(d.plots) + return nothing +end + +#= +Scrub +=# + +""" + _draw_scrub!(gui, poses) + +Draws a dashed ghost frame of each object of the `gui` at its pose of `poses` (key => (position, +orientation), see `_poses_at`) that differs from its pose now, in the colour of the participant +who changed it first after the time of the scrub (`activity.cursor`); a key of an object that is +not in the view any more takes the frame of its latest episode. `nothing` clears them. One plot +for all ghosts; the keys it shows are `cue_plots.scrub_keys`. +""" +function _draw_scrub!(gui::LiveView, poses) + if isnothing(poses) + a = gui.activity + a isa _Activity || return nothing + c = a.cue_plots + c isa _CuePlots || return nothing + if !isnothing(c.scrub) + try + delete!(gui.ax, c.scrub) + catch + end + c.scrub = nothing + end + empty!(c.scrub_keys) + return nothing + end + c = _cue_plots(gui) + pts, colors, keys_ = Point3f[], RGBAf[], String[] + for key in sort!(collect(keys(poses))) + old = poses[key] + frame = _scrub_frame(gui, key, old) + isnothing(frame) && continue + color = _scrub_color(gui, key) + append!(pts, frame) + append!(colors, fill(color, length(frame))) + push!(keys_, key) + end + c.scrub_keys = keys_ + if isnothing(c.scrub) + c.scrub = linesegments!(gui.ax, pts; _CUE_3D..., color = colors, linewidth = _CUE_GHOST_WIDTH, + linestyle = :dash) + else + Makie.update!(c.scrub; arg1 = pts, color = colors) + end + return nothing +end + +# The ghost frame of the line `key` at the pose `old`, `nothing` if it is where it is now +function _scrub_frame(gui::LiveView, key::String, old) + pos, R = Vector{Float64}(old[1]), Matrix{Float64}(old[2]) + obj = _object_of_key(gui, key) + if !isnothing(obj) && !(obj isa BMO.AbstractSystem) + bb = _part_box(gui, obj) + now = _cue_pose(obj) + if !isnothing(bb) && !isnothing(now) + _same_pose(now, (pos, R)) && return nothing + return _move_points(_bbox_wireframe(bb), now, (pos, R)) + end + end + # not in the view (any more): the frame of its latest item + a = _activity(gui) + for ep in Iterators.reverse(a.episodes), it in ep.items + (it.key == key && !isempty(it.frame)) || continue + ref = it.change === :removed ? it.before : it.after + isnothing(ref) && return nothing + _same_pose(ref, (pos, R)) && it.change !== :removed && return nothing + return _move_points(it.frame, ref, (pos, R)) + end + return nothing +end + +# The pose (position, orientation) of the object `obj`, `nothing` if it has none +function _cue_pose(@nospecialize(obj)) + try + return (Vector{Float64}(position(obj)), Matrix{Float64}(BMO.orientation(obj))) + catch + return nothing + end +end + +_same_pose(a, b) = maximum(abs, a[1] - b[1]) < 1e-9 && maximum(abs, a[2] - b[2]) < 1e-9 + +# The colour of the ghost of the line `key` in a scrub: of the participant who changed it first +# after the cursor +function _scrub_color(gui::LiveView, key::String) + a = _activity(gui) + t = something(a.cursor, -Inf) + for ep in a.episodes + ep.t > t || continue + any(it -> it.key == key, ep.items) && return _activity_color(gui, ep.who; alpha = _SCRUB_ALPHA) + end + return RGBAf(Makie.to_color(gui.layout.theme.muted), _SCRUB_ALPHA) +end diff --git a/test/TestLiveActivityCues.jl b/test/TestLiveActivityCues.jl new file mode 100644 index 0000000..8a9dd84 --- /dev/null +++ b/test/TestLiveActivityCues.jl @@ -0,0 +1,325 @@ +module TestLiveActivityCues + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using Makie +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI + +@testset "Live activity cues" begin + + function _fixture() + m1 = RoundPlanoMirror(0.0254, 0.006) + m2 = RoundPlanoMirror(0.0254, 0.006) + translate3d!(m2, [0.15, 0.1, 0]) + lens = SphericalLens(0.05, -0.05, 5e-3, 25.4e-3, ConstantRefractiveIndex(1.5)) + translate3d!(lens, [0, 0.12, 0]) + return (; m1, m2, lens) + end + + function _live_view(fx; theme = :light) + gui = live_view(System([fx.m1, fx.m2, fx.lens]), Beam([0.0, -0.05, 0], [0.0, 1, 0]); + theme, detectors = [], trace_budget = Inf, throttle = false) + screen = GLMakie.Screen(visible = false, start_renderloop = false) + display(screen, gui.fig) + Makie.colorbuffer(screen) + return gui, screen + end + + _frame(gui, obj) = GUI._bbox_wireframe(GUI._part_box(gui, obj)) + _pose(obj) = (Vector{Float64}(BMO.position(obj)), Matrix{Float64}(BMO.orientation(obj))) + _shift(p, d) = (p[1] .+ d, p[2]) + _turn(p, deg) = (p[1], GUI._rotation_matrix([0.0, 0, 1], deg2rad(deg))' * p[2]) + + # An item of the object `obj` of the kind `change` + function _item(gui, obj, change; key = "u-$change") + after = _pose(obj) + f = _frame(gui, obj) + change === :moved && return GUI._ActivityItem(key, :L1, change, _shift(after, [0.0, -0.03, 0]), after, f) + change === :rotated && return GUI._ActivityItem(key, :L1, change, _turn(after, 40), after, f) + change === :added && return GUI._ActivityItem(key, :L1, change, nothing, after, f) + change === :removed && return GUI._ActivityItem(key, :L1, change, after, nothing, f) + return GUI._ActivityItem(key, :L1, change, after, after, f) + end + _episode(items; n = 1, who = "claude@agent", name = "Claude", text = "moved L1 by 30 mm", + message = "", count = 1) = GUI._Episode(n, who, name, true, 0.0, items, text, message, count) + + _drawn(gui) = GUI._cue_plots(gui).drawn + _in(scene, p) = any(q -> q === p, scene.plots) + _rgb(c) = (c = Makie.to_color(c); (Float32(c.r), Float32(c.g), Float32(c.b))) + _alpha(c) = Float32(Makie.to_color(c).alpha) + + # The state of the plots of the components, which the cues must not change + _component_plots(gui, fx) = [p for obj in values(fx) for p in GUI._object_plots(gui.controls.h, obj)] + _state(p) = (p.color[], p.visible[], p.alpha[], p.transparency[]) + + @testset "kinds of change" begin + fx = _fixture() + gui, screen = _live_view(fx) + comps = _component_plots(gui, fx) + @test !isempty(comps) + before = IdDict(p => _state(p) for p in comps) + nplots = length(gui.ax.scene.plots) + expected = Dict(:moved => (:frames, :ghosts, :arcs, :heads), :rotated => (:frames, :ghosts, :arcs, :heads), + :added => (:frames,), :removed => (:ghosts, :crosses), :changed => (:frames,), + :grouped => (:frames,), :ungrouped => (:frames,), :system => (:frames,)) + all3d = (:frames, :ghosts, :arcs, :heads, :crosses) + who = "claude@agent" + for (n, (change, parts)) in enumerate(expected) + ep = _episode([_item(gui, fx.lens, change)]; n) + GUI._draw_cues!(gui, [(ep, 1.0)], 10.0) + Makie.colorbuffer(screen) + d = only(_drawn(gui)) + @test d.episode === ep + for s in all3d + @test haskey(d.plots, s) == (s in parts) + end + # the chip + for s in (:background, :dot, :name, :text) + @test haskey(d.plots, s) + end + @test GUI._label_text(d.plots[:text]) == " · moved L1 by 30 mm" + @test GUI._label_text(d.plots[:name]) == "Claude" + # the colour of the participant + c = GUI._activity_color(gui, who) + for s in (parts..., :dot) + @test _rgb(d.plots[s].color[]) == _rgb(c) + end + # overlays only: in the 3D view, not clipped, not picked + for (s, p) in d.plots + @test p.inspectable[] == false + @test isempty(p.clip_planes[]) + s in all3d ? @test(_in(gui.ax.scene, p)) : @test(_in(GUI._cue_plots(gui).hud, p)) + s in all3d && @test p.transparency[] + end + plots = collect(values(d.plots)) + # the cue ends with the end of its life: its plots are deleted + GUI._draw_cues!(gui, Tuple{GUI._Episode, Float64}[], 20.0) + @test isempty(_drawn(gui)) + @test !any(p -> _in(gui.ax.scene, p) || _in(GUI._cue_plots(gui).hud, p), plots) + end + # the arc of a move goes from the old to the new centre, lifted towards the camera's up vector + ep = _episode([_item(gui, fx.lens, :moved)]) + GUI._draw_cues!(gui, [(ep, 1.0)], 10.0) + d = only(_drawn(gui)) + arc = only(d.arcs) + @test length(arc) == GUI._CUE_ARC_POINTS + c1 = GUI._frame_center(_frame(gui, fx.lens)) + @test arc[end] ≈ c1 atol = 1e-6 + @test arc[1] ≈ c1 - Point3f(0, 0.03, 0) atol = 1e-6 + up = GUI._cue_up(gui) + mid = arc[GUI._CUE_ARC_POINTS ÷ 2] + lift = Vector{Float64}(mid - (arc[1] + arc[end]) / 2) + @test sum(lift .* up) > 0 + # drawn out during `_CUE_IN`: partial at first, whole afterwards + GUI._draw_cues!(gui, [(ep, 0.2)], 10.0 - 0.8) + @test length(_drawn(gui)) == 1 + d = only(_drawn(gui)) + @test length(d.plots[:arcs][1][]) < GUI._CUE_ARC_POINTS + GUI._draw_cues!(gui, [(ep, 0.8)], 10.0 - 0.2) + @test length(d.plots[:arcs][1][]) == GUI._CUE_ARC_POINTS + @test length(d.plots[:heads][1][]) == 1 + # a cue that is not passed any more is deleted + GUI._draw_cues!(gui, Tuple{GUI._Episode, Float64}[], 10.0) + @test isempty(_drawn(gui)) + # the components: the same plots, unchanged + Makie.colorbuffer(screen) + @test length(gui.ax.scene.plots) == nplots + @test _component_plots(gui, fx) == comps + @test all(p -> _state(p) == before[p], comps) + close(screen) + end + + @testset "alpha over the life" begin + fx = _fixture() + gui, screen = _live_view(fx) + ep = _episode([_item(gui, fx.lens, :moved)]) + start = 100.0 + life = GUI._CUE_IN + GUI._CUE_HOLD + GUI._CUE_OUT + for age in (0.0, 0.1, 0.3, 0.6, 1.0, 2.0, 2.5, 3.0) + GUI._draw_cues!(gui, [(ep, age)], start + age) + d = only(_drawn(gui)) + a = GUI._cue_alpha(age) + @test _alpha(d.plots[:frames].color[]) ≈ a atol = 1e-6 + @test _alpha(d.plots[:arcs].color[]) ≈ a atol = 1e-6 + @test _alpha(d.plots[:ghosts].color[]) ≈ GUI._CUE_GHOST_ALPHA * a atol = 1e-6 + @test _alpha(d.plots[:text].color[]) ≈ a atol = 1e-6 + @test _alpha(d.plots[:name].color[]) ≈ a atol = 1e-6 + @test _alpha(d.plots[:background].color[]) ≈ GUI._CHIP_BACKGROUND_ALPHA * a atol = 1e-6 + end + # the same cue all along: its plots were created once + @test length(_drawn(gui)) == 1 + GUI._draw_cues!(gui, [(ep, life + 0.01)], start + life + 0.01) + @test isempty(_drawn(gui)) + close(screen) + end + + @testset "summary, message, several cues" begin + fx = _fixture() + gui, screen = _live_view(fx) + items = [_item(gui, fx.lens, :moved; key = "a"), _item(gui, fx.m2, :rotated; key = "b"), + _item(gui, fx.m1, :added; key = "c")] + summary = _episode(items; n = 0, text = "14 changes (3 objects)", count = 14) + GUI._draw_cues!(gui, [(summary, 1.0)], 10.0) + d = only(_drawn(gui)) + @test d.summary + # one chip for all objects; their frames and arcs in a few plots + @test count(s -> s === :background, keys(d.plots)) == 1 + @test GUI._label_text(d.plots[:text]) == " · 14 changes (3 objects)" + @test length(d.plots[:frames][1][]) == 3 * 24 + @test length(d.arcs) == 2 + # at lower alpha + @test _alpha(d.plots[:frames].color[]) ≈ GUI._CUE_SUMMARY_ALPHA * GUI._cue_alpha(1.0) atol = 1e-6 + @test _alpha(d.plots[:text].color[]) ≈ GUI._cue_alpha(1.0) atol = 1e-6 + GUI._clear_cues!(gui) + @test isempty(_drawn(gui)) + + # a summary of several participants is drawn in the muted colour of the theme + several = _episode(items; n = 0, who = "several", name = "several", text = "3 changes", count = 3) + GUI._draw_cues!(gui, [(several, 1.0)], 10.0) + @test _rgb(only(_drawn(gui)).plots[:frames].color[]) == _rgb(gui.layout.theme.muted) + @test !haskey(GUI._activity(gui).colors, "several") + GUI._clear_cues!(gui) + + # a message: a bubble with the colour at its left edge, wrapped + text = "aligning the fold mirror M1 to the beam, then checking the focus on the detector and the spot size of the second camera while waiting for the next solve" + msg = _episode([_item(gui, fx.m1, :changed)]; n = 2, who = "bob@PC3", name = "Bob", text = "", message = text) + GUI._draw_cues!(gui, [(msg, 1.0)], 10.0) + d = only(_drawn(gui)) + @test d.bubble + @test haskey(d.plots, :bar) && !haskey(d.plots, :dot) + @test _rgb(d.plots[:bar].color[]) == _rgb(GUI._activity_color(gui, "bob@PC3")) + lines = split(GUI._label_text(d.plots[:text]), '\n') + @test length(lines) == GUI._BUBBLE_LINES + @test all(l -> length(l) <= GUI._BUBBLE_WRAP, lines) + @test endswith(lines[end], "…") + # a message without an object: at the corner of the view + GUI._clear_cues!(gui) + alone = _episode(GUI._ActivityItem[]; n = 3, who = "bob@PC3", name = "Bob", text = "", message = "back in 5") + GUI._draw_cues!(gui, [(alone, 1.0)], 10.0) + d = only(_drawn(gui)) + @test !isempty(d.plots) + @test all(isfinite, d.pos[]) + + # several cues at once at one object stack their chips + GUI._clear_cues!(gui) + e1 = _episode([_item(gui, fx.lens, :moved)]; n = 4) + e2 = _episode([_item(gui, fx.lens, :changed)]; n = 5, text = "changed L1") + GUI._draw_cues!(gui, [(e1, 1.5), (e2, 0.6)], 10.0) + d1, d2 = _drawn(gui) + @test d1.slot != d2.slot + @test d1.pos[] != d2.pos[] + # a replay of the episode while its cue is still shown: a cue of its own + GUI._draw_cues!(gui, [(e1, 1.6), (e2, 0.7), (e1, 0.0)], 10.1) + @test length(_drawn(gui)) == 3 + GUI._clear_cues!(gui) + close(screen) + end + + @testset "spectator mode" begin + fx = _fixture() + gui, screen = _live_view(fx) + ep = _episode([_item(gui, fx.lens, :moved)]) + spectator!(gui, true) + GUI._draw_cues!(gui, [(ep, 1.0)], 10.0) + d = only(_drawn(gui)) + for s in (:background, :dot, :name, :text) + @test !d.plots[s].visible[] + end + for s in (:frames, :ghosts, :arcs, :heads) + @test d.plots[s].visible[] + end + spectator!(gui, false) + GUI._draw_cues!(gui, [(ep, 1.1)], 10.1) + @test all(s -> d.plots[s].visible[], (:background, :dot, :name, :text)) + close(screen) + end + + # The contrast of two colours (WCAG) + _lum(c) = (f(x) = x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055)^2.4; + 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b)) + _contrast(a, b) = ((x, y) = extrema((_lum(a), _lum(b))); (y + 0.05) / (x + 0.05)) + # A colour `c` with alpha over the opaque `under` + _over(c, under) = (a = Makie.to_color(c).alpha; RGBf((a .* _rgb(c) .+ (1 - a) .* _rgb(under))...)) + + @testset "readable text ($theme)" for theme in (:light, :dark) + fx = _fixture() + gui, screen = _live_view(fx; theme) + msg = _episode([_item(gui, fx.m1, :changed)]; n = 2, text = "", message = "checking the focus") + ep = _episode([_item(gui, fx.lens, :moved)]) + GUI._draw_cues!(gui, [(ep, 1.0), (msg, 1.0)], 10.0) + for d in _drawn(gui) + # the background over whatever lies behind it, black or white + for under in (RGBf(0, 0, 0), RGBf(1, 1, 1)) + bg = _over(d.plots[:background].color[], under) + for s in (:name, :text) + @test _contrast(_over(d.plots[s].color[], bg), bg) >= 4.5 + end + end + end + img = Makie.colorbuffer(screen) + @test img isa AbstractMatrix + close(screen) + end + + @testset "scrub" begin + fx = _fixture() + gui, screen = _live_view(fx) + gui.sync.ids[fx.lens] = (:L1, "u1") + gui.sync.ids[fx.m2] = (:M2, "u2") + gui.sync.ids[fx.m1] = (:M1, "u3") + a = GUI._activity(gui) + lens_before = _shift(_pose(fx.lens), [0.0, -0.03, 0]) + m2_before = _turn(_pose(fx.m2), 30) + push!(a.episodes, GUI._Episode(1, "bob@PC3", "Bob", false, 5.0, + [GUI._ActivityItem("u1", :L1, :moved, lens_before, _pose(fx.lens), _frame(gui, fx.lens))], "moved L1", "", 1)) + push!(a.episodes, GUI._Episode(2, "claude@agent", "Claude", true, 6.0, + [GUI._ActivityItem("u2", :M2, :rotated, m2_before, _pose(fx.m2), _frame(gui, fx.m2))], "rotated M2", "", 1)) + # a removed object, not in the view any more + gone = (Vector{Float64}([0.3, 0.0, 0.0]), Matrix{Float64}(BMO.orientation(fx.m1))) + gone_frame = GUI._move_points(_frame(gui, fx.m1), _pose(fx.m1), gone) + push!(a.episodes, GUI._Episode(3, "claude@agent", "Claude", true, 7.0, + [GUI._ActivityItem("u9", :M9, :removed, gone, nothing, gone_frame)], "removed M9", "", 1)) + a.cursor = 4.0 + comps = _component_plots(gui, fx) + before = IdDict(p => _state(p) for p in comps) + # one ghost frame per key whose pose differs from now; m1 is where it is + poses = Dict("u1" => lens_before, "u2" => m2_before, "u3" => _pose(fx.m1), "u9" => gone) + GUI._draw_scrub!(gui, poses) + c = GUI._cue_plots(gui) + @test c.scrub_keys == ["u1", "u2", "u9"] + pts = c.scrub[1][] + @test length(pts) == 3 * 24 + # the ghost of the lens is its frame 30 mm lower + @test pts[1:24] ≈ [p - Point3f(0, 0.03, 0) for p in _frame(gui, fx.lens)] atol = 1e-6 + @test pts[49:72] ≈ gone_frame atol = 1e-6 + # in the colour of who changed it after the cursor + @test _rgb(c.scrub.color[][1]) == _rgb(GUI._activity_color(gui, "bob@PC3")) + @test _rgb(c.scrub.color[][25]) == _rgb(GUI._activity_color(gui, "claude@agent")) + @test c.scrub.inspectable[] == false && isempty(c.scrub.clip_planes[]) + Makie.colorbuffer(screen) + # an update reuses the plot + p = c.scrub + GUI._draw_scrub!(gui, Dict("u1" => lens_before)) + @test c.scrub === p && length(p[1][]) == 24 + GUI._draw_scrub!(gui, nothing) + @test isnothing(c.scrub) && isempty(c.scrub_keys) + @test !_in(gui.ax.scene, p) + @test all(p -> _state(p) == before[p], comps) + close(screen) + end + + @testset "wrap and presence colour" begin + @test GUI._wrap_text("short") == ["short"] + @test GUI._wrap_text("one two three", 7) == ["one two", "three"] + @test GUI._wrap_text("a" ^ 12, 5, 3) == ["aaaaa", "aaaaa", "aa"] + w = GUI._wrap_text(repeat("word ", 40), 10, 3) + @test length(w) == 3 && endswith(w[end], "…") && all(l -> length(l) <= 10, w) + @test GUI._wrap_text("") == String[] + gui = live_view(System([RoundPlanoMirror(0.02, 0.004)])) + @test GUI._presence_color(gui, "bob@PC3") == GUI._activity_color(gui, "bob@PC3") + end +end + +end From ba78a2862e268842f02e1ca88b90fab01439c482 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:52:54 +0100 Subject: [PATCH 44/47] The timeline of the activity of the others (plans/activity-cues.md, W3): a strip at the bottom of the 3D view in both layouts, hidden until an other was there; collapsed one line with each participant's last change and its age, expanded a lane per participant with the marks of its changes, pills of bursts and messages; hover shows the change, a click replays it, a double click frames it, a drag scrubs the time, the eye follows a participant, the wheel zooms the time span Co-Authored-By: Claude Opus 5.5 --- src/LiveTimeline.jl | 881 ++++++++++++++++++++++++++++++++++++++- test/TestLiveTimeline.jl | 284 +++++++++++++ 2 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 test/TestLiveTimeline.jl diff --git a/src/LiveTimeline.jl b/src/LiveTimeline.jl index 2ee8fb5..3515209 100644 --- a/src/LiveTimeline.jl +++ b/src/LiveTimeline.jl @@ -3,11 +3,882 @@ The timeline of the activity of the others at the bottom of the 3D view, see `Li `plans/activity-cues.md` (W3). =# -"""Builds the timeline strip of the `gui`, hidden until an other participant was there.""" -_timeline!(gui::LiveView) = nothing +# z translation of the scene of the strip: over the 3D view, under the overlay of the compact +# layout (`_OVERLAY_Z`) and the cards; its tooltip lies over the cards, see `_TL_TIP_Z` +const _TL_Z = _OVERLAY_Z - 100.0f0 +const _TL_TIP_Z = _CARD_Z + 60.0f0 - _TL_Z +# Distance from the edges of the 3D view, largest width, heights [px] +const _TL_MARGIN = 12.0f0 +const _TL_MAX_WIDTH = 760.0f0 +const _TL_COLLAPSED = 26.0f0 +const _TL_EXPANDED = 110.0f0 +# Expanded: the head (chevron, title, span), the height of a lane, the foot (the labels of the time +# axis), the column of the lanes' names, the lanes shown at most +const _TL_HEAD = 24.0f0 +const _TL_LANE = 22.0f0 +const _TL_FOOT = 18.0f0 +const _TL_LABELS = 150.0f0 +const _TL_MAX_LANES = 6 +# Horizontal padding inside the strip, corner radius, opacity of the background +const _TL_PAD = 10.0f0 +const _TL_CORNER = 8.0f0 +const _TL_ALPHA = 0.92 +const _TL_FONTSIZE = 12.0f0 +const _TL_SMALL = 10.5f0 +# Collapsed: the participants shown at most, the gap between them [px] +const _TL_SHOWN = 4 +const _TL_GAP = 22.0f0 +# Marks: their size, while hovered, and the distance [px] below which marks of a lane become a pill +const _TL_MARK = 8.0f0 +const _TL_MARK_HOVER = 12.0f0 +const _TL_CLUSTER = 7.0f0 +const _TL_PILL = 13.0f0 +# Before the placement (210), the controls (200) and the camera (0), after the cards, which end the +# input into their textboxes (250), and the splitters, the help and the view cube (≥ 300) +const _TL_PRIORITY = 240 +# The time span of the expanded strip [s]: default, bounds of the wheel, factor per step +const _TL_SPAN = 300.0 +const _TL_SPAN_MIN = 60.0 +const _TL_SPAN_MAX = 3600.0 +const _TL_ZOOM = 1.25 +# Distance [px] after which a press in the lanes scrubs, pause [s] of a double click +const _TL_DRAG_MIN = 3.0f0 +const _TL_DOUBLE_CLICK = 0.4 -"""Updates the timeline strip of the `gui` at the time `now`: lanes, marks, ages, tooltip.""" -_update_timeline!(gui::LiveView, now::Real) = nothing +# The glyphs of the strip: a dot (a person), a small diamond (an agent), a speech bubble (a message) +const _TL_DOT = Makie.BezierCircle +const _TL_DIAMOND = Makie.BezierPath([Makie.MoveTo(Point2d(0, 0.5)), Makie.LineTo(Point2d(0.4, 0)), + Makie.LineTo(Point2d(0, -0.5)), Makie.LineTo(Point2d(-0.4, 0)), Makie.ClosePath()]) +const _TL_SPEECH = _svg_path("M120-120v-680q0-33 23.5-56.5T200-880h560q33 0 56.5 23.5T840-800v440q0 33-23.5 56.5T760-280H280L120-120Z") + +""" + _Timeline + +The strip of the timeline of the `gui` (`gui.activity.strip`, see `_timeline!`): plain plots in its +own `scene`, a scene with a pixel camera over the whole figure, translated to `_TL_Z`, in the +color tokens `theme`; the plots are kept and updated, see `_draw_timeline!`. + +`rect` is its rectangle [figure px], `shown` whether it is shown (an other was there, not in the +spectator mode), `span` the time span of the expanded strip [s] (the wheel changes it), `now` the +time of the last update. `hits` are the regions that take the mouse: `(rect, kind, data)` with the +kinds `:toggle`, `:eye` (participant id), `:mark` (episode numbers, the last one is shown) and +`:lanes`; `axis` the x range of the time axis. `pressed` is the press on the strip (kind, data, +mouse, scrubbing, button) or `nothing`, `click` the episode and time of the last click on a mark (the +double click), `hover_own` whether the strip set `activity.hover`, `sig` what the plots show, +such that unchanged frames cost nothing, `widths` the measured texts. +""" +mutable struct _Timeline + const scene::Scene + const theme::NamedTuple + const font::Any + const bold::Any + const plots::NamedTuple + rect::Rect2f + shown::Bool + span::Float64 + now::Float64 + hits::Vector{Tuple{Rect2f, Symbol, Any}} + axis::Tuple{Float32, Float32} + pressed::Any + click::Tuple{Int, Float64} + hover_own::Bool + sig::Any + const widths::Dict{Tuple{String, Bool, Float32}, Float32} +end + +""" + _timeline!(gui) -> _Timeline + +Builds the timeline strip of the `gui` (see `_Timeline`) at the bottom of its 3D view, hidden until +an other participant was there (`activity.seen`), and connects the mouse, see +`_connect_timeline!`. Returns the strip that exists already if called again. +""" +function _timeline!(gui::LiveView) + a = _activity(gui) + a.strip isa _Timeline && return a.strip + t = gui.layout.theme + scene = Scene(gui.fig.scene; camera = Makie.campixel!, clear = false) + translate!(scene, 0, 0, _TL_Z) + common = (; inspectable = false, space = :pixel) + # GLMakie can not draw an empty vector of markers: every vector holds one hidden entry at least + away = [Point2f(-100, -100)] + none = RGBAf[_TRANSPARENT] + box = _rounded_rect(Rect2f(0, 0, 1, 1), 0) + panel = poly!(scene, box; common..., color = _rgba(t.view, _TL_ALPHA), strokecolor = t.border, + strokewidth = 1) + grid = linesegments!(scene, [away; away]; common..., color = none, linewidth = 1) + chevron = lines!(scene, [away; away; away]; common..., color = t.muted, linewidth = 1.5) + pills = poly!(scene, [Makie.Polygon(box)]; common..., color = none, strokecolor = none, + strokewidth = 1) + marks = scatter!(scene, away; common..., marker = [_TL_DOT], markersize = [_TL_MARK], color = none, + strokewidth = 0, markerspace = :pixel) + dots = scatter!(scene, away; common..., marker = [_TL_DOT], markersize = [_TL_MARK], color = none, + markerspace = :pixel) + eyes = scatter!(scene, away; common..., marker = [_icon(:eye)], markersize = [15.0f0], color = none, + markerspace = :pixel) + text_kw = (; common..., markerspace = :pixel, align = (:left, :center)) + names = text!(scene, Point2f[]; text_kw..., text = String[], color = RGBAf[], font = :bold, + fontsize = _TL_FONTSIZE) + details = text!(scene, Point2f[]; text_kw..., text = String[], color = t.muted, + fontsize = _TL_FONTSIZE) + labels = text!(scene, Point2f[]; text_kw..., text = String[], color = RGBAf[], + fontsize = _TL_SMALL) + cursor = linesegments!(scene, [away; away]; common..., color = t.accent, linewidth = 1.5, + visible = false) + tip_box = poly!(scene, box; common..., color = t.tooltip, strokewidth = 0, visible = false) + tip_text = text!(scene, Point2f(0); text_kw..., text = "", color = t.tooltip_text, + fontsize = _TL_FONTSIZE, visible = false) + # The lines and pills over the background, the marks over them, the texts on top + foreach(p -> translate!(p, 0, 0, 1), (grid, pills)) + foreach(p -> translate!(p, 0, 0, 2), (marks, dots, eyes, chevron, cursor)) + foreach(p -> translate!(p, 0, 0, 3), (names, details, labels)) + translate!(tip_box, 0, 0, _TL_TIP_Z) + translate!(tip_text, 0, 0, _TL_TIP_Z + 1) + scene.visible[] = false + plots = (; panel, grid, chevron, pills, marks, dots, eyes, names, details, labels, cursor, + tip_box, tip_text) + tl = _Timeline(scene, t, _tree_font(scene, :regular), _tree_font(scene, :bold), plots, + Rect2f(0, 0, 0, 0), false, _TL_SPAN, time(), Tuple{Rect2f, Symbol, Any}[], (0.0f0, 0.0f0), + nothing, (0, -Inf), false, nothing, Dict{Tuple{String, Bool, Float32}, Float32}()) + a.strip = tl + _connect_timeline!(gui, tl) + _update_timeline!(gui, time()) + return tl +end + +"""The strip of the timeline of the `gui`, `nothing` before `_timeline!`.""" +function _timeline(gui::LiveView) + s = _activity(gui).strip + return s isa _Timeline ? s : nothing +end + +""" + _update_timeline!(gui, now) + +Updates the timeline strip of the `gui` at the time `now` [s, see `time`]: shows it once an other +was there (hidden in the spectator mode), places it at the bottom of the 3D view and redraws its +lanes, marks, ages and tooltip if any of them changed, see `_draw_timeline!`. Called every frame. +""" +function _update_timeline!(gui::LiveView, now::Real) + tl = _timeline(gui) + isnothing(tl) && return nothing + a = _activity(gui) + tl.now = Float64(now) + shown = a.seen && !gui.controls.spectator[] && !isempty(a.episodes) + rect = shown ? _tl_rect(gui, a) : nothing + shown = shown && !isnothing(rect) + if !shown + tl.shown && _hide_timeline!(gui, tl) + return nothing + end + tl.shown = true + tl.scene.visible[] = true + tl.rect = rect + # what the plots show: the ages and the time axis move with `now`, by the second or the pixel + tick = a.expanded ? floor(Int, now / (tl.span / max(_tl_axis_width(rect), 1))) : floor(Int, now / 1) + last = a.episodes[end] + sig = (rect, a.expanded, length(a.episodes), last.n, tl.span, tick, a.hover, a.cursor, a.follow, + length(a.colors)) + sig == tl.sig && return nothing + tl.sig = sig + _draw_timeline!(gui, tl, a) + return nothing +end """Expands (`true`) or collapses the timeline strip of the `gui`.""" -_set_expanded!(gui::LiveView, on::Bool) = (_activity(gui).expanded = on; nothing) +function _set_expanded!(gui::LiveView, on::Bool) + a = _activity(gui) + a.expanded = on + tl = _timeline(gui) + isnothing(tl) || _update_timeline!(gui, tl.now) + return nothing +end + +# Hides the strip, and its hover and scrub +function _hide_timeline!(gui::LiveView, tl::_Timeline) + a = _activity(gui) + tl.shown = false + tl.scene.visible[] = false + tl.sig = nothing + tl.pressed = nothing + a.cursor = nothing + _tl_set_hover!(gui, tl, nothing) + empty!(tl.hits) + return nothing +end + +#= +Geometry +=# + +# The width of the time axis of the strip with the rectangle `r` +_tl_axis_width(r::Rect2f) = Makie.widths(r)[1] - _TL_LABELS - 2 * _TL_PAD - 6 + +# The height of the expanded strip with `n` lanes: at least the one of two lanes, at most of +# `_TL_MAX_LANES`, about `_TL_EXPANDED` with three +_tl_expanded_height(n::Int) = _TL_HEAD + _TL_FOOT + 6 + clamp(n, 2, _TL_MAX_LANES) * _TL_LANE + +""" + _tl_rect(gui, a) -> Union{Rect2f, Nothing} + +The rectangle [figure px] of the timeline strip of the `gui`: at the bottom of the 3D view, inset by +`_TL_MARGIN`, at most `_TL_MAX_WIDTH` wide and centered, right of (or left of) the parts of the +layout at the bottom corners of the view (see `_layout_obstacles`), e.g. the button "⋯" of the +compact layout. `nothing` if the view is too small. +""" +function _tl_rect(gui::LiveView, a::_Activity) + vp = Rect2f(gui.ax.scene.viewport[]) + lo, hi = minimum(vp), maximum(vp) + m = _TL_MARGIN + left, right = lo[1] + m, hi[1] - m + bottom = lo[2] + m + band = bottom + _TL_COLLAPSED + mid = (lo[1] + hi[1]) / 2 + for r in _layout_obstacles(gui) + rl, rh = minimum(r), maximum(r) + # only the parts at the bottom edge, not e.g. the tool rail above "⋯" + (rl[2] < band && rh[2] > bottom && rh[1] > left && rl[1] < right) || continue + if (rl[1] + rh[1]) / 2 < mid + left = max(left, rh[1] + _OVERLAY_GAP) + else + right = min(right, rl[1] - _OVERLAY_GAP) + end + end + w = min(right - left, _TL_MAX_WIDTH) + h = a.expanded ? _tl_expanded_height(length(_tl_participants(a))) : _TL_COLLAPSED + h = min(h, hi[2] - lo[2] - 2m) + (w < 160 || h < _TL_COLLAPSED) && return nothing + x = clamp(mid - w / 2, left, right - w) + return Rect2f(x, bottom, w, h) +end + +""" + _tl_participants(a) -> Vector + +The participants of the episodes of the activity `a`, in the order in which they were first seen: +each `(; who, name, agent, first, last, episodes)` with the index of its first episode, its last +episode and the indices of its episodes in `a.episodes`. +""" +function _tl_participants(a::_Activity) + index = Dict{String, Int}() + ps = NamedTuple{(:who, :name, :agent, :first, :last, :episodes), + Tuple{String, String, Bool, Int, _Episode, Vector{Int}}}[] + for (i, ep) in enumerate(a.episodes) + k = get(index, ep.who, 0) + if k == 0 + push!(ps, (; who = ep.who, name = ep.name, agent = ep.agent, first = i, last = ep, + episodes = [i])) + index[ep.who] = length(ps) + else + p = ps[k] + push!(p.episodes, i) + ps[k] = (; p..., name = ep.name, agent = ep.agent, last = ep) + end + end + return ps +end + +# The lanes of the expanded strip: the participants that were active last, at most +# `_TL_MAX_LANES`, in the order in which they were first seen +function _tl_lanes(a::_Activity) + ps = _tl_participants(a) + length(ps) <= _TL_MAX_LANES && return ps + keep = sort(sortperm(ps; by = p -> p.last.t, rev = true)[1:_TL_MAX_LANES]) + return ps[keep] +end + +#= +Texts +=# + +"""The age `dt` [s] of an episode as the strip shows it: "now", "12 s", "3 min", "1 h".""" +function _tl_age(dt::Real) + dt < 5 && return "now" + dt < 60 && return "$(floor(Int, dt)) s" + dt < 3600 && return "$(floor(Int, dt / 60)) min" + return "$(floor(Int, dt / 3600)) h" +end + +"""The time `t` [s, see `time`] as `HH:MM:SS` of the local clock.""" +_tl_clock(t::Real) = Libc.strftime("%H:%M:%S", t) + +# The span of the time axis `span` [s] in its head: "90 s", "5 min", "1 h" +function _tl_span_label(span::Real) + span < 120 && return "$(round(Int, span)) s" + span < 3600 - 30 && return "$(round(Int, span / 60)) min" + return "1 h" +end + +# The label of a tick of the time axis `dt` [s] before now +function _tl_tick_label(dt::Integer) + dt == 0 && return "now" + dt % 3600 == 0 && return "$(dt ÷ 3600) h" + dt % 60 == 0 && return "$(dt ÷ 60) min" + return "$(dt) s" +end + +# What an episode says: its text, or its message in quotes +_tl_text(ep::_Episode) = isempty(ep.message) ? ep.text : "“" * ep.message * "”" + +# The width [px] of the text `s` in the regular or `bold` font, measured once +function _tl_width(tl::_Timeline, s::String, bold::Bool = false, size::Float32 = _TL_FONTSIZE) + length(tl.widths) > 2000 && empty!(tl.widths) + return get!(tl.widths, (s, bold, size)) do + Float32(Makie.widths(Makie.text_bb(s, bold ? tl.bold : tl.font, size))[1]) + end +end + +"""The text `s`, shortened with "…" such that it is at most `w` [px] wide; "" if not even "…" fits.""" +function _tl_fit(tl::_Timeline, s::String, w::Real, bold::Bool = false) + _tl_width(tl, s, bold) <= w && return s + _tl_width(tl, "…", bold) > w && return "" + chars = collect(s) + lo, hi = 0, length(chars) + while lo < hi + k = (lo + hi + 1) ÷ 2 + if _tl_width(tl, rstrip(String(chars[1:k])) * "…", bold) <= w + lo = k + else + hi = k - 1 + end + end + return rstrip(String(chars[1:lo])) * "…" +end + +# The tooltip of a mark of the episodes `ns` of the activity `a` +function _tl_tooltip(a::_Activity, ns::Vector{Int}) + eps = [ep for ep in a.episodes if ep.n in ns] + isempty(eps) && return "" + ep = last(eps) + length(eps) == 1 && return "$(ep.name) · $(_tl_clock(ep.t)) · $(_tl_text(ep))" + count = sum(e -> e.count, eps) + return "$(ep.name) · $(_tl_clock(first(eps).t))–$(_tl_clock(ep.t)) · $count changes, last: $(_tl_text(ep))" +end + +#= +Drawing +=# + +# The color of the participant `who` of the `gui` with the opacity `alpha` +_tl_color(gui::LiveView, who::String, alpha::Real = 1.0) = RGBAf(_activity_color(gui, who; alpha)) + +""" + _draw_timeline!(gui, tl, a) + +Draws the strip `tl` of the `gui` in its rectangle: the panel, then the collapsed line or the +expanded lanes (see `_draw_collapsed!`, `_draw_expanded!`), the scrub cursor and the tooltip of the +hovered mark, and collects the regions that take the mouse in `tl.hits`. +""" +function _draw_timeline!(gui::LiveView, tl::_Timeline, a::_Activity) + r = tl.rect + p = tl.plots + Makie.update!(p.panel; arg1 = _rounded_rect(r, _TL_CORNER)) + empty!(tl.hits) + # the chevron at the left of the line or of the head: up to expand, down to collapse + x, y = minimum(r)[1] + _TL_PAD + 5, maximum(r)[2] - (a.expanded ? _TL_HEAD : _TL_COLLAPSED) / 2 + s = a.expanded ? -1 : 1 + Makie.update!(p.chevron; + arg1 = Point2f[(x - 4, y - 2s), (x, y + 2s), (x + 4, y - 2s)]) + a.expanded ? _draw_expanded!(gui, tl, a) : _draw_collapsed!(gui, tl, a) + _draw_tooltip!(gui, tl, a) + return nothing +end + +""" +Draws the collapsed line of the strip `tl`: per participant of the `_TL_SHOWN` that were active +last (as many as fit), its dot (a diamond for an agent), its name, its last text and its age. A +click on the line expands the strip; the mouse over a participant shows its last episode. +""" +function _draw_collapsed!(gui::LiveView, tl::_Timeline, a::_Activity) + t, r, p = tl.theme, tl.rect, tl.plots + ps = sort(_tl_participants(a); by = q -> q.last.t, rev = true) + ps = ps[1:min(length(ps), _TL_SHOWN)] + x0 = minimum(r)[1] + _TL_PAD + 18 + avail = maximum(r)[1] - _TL_PAD - x0 + y = minimum(r)[2] + _TL_COLLAPSED / 2 + # the fixed part of a participant: its dot, its name (at most 120 px) and the age + fixed(q) = 12 + min(_tl_width(tl, q.name, true), 120) + 6 + + _tl_width(tl, " · " * _tl_age(tl.now - q.last.t)) + k = length(ps) + while k > 1 && sum(fixed, ps[1:k]) + (k - 1) * _TL_GAP + k * 40 > avail + k -= 1 + end + ps = ps[1:k] + # the widths: those that need less than an equal share get what they need, the others share + # the rest equally + need = [fixed(q) + _tl_width(tl, _tl_text(q.last)) + 1 for q in ps] + budgets = fill(0.0f0, k) + open = collect(1:k) + space = Float32(avail - (k - 1) * _TL_GAP) + while !isempty(open) + share = space / length(open) + small = filter(i -> need[i] <= share, open) + if isempty(small) + budgets[open] .= share + break + end + for i in small + budgets[i] = need[i] + space -= need[i] + end + filter!(i -> !(i in small), open) + end + dots, dot_markers, dot_colors = Point2f[], Makie.BezierPath[], RGBAf[] + name_pos, name_text, name_colors = Point2f[], String[], RGBAf[] + det_pos, det_text = Point2f[], String[] + x = x0 + for (q, budget) in zip(ps, budgets) + name = _tl_fit(tl, q.name, min(120, budget - 12), true) + wn = _tl_width(tl, name, true) + age = " · " * _tl_age(tl.now - q.last.t) + rest = budget - 12 - wn - 6 + text = _tl_text(q.last) + detail = _tl_fit(tl, text, rest - _tl_width(tl, age)) + detail = isempty(detail) ? lstrip(age, [' ', '·']) : detail * age + w = 12 + wn + 6 + _tl_width(tl, String(detail)) + push!(dots, Point2f(x + 4, y)) + push!(dot_markers, q.agent ? _TL_DIAMOND : _TL_DOT) + push!(dot_colors, _tl_color(gui, q.who)) + push!(name_pos, Point2f(x + 12, y)) + push!(name_text, name) + push!(name_colors, _rgba(t.text)) + push!(det_pos, Point2f(x + 12 + wn + 6, y)) + push!(det_text, String(detail)) + push!(tl.hits, (Rect2f(x - 4, minimum(r)[2], w + 8, _TL_COLLAPSED), :mark, [q.last.n])) + x += w + _TL_GAP + end + # the whole line expands the strip + push!(tl.hits, (r, :toggle, nothing)) + _tl_scatter!(p.dots, dots, dot_markers, dot_colors, _TL_MARK + 1) + Makie.update!(p.names; arg1 = name_pos, text = name_text, color = name_colors) + Makie.update!(p.details; arg1 = det_pos, text = det_text) + _tl_hide!(p.grid, p.pills, p.marks, p.eyes, p.labels) + _tl_draw_cursor!(gui, tl, a) + return nothing +end + +""" +Draws the expanded strip `tl`: the head (the chevron, "Activity", the span or the time of the scrub +cursor), one lane per participant (see `_tl_lanes`) with its eye (follow), its dot and name, and +the marks of its episodes on the time axis of the last `tl.span` seconds ("now" at the right edge): +a dot per change, a speech bubble per message, a pill with the count where marks lie closer than +`_TL_CLUSTER`; ticks at the bottom. +""" +function _draw_expanded!(gui::LiveView, tl::_Timeline, a::_Activity) + t, r, p = tl.theme, tl.rect, tl.plots + lanes = _tl_lanes(a) + (x0, y0), (W, H) = minimum(r), Makie.widths(r) + top = y0 + H + left = x0 + _TL_PAD + ax0, ax1 = left + _TL_LABELS, x0 + W - _TL_PAD - 6 + tl.axis = (Float32(ax0), Float32(ax1)) + now, span = tl.now, tl.span + xt(time) = Float32(ax1 - (now - time) / span * (ax1 - ax0)) + # the lanes: centered in the area between the head and the foot + n = length(lanes) + area_top, area_bottom = top - _TL_HEAD, y0 + _TL_FOOT + 6 + lane_h = min(_TL_LANE, (area_top - area_bottom) / max(n, 1)) + first_y = (area_top + area_bottom) / 2 + (n - 1) * lane_h / 2 + lane_y(i) = Float32(first_y - (i - 1) * lane_h) + muted, border = _rgba(t.muted), _rgba(t.border) + # head + labels_pos, labels_text, labels_color = Point2f[], String[], RGBAf[] + # a small text, aligned at the left, center or right of `pos` + function label!(pos, s, c, align) + w = _tl_width(tl, s, false, _TL_SMALL) + dx = align === :left ? 0.0f0 : align === :center ? w / 2 : w + push!(labels_pos, pos - Point2f(dx, 0)) + push!(labels_text, s) + push!(labels_color, c) + return nothing + end + hy = Float32(top - _TL_HEAD / 2) + label!(Point2f(left + 18, hy), "Activity", muted, :left) + if isnothing(a.cursor) + label!(Point2f(ax1, hy), "last " * _tl_span_label(span), muted, :right) + else + label!(Point2f(ax1, hy), _tl_clock(a.cursor), _rgba(t.accent), :right) + end + push!(tl.hits, (Rect2f(x0, top - _TL_HEAD, W, _TL_HEAD), :toggle, nothing)) + # the grid: the baselines of the lanes and a faint line per tick + segs, seg_colors = Point2f[], RGBAf[] + step = _tl_tick_step(span, ax1 - ax0) + k = 0 + while k * step <= span + 1.0e-6 + x = Float32(ax1 - k * step / span * (ax1 - ax0)) + push!(segs, Point2f(x, area_bottom - 3), Point2f(x, area_top + 2)) + push!(seg_colors, RGBAf(border.r, border.g, border.b, 0.55), RGBAf(border.r, border.g, border.b, 0.55)) + s = _tl_tick_label(k * step) + # no label that would reach into the names of the lanes + (k == 0 || x - _tl_width(tl, s, false, _TL_SMALL) / 2 >= ax0 - 4) && + label!(Point2f(x, y0 + _TL_FOOT / 2 + 2), s, muted, k == 0 ? :right : :center) + k += 1 + end + for i in 1:n + y = lane_y(i) + push!(segs, Point2f(ax0, y), Point2f(ax1, y)) + push!(seg_colors, border, border) + end + Makie.update!(p.grid; arg1 = segs, color = seg_colors) + # the labels of the lanes: eye, dot, name + eye_pos, eye_markers, eye_colors = Point2f[], Makie.BezierPath[], RGBAf[] + dots, dot_markers, dot_colors = Point2f[], Makie.BezierPath[], RGBAf[] + name_pos, name_text, name_colors = Point2f[], String[], RGBAf[] + for (i, q) in enumerate(lanes) + y = lane_y(i) + following = a.follow == q.who + push!(eye_pos, Point2f(left + 7, y)) + push!(eye_markers, _icon(following ? :eye : :eye_off)) + push!(eye_colors, following ? _rgba(t.accent) : RGBAf(muted.r, muted.g, muted.b, 0.7)) + push!(tl.hits, (Rect2f(left - 3, y - lane_h / 2, 20, lane_h), :eye, q.who)) + push!(dots, Point2f(left + 25, y)) + push!(dot_markers, q.agent ? _TL_DIAMOND : _TL_DOT) + push!(dot_colors, _tl_color(gui, q.who)) + push!(name_pos, Point2f(left + 34, y)) + push!(name_text, _tl_fit(tl, q.name, _TL_LABELS - 44, true)) + push!(name_colors, _rgba(t.text)) + end + _tl_scatter!(p.eyes, eye_pos, eye_markers, eye_colors, 15) + _tl_scatter!(p.dots, dots, dot_markers, dot_colors, _TL_MARK + 1) + Makie.update!(p.names; arg1 = name_pos, text = name_text, color = name_colors) + Makie.update!(p.details; arg1 = Point2f[], text = String[]) + # the marks + mark_pos, mark_markers, mark_colors, mark_sizes = Point2f[], Makie.BezierPath[], RGBAf[], + Float32[] + pills, pill_colors, pill_strokes = Makie.Polygon[], RGBAf[], RGBAf[] + for (i, q) in enumerate(lanes) + y = lane_y(i) + for cluster in _tl_clusters(a, q.episodes, xt, ax0, ax1) + ns = [a.episodes[j].n for j in cluster] + eps = a.episodes[cluster] + xs = [xt(ep.t) for ep in eps] + hovered = !isnothing(a.hover) && a.hover in ns + c = _tl_color(gui, q.who) + if length(cluster) == 1 && only(eps).count == 1 + ep = only(eps) + message = !isempty(ep.message) + size = (message ? _TL_MARK + 3 : _TL_MARK) * (hovered ? _TL_MARK_HOVER / _TL_MARK : 1) + push!(mark_pos, Point2f(only(xs), y)) + push!(mark_markers, message ? _TL_SPEECH : _TL_DOT) + push!(mark_colors, c) + push!(mark_sizes, size) + h = max(size, 12) + push!(tl.hits, (Rect2f(only(xs) - h / 2, y - lane_h / 2, h, lane_h), :mark, ns)) + else + count = sum(ep -> ep.count, eps) + label = string(count) + wl = _tl_width(tl, label, false, _TL_SMALL) + xa, xb = minimum(xs), maximum(xs) + w = max(xb - xa + _TL_PILL, wl + 12) + xm = clamp((xa + xb) / 2, ax0 + w / 2, ax1 + 3 - w / 2) + pr = Rect2f(xm - w / 2, y - _TL_PILL / 2, w, _TL_PILL) + push!(pills, Makie.Polygon(_rounded_rect(pr, _TL_PILL / 2))) + push!(pill_colors, _tl_color(gui, q.who, hovered ? 0.42 : 0.22)) + push!(pill_strokes, _tl_color(gui, q.who, hovered ? 1.0 : 0.8)) + label!(Point2f(xm, y), label, _rgba(t.text), :center) + push!(tl.hits, (Rect2f(minimum(pr)[1], y - lane_h / 2, w, lane_h), :mark, ns)) + end + end + end + _tl_scatter!(p.marks, mark_pos, mark_markers, mark_colors, mark_sizes) + if isempty(pills) + Makie.update!(p.pills; visible = false) + else + Makie.update!(p.pills; arg1 = pills, color = pill_colors, strokecolor = pill_strokes, + visible = true) + end + Makie.update!(p.labels; arg1 = labels_pos, text = labels_text, color = labels_color) + push!(tl.hits, (Rect2f(ax0 - 4, area_bottom - 4, ax1 - ax0 + 8, area_top - area_bottom + 6), :lanes, nothing)) + _tl_draw_cursor!(gui, tl, a) + return nothing +end + +""" + _tl_clusters(a, indices, xt, ax0, ax1) -> Vector{Vector{Int}} + +The episodes `indices` (in `a.episodes`, oldest first) of a lane that lie on the time axis from +`ax0` to `ax1` (`xt` maps a time to x), grouped such that each group holds the marks that follow +each other closer than `_TL_CLUSTER` [px]; a group of one is a mark, a larger one a pill. +""" +function _tl_clusters(a::_Activity, indices::Vector{Int}, xt, ax0::Real, ax1::Real) + groups = Vector{Int}[] + lastx = -Inf32 + for j in indices + x = xt(a.episodes[j].t) + (ax0 - 2 <= x <= ax1 + 2) || continue + if !isempty(groups) && x - lastx < _TL_CLUSTER + push!(last(groups), j) + else + push!(groups, [j]) + end + lastx = x + end + return groups +end + +# The step [s] of the ticks of a time axis of `span` seconds over `w` pixels: about 70 px apart +function _tl_tick_step(span::Real, w::Real) + for s in (10, 15, 30, 60, 120, 300, 600, 900, 1800) + s / span * w >= 70 && return s + end + return 3600 +end + +# Sets the points, markers, colors and sizes of a scatter, with a hidden entry if there are none; the +# sizes are a vector always, a plot can not switch from a scalar to a vector +function _tl_scatter!(plot, pos::Vector{Point2f}, markers::Vector{<:Makie.BezierPath}, + colors::Vector{RGBAf}, size) + if isempty(pos) + Makie.update!(plot; arg1 = [Point2f(-100, -100)], marker = [_TL_DOT], + color = RGBAf[_TRANSPARENT], markersize = [1.0f0]) + else + n = length(pos) + Makie.update!(plot; arg1 = pos, marker = markers, color = colors, + markersize = size isa AbstractVector ? Float32.(size) : fill(Float32(size), n)) + end + return nothing +end + +# Hides the parts of the expanded strip, e.g. while it is collapsed +function _tl_hide!(grid, pills, marks, eyes, labels) + away = Point2f(-100, -100) + Makie.update!(grid; arg1 = [away, away], color = RGBAf[_TRANSPARENT, _TRANSPARENT]) + Makie.update!(pills; visible = false) + for s in (marks, eyes) + _tl_scatter!(s, Point2f[], Makie.BezierPath[], RGBAf[], 1.0f0) + end + Makie.update!(labels; arg1 = Point2f[], text = String[], color = RGBAf[]) + return nothing +end + +# The scrub cursor: a line in the accent color across the lanes at `a.cursor` +function _tl_draw_cursor!(gui::LiveView, tl::_Timeline, a::_Activity) + c = tl.plots.cursor + if !a.expanded || isnothing(a.cursor) + c.visible[] && Makie.update!(c; visible = false) + return nothing + end + r = tl.rect + ax0, ax1 = tl.axis + x = Float32(clamp(ax1 - (tl.now - a.cursor) / tl.span * (ax1 - ax0), ax0, ax1)) + Makie.update!(c; arg1 = [Point2f(x, minimum(r)[2] + _TL_FOOT + 2), + Point2f(x, maximum(r)[2] - _TL_HEAD + 2)], visible = true) + return nothing +end + +""" +Draws the tooltip of the hovered mark of the strip `tl` above the strip, e.g. +`Claude · 23:41:07 · moved M2 by 12 mm`, in the colors of the tooltips of the theme; hidden +without a hovered mark of the strip and while scrubbing. +""" +function _draw_tooltip!(gui::LiveView, tl::_Timeline, a::_Activity) + p = tl.plots + i = findfirst(h -> h[2] === :mark && !isnothing(a.hover) && a.hover in h[3], tl.hits) + if isnothing(i) || !tl.hover_own || !isnothing(a.cursor) + p.tip_box.visible[] && (Makie.update!(p.tip_box; visible = false); + Makie.update!(p.tip_text; visible = false)) + return nothing + end + rect, _, ns = tl.hits[i] + vp = Rect2f(gui.ax.scene.viewport[]) + text = _tl_tooltip(a, ns) + maxw = Makie.widths(vp)[1] - 2 * _TL_MARGIN - 20 + text = _tl_fit(tl, text, maxw) + w, h = _tl_width(tl, text) + 20, 24.0f0 + cx = minimum(rect)[1] + Makie.widths(rect)[1] / 2 + x = clamp(cx - w / 2, minimum(vp)[1] + _TL_MARGIN, maximum(vp)[1] - _TL_MARGIN - w) + y = maximum(tl.rect)[2] + 6 + Makie.update!(p.tip_box; arg1 = _rounded_rect(Rect2f(x, y, w, h), 6), visible = true) + Makie.update!(p.tip_text; arg1 = Point2f(x + 10, y + h / 2), text, visible = true) + return nothing +end + +#= +Mouse +=# + +# The region of the strip at the point `p` [figure px]: the first hit of the `kinds`, else `nothing` +function _tl_hit(tl::_Timeline, p::Point2f, kinds::Symbol...) + for k in kinds, h in tl.hits + h[2] === k && p in h[1] && return h + end + return nothing +end + +# Whether something over the strip takes the mouse: a card, the selection card, the catalog, a part +# of the layout (e.g. the tool rail of the compact layout), the debug card +function _tl_covered(gui::LiveView) + ev = events(gui.ax.scene) + return any(c -> _over_card(c, ev), gui.cards.all) || _over_browse_card(gui) || + _over_catalog(gui) || _over_layout(gui) || _over_debug(gui) +end + +# Whether the mouse at `p` is over the shown strip `tl` and nothing covers it there +_tl_over(gui::LiveView, tl::_Timeline, p::Point2f) = tl.shown && p in tl.rect && !_tl_covered(gui) + +# Sets `activity.hover` to the episode `n` (`nothing`: none), as the strip's own +function _tl_set_hover!(gui::LiveView, tl::_Timeline, n::Union{Nothing, Int}) + a = _activity(gui) + if isnothing(n) + # the strip only clears a hover that it set + tl.hover_own || return nothing + tl.hover_own = false + a.hover = nothing + else + tl.hover_own = true + a.hover = n + end + return nothing +end + +# The time [s] at the x position `x` [figure px] of the time axis of the strip +function _tl_time_at(tl::_Timeline, x::Real) + ax0, ax1 = tl.axis + x = clamp(x, ax0, ax1) + return tl.now - (ax1 - x) / max(ax1 - ax0, 1) * tl.span +end + +""" + _frame_points!(gui, pts) + +Moves the camera of the `gui`, animated, such that the bounding sphere of the points `pts` fills the +view, along the current view direction, like `_zoom_to_selection!`; nothing without points. +""" +function _frame_points!(gui::LiveView, pts::AbstractVector{<:Point3}) + pts = [p for p in pts if all(isfinite, p)] + isempty(pts) && return nothing + bb = GeometryBasics.Rect3d(pts) + center = Vector{Float64}(minimum(bb) .+ GeometryBasics.widths(bb) ./ 2) + # a single point or a flat box: at least 2 cm + d = max(norm(Vector{Float64}(GeometryBasics.widths(bb))), 0.02) + cam = cameracontrols(gui.ax.scene) + dist = cam.settings.projectiontype[] == Makie.Perspective ? (d / 2) / sind(cam.fov[] / 2) : d / 2 + eye, lookat, up = _current_view(gui) + o = norm(eye .- lookat) > 0 ? normalize(eye .- lookat) : [0.0, 0.0, 1.0] + _animate_camera!(gui, center .+ dist .* o, center, up) + return nothing +end + +# The points of the objects of the episode `n` of the activity `a`: their frames, else their poses +function _tl_points(a::_Activity, n::Int) + i = findfirst(ep -> ep.n == n, a.episodes) + isnothing(i) && return Point3f[] + pts = Point3f[] + for it in a.episodes[i].items + if !isempty(it.frame) + append!(pts, it.frame) + else + for pose in (it.before, it.after) + isnothing(pose) || push!(pts, Point3f(pose[1]...)) + end + end + end + return pts +end + +""" + _connect_timeline!(gui, tl) + +Connects the mouse to the strip `tl` of the `gui`, before the controls of the 3D view and the camera +(`_TL_PRIORITY`), which get nothing of what happens over the strip: + +- the mouse over a mark (or a participant of the collapsed line) sets `activity.hover` and shows + its tooltip; +- a click on the collapsed line or on the head of the expanded strip expands or collapses it; on + the eye of a lane follows its participant or stops following (`_set_follow!`); on a mark plays its + cue again (`_replay!`), a double click frames its objects (`_frame_points!`); +- a drag in the lanes moves the scrub cursor (`activity.cursor`), releasing ends it; +- the wheel zooms the time axis of the expanded strip from 1 min to 1 h. +""" +function _connect_timeline!(gui::LiveView, tl::_Timeline) + ev = events(gui.ax.scene) + listeners = gui.controls.listeners + a = _activity(gui) + push!(listeners, on(ev.mousebutton; priority = _TL_PRIORITY) do event + p = Point2f(ev.mouseposition[]) + if event.action == Mouse.press + isnothing(tl.pressed) || return Consume(true) + _tl_over(gui, tl, p) || return Consume(false) + # the other buttons do nothing, but their drags do not reach the camera either + h = if event.button != Mouse.left + nothing + elseif a.expanded + _tl_hit(tl, p, :toggle, :eye, :mark, :lanes) + else + # the collapsed line takes the click as a whole + _tl_hit(tl, p, :toggle) + end + tl.pressed = isnothing(h) ? (:panel, nothing, p, false, event.button) : + (h[2], h[3], p, false, event.button) + return Consume(true) + elseif event.action == Mouse.release && !isnothing(tl.pressed) + kind, data, _, scrubbing, button = tl.pressed + event.button == button || return Consume(true) + tl.pressed = nothing + if scrubbing + a.cursor = nothing + _update_timeline!(gui, tl.now) + return Consume(true) + end + # the release must be over what was pressed + h = tl.shown ? _tl_hit(tl, p, kind) : nothing + (isnothing(h) || h[3] != data) && return Consume(true) + if kind === :toggle + _set_expanded!(gui, !a.expanded) + elseif kind === :eye + _set_follow!(gui, a.follow == data ? nothing : data) + _update_timeline!(gui, tl.now) + elseif kind === :mark + n = last(data) + prev, t0 = tl.click + if prev == n && time() - t0 < _TL_DOUBLE_CLICK + tl.click = (0, -Inf) + _frame_points!(gui, reduce(vcat, (_tl_points(a, m) for m in data); init = Point3f[])) + else + tl.click = (n, time()) + _replay!(gui, n; now = tl.now) + end + end + return Consume(true) + end + return Consume(false) + end) + push!(listeners, on(ev.mouseposition; priority = _TL_PRIORITY) do xy + p = Point2f(xy) + if !isnothing(tl.pressed) + kind, data, p0, scrubbing, button = tl.pressed + if a.expanded && kind in (:mark, :lanes) && (scrubbing || maximum(abs, p - p0) >= _TL_DRAG_MIN) + scrubbing || (tl.pressed = (kind, data, p0, true, button)) + a.cursor = _tl_time_at(tl, p[1]) + _tl_set_hover!(gui, tl, nothing) + _update_timeline!(gui, tl.now) + end + return Consume(true) + end + if !_tl_over(gui, tl, p) + if tl.hover_own + _tl_set_hover!(gui, tl, nothing) + _update_timeline!(gui, tl.now) + end + return Consume(false) + end + h = _tl_hit(tl, p, :mark) + n = isnothing(h) ? nothing : last(h[3]) + if n != a.hover || (!isnothing(n) && !tl.hover_own) + _tl_set_hover!(gui, tl, n) + _update_timeline!(gui, tl.now) + end + # the controls do not hover the objects behind the strip, unless a drag of the 3D view goes on + return Consume(isempty(ev.mousebuttonstate)) + end) + push!(listeners, on(ev.scroll; priority = _TL_PRIORITY) do (_, dy) + _tl_over(gui, tl, Point2f(ev.mouseposition[])) || return Consume(false) + if a.expanded && dy != 0 + tl.span = clamp(tl.span * _TL_ZOOM^(-sign(dy)), _TL_SPAN_MIN, _TL_SPAN_MAX) + _update_timeline!(gui, tl.now) + end + return Consume(true) + end) + push!(listeners, on(_ -> _update_timeline!(gui, tl.now), gui.ax.scene.viewport)) + return nothing +end diff --git a/test/TestLiveTimeline.jl b/test/TestLiveTimeline.jl new file mode 100644 index 0000000..7474efe --- /dev/null +++ b/test/TestLiveTimeline.jl @@ -0,0 +1,284 @@ +module TestLiveTimeline + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using Makie +using Test + +const GUI = BeamletOpticsGUI + +@testset "Live timeline" begin + + function _view(layout; theme = :light) + lens = SphericalLens(0.05, -0.05, 5e-3, 25.4e-3, ConstantRefractiveIndex(1.5)) + translate3d!(lens, [0, 0.1, 0]) + m = RoundPlanoMirror(25e-3, 5e-3) + translate3d!(m, [0, 0.2, 0]) + gui = live_view(System([lens, m]) => Beam([0.0, 0, 0], [0.0, 1, 0]); layout, theme, + size = (1300, 800), throttle = false, detectors = [], trace_budget = Inf) + screen = GLMakie.Screen(visible = false, start_renderloop = false) + display(screen, gui.fig) + @test_logs min_level = Base.CoreLogging.Warn Makie.colorbuffer(screen) + return gui, screen, lens, m + end + + I3 = [1.0 0 0; 0 1 0; 0 0 1] + _item(key) = GUI._ActivityItem(key, Symbol(key), :moved, ([0.0, 0.1, 0], I3), ([0.0, 0.11, 0], I3), + Point3f[(-0.01, 0.1, -0.01), (0.01, 0.12, 0.01)]) + + ev(gui) = events(gui.ax.scene) + move!(gui, p) = (ev(gui).mouseposition[] = (Float64(p[1]), Float64(p[2]))) + press!(gui, b = Mouse.left) = (ev(gui).mousebutton[] = Makie.MouseButtonEvent(b, Mouse.press)) + release!(gui, b = Mouse.left) = (ev(gui).mousebutton[] = Makie.MouseButtonEvent(b, Mouse.release)) + click!(gui, p) = (move!(gui, p); press!(gui); release!(gui)) + center(r) = Point2f(minimum(r) .+ Makie.widths(r) ./ 2) + inside(r, vp) = all(minimum(r) .>= minimum(vp) .- 0.5) && all(maximum(r) .<= maximum(vp) .+ 0.5) + hits(tl, kind) = [h for h in tl.hits if h[2] === kind] + eye_of(gui) = Vector{Float64}(cameracontrols(gui.ax.scene).eyeposition[]) + viewport(gui) = Rect2f(gui.ax.scene.viewport[]) + # the shown entries of a scatter: those not parked at (-100, -100) + shown(plot) = count(p -> p[1] > -50, plot[1][]) + # the text of the tooltip + tiptext(tl) = (s = tl.plots.tip_text.text[]; s isa AbstractString ? s : only(s)) + + @testset "strip ($layout)" for layout in (:compact, :app) + gui, screen, lens, m = _view(layout) + a = GUI._activity(gui) + tl = GUI._timeline!(gui) + @test GUI._timeline!(gui) === tl + @test a.strip === tl + t0 = 1.0e9 + + # hidden until an other was there + GUI._update_timeline!(gui, t0) + @test !tl.scene.visible[] && !tl.shown + + # Bob and Claude (an agent), 40 s apart: separate marks on the axis of 5 min; a message + rec(who, name, agent, key, text, t; message = "") = + GUI._record_episode!(gui, who, name, agent, [_item(key)]; text, message, now = t) + rec("Bob@PC3", "Bob", false, "L1", "moved L1 by 4 mm", t0) + rec("claude@agent", "Claude", true, "M2", "moved M2 by 12 mm", t0 + 40) + rec("Bob@PC3", "Bob", false, "M1", "rotated M1 by 2°", t0 + 80) + rec("claude@agent", "Claude", true, "M2", "", t0 + 120; message = "aligning") + now = t0 + 150 + GUI._update_timeline!(gui, now) + @test tl.scene.visible[] && tl.shown + vp = viewport(gui) + r = tl.rect + + # collapsed: one line at the bottom of the 3D view, inside it, clear of the layout + @test Makie.widths(r)[2] == GUI._TL_COLLAPSED + @test minimum(r)[2] ≈ minimum(vp)[2] + GUI._TL_MARGIN + @test Makie.widths(r)[1] <= GUI._TL_MAX_WIDTH + @test inside(r, vp) + bottom = [o for o in GUI._layout_obstacles(gui) if minimum(o)[2] < maximum(r)[2]] + @test all(o -> !Makie.overlaps(o, r), bottom) + # the names and last texts with their ages, the latest first + @test tl.plots.names.text[] == ["Claude", "Bob"] + details = tl.plots.details.text[] + @test occursin("aligning", details[1]) && occursin("30 s", details[1]) + @test occursin("rotated M1 by 2°", details[2]) && occursin("1 min", details[2]) + @test shown(tl.plots.dots) == 2 + @test_logs min_level = Base.CoreLogging.Warn Makie.colorbuffer(screen) + + # hover a participant: its last episode, the tooltip + seg = hits(tl, :mark) + @test length(seg) == 2 + move!(gui, center(seg[2][1])) + @test a.hover == 3 + GUI._update_timeline!(gui, now) + @test tl.plots.tip_box.visible[] + @test occursin("Bob", tiptext(tl)) && occursin("rotated M1", tiptext(tl)) + move!(gui, center(vp)) + @test isnothing(a.hover) + GUI._update_timeline!(gui, now) + @test !tl.plots.tip_box.visible[] + + # a click on the line expands it; nothing reaches the 3D view: the selection stays + GUI._select!(gui, lens) + eye0 = eye_of(gui) + click!(gui, center(r)) + @test a.expanded + @test gui.controls.selected[] === lens + @test eye_of(gui) ≈ eye0 + r = tl.rect + @test Makie.widths(r)[2] ≈ GUI._tl_expanded_height(2) + @test 90 <= Makie.widths(r)[2] <= 120 + @test inside(r, viewport(gui)) + + # one lane per participant (eye, dot, name), one mark per episode + @test length(hits(tl, :eye)) == 2 + @test [h[3] for h in hits(tl, :eye)] == ["Bob@PC3", "claude@agent"] + @test tl.plots.names.text[] == ["Bob", "Claude"] + marks = hits(tl, :mark) + @test sort([only(h[3]) for h in marks]) == [1, 2, 3, 4] + @test shown(tl.plots.marks) == 4 + @test !tl.plots.pills.visible[] + # the message is a speech bubble, the changes are dots + @test count(==(GUI._TL_SPEECH), tl.plots.marks.marker[]) == 1 + # the participant's color + k = findfirst(h -> only(h[3]) == 2, marks) + @test RGBAf(GUI._activity_color(gui, "claude@agent")) in tl.plots.marks.color[] + # newer is further right, "now" at the right edge + x(n) = center(marks[findfirst(h -> only(h[3]) == n, marks)][1])[1] + @test x(1) < x(2) < x(3) < x(4) < tl.axis[2] + @test tl.axis[2] - x(4) ≈ 30 / 300 * (tl.axis[2] - tl.axis[1]) atol = 1 + @test_logs min_level = Base.CoreLogging.Warn Makie.colorbuffer(screen) + + # hover a mark: it grows, its tooltip with the time, `activity.hover` + move!(gui, center(marks[k][1])) + @test a.hover == 2 + GUI._update_timeline!(gui, now) + @test maximum(m -> m[1], tl.plots.marks.markersize[]) > GUI._TL_MARK + tip = tiptext(tl) + @test startswith(tip, "Claude · " * GUI._tl_clock(t0 + 40)) && endswith(tip, "moved M2 by 12 mm") + # above the strip + @test minimum(Rect2f(tl.plots.tip_box[1][]))[2] >= maximum(tl.rect)[2] + + # a click plays its cue again + empty!(a.cues) + click!(gui, center(marks[k][1])) + @test any(c -> c[1].n == 2, a.cues) + @test gui.controls.selected[] === lens + # a double click frames its objects + @test isnothing(gui.camera.animation) + click!(gui, center(marks[k][1])) + @test !isnothing(gui.camera.animation) + @test gui.camera.animation.lookat1 ≈ [0.0, 0.11, 0.0] atol = 1.0e-6 + gui.camera.animation = nothing + eye0 = eye_of(gui) + + # a drag in the lanes scrubs: the cursor follows the mouse, releasing ends it + lanes = only(hits(tl, :lanes))[1] + y = center(lanes)[2] + move!(gui, (tl.axis[1] + 100, y)) + press!(gui) + @test isnothing(a.cursor) + move!(gui, (tl.axis[1] + 140, y)) + c1 = a.cursor + @test !isnothing(c1) + @test c1 ≈ GUI._tl_time_at(tl, tl.axis[1] + 140) + @test tl.plots.cursor.visible[] + move!(gui, (tl.axis[2] - 10, y)) + @test a.cursor > c1 + # the cursor stays on the axis when the mouse leaves the strip + move!(gui, (tl.axis[2] + 400, y + 300)) + @test a.cursor ≈ now + release!(gui) + @test isnothing(a.cursor) + @test !tl.plots.cursor.visible[] + @test eye_of(gui) ≈ eye0 + @test gui.controls.selected[] === lens + + # the eye follows its participant, again stops + eye = hits(tl, :eye)[2] + click!(gui, center(eye[1])) + @test a.follow == "claude@agent" + @test tl.plots.eyes.marker[][2] == GUI._icon(:eye) + click!(gui, center(hits(tl, :eye)[1][1])) + @test a.follow == "Bob@PC3" + click!(gui, center(hits(tl, :eye)[1][1])) + @test isnothing(a.follow) + + # the wheel zooms within 1 min … 1 h, the camera does not get it + move!(gui, center(lanes)) + ev(gui).scroll[] = (0.0, 1.0) + @test tl.span < GUI._TL_SPAN + for _ in 1:30 + ev(gui).scroll[] = (0.0, 1.0) + end + @test tl.span == GUI._TL_SPAN_MIN + GUI._update_timeline!(gui, now) + @test occursin("60 s", join(tl.plots.labels.text[])) + for _ in 1:40 + ev(gui).scroll[] = (0.0, -1.0) + end + @test tl.span == GUI._TL_SPAN_MAX + @test eye_of(gui) ≈ eye0 + tl.span = GUI._TL_SPAN + + # marks closer than a few pixels become one pill with their count + for i in 1:9 + rec("claude@agent", "Claude", true, "M2", "moved M2 by $i mm", now + i * 0.05) + end + now += 1 + GUI._update_timeline!(gui, now) + @test tl.plots.pills.visible[] + @test "9" in tl.plots.labels.text[] + pill = only(h for h in hits(tl, :mark) if length(h[3]) == 9) + move!(gui, center(pill[1])) + @test a.hover == 13 + GUI._update_timeline!(gui, now) + @test occursin("9 changes", tiptext(tl)) + + # the right button and its drags over the strip do not reach the camera either + move!(gui, center(lanes)) + press!(gui, Mouse.right) + move!(gui, center(lanes) .+ (60, 0)) + release!(gui, Mouse.right) + @test eye_of(gui) ≈ eye0 + + # the head collapses it again + click!(gui, center(only(hits(tl, :toggle))[1])) + @test !a.expanded + @test Makie.widths(tl.rect)[2] == GUI._TL_COLLAPSED + + # hidden in the spectator mode, shown again after it + spectator!(gui) + GUI._update_timeline!(gui, now) + @test !tl.scene.visible[] + spectator!(gui, false) + GUI._update_timeline!(gui, now) + @test tl.scene.visible[] + + # inside the 3D view when the window or a sidebar is resized + GUI._set_expanded!(gui, true) + for sz in ((1000, 650), (1600, 900), (1100, 600)) + resize!(gui.fig, sz...) + Makie.colorbuffer(screen) + GUI._update_timeline!(gui, now) + @test tl.shown + @test inside(tl.rect, viewport(gui)) + @test Makie.widths(tl.rect)[1] <= GUI._TL_MAX_WIDTH + end + if layout === :app + w0 = Makie.widths(viewport(gui))[1] + GUI._set_shown!(gui.layout.left, false) + Makie.colorbuffer(screen) + GUI._update_timeline!(gui, now) + @test Makie.widths(viewport(gui))[1] > w0 + @test inside(tl.rect, viewport(gui)) + # centered in the 3D view + @test center(tl.rect)[1] ≈ center(viewport(gui))[1] atol = 1 + end + @test_logs min_level = Base.CoreLogging.Warn Makie.colorbuffer(screen) + close(screen) + end + + @testset "dark theme" begin + gui, screen, _, _ = _view(:compact; theme = :dark) + tl = GUI._timeline!(gui) + GUI._record_episode!(gui, "Bob@PC3", "Bob", false, [_item("L1")]; text = "moved L1", now = 1.0e9) + GUI._update_timeline!(gui, 1.0e9 + 2) + t = gui.layout.theme + @test tl.plots.panel.color[] == GUI._rgba(t.view, GUI._TL_ALPHA) + @test tl.plots.names.color[] == [RGBAf(t.text)] + @test only(tl.plots.details.text[]) == "moved L1 · now" + @test_logs min_level = Base.CoreLogging.Warn Makie.colorbuffer(screen) + close(screen) + end + + @testset "texts" begin + @test GUI._tl_age(0) == "now" + @test GUI._tl_age(12.7) == "12 s" + @test GUI._tl_age(185) == "3 min" + @test GUI._tl_age(3700) == "1 h" + @test occursin(r"^\d\d:\d\d:\d\d$", GUI._tl_clock(1.0e9)) + @test GUI._tl_tick_label(0) == "now" + @test GUI._tl_tick_label(30) == "30 s" + @test GUI._tl_tick_label(120) == "2 min" + @test GUI._tl_span_label(300) == "5 min" + @test GUI._tl_span_label(3600) == "1 h" + end +end + +end From d5152af66ac6fc94a74068459918b553f6fba030 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 00:58:30 +0100 Subject: [PATCH 45/47] Own types of a project take part in the window (#66, #63, #65): the window includes src/gui.jl of a project into its module (again when src/ changes, its error in the status line); an input on a card that changes the constructor code of an object (BeamletOpticsProjects.constructor_code) draws it again (rerender!) and writes the new code to its line, with undo; a type without it says once that changes of its shape are not saved; card_widget(gui, obj, name) finds a widget of a card. Compat BeamletOptics 0.14.1; BeamletOpticsProjects from its branch feature/own-types until its pull requests are merged Co-Authored-By: Claude Opus 5.5 --- Project.toml | 4 +- docs/Project.toml | 2 +- docs/src/api.md | 1 + skills/beamletopticsgui/API.md | 2 +- skills/beamletopticsgui/SKILL.md | 6 +- skills/beamletopticsgui/WIDGETS.md | 56 +++++++++ src/API.jl | 18 +++ src/BeamletOpticsGUI.jl | 2 +- src/LiveCards.jl | 172 +++++++++++++++++++++++++- src/LiveProject.jl | 72 +++++++++++ src/LiveProjectSync.jl | 11 +- test/Project.toml | 2 +- test/TestLiveProjectOwnTypes.jl | 186 +++++++++++++++++++++++++++++ 13 files changed, 522 insertions(+), 12 deletions(-) create mode 100644 test/TestLiveProjectOwnTypes.jl diff --git a/Project.toml b/Project.toml index 219ca67..d9d833e 100644 --- a/Project.toml +++ b/Project.toml @@ -28,11 +28,11 @@ BeamletOpticsGUIGLMakieExt = "GLMakie" [sources] BeamletOptics = {url = "https://github.com/StackEnjoyer/BeamletOptics.jl", rev = "master"} -BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} +BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "feature/own-types"} [compat] AbstractTrees = "0.4" -BeamletOptics = "0.14" +BeamletOptics = "0.14.1" BeamletOpticsProjects = "0.1" FileWatching = "1" GLMakie = "0.13" diff --git a/docs/Project.toml b/docs/Project.toml index 855980a..19f7780 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -8,7 +8,7 @@ GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a" [sources] BeamletOptics = {url = "https://github.com/StackEnjoyer/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} -BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} +BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "feature/own-types"} [compat] BeamletOptics = "0.14" diff --git a/docs/src/api.md b/docs/src/api.md index ad1f696..708984e 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -75,6 +75,7 @@ CardRow CardWidget card_input card_show! +card_widget ``` ## Agent skill diff --git a/skills/beamletopticsgui/API.md b/skills/beamletopticsgui/API.md index 17655b7..4d4005b 100644 --- a/skills/beamletopticsgui/API.md +++ b/skills/beamletopticsgui/API.md @@ -28,7 +28,7 @@ Look up the docstring of any name before use, e.g. | Components and systems at runtime | `add_component!`, `remove_component!`, `add_system!`, `remove_system!`, `CatalogEntry`, `CatalogParam`, `CatalogGlass`, `component_catalog`, `catalog_glasses` | | Interactive helpers | `kinematic_controls!`, `view_cube!` | | Extending the window | `add_panel!`, `add_controls!`, `add_tool!` | -| Cards | `card_rows`, `pose_card_rows`, `beam_card_rows`, `card_actions`, `CardRow`, `CardWidget`, `card_input`, `card_show!` | +| Cards | `card_rows`, `pose_card_rows`, `beam_card_rows`, `card_actions`, `CardRow`, `CardWidget`, `card_input`, `card_show!`, `card_widget` | Not exported: `BeamletOpticsGUI.install_agent_skill`. diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index 6f5dcca..acade6d 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -128,7 +128,11 @@ When this Skill is active: any window, e.g. of `live_view(System())`). `Ctrl+N` or the tool "New project" makes an empty project in a new or empty folder (the dialog of "Save as") and opens it in place of the window, after writing what is pending of the old project; from code: `BeamletOpticsProjects.new_project(dir)`, then - `open_project(dir)`. + `open_project(dir)`. Own types of the project's code (`src/.jl`) get their card in + `src/gui.jl` of the project, which only the window loads (again when `src/` changes); a change + of their shape on the card is drawn at once, saved to the line and undone with `Ctrl+Z` if the + type has `BeamletOpticsProjects.constructor_code`, see "a card for a type of a project" in + `WIDGETS.md`. - A group or a selection of several rotates about its pivot, a dot (dashed line to the gizmo) while it is selected: the mean of the objects for one made in the window or a selection, the pivot of the script for a group of the script (BeamletOptics starts it at the origin). The row "pivot" of the diff --git a/skills/beamletopticsgui/WIDGETS.md b/skills/beamletopticsgui/WIDGETS.md index 3c6bd61..44c1865 100644 --- a/skills/beamletopticsgui/WIDGETS.md +++ b/skills/beamletopticsgui/WIDGETS.md @@ -117,6 +117,62 @@ BeamletOpticsGUI.card_rows(w::MyFilterWheel) = (pose_card_rows(w)..., aligned = true)) ``` +## Recipe: a card for a type of a project + +A project of BeamletOpticsProjects (`open_project(dir)`) may define own types in its code +`src/.jl`. Their card goes into `src/gui.jl` of the project, which only the window loads: + +1. `src/.jl` defines the type and tells its constructor with a method of + `BeamletOpticsProjects.constructor_code(x)`: the code (a `String`) that builds `x` as it is now, + without its pose. Only then is a change of its shape on a card saved to its line of `system.jl`. +2. `src/gui.jl` defines `BeamletOpticsGUI.card_rows(x::MyType)` as above. The window includes it into + the module of the project after `src/.jl` (its exported names are known, `BeamletOpticsGUI` + is bound), at the start and again whenever the files of `src/` change (the card shows the new rows + without a restart). An error in it is named in the status line (`src/gui.jl:: ...`); the + window opens without its methods. +3. An input of the card that changes what `constructor_code` returns (e.g. a slider of a parameter of + the shape, `solve = true`) draws the object again (`BeamletOptics.rerender!`), writes the new code + to its line (the line keeps `pos` and `rot`) and is one entry of the undo history: `Ctrl+Z` builds + the object again from the old code in its place, `Ctrl+Y` brings the changed one back. The steps + of a dragged slider are one entry. +4. A type without `constructor_code` takes the inputs as in any window, but they live only in the + window: the status line says once per type that changes of its shape are not saved. +5. Agents never load `src/gui.jl` (`load_project` does not include it, its packages are not part of + the project's `[packages]`); they see the new code in the line of the object and can change it + there. + +```julia +# src/Bench.jl of the project "Bench" +module Bench +using BeamletOptics +import ..BeamletOpticsProjects +export Block + +# A block of a width and a thickness [m], which absorbs the rays that hit it +struct Block{T} <: BeamletOptics.AbstractObject{T} + shape::BeamletOptics.PlanoSurfaceSDF{T} +end +Block(width, thickness) = Block(BeamletOptics.PlanoSurfaceSDF(thickness, width)) +BeamletOptics.interact3d(::BeamletOptics.AbstractSystem, ::Block, ::Beam, ::Ray) = nothing +BeamletOpticsProjects.constructor_code(b::Block) = "Block($(b.shape.diameter), $(b.shape.thickness))" +end +``` + +```julia +# src/gui.jl of the project: the card of a Block, a slider of its width +using BeamletOpticsGUI +using GLMakie: Slider + +BeamletOpticsGUI.card_rows(b::Block) = (pose_card_rows(b)..., + CardRow("width [mm]", CardWidget(Slider; name = :width, range = 10.0:5.0:50.0, + value = (gui, b) -> 1e3 * b.shape.diameter, + on = (gui, b, w) -> (b.shape.diameter = 1e-3 * w), solve = true))) +``` + +A line `@component K1 = Block(0.025, 0.005) pos = (0.0, 0.1, 0.0)` of `system.jl` becomes +`Block(0.04, 0.005)` after the slider moved to 40. Test it with `open_project(dir; display = false)`, +`select!(gui, obj)` and `card_widget(gui, obj, :width)` (see `API.md`). + ## Recipe: a card for your system type A system (subtype of `AbstractSystem`) has a card too: shown, without gizmo and without a selection diff --git a/src/API.jl b/src/API.jl index a26de2f..b4e2fea 100644 --- a/src/API.jl +++ b/src/API.jl @@ -434,6 +434,24 @@ unless it is called with `force = true`, which the card does after an input was """ function card_show! end +""" + card_widget(gui::LiveView, obj, name::Symbol) -> Union{Nothing, Makie.Block} + +The widget declared with `name` (see [`CardWidget`](@ref)) on a card of the [`live_view`](@ref) +`gui` that shows `obj`: the card of the selection, a pinned card or, in the layout `:app`, the +docked card. `nothing` if no card shows `obj` or its page has no widget of that `name`. An own +widget type is returned as the object that the card built (see [`card_input`](@ref)). + +For tests and scripts: e.g. a slider of [`card_rows`](@ref) of an own type, moved as by the mouse: + +```julia +select!(gui, obj) # opens the card of the selection +s = card_widget(gui, obj, :width) +Makie.set_close_to!(s, 30.0) +``` +""" +function card_widget end + """ add_component!(gui, obj; system = nothing, select = true, label = nothing, code = nothing) -> obj diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 7bcc7d9..c638194 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -17,7 +17,7 @@ import BeamletOptics: translate3d!, translate_to3d!, rotate3d!, set_pivot3d! const BMO = BeamletOptics export live_view, kinematic_controls!, view_cube!, export_changes, export_script, card_rows, - pose_card_rows, beam_card_rows, card_actions, CardRow, CardWidget, card_input, card_show!, + pose_card_rows, beam_card_rows, card_actions, CardRow, CardWidget, card_input, card_show!, card_widget, add_panel!, add_controls!, add_tool!, retrace!, add_component!, remove_component!, open_system, add_system!, remove_system!, CatalogEntry, CatalogParam, CatalogGlass, component_catalog, catalog_glasses, select!, diff --git a/src/LiveCards.jl b/src/LiveCards.jl index 8d4d0d2..bf81c27 100644 --- a/src/LiveCards.jl +++ b/src/LiveCards.jl @@ -702,7 +702,9 @@ host of the declarations, see `_AbstractCard`, e.g. the floating card, whose new before the mouse shield of the cards (see `_on_content_built!`), or the docked card of the app. """ function _build_content!(gui::LiveView, c::_AbstractCard, @nospecialize(obj)) - actions, rows = _declarations(gui, c, obj) + # in the newest world: `card_rows` of a type of a project may be newer than the running code, + # e.g. of its `src/gui.jl`, see `_include_gui!` + actions, rows = Base.invokelatest(_declarations, gui, c, obj) key = (_layout_key(actions), _layout_key(rows)) declared = CardWidget[_declared_widgets(actions)..., _declared_widgets(rows)...] if key == c.content_key @@ -932,12 +934,173 @@ _apply_input!(gui::LiveView, on, @nospecialize(obj), v, solve::Bool) = _apply_on _apply_input!(::LiveView, ::Nothing, _, _, ::Bool) = nothing _apply_input!(::LiveView, _, ::Nothing, _, ::Bool) = nothing _apply_input!(::LiveView, ::Nothing, ::Nothing, _, ::Bool) = nothing -_apply_on!(gui::LiveView, on, @nospecialize(obj), v, ::Val{false}) = (on(gui, obj, v); nothing) +_apply_on!(gui::LiveView, on, @nospecialize(obj), v, ::Val{false}) = (_shape_input!(gui, on, obj, v); nothing) # The input changes the optics, see `solve` of `CardWidget`: like a move, via the `on_change` of # the controls, which callers of the live view may extend function _apply_on!(gui::LiveView, on, @nospecialize(obj), v, ::Val{true}) - _change!(() -> on(gui, obj, v), gui.controls, obj) + unsaved = Ref(false) + _change!(() -> (unsaved[] = _shape_input!(gui, on, obj, v)), gui.controls, obj) gui.controls.on_change(obj) + # after the status of the solve + unsaved[] && _note_unsaved_shape!(gui, obj) + return nothing +end + +#= +Changes of the shape on a card: an input that changes the code of the constructor of an object of +the code of a project (`BeamletOpticsProjects.constructor_code`, e.g. of an own type of the project) +draws it again, saves the new code to its line and is undone by building it from the old code +=# + +""" + _shape_input!(gui, on, obj, v) -> Bool + +Calls `on(gui, obj, v)` of an input of a card of `obj` (in the newest world: `on` may be of the +`src/gui.jl` of a project, see `_include_gui!`). For an object with the code of its constructor in +its `origin` (an object of a project, of the catalog or added with `code`), the code that +`BeamletOpticsProjects.constructor_code` tells before and after `on` is compared: if it changed, the +shape changed, see `_shape_changed!`. Returns whether the type of such an object tells no code in a +window of a project (its changes of the shape are not saved, see `_note_unsaved_shape!`). +""" +function _shape_input!(gui::LiveView, on, @nospecialize(obj), v) + origin = get(gui.components.origin, obj, nothing) + code = isnothing(origin) ? nothing : get(origin, :code, nothing) + if !(code isa AbstractString) + Base.invokelatest(on, gui, obj, v) + return false + end + before = _constructor_code(obj) + Base.invokelatest(on, gui, obj, v) + after = _constructor_code(obj) + isnothing(after) && return !isnothing(_project_view(gui)) + after == before || _shape_changed!(gui, obj, String(code), after) + return false +end + +"""The code of the constructor of `obj` by `BeamletOpticsProjects.constructor_code`, or `nothing`.""" +function _constructor_code(@nospecialize(obj)) + # a method of the code of a project is newer than the running code + code = Base.invokelatest(BMOP.constructor_code, obj) + return code isa AbstractString ? String(code) : nothing +end + +""" + _note_unsaved_shape!(gui, obj) + +Says in the status line of the `gui`, once per type, that the changes of the shape of objects of the +type of `obj` are not saved to the files of the project: it has no +`BeamletOpticsProjects.constructor_code`. The input itself is applied as without a project. +""" +function _note_unsaved_shape!(gui::LiveView, @nospecialize(obj)) + T = nameof(typeof(obj)) + told = _own_code(something(_project_view(gui), gui)).unsaved + T in told && return nothing + push!(told, T) + gui.status.text[] = "changes of the shape of $T are not saved: it has no `constructor_code`" + return nothing +end + +""" + _ShapeChange + +The object of the entry of the undo history of a change of the shape of `new` on its card (see +`_shape_changed!`), from the code `old_code` of its constructor: undo puts `old`, built from +`old_code` at the first undo (see `_build_from_code`), in the place of `new` (see `_replace!`), redo +puts `new` back, each with its `origin` (`old_origin`, `new_origin`). Its objects (see +`_selection_objects`) are both, such that an undo and a redo ask the claims of the entry of the +project. Inputs on the same object, each less than 1 s after the last one (`time`), e.g. the steps +of a slider that is dragged, are one entry. +""" +mutable struct _ShapeChange + const new::Any + const old_code::String + old::Any + old_origin::Any + new_origin::Any + time::Float64 +end +_selection_objects(s::_ShapeChange) = isnothing(s.old) ? Any[s.new] : Any[s.new, s.old] + +""" + _shape_changed!(gui, obj, old, new) + +An input on the card of `obj` changed the code of its constructor from `old` to `new`: the code of +its `origin` becomes `new` (the autosave writes it to the line of `obj`, see `_project_parts`), the +handles of `obj` in the pool draw it again (`BeamletOptics.rerender!`), and the change is recorded +in the undo history, see `_ShapeChange`. +""" +function _shape_changed!(gui::LiveView, @nospecialize(obj), old::String, new::String) + comp = gui.components + # e.g. removed by the input + haskey(comp.origin, obj) || return nothing + comp.origin[obj] = merge(comp.origin[obj], (; code = new)) + for oh in _pool_handles(gui, obj) + Base.invokelatest(BMO.rerender!, oh) + end + _update_selection_box!(gui.controls) + comp.recording && _record_shape!(gui, obj, old) + return nothing +end + +# Records the change of the shape of `obj` from the code `old`, see `_ShapeChange` +function _record_shape!(gui::LiveView, @nospecialize(obj), old::String) + ctrl = gui.controls + now = time() + if !isempty(ctrl.undo_stack) + e = last(ctrl.undo_stack) + if e isa _ActionEntry && e.obj isa _ShapeChange && e.obj.new === obj && isnothing(e.obj.old) && + now - e.obj.time <= 1.0 + e.obj.time = now + empty!(ctrl.redo_stack) + ctrl.last_key_step = nothing + return nothing + end + end + s = _ShapeChange(obj, old, nothing, nothing, nothing, now) + _push_action!(ctrl, s, () -> _undo_shape!(gui, s), () -> _redo_shape!(gui, s)) + return nothing +end + +function _undo_shape!(gui::LiveView, s::_ShapeChange) + if isnothing(s.old) + old = _build_from_code(gui, s.new, s.old_code) + s.old_origin = merge(gui.components.origin[s.new], (; code = s.old_code, pose0 = _pose(old))) + s.old = old + end + s.new_origin = gui.components.origin[s.new] + _unrecorded(() -> _replace!(gui, s.new, s.old, s.old_origin), gui) + return nothing +end + +_redo_shape!(gui::LiveView, s::_ShapeChange) = + (_unrecorded(() -> _replace!(gui, s.old, s.new, s.new_origin), gui); nothing) + +""" + _build_from_code(gui, obj, code) + +Builds an object anew from the `code` of the constructor of `obj`: evaluated in the module of the +project of the `gui` as the window loaded it last (see `_OwnCode`), else in the module of the type of +`obj`. Throws an `ArgumentError` if the code does not build, which drops the entry of the undo +history (see `_history_step!`). +""" +function _build_from_code(gui::LiveView, @nospecialize(obj), code::String) + mod = something(_own_code(something(_project_view(gui), gui)).mod, parentmodule(typeof(obj))) + try + return Core.eval(mod, Meta.parse(code)) + catch e + e isa InterruptException && rethrow() + throw(ArgumentError("$code does not build: $(_message_line(e))")) + end +end + +# See the docstring in `API.jl`: the cards, floating or docked, see `_edit_cards`, whose widgets were +# built for `obj` (`pose`, see `_refresh_card!`) +function card_widget(gui::LiveView, @nospecialize(obj), name::Symbol) + for c in _edit_cards(gui) + (c.pose !== nothing && c.pose[1] === obj && _card_object(gui, c) === obj) || continue + w = _card_widget(c, name) + isnothing(w) || return w + end return nothing end @@ -963,7 +1126,8 @@ function _refresh_card!(gui::LiveView, c::_AbstractCard; force::Bool = false) _refresh_selection_part!(gui, c, obj) return nothing end -_refresh_widget!(b, value, gui::LiveView, @nospecialize(obj); force::Bool = false) = _show_value!(b, value(gui, obj), force) +_refresh_widget!(b, value, gui::LiveView, @nospecialize(obj); force::Bool = false) = + _show_value!(b, Base.invokelatest(value, gui, obj), force) _refresh_widget!(_, ::Nothing, ::LiveView, _; force::Bool = false) = nothing """ diff --git a/src/LiveProject.jl b/src/LiveProject.jl index df7e071..7567b26 100644 --- a/src/LiveProject.jl +++ b/src/LiveProject.jl @@ -640,6 +640,70 @@ function _open_from(project; view = _read_view(project), kwargs...) return gui end +#= +The window's part of the own code of a project: `src/gui.jl` +=# + +""" +The file of the window's part of the own code of a project, relative to its folder: the card rows +of its own types (`card_rows`, ...), which `BeamletOpticsProjects.load_project` never includes (the +agents do not need the GUI), see `_include_gui!`. +""" +const _GUI_FILE = joinpath("src", "gui.jl") + +""" + _OwnCode + +What a live view keeps of the own code of its project: `mod`, the module of the project as the +window loaded it last (`src/gui.jl` is included into it, and the code of the constructor of an +object is evaluated in it, see `_build_from_code`), and the names of the types whose changes of the +shape the status line said are not saved (`unsaved`, see `_note_unsaved_shape!`). +""" +mutable struct _OwnCode + mod::Union{Nothing, Module} + const unsaved::Set{Symbol} +end + +# Per live view, without a field of it: the window of a project only +const _OWN_CODE = WeakKeyDict{LiveView, _OwnCode}() + +"""The `_OwnCode` of the `gui`.""" +_own_code(gui::LiveView) = get!(() -> _OwnCode(nothing, Set{Symbol}()), _OWN_CODE, gui) + +"""Notes that the `gui` shows the objects of the loaded `project`, whose module it keeps, see `_OwnCode`.""" +function _own_module!(gui::LiveView, project) + _own_code(gui).mod = project.mod + return nothing +end + +""" + _include_gui!(project) -> Union{Nothing, String} + +Includes `src/gui.jl` of the loaded `project` (see `_GUI_FILE`) into the module of the project, +after the own code `src/.jl` and `system.jl`: `BeamletOpticsGUI` is bound there (also without +`using BeamletOpticsGUI` in the file), the names of `src/.jl` are known (see +`BeamletOpticsProjects.load_project`). Every load of the window does it again, since each one makes +a new module, whose types the window shows once it adopts their objects. Returns `nothing`, or the +message of an error of the file for the status line, `"src/gui.jl:: "` (logged once); +the window works on without the file's methods. +""" +function _include_gui!(project) + mod = project.mod + file = joinpath(project.path, _GUI_FILE) + (isnothing(mod) || !isfile(file)) && return nothing + try + Base.invokelatest(isdefined, mod, :BeamletOpticsGUI) || + Core.eval(mod, :(const BeamletOpticsGUI = $(@__MODULE__))) + Base.include(mod, file) + catch e + e isa InterruptException && rethrow() + msg = "src/" * _load_message(e) + @warn "the window's code of the project was not loaded: $msg" _id = Symbol(msg) maxlog = 1 + return msg + end + return nothing +end + """ open_project(path; display = true, kwargs...) -> LiveView @@ -655,6 +719,11 @@ to them (an agent or the window of another person), notes the selection and the of the person in `.bmo/session-.md` and shows where the others work (see `LiveProjectPresence.jl`). `Ctrl+S` saves a version of the project (a git commit with a message), see [`save_project`](@ref). The components and sources from the catalog can be edited and copied. +The window's part of the own code of the project, `src/gui.jl` (e.g. [`card_rows`](@ref) of its +own types), is loaded into the module of the project after `src/.jl`, and again when the files +of `src/` change; an error in it is named in the status line, and the window opens without it. A +change of the shape of an object on its card is saved to its line if its type tells its +constructor (`BeamletOpticsProjects.constructor_code`). `Ctrl+O` or the tool "Open" of a window opens a project chosen in a dialog and closes the window; `Ctrl+N` or the tool "New project" makes an empty project and opens it the same way. Warns when the project was saved with newer or incompatible versions of the packages, or its own @@ -664,7 +733,10 @@ dialog of "Open" asks instead, see `_open_checked!`. function open_project(path::AbstractString; display::Bool = true, kwargs...) _warn_versions(path) project = BMOP.load_project(path) + gui_error = _include_gui!(project) gui = _open_from(project; kwargs...) + _own_module!(gui, project) + isnothing(gui_error) || (gui.status.text[] = gui_error) if display title = basename(project.path) _multi_light_backend() ? Base.display(gui; title) : Base.display(gui) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 6ef8fd4..8fa80ee 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -575,11 +575,16 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) end s.failed = false s.load_error = "" + # The window's part of the own code, in the new module of the project, whose types the adopted + # objects have; its error is told when the own code changed + code_changed = src != s.src + gui_error = _include_gui!(disk) + _own_module!(gui, disk) # The window compares in the poses of the files of the last merge _finish_glides!(gui) # The lines without uid (of an agent) get those of their objects in the window _align_uids!(gui, disk) - rebuild = src == s.src ? Symbol[] : _calls_own_code(disk) + rebuild = code_changed ? _calls_own_code(disk) : Symbol[] mine = _collect_project(gui, dir) # A conflict of before stays until the person decides, or the files agree with the window. A # new id of the files that an unsaved object of the window has too stays the files': the @@ -600,6 +605,7 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) # The other person whose window wrote these files, else an agent who = something(_writer(gui, disk.stamp), "agent") isempty(done) || (gui.status.text[] = "$who: " * join(done, ", ")) + (code_changed && !isnothing(gui_error)) && (gui.status.text[] = gui_error) _note_session!(gui) return :applied end @@ -1189,6 +1195,8 @@ function _load_from_disk!(gui::LiveView) _note_session!(gui) return nothing end + gui_error = _include_gui!(disk) + _own_module!(gui, disk) _finish_glides!(gui) _align_uids!(gui, disk) mine = _collect_project(gui, gui.project) @@ -1199,6 +1207,7 @@ function _load_from_disk!(gui::LiveView) # Taken in from the files, not written by the window _saved!(gui, disk; written = false) gui.status.text[] = isempty(done) ? "project reloaded, nothing changed" : "reloaded: " * join(done, ", ") + isnothing(gui_error) || (gui.status.text[] = gui_error) return nothing end diff --git a/test/Project.toml b/test/Project.toml index 36c1f77..4ca2e89 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -14,7 +14,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [sources] BeamletOptics = {url = "https://github.com/StackEnjoyer/BeamletOptics.jl", rev = "master"} BeamletOpticsGUI = {path = ".."} -BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "main"} +BeamletOpticsProjects = {url = "https://github.com/StackEnjoyer/BeamletOpticsProjects.jl", rev = "feature/own-types"} [compat] Aqua = "0.8" diff --git a/test/TestLiveProjectOwnTypes.jl b/test/TestLiveProjectOwnTypes.jl new file mode 100644 index 0000000..f739719 --- /dev/null +++ b/test/TestLiveProjectOwnTypes.jl @@ -0,0 +1,186 @@ +module TestLiveProjectOwnTypes + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using BeamletOptics: render_plots +using Makie +using Test + +const GUI = BeamletOpticsGUI +const BMOP = BeamletOpticsProjects +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +# An own type with two parameters of its shape that tells its constructor, and one that does not +const BENCH = """ + module Bench + using BeamletOptics + import ..BeamletOpticsProjects + export Block, Plate + + struct Block{T} <: BeamletOptics.AbstractObject{T} + shape::BeamletOptics.PlanoSurfaceSDF{T} + end + Block(width, thickness) = Block(BeamletOptics.PlanoSurfaceSDF(thickness, width)) + BeamletOpticsProjects.constructor_code(b::Block) = "Block(\$(b.shape.diameter), \$(b.shape.thickness))" + + struct Plate{T} <: BeamletOptics.AbstractObject{T} + shape::BeamletOptics.PlanoSurfaceSDF{T} + end + Plate(width) = Plate(BeamletOptics.PlanoSurfaceSDF(0.002, width)) + end + """ + +# The window's part: a slider of the width of each +rows(name) = """ + using BeamletOpticsGUI + using GLMakie: Slider + + width_row(name) = CardRow("width [mm]", CardWidget(Slider; name, range = 10.0:5.0:50.0, + value = (gui, b) -> 1e3 * b.shape.diameter, on = (gui, b, w) -> (b.shape.diameter = 1e-3 * w), + solve = true)) + BeamletOpticsGUI.card_rows(b::Block) = (pose_card_rows(b)..., width_row(:$name)) + BeamletOpticsGUI.card_rows(p::Plate) = (pose_card_rows(p)..., width_row(:plate_width)) + """ + +const SYSTEM = """ + @component K1 = Block(0.025, 0.005) pos = (0.0, 0.1, 0.0) + @component P1 = Plate(0.02) pos = (0.0, 0.2, 0.0) + @system S1 = [K1, P1] + """ + +"""A project with the own types of `BENCH`, its `src/gui.jl` the text `gui` (none for `nothing`).""" +function project(; gui = rows("width")) + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "src", "Bench.jl"), BENCH) + isnothing(gui) || write(joinpath(dir, "src", "gui.jl"), gui) + write(joinpath(dir, "system.jl"), SYSTEM) + return dir +end + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +# The line of `id` in `system.jl`, and the width of its object as the files build it +line(dir, id) = only(filter(l -> startswith(l, "@component $id ="), readlines(joinpath(dir, "system.jl")))) +width(dir, id) = only(e for e in BMOP.load_project(dir).entries if e.name === id).object.shape.diameter + +handle(gui, x) = only(GUI._pool_handles(gui, x)) + +"""Two looks of the watcher: the files changed, then settled.""" +function tick!(gui) + t = time() + first = GUI._check_files!(gui; now = t) + return first === :pending ? GUI._check_files!(gui; now = t + 1) : first +end + +@testset "Own types of a project" begin + @testset "a change of the shape on the card is drawn, saved and undone" begin + dir = project() + gui = open_project(dir; display = false, const_kw...) + k1 = obj(gui, :K1) + select!(gui, k1) + s = card_widget(gui, k1, :width) + @test s isa Slider + @test s.value[] == 25.0 + @test isnothing(card_widget(gui, k1, :no_such_widget)) + # no card shows P1 + @test isnothing(card_widget(gui, obj(gui, :P1), :plate_width)) + plot = first(render_plots(handle(gui, k1))) + Makie.set_close_to!(s, 40.0) + @test k1.shape.diameter ≈ 0.04 + @test gui.components.origin[k1].code == "Block(0.04, 0.005)" + @test first(render_plots(handle(gui, k1))) !== plot + # the steps of a dragged slider are one entry of the history (less than 1 s apart, which the + # test sets: the first call of each step compiles) + undo = length(gui.controls.undo_stack) + step!(v) = (last(gui.controls.undo_stack).obj.time = time(); Makie.set_close_to!(s, v)) + step!(45.0) + step!(40.0) + @test length(gui.controls.undo_stack) == undo + @test GUI._autosave!(gui; force = true) === :saved + @test startswith(line(dir, :K1), "@component K1 = Block(0.04, 0.005) pos = (0.0, 0.1, 0.0)") + @test width(dir, :K1) ≈ 0.04 + # Ctrl+Z: built again from the old line, in its place + plot = first(render_plots(handle(gui, k1))) + @test GUI._undo!(gui.controls) + old = obj(gui, :K1) + @test old !== k1 && old.shape.diameter == 0.025 + @test position(old) == [0.0, 0.1, 0.0] + @test any(o -> o === old, only(GUI._systems(gui)).objects) + @test first(render_plots(handle(gui, old))) !== plot + @test GUI._autosave!(gui; force = true) === :saved + @test startswith(line(dir, :K1), "@component K1 = Block(0.025, 0.005) pos = (0.0, 0.1, 0.0)") + @test width(dir, :K1) == 0.025 + # Ctrl+Y: the changed one again + @test GUI._redo!(gui.controls) + @test obj(gui, :K1) === k1 && k1.shape.diameter ≈ 0.04 + @test GUI._autosave!(gui; force = true) === :saved + @test width(dir, :K1) ≈ 0.04 + # a move afterwards keeps the new shape in the line + translate3d!(gui, k1, [0.0, 0.0, 0.01]) + @test GUI._autosave!(gui; force = true) === :saved + @test occursin("Block(0.04, 0.005)", line(dir, :K1)) && occursin("0.01", line(dir, :K1)) + close(gui) + end + + @testset "a type without constructor_code" begin + dir = project() + gui = open_project(dir; display = false, const_kw...) + p1 = obj(gui, :P1) + select!(gui, p1) + s = card_widget(gui, p1, :plate_width) + @test s isa Slider + undo = length(gui.controls.undo_stack) + Makie.set_close_to!(s, 30.0) + @test p1.shape.diameter ≈ 0.03 + note = "changes of the shape of Plate are not saved: it has no `constructor_code`" + @test gui.status.text[] == note + @test length(gui.controls.undo_stack) == undo + # once per type + gui.status.text[] = "-" + Makie.set_close_to!(s, 35.0) + @test gui.status.text[] != note + @test GUI._autosave!(gui; force = true) === :none + @test startswith(line(dir, :P1), "@component P1 = Plate(0.02)") + close(gui) + end + + @testset "src/gui.jl changes in the files" begin + dir = project() + gui = open_project(dir; display = false, const_kw...) + select!(gui, obj(gui, :K1)) + @test card_widget(gui, obj(gui, :K1), :width) isa Slider + write(joinpath(dir, "src", "gui.jl"), rows("size")) + @test tick!(gui) === :applied + k1 = obj(gui, :K1) + select!(gui, k1) + @test card_widget(gui, k1, :size) isa Slider + @test isnothing(card_widget(gui, k1, :width)) + # the new type takes inputs and saves them + Makie.set_close_to!(card_widget(gui, k1, :size), 20.0) + @test GUI._autosave!(gui; force = true) === :saved + @test width(dir, :K1) ≈ 0.02 + close(gui) + end + + @testset "src/gui.jl with an error" begin + dir = project(; gui = "using BeamletOpticsGUI\nerror(\"broken card\")\n") + gui = open_project(dir; display = false, const_kw...) + @test gui isa GUI.LiveView + @test startswith(gui.status.text[], "src/gui.jl:") && occursin("broken card", gui.status.text[]) + k1 = obj(gui, :K1) + select!(gui, k1) + @test isnothing(card_widget(gui, k1, :width)) + # the agents' load never includes it + @test width(dir, :K1) == 0.025 + close(gui) + end +end + +end From 702e48b824fe3cfae6aafd4efbec7103b519a980 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 01:14:18 +0100 Subject: [PATCH 46/47] The activity of the others in a watched window (plans/activity-cues.md, integration): a merge of the files of another participant is an episode (who, which objects, poses before and after, "moved M2 by 12 mm"), named once its writer publishes the stamp; a new `doing` of an agent is a message; every frame draws the cues at their pace, the timeline, a replay of the mark under the mouse, the ghosts of the scrub and the camera that follows a participant; presence boxes, names and locks in the colour of the participant; the toast of the compact layout stands above the timeline; a step of the precompile session; docs and skill Co-Authored-By: Claude Opus 5.5 --- docs/src/projects.md | 15 +- ext/BeamletOpticsGUIGLMakieExt/session.jl | 58 ++++++ skills/beamletopticsgui/SKILL.md | 9 +- src/BeamletOpticsGUI.jl | 1 + src/LiveActivity.jl | 5 + src/LiveActivityHooks.jl | 224 ++++++++++++++++++++++ src/LiveOverlay.jl | 23 ++- src/LiveProjectClaims.jl | 6 +- src/LiveProjectPresence.jl | 27 +-- src/LiveProjectSync.jl | 3 + src/LiveProjectWatch.jl | 2 + test/TestLiveActivityHooks.jl | 138 +++++++++++++ test/TestLiveProjectPresence.jl | 4 +- 13 files changed, 496 insertions(+), 19 deletions(-) create mode 100644 src/LiveActivityHooks.jl create mode 100644 test/TestLiveActivityHooks.jl diff --git a/docs/src/projects.md b/docs/src/projects.md index f00d198..69b0bb0 100644 --- a/docs/src/projects.md +++ b/docs/src/projects.md @@ -69,7 +69,20 @@ state in another folder. `Ctrl+S` saves no version while a conflict is open. Several windows and agents can work on one project at once, also from a shared drive. - **Presence.** Each window shows where the others work: a box at the object they selected, with - their name and what they are doing, people and agents alike. + their name and what they are doing, people and agents alike, each in a calm colour of its own. +- **What the others changed.** A change of another participant is shown where it happened: a thin + frame in the participant's colour at the new place, a dashed ghost at the old one and a soft arc + between them, a small chip with the name and the change ("claude · moved M2 by 12 mm"); new + objects settle into a frame, removed ones leave a ghost. What an agent says it does next + (`announce(...; doing)`) appears as a bubble at its object. The cues fade after about 3 s. When an + agent changes things faster than one can follow, the setup changes at once, but the cues play one + after the other at a readable pace; more than 3 s behind, the waiting ones become one cue + ("claude · 14 changes"). +- **Timeline.** Once another participant was there, a strip at the bottom of the 3D view lists who + changed what and when, in one line, or, after a click, in a lane per participant. The mouse over + a mark shows the change again, a click replays it, a double click frames its objects, dragging + along the lanes shows ghosts of how the setup was at that time, and the eye of a lane makes the + camera follow that participant. - **Claims.** The first gesture on an object claims it for the person, until it is deselected or 30 s pass without a gesture. A gesture on an object that another participant claims is refused, the status line names the holder ("Bob is editing M1"), and the object shows a lock. A change diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index 9bb42ed..9abcf0e 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -1249,6 +1249,64 @@ function _project_session(step; dir = mktempdir()) _check(_project_pos(gui, mirror) ≈ target && _pos(mirror) ≈ target, "the mirror did not glide") settle!() end + step("project: the activity of the others, its cues and its timeline") do + a = GUI._activity(gui) + # following the agent moves the camera: the steps after this one find the view as it was + home = GUI._current_view(gui) + # A move and a message of an agent (in a watched window, a merge and `doing` record them) + p = _pos(mirror) + R = Matrix{Float64}(BMO.orientation(mirror)) + bb = GUI._part_box(gui, mirror) + frame = GUI._bbox_wireframe(bb) + item = GUI._ActivityItem(gui.sync.ids[mirror][2], :M1, :moved, (p .- [0.0, 0.01, 0.0], R), (p, R), frame) + t = time() + GUI._record_episode!(gui, "claude@agent", "claude", true, [item]; text = "moved M1 by 10 mm", now = t) + GUI._record_episode!(gui, "claude@agent", "claude", true, [item]; text = "aligning M1", + message = "aligning M1", now = t) + for _ in 1:3 + GUI._activity_tick!(gui) + frame!() + end + _check(!isnothing(a.strip) && !isempty(a.cues), "the cues and the timeline are not shown") + tl = a.strip + hit(kind) = (h = findfirst(x -> x[2] === kind, tl.hits); isnothing(h) ? nothing : tl.hits[h]) + center(r) = Tuple(Float64.(minimum(r) .+ widths(r) ./ 2)) + # expand, hover a mark, click it, scrub the lanes, follow, collapse + _click!(gui, center(hit(:toggle)[1])) + GUI._activity_tick!(gui) + frame!() + _check(a.expanded, "a click on the timeline did not expand it") + m = hit(:mark) + _move!(gui, center(m[1])) + GUI._activity_tick!(gui) + frame!() + _check(!isnothing(a.hover), "the mark under the mouse is not hovered") + _click!(gui) + GUI._activity_tick!(gui) + frame!() + lanes = hit(:lanes)[1] + y = minimum(lanes)[2] + widths(lanes)[2] / 2 + _drag!(gui, (minimum(lanes)[1] + 10, y), (maximum(lanes)[1] - 10, y)) + GUI._activity_tick!(gui) + frame!() + eye = hit(:eye) + _click!(gui, center(eye[1])) + GUI._activity_tick!(gui) + frame!() + _check(a.follow == "claude@agent", "the eye did not follow the agent") + _click!(gui, center(eye[1])) + _click!(gui, center(hit(:toggle)[1])) + GUI._activity_tick!(gui) + frame!() + _check(!a.expanded, "a click on the head did not collapse the timeline") + # the cues end + a.cues = Tuple{GUI._Episode, Float64}[] + empty!(a.queue) + GUI._activity_tick!(gui) + settle!() + GUI._animate_camera!(gui, home...) + settle!() + end step("project: Ctrl+S, the card of the version, Enter") do ask_version!() foreach(char -> (_events(gui).unicode_input[] = char), " ok") diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index acade6d..43f9586 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -120,7 +120,14 @@ When this Skill is active: "theirs" or "mine" on the card. Lines that another participant claims are held the same way: the window saves everything else and writes its change of such an object once the claim is gone. The objects - of one change of the files glide together; a group moved as a whole glides as one. The table + of one change of the files glide together; a group moved as a whole glides as one. Each other + participant has a colour; its changes show as cues in the 3D view (a frame at the new place, a + ghost at the old one, an arc, a chip "claude · moved M2 by 12 mm"), paced one after the other and + summarised when more than 3 s behind, and its `doing` (`announce`) as a bubble at its object; a + timeline at the bottom of the view lists them (hover, click to replay, drag to see the setup at a + time, the eye follows a participant). An agent that tells the person what it does next with + `announce(...; doing = "...")` is easy to follow. The window writes its files in the background, + also on a slow shared drive. The table fits the setup again shortly after every change (a drag, a key step, undo, a removal, a change of the files). How to work on the files is in the skill `beamletopticsprojects` (`BeamletOpticsProjects.install_agent_skill()`). A project is a folder with a `system.jl`, not a Julia environment: open it from any environment diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index c638194..a6f9465 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -122,6 +122,7 @@ include("LiveProjectClaims.jl") include("LiveActivity.jl") include("LiveActivityCues.jl") include("LiveTimeline.jl") +include("LiveActivityHooks.jl") include("LiveProjectVersions.jl") include("LivePivot.jl") include("LiveDebug.jl") diff --git a/src/LiveActivity.jl b/src/LiveActivity.jl index 60579ac..0bc7ad4 100644 --- a/src/LiveActivity.jl +++ b/src/LiveActivity.jl @@ -95,6 +95,11 @@ Base.@kwdef mutable struct _Activity hover::Union{Nothing, Int} = nothing cue_plots::Any = nothing strip::Any = nothing + # the mark under the mouse and the scrub as the step per frame last took them, see `_activity_tick!` + shown_hover::Union{Nothing, Int} = nothing + shown_cursor::Union{Nothing, Float64} = nothing + # the episode of a merge whose writer was not known yet and the stamp of its files, see `_name_episode!` + unnamed::Union{Nothing, Tuple{Int, String}} = nothing end """The activity of the others in the `gui`, see `_Activity`; created at the first use.""" diff --git a/src/LiveActivityHooks.jl b/src/LiveActivityHooks.jl new file mode 100644 index 0000000..8d8772f --- /dev/null +++ b/src/LiveActivityHooks.jl @@ -0,0 +1,224 @@ +#= +The activity of the others in a window of a project: the episodes of the merges of their changes +and of their messages, and the step per frame that draws the cues (`LiveActivityCues.jl`) and the +timeline (`LiveTimeline.jl`), see `LiveActivity.jl` and `plans/activity-cues.md`. +=# + +""" + _merge_snapshot(gui, take) -> Dict{String, Any} + +What the objects of the `gui` at the keys `take` (of a merge, see `_sync!`) are before the merge +applies the files: their pose and the wireframe of their box, by key. +""" +function _merge_snapshot(gui::LiveView, take) + out = Dict{String, Any}() + for k in take + obj = _object_of_key(gui, String(k)) + (isnothing(obj) || obj isa BMO.AbstractSystem) && continue + bb = _part_box(gui, obj) + pose = try + _pose(obj) + catch + continue + end + out[String(k)] = (; pose = (Vector{Float64}(pose[1]), Matrix{Float64}(pose[2])), + frame = isnothing(bb) ? Point3f[] : _bbox_wireframe(bb)) + end + return out +end + +# The angle [°] between the orientations `R0` and `R1` +_turned(R0, R1) = rad2deg(acos(clamp((sum(R0 .* R1) - 1) / 2, -1.0, 1.0))) + +# The readable id of the line of key `k` in the project `p`, or `nothing` +function _id_of_key(p, k::String) + for x in Iterators.flatten((p.entries, p.groups, p.systems)) + _key(x) == k && return x.name + end + return nothing +end + +""" + _merge_items(gui, before, mine, disk, take) -> (items, text) + +The items of the episode of a merge: per key of `take` an `_ActivityItem` with the kind of the +change, the pose `before` (of `_merge_snapshot`) and after (of the object of the files), and the +frame of its box at the new place; and the text of the episode ("moved M2 by 12 mm, added L3"). +""" +function _merge_items(gui::LiveView, before, mine, disk, take) + items = _ActivityItem[] + words = String[] + for k in sort!(String.(collect(take))) + id = something(_id_of_key(disk, k), _id_of_key(mine, k), Symbol(k)) + b = get(before, k, nothing) + obj = _object_of_key(gui, k) + if obj isa BMO.AbstractSystem || (isnothing(obj) && isnothing(b)) + # a system or a group line: in the text only + any(s -> _key(s) == k, disk.systems) && push!(words, "changed $id") + continue + end + if isnothing(obj) + push!(items, _ActivityItem(k, id, :removed, b.pose, nothing, b.frame)) + push!(words, "removed $id") + continue + end + e = findfirst(x -> _key(x) == k, disk.entries) + after = if isnothing(e) || isnothing(disk.entries[e].object) + p = _pose(obj) + (Vector{Float64}(p[1]), Matrix{Float64}(p[2])) + else + o = disk.entries[e].object + (Vector{Float64}(position(o)), Matrix{Float64}(BMO.orientation(o))) + end + if isnothing(b) + bb = _part_box(gui, obj) + push!(items, _ActivityItem(k, id, :added, nothing, after, isnothing(bb) ? Point3f[] : _bbox_wireframe(bb))) + push!(words, "added $id") + continue + end + d = after[1] .- b.pose[1] + frame = Point3f[p + Point3f(d...) for p in b.frame] + dist = sqrt(sum(abs2, d)) + angle = _turned(b.pose[2], after[2]) + change, word = if dist > 1e-9 + :moved, "moved $id by " * _mm_text(dist) + elseif angle > 1e-6 + :rotated, "rotated $id by " * _short_number(angle) * "°" + else + :changed, "changed $id" + end + push!(items, _ActivityItem(k, id, change, b.pose, after, frame)) + push!(words, word) + end + return items, join(words, ", ") +end + +# A distance [m] as a short text in mm or µm +_mm_text(d) = d >= 1e-3 ? _short_number(1e3 * d) * " mm" : _short_number(1e6 * d) * " µm" + +# A number with one decimal below 10 and none above, without a trailing ".0" +function _short_number(v) + r = round(v; digits = v < 10 ? 1 : 0) + return isinteger(r) ? string(Int(r)) : string(r) +end + +""" + _writer_participant(gui, stamp; now = time()) -> (; who, name, agent) + +The participant who wrote the files with the `stamp` (see `_writer`): its presence id, name and +whether it is an agent; an unnamed agent if none is found. +""" +function _writer_participant(gui::LiveView, stamp; now::Real = time()) + if !(isnothing(stamp) || isempty(stamp)) + for id in sort!(collect(keys(gui.sync.others))) + o = gui.sync.others[id] + _alive(o, now) && o.written == stamp && return (; who = id, name = String(o.name), agent = o.kind != "person") + end + end + return (; who = "agent", name = "agent", agent = true) +end + +""" + _record_merge!(gui, before, mine, disk, take; now = time()) + +Records the episode of a merge of the files of another participant into the `gui` (see `_sync!`): +`before` the snapshot of the objects before the merge (see `_merge_snapshot`). Nothing for a merge +that changed nothing in the window. +""" +function _record_merge!(gui::LiveView, before, mine, disk, take; now::Real = time()) + isempty(take) && return nothing + items, text = _merge_items(gui, before, mine, disk, take) + isempty(text) && return nothing + w = _writer_participant(gui, disk.stamp; now) + ep = _record_episode!(gui, w.who, w.name, w.agent, items; text, now) + stamp = something(disk.stamp, "") + # The writer publishes the stamp of its files often only after the merge, see `_name_episode!` + _activity(gui).unnamed = w.who == "agent" && !isempty(stamp) ? (ep.n, String(stamp)) : nothing + return nothing +end + +""" + _name_episode!(gui; now = time()) + +Gives the episode of the last merge whose writer was not known (see `_record_merge!`) the writer, +once an other that is there published the stamp of those files (as `_name_writer!` does for the +status line). The cue that already plays keeps its name; the timeline shows the new one. +""" +function _name_episode!(gui::LiveView; now::Real = time()) + a = gui.activity + (a isa _Activity && !isnothing(a.unnamed)) || return nothing + n, stamp = a.unnamed + w = _writer_participant(gui, stamp; now) + w.who == "agent" && return nothing + a.unnamed = nothing + i = findfirst(e -> e.n == n, a.episodes) + isnothing(i) && return nothing + e = a.episodes[i] + a.episodes[i] = _Episode(e.n, w.who, w.name, w.agent, e.t, e.items, e.text, e.message, e.count) + return nothing +end + +# The texts of `doing` that a write of an agent sets itself (`_announce_wrote` of +# BeamletOpticsProjects): no message of the agent, the merge is its episode +const _WRITE_DOINGS = ("added ", "moved ", "changed ", "copied ", "removed ", "put ", "took ") + +""" + _record_message!(gui, id, o; old = nothing, now = time()) + +Records a message of the other participant `id` (its presence `o`) if its `doing` is new and not the +note of one of its writes (see `_WRITE_DOINGS`): an agent says what it does next. People's windows +write no `doing`. +""" +function _record_message!(gui::LiveView, id::AbstractString, o; old = nothing, now::Real = time()) + doing = strip(String(o.doing)) + (isempty(doing) || (!isnothing(old) && String(old.doing) == o.doing)) && return nothing + any(p -> startswith(doing, p), _WRITE_DOINGS) && return nothing + items = _ActivityItem[] + obj = isempty(o.selected_id) ? nothing : _object_of_id(gui, o.selected_id) + if !isnothing(obj) + bb = _part_box(gui, obj) + p = _pose(obj) + pose = (Vector{Float64}(p[1]), Matrix{Float64}(p[2])) + push!(items, _ActivityItem(String(o.selected_uid), Symbol(o.selected_id), :changed, pose, pose, + isnothing(bb) ? Point3f[] : _bbox_wireframe(bb))) + end + _record_episode!(gui, id, String(o.name), o.kind != "person", items; text = doing, + message = doing, now) + return nothing +end + +""" + _activity_tick!(gui) + +The step per frame of the activity of the others in the `gui` (a watched window of a project, see +`_start_watching!`): builds the timeline once an other was there, draws the cues at their pace +(see `_activity_step!`), shows a mark that the mouse is over again as a cue, the ghosts of the +scrub, and moves the camera to a participant that is followed. Nothing before the first episode. +""" +function _activity_tick!(gui::LiveView) + a = gui.activity + a isa _Activity || return nothing + now = time() + a.seen && isnothing(a.strip) && _timeline!(gui) + # a mark under the mouse plays its cue, once per mark + hover = a.hover + if hover != a.shown_hover + isnothing(hover) || _replay!(gui, hover; now) + end + _draw_cues!(gui, _activity_step!(gui, now), now) + if a.cursor != a.shown_cursor + _draw_scrub!(gui, isnothing(a.cursor) ? nothing : _poses_at(gui, a.cursor)) + end + a.shown_hover, a.shown_cursor = hover, a.cursor + if !isnothing(a.strip) + _update_timeline!(gui, now) + # the toast of the compact layout stands above the timeline while it is shown + tl = a.strip + top = tl.shown ? maximum(tl.rect)[2] - minimum(gui.ax.scene.viewport[])[2] - _OVERLAY_MARGIN + 6 : 0 + _lift_toast!(gui, top) + end + target = _follow_target(gui, now) + isnothing(target) || _frame_points!(gui, target) + return nothing +end + diff --git a/src/LiveOverlay.jl b/src/LiveOverlay.jl index 7ce2336..0105b91 100644 --- a/src/LiveOverlay.jl +++ b/src/LiveOverlay.jl @@ -587,7 +587,28 @@ function _arrange_toast!(o::_CompactOverlay, vp::Rect2f) (o.toast_shown && !o.hidden) || return _park!(o.toast.outer) ts = _card_size(o.toast.outer) x = minimum(vp)[1] + (Makie.widths(vp)[1] - ts[1]) / 2 - _place!(o.toast.outer, Point2f(max(x, minimum(vp)[1]), minimum(vp)[2] + _OVERLAY_MARGIN + ts[2])) + # above the timeline of the activity of the others while it is shown, see `_lift_toast!` + lift = get(_TOAST_LIFT, o, 0.0f0) + _place!(o.toast.outer, Point2f(max(x, minimum(vp)[1]), minimum(vp)[2] + _OVERLAY_MARGIN + lift + ts[2])) + return nothing +end + +# How far [px] the toast of an overlay stands above its place, see `_lift_toast!` +const _TOAST_LIFT = WeakKeyDict{_CompactOverlay, Float32}() + +""" + _lift_toast!(gui, top) + +Places the toast of the compact layout of the `gui` above the height `top` [px above the bottom of +the 3D view], e.g. the timeline of the activity of the others (`0`: at its place). Nothing in the +app layout, whose status line is not over the view. +""" +_lift_toast!(::LiveView, ::Real) = nothing +function _lift_toast!(gui::CompactView, top::Real) + o = gui.layout.overlay + lift = Float32(max(top, 0)) + get(_TOAST_LIFT, o, 0.0f0) == lift && return nothing + _TOAST_LIFT[o] = lift return nothing end diff --git a/src/LiveProjectClaims.jl b/src/LiveProjectClaims.jl index 964e3c7..42bdbea 100644 --- a/src/LiveProjectClaims.jl +++ b/src/LiveProjectClaims.jl @@ -58,7 +58,7 @@ Base.@kwdef mutable struct _ClaimState task::Union{Nothing, Task} = nothing run_at::Float64 = -Inf dirty::Bool = false - drawn::Vector{Tuple{Vector{Point3f}, String, Bool}} = Tuple{Vector{Point3f}, String, Bool}[] + drawn::Vector{Tuple{Vector{Point3f}, String, Bool, String}} = Tuple{Vector{Point3f}, String, Bool, String}[] locks::Vector{Any} = Any[] last_error::String = "" end @@ -558,10 +558,10 @@ end # Draws the locks at the claimed objects of `shown` (see `_presence_shown`), after the boxes function _draw_locks!(gui::LiveView, shown, visible::Bool) c = _claims(gui) - for (pts, _, locked) in shown + for (pts, _, locked, who) in shown locked || continue lock = scatter!(gui.ax, [maximum(pts)]; marker = _LOCK_ICON, markersize = _LOCK_SIZE, - color = _OTHER_COLOR, markerspace = :pixel, visible, clip_planes = Plane3f[], + color = _presence_color(gui, who), markerspace = :pixel, visible, clip_planes = Plane3f[], inspectable = false) push!(c.locks, lock) end diff --git a/src/LiveProjectPresence.jl b/src/LiveProjectPresence.jl index 6a09403..34213e6 100644 --- a/src/LiveProjectPresence.jl +++ b/src/LiveProjectPresence.jl @@ -28,9 +28,7 @@ const _BEAT = 20.0 const _ALIVE = 60.0 # At most this many characters of `doing` are shown const _DOING_CHARS = 80 -# The color of the boxes and names of the others: the green of Julia, not the accent of the themes -# (the selection, the boxes of the parts), not the purple of the clip planes or the orange of aiming -const _OTHER_COLOR = RGBf(0.22, 0.596, 0.149) +# The boxes and names of the others have the colour of each participant, see `_presence_color` const _OTHER_BOX_WIDTH = 2 const _OTHER_FONTSIZE = 14 @@ -228,6 +226,8 @@ function _check_presence!(gui::LiveView; now::Real = time(), look = nothing) selected_id = data["selected_id"], selected_uids = _string_list(get(data, "selected_uids", nothing)), selected_ids = _string_list(get(data, "selected_ids", nothing)), doing = first(data["doing"], _DOING_CHARS), written = data["written"], ttl = data["ttl"], seen_at, beat) + # what an agent says it does next, in the activity of a watched window, see `_record_message!` + s.background && _record_message!(gui, id, s.others[id]; old, now) end # A file that is skipped (it does not parse) keeps its participant as it was; a file that is gone drops it for id in collect(keys(s.others)) @@ -235,6 +235,7 @@ function _check_presence!(gui::LiveView; now::Real = time(), look = nothing) id in look.known && !(id in look.present) && delete!(s.others, id) end _name_writer!(gui, now) + _name_episode!(gui; now) _versions_notice!(gui, look, now) _claims_tick!(gui; now) _draw_presence!(gui, now) @@ -290,7 +291,7 @@ _other_label(o) = isempty(o.doing) ? o.name : o.name * "\n" * o.doing # object of the view that a participant claims (see `_claimed_objects`, also of one whose presence # is not shown) the box, the label with "editing" and `true` for the lock function _presence_shown(gui::LiveView, now::Real) - shown = Tuple{Vector{Point3f}, String, Bool}[] + shown = Tuple{Vector{Point3f}, String, Bool, String}[] claimed = _claimed_objects(gui) for id in sort!(unique!([collect(keys(gui.sync.others)); collect(keys(claimed))])) o = get(gui.sync.others, id, nothing) @@ -302,13 +303,13 @@ function _presence_shown(gui::LiveView, now::Real) _has(objs, obj) && continue bb = _part_box(gui, obj) isnothing(bb) && continue - push!(shown, (_bbox_wireframe(bb), label, false)) + push!(shown, (_bbox_wireframe(bb), label, false, String(id))) label = "" end end for obj in objs bb = _part_box(gui, obj) - isnothing(bb) || push!(shown, (_bbox_wireframe(bb), _claim_label(gui, id, o), true)) + isnothing(bb) || push!(shown, (_bbox_wireframe(bb), _claim_label(gui, id, o), true, String(id))) end end return shown @@ -317,7 +318,7 @@ end # What the plots of the overlay show now, in the form of `_presence_shown` function _presence_drawn(gui::LiveView) c = gui.sync.claim - return c isa _ClaimState ? c.drawn : Tuple{Vector{Point3f}, String, Bool}[] + return c isa _ClaimState ? c.drawn : Tuple{Vector{Point3f}, String, Bool, String}[] end # The text of a label of the overlay (Makie keeps it as a vector of one string) @@ -327,8 +328,9 @@ _label_text(p) = (t = p.text[]; String(t isa AbstractString ? t : only(t))) _draw_presence!(gui, now) Draws where the others of the `gui` (people and agents alike) are at `now` (see -`_check_presence!`): the box of the object each works on in `_OTHER_COLOR` and its name at the -upper corner, with what it is doing (`doing`) in a second line; overlays in `gui.sync.presence` (a +`_check_presence!`): the box of the object each works on and its name at the +upper corner, with what it is doing (`doing`) in a second line, in the colour of the participant +(see `_presence_color`); overlays in `gui.sync.presence` (a box and a label per other). An object that a participant claims (see `LiveProjectClaims.jl`) has a lock at the corner (`gui.sync.claim.locks`) and "editing" after the name. Only an object of the view is shown. Draws again only if the boxes or names changed; hidden in the spectator mode. @@ -339,11 +341,12 @@ function _draw_presence!(gui::LiveView, now::Real) visible = !gui.controls.spectator[] if shown != _presence_drawn(gui) _delete_presence!(gui) - for (pts, name, locked) in shown - box = linesegments!(gui.ax, pts; color = _OTHER_COLOR, linewidth = _OTHER_BOX_WIDTH, visible, + for (pts, name, locked, who) in shown + color = _presence_color(gui, who) + box = linesegments!(gui.ax, pts; color, linewidth = _OTHER_BOX_WIDTH, visible, clip_planes = Plane3f[], inspectable = false) # the name of a claimed object after its lock - label = text!(gui.ax, maximum(pts); text = name, color = _OTHER_COLOR, fontsize = _OTHER_FONTSIZE, + label = text!(gui.ax, maximum(pts); text = name, color, fontsize = _OTHER_FONTSIZE, align = (:left, :bottom), offset = (locked ? 0.6 * _LOCK_SIZE : 0.0, 0.0), visible, clip_planes = Plane3f[], inspectable = false) push!(s.presence, box, label) diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 8fa80ee..51a38ca 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -594,7 +594,10 @@ function _sync!(gui::LiveView; animate::Bool = _shown(gui)) keep = s.conflicts) conflicts = plan.conflicts new_conflicts = setdiff(conflicts, s.conflicts) + # A watched window shows the change of the others as an episode of their activity + before = s.background ? _merge_snapshot(gui, plan.take) : nothing done = _adopt!(gui, mine, disk, plan.take; rebuild = Set(rebuild), animate, merged = plan.merged) + isnothing(before) || isempty(done) || _record_merge!(gui, before, mine, disk, plan.take) s.base = _align_uids!(gui, disk) s.stamp = disk.stamp s.src = src diff --git a/src/LiveProjectWatch.jl b/src/LiveProjectWatch.jl index 27b30f6..b288ea5 100644 --- a/src/LiveProjectWatch.jl +++ b/src/LiveProjectWatch.jl @@ -249,6 +249,8 @@ function _start_watching!(gui::LiveView; notes::Bool = true, poll::Real = _POLL_ # The autosave and the session file are written in the background, see `_start_save!` s.background = true _connect_session!(gui) + # The cues and the timeline of the activity of the others, see `_activity_tick!` + push!(gui.controls.listeners, on(_ -> _activity_tick!(gui), events(gui.ax.scene).tick)) # The presence file is written by the looks of the watcher, see `_presence_due!` s.presence_due = true push!(gui.controls.listeners, on(gui.controls.selected) do _ diff --git a/test/TestLiveActivityHooks.jl b/test/TestLiveActivityHooks.jl new file mode 100644 index 0000000..53d6c40 --- /dev/null +++ b/test/TestLiveActivityHooks.jl @@ -0,0 +1,138 @@ +module TestLiveActivityHooks + +# The activity of the others in a watched window of a project: episodes of merges and messages, +# and the step per frame that draws the cues and the timeline (see `LiveActivityHooks.jl`) + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const GUI = BeamletOpticsGUI +const BMOP = BeamletOpticsProjects +const TOML = GUI.TOML +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const START = """ + @component L1 = SphericalLens(0.05, -0.05, 0.005, 0.0254, ConstantRefractiveIndex(1.5)) pos = (0.0, 0.1, 0.0) + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) rot = (0.0, 0.0, 45.0) + @component M2 = RoundPlanoMirror(0.0254, 0.006) pos = (0.1, 0.2, 0.0) rot = (0.0, 0.0, 45.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [L1, M1, M2] sources = [B1] + """ + +function opened() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + gui = open_project(dir; display = false, const_kw...) + save_project(gui) + # as a watched window, without its watcher + gui.sync.background = true + return gui, dir +end + +"""Writes the presence file of an agent `name`, alive, with `doing` and the stamp `written`.""" +function agent!(dir, name; doing = "", selected_id = "", written = "", beat = 1) + id = "$name@agent" + file = GUI._presence_file(dir, id) + mkpath(dirname(file)) + data = Dict{String, Any}("name" => name, "host" => "PC9", "kind" => "agent", "beat" => beat, + "ttl" => 120, "selected" => "", "selected_id" => selected_id, "written" => written, "doing" => doing) + open(io -> TOML.print(io, data), file, "w") + return id +end + +"""Lets the window see the files: merges them as the watcher would.""" +function merge!(gui) + t = time() + GUI._check_files!(gui; now = t) + return GUI._check_files!(gui; now = t + 1) +end + +@testset "the activity of the others in a window" begin + gui, dir = opened() + try + a() = GUI._activity(gui) + @testset "a move of an agent is an episode" begin + id = agent!(dir, "claude") + GUI._check_presence!(gui; now = time()) + BMOP.move!(dir, [:M1]; offset = (0.0, 0.0, 0.012), by = id) + # the agent publishes the stamp of its write + agent!(dir, "claude"; written = BMOP.project_stamp(dir), beat = 2, doing = "moved M1") + GUI._check_presence!(gui; now = time()) + @test merge!(gui) === :applied + eps = a().episodes + @test length(eps) == 1 + ep = only(eps) + @test ep.who == id && ep.name == "claude" && ep.agent + @test ep.text == "moved M1 by 12 mm" + item = only(ep.items) + @test item.change === :moved && item.id === :M1 + @test item.after[1] - item.before[1] ≈ [0.0, 0.0, 0.012] + @test length(item.frame) == 24 + # the note of the write in `doing` is no message + @test all(e -> isempty(e.message), eps) + @test a().seen + end + @testset "a message of an agent at its object" begin + id = agent!(dir, "claude"; doing = "aligning the fold mirror M2", selected_id = "M2", beat = 3) + GUI._check_presence!(gui; now = time()) + ep = a().episodes[end] + @test ep.message == "aligning the fold mirror M2" + @test only(ep.items).id === :M2 + # the same text again is no new message + agent!(dir, "claude"; doing = "aligning the fold mirror M2", selected_id = "M2", beat = 4) + GUI._check_presence!(gui; now = time()) + @test length(a().episodes) == 2 + end + @testset "an unknown writer is named once it publishes its stamp" begin + BMOP.move!(dir, [:M2]; offset = (0.003, 0.0, 0.0)) + @test merge!(gui) === :applied + ep = a().episodes[end] + @test ep.who == "agent" && ep.text == "moved M2 by 3 mm" + agent!(dir, "claude"; written = BMOP.project_stamp(dir), beat = 5) + GUI._check_presence!(gui; now = time()) + @test a().episodes[end].name == "claude" + @test a().episodes[end].n == ep.n + end + @testset "the step per frame: cues and the timeline" begin + screen = GLMakie.Screen(; visible = false, start_renderloop = false) + try + display(screen, gui.fig) + for _ in 1:3 + GUI._activity_tick!(gui) + Makie.colorbuffer(screen) + end + @test !isnothing(a().strip) + @test !isnothing(a().cue_plots) + # a mark under the mouse plays its cue again + n = a().episodes[1].n + a().hover = n + GUI._activity_tick!(gui) + @test any(c -> c[1].n == n, a().cues) + # the scrub shows ghosts until it ends + a().cursor = a().episodes[1].t - 1 + GUI._activity_tick!(gui) + @test a().shown_cursor == a().cursor + a().cursor = nothing + GUI._activity_tick!(gui) + Makie.colorbuffer(screen) + @test isnothing(a().shown_cursor) + # the toast of the compact layout stands above the shown timeline + if gui isa GUI.CompactView && a().strip.shown + lift = get(GUI._TOAST_LIFT, gui.layout.overlay, 0.0f0) + @test lift > 0 + @test GUI._TOAST_LIFT[gui.layout.overlay] + minimum(gui.ax.scene.viewport[])[2] >= + maximum(a().strip.rect)[2] - GUI._OVERLAY_MARGIN + end + finally + close(screen) + end + end + finally + close(gui) + end +end + +end diff --git a/test/TestLiveProjectPresence.jl b/test/TestLiveProjectPresence.jl index 9d1ebe7..8d6f639 100644 --- a/test/TestLiveProjectPresence.jl +++ b/test/TestLiveProjectPresence.jl @@ -107,7 +107,9 @@ labels(gui) = [GUI._label_text(p) for p in gui.sync.presence if p isa Makie.Text box = only(boxes(gui)) bb = GUI._part_box(gui, obj(gui, :M1)) @test box[1][] == GUI._bbox_wireframe(bb) - @test box.color[] == Makie.to_color(GUI._OTHER_COLOR) + # in the colour of Bob, the same for its name + @test box.color[] == Makie.to_color(GUI._presence_color(gui, "bob@PC9")) + @test only(filter(p -> p isa Makie.Text, gui.sync.presence)).color[] == box.color[] # Nothing changed: not drawn again GUI._check_presence!(gui; now = 201.0) @test only(boxes(gui)) === box From 3977bf7372fbdb0004b028f2f2142f0e773b52f4 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Thu, 12 Nov 2026 01:14:18 +0100 Subject: [PATCH 47/47] watch_notify.jl retries a write that the drive refuses for a moment and counts the refusals; two_machines.jl records a failed writer as a check instead of halting Co-Authored-By: Claude Opus 5.5 --- benchmark/two_machines.jl | 6 ++++-- benchmark/watch_notify.jl | 32 +++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/benchmark/two_machines.jl b/benchmark/two_machines.jl index 75f2fdc..c1575aa 100644 --- a/benchmark/two_machines.jl +++ b/benchmark/two_machines.jl @@ -401,8 +401,10 @@ function notifications() info("notifications, $OTHER writes, $ROLE watches", summary) else await("watching-$n"; timeout = 120.0) - run(pipeline(`$julia --startup-file=no $script write $dir 60`; stdout = devnull)) - info("wrote 60 probes for the watcher on $watcher") + out = joinpath(RESULTS, "notify-$ROLE-writes.txt") + ok = success(pipeline(`$julia --startup-file=no $script write $dir 60`; stdout = out, stderr = out)) + last = filter(!isempty, split(read(out, String), '\n')) + check(ok, "the writer of the probes ran through", isempty(last) ? "" : String(last[end])) end barrier("notify-$n-done"; timeout = 600.0) end diff --git a/benchmark/watch_notify.jl b/benchmark/watch_notify.jl index c3c415d..2584829 100644 --- a/benchmark/watch_notify.jl +++ b/benchmark/watch_notify.jl @@ -29,19 +29,37 @@ function write_probes(folder; n = 60, mode = "rename") mkpath(probe_dir(folder)) file = probe_file(folder) tmp = joinpath(probe_dir(folder), ".probe.$(getpid()).tmp") + refused = 0 for k in 1:n text = "$k $(time())\n" - if mode == "direct" - write(file, text) - else - write(tmp, text) - mv(tmp, file; force = true) + # A shared drive refuses the write for a moment while the watcher reads the file (as for the + # writes of a project): tried again, and counted + refused += retry_refused() do + if mode == "direct" + write(file, text) + else + write(tmp, text) + mv(tmp, file; force = true) + end end println("wrote $k") sleep(1.0) end - write(file, "done $(time())\n") - println("done") + refused += retry_refused(() -> write(file, "done $(time())\n")) + println("done; writes refused for a moment by the drive: $refused") +end + +# Calls `f()` until the file system takes it (at most 40 times, 50 ms apart); returns how often it refused +function retry_refused(f) + for attempt in 1:40 + try + f() + return attempt - 1 + catch e + (e isa Union{Base.IOError, SystemError} && attempt < 40) || rethrow() + sleep(0.05) + end + end end # The counter and the writer's time in the probe file, `nothing` if it can not be read now