Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
09c5d52
Core of the selection of several objects (_MultiSelection), select!(g…
Oct 23, 2026
30f35be
Card and inspector of a selection of several objects, hiding all (W3)
Oct 23, 2026
d8230c7
Selections of several objects in the 3D view: Ctrl+click, drag, keys,…
Oct 23, 2026
699af97
W4: removing and solving a selection of several objects, guards of th…
Oct 23, 2026
3940829
Core: drop the methods that clash with LiveSnap and are ambiguous, Ct…
Oct 23, 2026
ef655b9
Merge branch 'ms/w3' into feature/multi-select
Oct 23, 2026
8a8f1d7
Object tree: Ctrl/Cmd+click toggles a row, several highlighted rows (W2)
Oct 23, 2026
794f82f
Merge branch 'ms/w2' into feature/multi-select
Oct 23, 2026
1668fe6
Controls of a selection of several: own methods of _allowed_axes and …
Oct 23, 2026
08a251a
Tests of the tree: the inspector of a selection of several is merged
Oct 23, 2026
3304408
Merge branch 'ms/w1' into feature/multi-select
Oct 23, 2026
0c31cf9
W4: keep the trait _snaps of LiveSnap.jl, drop the test workaround of…
Oct 23, 2026
a64a52d
Merge branch 'ms/w4' into feature/multi-select
Oct 23, 2026
c8c2c9e
Integration of the selection of several: session steps, test list
Oct 23, 2026
d748e49
Docs and skill: several objects selected at once
Oct 23, 2026
535ff91
Session: undo of the removed selection without the key, which depends…
Oct 23, 2026
587ae75
Axes of a selection of several stay orthogonal to the rotation axis a…
Oct 24, 2026
5d4b2f1
Camera keeps its view at Ctrl+click: the controls switch off the rese…
Oct 25, 2026
5882b87
Axes of the controls stay perpendicular after a tilt, for every objec…
Oct 25, 2026
e2b218c
Undo after rotations without NaN (axis-angle of a rounding error is n…
Oct 25, 2026
2d4520e
One axis-angle conversion: _rotation_axis_angle replaces the trace-ba…
Oct 26, 2026
232dc3c
Merge remote-tracking branch 'origin/main' into feature/multi-select
Oct 26, 2026
c263fff
Labels of new card widgets no longer drawn too large or too small: on…
Oct 26, 2026
103a50e
Settle of new card widgets without moving their columns: the growth o…
Oct 26, 2026
09cda31
Form of the catalog settled like the cards: its viewports grow by a p…
Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/src/kinematic_controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ mode, which is switched with `m`:
| `Backspace` | Reset the selected component to its initial pose |
| `Ctrl`/`Cmd` + `Z` | Undo the last change |
| `Ctrl`/`Cmd` + `Y` | Redo, also `Ctrl`/`Cmd` + `Shift` + `Z` |
| `Ctrl`/`Cmd` + left-click | Add a component to the selection, or remove it from it |
| `Esc` | Select the enclosing group, or deselect at the top level |
| Left-click on empty space | Deselect |
| `v` | Switch the spectator mode on or off |
Expand All @@ -50,10 +51,11 @@ camera, such that the system can be viewed without moving a component by acciden
help. Components
whose kinematic trait is `Static` can not be selected.

The selected component is marked by a box and three axes above it: its local y-axis (green), its
local x-axis (red) and the vertical rotation axis (blue). If a local axis is parallel to the
rotation axis, e.g. the local x-axis of a `CollimatedSource` along +y, the axis perpendicular to
the other two takes its place, such that the component can be moved in all directions. In the move mode the axes are shown as
The selected component is marked by a box and three perpendicular axes above it: the vertical
rotation axis (blue), the heading of the component in the horizontal plane (green, its local y-axis
projected onto that plane) and the horizontal axis beside it (red). For a component that is only
turned about the vertical, these are its local y- and x-axes; a tilted component keeps
perpendicular axes. In the move mode the axes are shown as
arrows, in the rotate mode as rings. The first key of each pair moves the component in the
direction of the arrow, or rotates it in the direction of the ring. In the rotate mode, a drag on
a ring (the ring under the cursor is highlighted) rotates the component around that ring, through
Expand Down
18 changes: 18 additions & 0 deletions docs/src/live_view.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,24 @@ small menu or of the selection card moves it, e.g. off the parts that it covers.
group opens its card at once. A click on a pinned card beside its widgets selects its object. The standalone [`kinematic_controls!`](@ref) keeps its drill-down (a second click
on a selected group selects the part). See the section "Selection card" of [`live_view`](@ref).

`Ctrl`+click (`Cmd` on macOS) on an object in the 3D view adds its top-level object to the
selection, or removes it if it is selected; in the object tree of the app layout, `Ctrl`/`Cmd`+click
on a row does the same, and every row of a selected object is highlighted. A plain click on an object
of the selection selects that object alone, `Ctrl`+click on the background keeps the selection and
`Esc` clears it. With a `select_modifier` set, see [`kinematic_controls!`](@ref), a toggle needs that
modifier and `Ctrl` held. A selection of several is moved (a drag on any of its objects, the arrows,
the arrow and page keys) and rotated (rotate mode, rings, keys) as one, about the mean of the
positions of its objects, with the axes of the world as its gizmo axes. It does not snap onto beams
or the table grid, and only the axes that all its objects allow are free. One drag, one run of key
steps and one `Backspace` (which resets all its objects to their initial poses) are one undo step,
and `on_change` is called once per object of a moved selection. `Ctrl+C` copies nothing while
several objects are selected. Its card (compact layout) or the inspector (app layout) shows "N
objects" with the subtitle "Selection" and one row per object with its name, where a click selects
that object alone. It has no pages and no pin, and two actions: hide (all objects, or show all if all are
hidden) and remove (all removable objects as one undo step; the others stay selected and the status
line says why). From code, `select!(gui, [lens, mirror])` selects several; `gui.controls.selected[]`
then holds an internal value for the selection, not a component.

With `live_view(...; layout = :app)`, the cards are docked in the "Properties" sidebar instead of
floating next to the components: the card of the selection at the top, with the same rows and
actions, and below it the pinned cards, one below the other, each with its own head (icon, label,
Expand Down
59 changes: 59 additions & 0 deletions ext/BeamletOpticsGUIGLMakieExt/session.jl
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ function _key!(gui, key, modifiers...)
return nothing
end

# Holds (`Keyboard.press`) or lets go of (`Keyboard.release`) the left Ctrl key
_ctrl!(gui, action) = (_events(gui).keyboardbutton[] = Makie.KeyEvent(Keyboard.left_control, action))

function _drag!(gui, a, b; button = Mouse.left, n = 6)
_move!(gui, a)
_press!(gui, button)
Expand Down Expand Up @@ -261,6 +264,50 @@ function _session(step, layout::Symbol, fixture::_Fixture = _fixture())
end
step(deselect!, "click background (deselect) of the detector")

# Several objects at once: Ctrl+click adds to and removes from the selection, which is dragged,
# stepped by the keys and reset as one
ctrl_click!(xy) = (_ctrl!(gui, Keyboard.press); _click!(gui, xy); _ctrl!(gui, Keyboard.release))
in_selection(objs...) = _check(GUI._selection_objects(gui.controls.selected[]) == Any[objs...],
"the selection does not hold the $(join((nameof(typeof(o)) for o in objs), " and "))")
step("ctrl+click lens and mirror (select both)") do
_click!(gui, _px(gui, _pos(fx.lens)))
frame!()
ctrl_click!(_px(gui, _pos(fx.mirror)))
frame!()
in_selection(fx.lens, fx.mirror)
end
step("drag the selection of several") do
a = _px(gui, _pos(fx.lens))
_drag!(gui, a, a .+ (20, 5))
settle!()
in_selection(fx.lens, fx.mirror)
end
step("key steps, undo and reset of the selection of several") do
_key!(gui, Keyboard.up)
_key!(gui, Keyboard.z, Keyboard.left_control)
_key!(gui, Keyboard.backspace)
settle!()
end
step("ctrl+click mirror (remove it from the selection)") do
ctrl_click!(_px(gui, _pos(fx.mirror)))
frame!()
_selected(gui, fx.lens)
end
step("delete the selection of several, undo") do
ctrl_click!(_px(gui, _pos(fx.mirror)))
frame!()
in_selection(fx.lens, fx.mirror)
_key!(gui, Keyboard.delete)
settle!()
_check(!GUI._has(fx.system.objects, fx.mirror), "the delete did not remove the selection")
# not by the key: `Ctrl+Z` is "Ctrl+Y" on a keyboard with another layout, see `_layout_key`
GUI._undo!(gui.controls)
settle!()
_check(GUI._has(fx.system.objects, fx.mirror) && GUI._has(fx.system.objects, fx.lens),
"the undo did not restore the selection")
end
step(deselect!, "click background (deselect) of the selection")

step("camera: rotate (left drag)") do
a = _free_px(gui)
_drag!(gui, a, a .+ (40, 20))
Expand Down Expand Up @@ -629,6 +676,18 @@ function _session(step, layout::Symbol, fixture::_Fixture = _fixture())
_check(right.size.x == w + 40, "the drag did not resize the right sidebar")
end
step(() -> (tree.clicked[] = fx.mirror; frame!()), "tree: click mirror")
step("tree: ctrl+click lens (select both)") do
i = findfirst(r -> r.key === fx.lens, tree.rows)
_check(!isnothing(i), "the tree has no row of the lens")
o = minimum(tree.scene.viewport[])
_move!(gui, (Float64(o[1] + GUI._row_columns(tree, tree.rows[i]).label + 5), Float64(o[2] + GUI._row_y(tree, i))))
_ctrl!(gui, Keyboard.press)
_click!(gui)
_ctrl!(gui, Keyboard.release)
frame!()
in_selection(fx.mirror, fx.lens)
deselect!()
end
step("splitter: drag the left sidebar") do
left = gui.layout.left
w = left.size.x
Expand Down
6 changes: 5 additions & 1 deletion skills/beamletopticsgui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ mouse drag on a ring of the gizmo (highlighted under the cursor) rotates the sel
that ring, e.g. to tilt it out of the table plane; locked rings (`constraints`) can not be dragged, a
drag elsewhere on the selection rotates around `rotation_axis` as before. In the move mode a drag on
an arrow of the gizmo moves the component along that arrow only, a drag elsewhere on the selection
in the plane of the view.
in the plane of the view. `Ctrl`+click (`Cmd` on macOS) on a component adds its top-level object to
the selection or removes it (no camera reset on `Ctrl`+click in the controls; with a `select_modifier`
set, a toggle needs that modifier and `Ctrl`); a plain click on an object of the selection selects it
alone, `Ctrl`+click on the background keeps the selection.

## Scripting a window

Expand All @@ -160,6 +163,7 @@ static objects throw `ArgumentError`):
translate3d!(gui, lens, [0, 1e-3, 0]); translate_to3d!(gui, lens, [0, 0.12, 0])
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
```
Expand Down
8 changes: 8 additions & 0 deletions skills/beamletopticsgui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ When this Skill is active:
`Ctrl+Z` and redone with `Ctrl+Y`. `Ctrl+C` and `Ctrl+V` copy and paste the selected component or
source from the catalog, in the window only (the copy is placed with the mouse); from code, construct
the object again and call `add_component!`.
- Several objects can be selected: `Ctrl`+click (`Cmd` on macOS) in the 3D view or on a row of the
tree (app layout) toggles an object, `select!(gui, [a, b])` from code, `Esc` clears. They are moved
and rotated as one about the mean of their positions with the world axes as gizmo axes, without
snapping, only on the axes that all allow; one drag, run of keys or `Backspace` is one undo step,
and `on_change` is called once per object. Their card (compact) or the inspector (app) shows "N
objects" ("Selection"), one row per object (click: that object alone), hide and remove, no pages and
no pin. `Ctrl+C` copies nothing. `gui.controls.selected[]` then holds an internal value, not a
component.
- 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 "−"
Expand Down
2 changes: 1 addition & 1 deletion skills/beamletopticsgui/WIDGETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ control on its page "Pose" (the rows of `card_rows`), and every card, also a pin
that extends the default ones via `invoke`, as below.
6. No state in the widgets and no captured `gui`: the card may be rebuilt at any time and shows other
objects with the same declarations.
7. Test it: `gui = live_view(System([...]) => beam; layout)`, `gui.controls.selected[] = obj`, find the
7. Test it: `gui = live_view(System([...]) => beam; layout)`, `select!(gui, obj)` (or `gui.controls.selected[] = obj`; for several objects only `select!(gui, [a, b])`, after which `gui.controls.selected[]` is not a component), find the
widget by `name` on the card, set an input, check the object, the shown value and the solve. The
examples of this file run as tests in `test/TestLiveWidgetRecipe.jl` of BeamletOpticsGUI.

Expand Down
1 change: 1 addition & 0 deletions src/BeamletOpticsGUI.jl
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const _Axis = Union{Axis3, LScene}
# include order dependant!
include("API.jl")
include("LiveHandles.jl")
include("LiveMultiSelect.jl")
include("LiveMarkers.jl")
include("LiveInteraction.jl")
include("LiveTree.jl")
Expand Down
2 changes: 1 addition & 1 deletion src/LiveAim.jl
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function _aim!(gui::LiveView, src::_Source, point)
return false
end
Q = _align_rotation(Vector{Float64}(BMO.direction(src)), d, ctrl.rotation_axis)
axis, angle = _axis_angle_from_rotmatrix(Q)
axis, angle = _rotation_axis_angle(Q)
angle > 1e-12 && _change!(() -> rotate3d!(src, axis, angle), ctrl, src)
_commit_pose!(gui, src, P0, R0)
return true
Expand Down
13 changes: 9 additions & 4 deletions src/LiveApp.jl
Original file line number Diff line number Diff line change
Expand Up @@ -450,11 +450,15 @@ _step_box(::AppLayout, w, _) = w.step_box
Connections and status
=#

# The inspector follows the selection via `_update_inspector!`, called right before
# The inspector follows the selection via `_update_inspector!`, called right before. The rows of all
# objects of a selection of several are shown and highlighted, see `_selection_objects`
function _on_selected!(gui::AppView)
obj = gui.controls.selected[]
_reveal!(gui, obj)
_set_selected!(gui.layout.tree, obj)
sel = gui.controls.selected[]
objs = _selection_objects(sel)
for obj in objs
_reveal!(gui, obj)
end
_set_selected!(gui.layout.tree, _is_multi(sel) ? objs : sel)
return nothing
end

Expand Down Expand Up @@ -504,6 +508,7 @@ function _connect_layout!(gui::AppView)
# Object tree
tree = layout.tree
push!(listeners, on(key -> _tree_click!(gui, key), tree.clicked))
push!(listeners, on(key -> _tree_toggle!(gui, key), tree.toggled))
push!(listeners, on(key -> _toggle_hidden!(gui, key), tree.eye_clicked))
push!(listeners, on(key -> _toggle_expanded!(gui, key), tree.expand_clicked))
push!(listeners, on(((key, button),) -> _tree_button!(gui, key, button), tree.button_clicked))
Expand Down
15 changes: 15 additions & 0 deletions src/LiveAppTree.jl
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,21 @@ function _tree_click!(gui::AppView, @nospecialize(obj))
return nothing
end

"""
_tree_toggle!(gui, key)

Handles a click with `Ctrl` (`Cmd` on macOS) on the label of a row of the object tree (see
`toggled` of `_ObjectTree`): adds the movable object to the selection or removes it from it, like
`Ctrl`+click in the 3D view, see `_toggle_select!`. On any other row, e.g. of a system or of an
object that is not movable, it is a plain click, see `_tree_click!`.
"""
_tree_toggle!(gui::AppView, h::AbstractSystemRenderHandle) = _tree_click!(gui, h)

function _tree_toggle!(gui::AppView, @nospecialize(obj))
_is_movable(gui.controls, obj) ? _toggle_select!(gui, obj) : _tree_click!(gui, obj)
return nothing
end

# The row of the inspected object is highlighted like the selected one, see `_on_selected!`
_show_inspected!(::AppView, ::Nothing) = nothing
function _show_inspected!(gui::AppView, @nospecialize(obj))
Expand Down
3 changes: 2 additions & 1 deletion src/LiveCamera.jl
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,8 @@ Keeps the view of the `gui` when plots are added to the open window, e.g. an ove
clip plane or a measurement: Makie's `plot!` into the `LScene` then calls `reset_limits!`, whose
`center!` would move the camera to fit the scene. Called once the window is shown, after the view
was fitted to the scene; `center!` then only updates the bounds of the camera (and its depth
range), the reset key of `Camera3D` (Ctrl + left click) still fits the view.
range). The reset key of `Camera3D` (Ctrl + left click) is taken by the selection, see
[`kinematic_controls!`](@ref); the home button fits the view.
"""
_keep_camera!(gui::LiveView) = (cameracontrols(gui.ax.scene).settings.center[] = false; nothing)

Expand Down
19 changes: 19 additions & 0 deletions src/LiveCard.jl
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,25 @@ function _show_pencil!(e::_TitleEdit, renamable::Bool)
return nothing
end

"""
Shows the pin of the floating card `c` among its tools, left of its chevron, or takes it out of the
tools, e.g. on the card of a selection of several objects, see `_pinnable`. Only a change updates
the layout.
"""
function _show_pin_button!(c::_ComponentCard, shown::Bool)
box = c.pin_button.box
gc = Makie.GridLayoutBase.gridcontent(box)
attached = !isnothing(gc) && !isnothing(gc.parent)
attached == shown && return nothing
if shown
col = first(Makie.GridLayoutBase.gridcontent(c.collapse_button.box).span.cols) - 1
_view_attach!(c.tools, 1, col, box, true)
else
_view_detach!(box)
end
return nothing
end

# Whether the point `p` [figure px] is over the pencil or the textbox of the line of the title `e`
function _over_name_widget(e::_TitleEdit, p::Point2f)
x = e.editing ? e.box : e.renamable ? e.pencil.box : nothing
Expand Down
2 changes: 2 additions & 0 deletions src/LiveCardPages.jl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ _has_page(@nospecialize(obj), ::Val{:results}) = _has_view(obj)
_has_page(@nospecialize(obj), ::Val{:properties}) = _has_properties(obj)
# A system has one page, the system widget, and hence no page bar, see `_system_rows`
_has_page(::BMO.AbstractSystem, ::Val{:properties}) = false
# A selection of several objects has one page, the names of its objects (see `_MultiSelection`)
_has_page(::_MultiSelection, ::Val{:properties}) = false

"""
_has_step(obj) -> Bool
Expand Down
17 changes: 17 additions & 0 deletions src/LiveCardRows.jl
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ card_rows(@nospecialize(src::Union{BMO.CollimatedSource, BMO.PointSource})) = (p
card_rows(@nospecialize(g::BMO.GaussianBeamlet)) = (pose_card_rows(g)..., _text_row("λ", :gauss, _gauss_text),
beam_card_rows(g)...)
# Systems: see `LiveSystemCard.jl`
# A selection of several objects (see `_MultiSelection`): a row per object with its name, a click
# on which selects that object alone, see `_member_name_row`
card_rows(s::_MultiSelection) = Tuple(_member_name_row(i, obj) for (i, obj) in enumerate(s.objects))

"""
_member_name_row(i, obj)

The row of the `i`-th object `obj` of a selection of several on its card (see `_MultiSelection`): a
button `:object_<i>` with the name of `obj` (see `_label`), a click on which selects `obj` alone,
see `_select!`.
"""
_member_name_row(i::Int, @nospecialize(obj)) = CardRow(CardWidget(Button; name = Symbol(:object_, i),
label = " ", value = (gui, _) -> _label(gui, obj), on = (gui, _, _) -> _select!(gui, obj)))

# The eye in the head of a card, which hides its object and shows it again: an icon toggle that is
# active, i.e. shows the crossed-out eye, while the object is hidden
Expand All @@ -80,6 +93,10 @@ _shown_hidden(gui::LiveView, @nospecialize(obj)) = !isempty(_leaves(obj)) && _al
_remove_button() = CardWidget(Button; name = :remove, label = "remove",
on = (gui, o, _) -> _remove_selected!(gui, o))

# A selection of several objects: the eye hides all of them (see `_toggle_hidden!`), "remove"
# removes all of them that can be removed, see `_remove_selected!`
card_actions(::_MultiSelection) = (_hide_action(), _remove_button())

card_actions(::LiveClipPlane) = (
CardWidget(Button; name = :flip, label = "flip", on = (gui, p, _) -> _flip_clip_plane!(gui, p)),
CardWidget(Button; name = :remove, label = "remove", on = (gui, p, _) -> _remove_clip_plane!(gui, p)))
Expand Down
Loading
Loading