All the new GUI features combined - #5702
Conversation
Introduces `Makie.derive_colors(; accent, gray, background)`, a small function that turns three user inputs into nine UI role colors (background, surface, surface_subtle, border, text, text_muted, text_on_accent, accent, accent_subtle), inspired by the Radix UI custom palette tool. The default inputs reproduce Makie's previous Block defaults within rounding so existing visuals don't shift. `MAKIE_DEFAULT_THEME` now contains a nested `colors` Attributes block populated from `derive_colors()` at module load, sitting next to `fonts`. Block defaults can then read from it (see follow-up commit), and users can recolour every interactive widget at once via `set_theme!(colors = derive_colors(accent = :crimson))` or override individual roles with `set_theme!(colors = (text = :navy,))`.
Every interactive Block now pulls its color defaults from the nested `colors` theme block: Button, Checkbox, Toggle, Slider, IntervalSlider, Menu, Textbox. State conventions are unified across blocks — idle uses `surface`, hover uses `accent_subtle`, and active/checked/focused uses `accent`. Mechanically, this required: - Extending `@inherit` (used inside `@Block` attribute declarations) to accept a tuple-of-symbols key path, e.g. `@inherit((:colors, :accent), fallback)`. The single-symbol form keeps working unchanged. The runtime walk is performed by a new `_inherit_nested` helper. - Deleting the `COLOR_ACCENT` / `COLOR_ACCENT_DIMMED` `Ref`s: their last remaining uses (Block defaults and the Textbox cursor animation) now read from the theme instead. The cursor base color now follows the textbox's own `bordercolor_focused`, so a per-Textbox theme override travels through to the cursor too. - Adding a `textcolor_active` attribute on Menu and rendering option text per-row, so the highlighted dropdown row gets `text_on_accent` (readable on the accent fill) while inactive rows keep base `textcolor`. Menu's previously-hardcoded `:black` `textcolor` and `dropdown_arrow_color` now route through `@inherit(:textcolor, ...)` so dark themes work without further overrides.
Two small menu follow-ups: - The mouse handler short-circuits its end-of-frame cleanup with an early return when the cursor is inside the dropdown, so the menu button's `color_selector` was never reset back to its inactive swatch. Result: clicking the button to open the menu and then moving the cursor down into the dropdown left the button stuck in its hover-coloured state until the menu was closed. Now we reset the selector at the top of the dropdown branch, before the early return. - Soften `dropdown_arrow_color` from full `textcolor` to the `text_muted` role so the arrow reads as a subtle ornament rather than a contrasty glyph, matching the original `(:black, 0.2)` intent but in a theme-aware way.
Extends `@inherit` to allow the nested-tuple form without a default value: `@inherit((:colors, :surface))` instead of `@inherit((:colors, :surface), RGBf(0.94, 0.94, 0.94))`. When the key is missing from both the scene-local theme and the current global theme, `_inherit_nested` now raises a clear error pointing at the missing key path. The `colors` block is always populated by `MAKIE_DEFAULT_THEME`, so the literal fallbacks were dead code that obscured what each Block default actually resolves to. If a user clears the block from their theme, they will hit a fail-loud error pointing at the key they removed, which is what we want.
Documents the nested `colors` theme block, the nine role tokens that Block defaults read from, and the `Makie.derive_colors` helper that turns an accent / gray / background trio into a full scheme. Includes two worked examples — a warm cream/terracotta light theme and a dark green theme — rendered with the same `widget_showcase` figure.
Lets the figure auto-fit its content (so the Menu row stops cutting text) instead of pinning row sizes against a fixed Figure size.
`derive_colors` now interpolates `background` against `gray`/`accent` in Oklab perceptual space instead of sRGB linear. Equal mix weights produce equal perceived lightness steps, which keeps the neutrals well balanced under tinted gray poles and non-default backgrounds (warm light themes, off-black dark themes, etc.) rather than swinging toward whichever pole has higher sRGB luminance. This shifts the default light-mode neutrals away from the historical hand-tuned literals: - `surface` ≈ 0.92 (was 0.94) - `surface_subtle` ≈ 0.96 (was 0.97) - `border` ≈ 0.74 (was 0.80) - `text_muted` ≈ 0.50 (unchanged; weight tuned from 0.50 → 0.40 to keep the muted-text luminance familiar) Other roles (`background`, `text`, `accent`, `text_on_accent`) are unchanged. The `accent_subtle` shift is well under a percent per channel. Tests and changelog updated accordingly.
Move the Oklab mix helper into `utilities.jl` as `lerp_oklab` so it sits next to the existing sRGB `lerp`, drop the private `_mix_oklab`, replace the hand-rolled `gray === automatic` branch with `default_automatic`, and use `Attributes(::NamedTuple)` directly instead of splatting `pairs(...)`.
Collapse the two near-duplicate methods of `_inherit_nested` into a single function with a `_NO_DEFAULT` sentinel, and replace the imperative `cur::Any` + boolean-flag walk with a type-stable tuple recursion through `_walk_nested(cur, keys_path)`. Also reject the degenerate `@inherit(())` form in `make_attr_dict_expr` so it falls through to the existing "must be a :symbol or tuple of symbols" error instead of compiling.
…ipping `forward_events!(target, source; active)` lets a subtree own its own `Events` object and receive input only while `active`; window/render-context events are always forwarded, so a hidden subtree still gets `tick` and resize updates. `Consume` returned by `target` listeners propagates back, and held mouse buttons / keys are released on deactivation to avoid stuck state. `scene_visible(scene)` walks ancestor visibility so a `Block` that force-shows its own scene still appears hidden under an invisible parent. `effective_viewport(scene)` is the intersection of `scene` and its ancestors viewports; `needs_ancestor_clip(scene)` returns `true` only when at least one ancestor actually clips the scene. CairoMakie now clips to `effective_viewport` (its previous behaviour was a subset of this), and GLMakie enables `glScissor` to the effective viewport only when an ancestor clips. Both backends skip rendering scenes that fail `scene_visible`. The combined effect is that overflowing children (e.g. scrolled axes) get cut at their container, while existing axis markers near the spines still extend past the plot area as before.
…youts Adds a `GridLayout` method that recurses into `gc.content` so blocks placed inside another block`s layout — e.g. an `Axis` inside a `Tabs` tab — also get their pre-display updates (auto axis limits, etc.). Previously only top-level blocks in `fig.content` were visited, and nested ones missed their auto-limit computation on the first display.
…tent Adds a `Tabs` block that contains a row of clickable tab headers and one content scene per tab. Place blocks via `tabs[i][row, col] = Axis(...)` (like a mini-figure) or plot directly into `content_scene(tabs, i)`. Each tab owns two scenes: a `clip` scene with viewport = visible content area, isolated `Events`, and visibility tied to `active == i`; and an `inner` scene with viewport = root figure viewport so that blockscenes placed underneath it map decoration coords (which the layout reports in absolute window pixels) 1:1 onto the window. Combined with the ancestor visibility / scissor cascade, inactive tabs receive no mouse or keyboard events and render nothing, while overflowing scrolled content clips cleanly at the content area. Content size derives from `GridLayoutBase.determinedirsize`, so setting fixed col/row sizes on `tabs[i]` makes the tab scrollable; smaller content fills the viewport. Mouse wheel / trackpad scroll over the tab runs at a low priority, so an inner Axis zoom-on-scroll consumes first and the container only scrolls when nothing else used the event. Visible scrollbar tracks + thumbs appear when content overflows, with hover and drag highlighting; the active thumb stays highlighted through the drag even if the mouse leaves it. Headers are square white tabs adjacent to each other (cmux style), with a single thin separator line that detours up and around the active tab so it merges with the panel below. Tabs work nested inside another Tabs tab.
Headless tests that drive root scene events manually: - forward_events! gates input events by `active`, forwards context events unconditionally, propagates Consume back to the source, and releases held mouse buttons / keys on deactivation. - Tabs: each tab content scene owns a distinct Events object; keyboard events only reach the active tab.
…h effective_clip Collapses the two helpers into one: `effective_clip(scene)` is the intersection of ancestor viewports *excluding* the scenes own. Backends unconditionally enable scissor at that rectangle, which: - still lets plots near a scenes edge (e.g. axis markers at the spines) extend past the scenes own viewport, since the scissor is the parents bounds rather than the scenes own; - still clips scenes that overflow an ancestor (scrolled containers) at the ancestor edge; - removes the conditional in GLMakies plot draw, so the scissor logic is the same shape on both backends.
The HoverMenu bar and its buttons used hardcoded dark-gray-on-white colors that clashed with the themed widgets. Default its attributes to the `colors` theme block via @inherit (surface bar with a border-colored stroke; buttons that use surface/accent_subtle/accent + text/text_on_accent like every other Button), and add button_color_active/label_color_active/bar_strokecolor plus a :regular font so the toolbar matches the rest of the widgets and the Tabs.
…nts!) Per the preferred design, isolate inactive tabs via the table PR's scene-stacking event router (receives_events, honored by is_mouseinside/addmouseevents!) plus scene visibility, rather than the tabs PR's forward_events! + isolated Events. Verified: with a tab's Subfigure on shared events, scrolling over a *hidden* tab's Axis does not change its limits (no leak), while the active tab's Axis zooms — so visibility + receives_events already keeps hidden subtrees inert. forward_events!/isolated Events are no longer used by Tabs.
Block geometry (computedbbox, slider endpoints) is in absolute window-pixel coordinates and blockscenes use the window-absolute campixel! camera, but the mouse-state machine hit-tested and reported positions with mouseposition_px, which is viewport-relative. For widgets whose blockscene inherits an offset viewport — anything placed inside a Subfigure or Tabs content scene — the two frames never matched, so Toggle/Checkbox/Button/Slider were dead there. Thread a `to_px` function through _addmouseevents!: the bbox variant (used by the widgets) hit-tests and reports `event.px` in absolute window coords, while the scene/elements variant (Axis, Textbox) keeps viewport-relative px so axis interactions and data-space unprojection behave as before. Verified: Toggle/Checkbox/Button click and Slider drag now work inside a Tab; standalone widgets, axis rectangle-zoom and ctrl+click limit reset unchanged.
Modal: a theme-styled dialog replacing the ad-hoc KJgui Popup. A translucent backdrop dims the figure and blocks pointer input to everything underneath while open; the centered body auto-sizes to its content (or scrolls at a fixed width/height — the content area is a Subfigure parented under the overlay so it shares the pointer-cover's subtree). Header with themed title, separator and a Tabs-style close ×; open!/close!/isopen API; optional dismissal by backdrop click; place content via modal[row, col]. Pointer capture: covers_pointer() previously required clear=true + z>0, which a translucent overlay can't satisfy (glClear can't alpha-blend a backdrop). Scenes now opt in explicitly with `scene.captures_mouse = true` — the modal overlay uses this, making it fully modal. Verified end-to-end: widgets inside the modal (Toggle/Slider/Button) work; a Table underneath receives no clicks while the modal is open; backdrop click dismisses; the table is interactive again afterwards.
|
This is so damn cool! |
| # text-input UX where the highlight disappears on click-away. | ||
| visible = plot.focused, | ||
| space = :pixel, transformation = :nothing, inspectable = false, | ||
| space = plot.space, transformation = :nothing, inspectable = false, |
There was a problem hiding this comment.
Does this work with any space other than pixel?
| # end | ||
| scene.visible[] || return false | ||
| in(Vec(scene.events.mouseposition[]), viewport(scene)[]) || return false | ||
| return receives_events(scene) |
There was a problem hiding this comment.
Why would a scene need to receive events for the mouse to be inside it? If there are multiple scenes where only one has focus it seems pretty natural to me to switch between them by clicking on an inactive one with a if is_mouseinside(inactive_scene) check
| # Project absolute window pixels (not viewport-local) to NDC so a coord | ||
| # `(X, Y)` in a campixel scene renders at window pixel `(X, Y)` regardless | ||
| # of where the scene sits in its parent. For a scene whose viewport is at | ||
| # the window origin this is identical to the old `(0..w, 0..h)` projection. | ||
| vx, vy = Float64.(minimum(window_size)) | ||
| w, h = Float64.(widths(window_size)) | ||
| projection = orthographicprojection(0.0, w, 0.0, h, cam.near, cam.far) | ||
| projection = orthographicprojection(vx, vx + w, vy, vy + h, cam.near, cam.far) |
There was a problem hiding this comment.
So with this you wouldn't be plotting to a (0..w, 0..h) range which is fully defined by the local scene, but a (x..x+w, y..y+h) range which is only defined in the context of the whole scene tree? That does not seem like a good change to me. And unnecessarily breaking. I'd rather see a new camera for this if it's needed
There was a problem hiding this comment.
I think it was the other way around, campixel seemed to plot to x,y in window coordinates regardless of scene position, and I thought it should be relative to the scene viewport instead. So as far as I remember I changed campixel to work that way and a fallback for the old behavior was added
There was a problem hiding this comment.
Without this pr, on breaking:
using GLMakie
scene = Scene(camera = campixel!)
left = Scene(scene, viewport = Rect2f(0, 0, 100, 100), camera = campixel!, clear = true, backgroundcolor = :lightblue)
scatter!(left, Rect2f(5, 5, 90, 90), color = :blue)
right = Scene(scene, viewport = Rect2f(300, 300, 100, 100), camera = campixel!, clear = true, backgroundcolor = RGBf(1, 0.8, 0.8))
scatter!(right, Rect2f(5, 5, 90, 90), color = :red)
scene
There was a problem hiding this comment.
maybe I'm misremembering, it was a while ago..
| g.box = Box( | ||
| g.layout[1, 1:3]; | ||
| height = g.height, | ||
| width = g.width, | ||
| color = g.bar_color, | ||
| cornerradius = g.corner_radius, | ||
| strokewidth = 1, | ||
| strokecolor = g.bar_strokecolor | ||
| ) |
There was a problem hiding this comment.
With complex/block recipes this can just be Box(g[1, 1:3], ...). @forwarded_layout is gone now, iirc.
Note that with nested attributes we can now have g.box.color etc. as attributes. That would take priority over g.box as a field in getproperty, iirc, but that shouldn't be a problem if all the relevant attributes are part of the HoverMenu. You can also do
@attributes begin
box = @attributes begin
documented_attributes(Box)...
end
endwithin a @Block. filtered_attributes should work too. You can still get the blocks from g.blocks or the internal layout outside initialize_block!() if you need them.
| for field in CONTEXT_EVENT_FIELDS | ||
| s = getfield(src, field) | ||
| d = getfield(dst, field) | ||
| push!( | ||
| obsfuncs, on(s; priority = priority) do value | ||
| d[] = value | ||
| return Consume(false) | ||
| end | ||
| ) | ||
| end |
There was a problem hiding this comment.
This means that all events of a child/target happen at the same priority in the parent/source. With default priority=0 you'd have an order like this:
30 parent interaction 1
1 parent interaction 2
0.20 child interaction 1
0.-200 child interaction 2
-1 parent interaction 3
Adding a parent interaction with priority 0 could happen before, after or between any of the child interactions.
That's what you naturally get by just forwarding events to another Observable and it never seemed right to me. I always though that priorities should be preserved, so you'd get this order:
30 parent interaction 1
20 child interaction 1
1 parent interaction 2
-1 parent interaction 3
-200 child interaction 2
That's what we currently have by just using the identical events object in every scene. I guess if we want to keep that we'd need a new Observable type that manages a shared pool of listeners alongside a local one?
| "The horizontal alignment of the subfigure in its suggested bounding box." | ||
| halign = :center | ||
| "The vertical alignment of the subfigure in its suggested bounding box." | ||
| valign = :center | ||
| "The alignment of the subfigure in its suggested bounding box." | ||
| alignmode = Inside() |
There was a problem hiding this comment.
These are added automatically (with the same values) by mixin_block_layout_observables. Could remove them.
| "The height setting of the table." | ||
| height = Auto() | ||
| "The width setting of the table." | ||
| width = nothing | ||
| "Controls if the parent layout can adjust to this element's width." | ||
| tellwidth = true | ||
| "Controls if the parent layout can adjust to this element's height." | ||
| tellheight = true | ||
| "The horizontal alignment of the table in its suggested bounding box." | ||
| halign = :center | ||
| "The vertical alignment of the table in its suggested bounding box." | ||
| valign = :center | ||
| "The alignment mode of the table in its parent GridLayout." | ||
| alignmode = Inside() |
There was a problem hiding this comment.
Only width is needed here
| A clipped, optionally event-isolated, scrollable region with its own | ||
| `Scene` paired with a `GridLayout` — the same shape as a `Figure`, scoped | ||
| to a sub-region. Place blocks via `Axis(subfig[1, 1])` etc., or plot | ||
| directly into `content_scene(subfig)` (in viewport-local pixel coords). |
There was a problem hiding this comment.
Subfigure makes me think this is just an area of a figure that acts as figure itself, like the stupidly simple Container block I added. But this also has a bunch of plots and the scroll interactions too... Makes me wonder if this is more than it should be, or if it should be split up into multiple things. I.e. should the plots be something that is added to a Subfigure (e.g. via Label, Box, ...) rather than being part of it directly? And should the scrolling be an add-on? Or perhaps the difference between Container and Subfigure? (which should be named more appropriately then)
| # NB: don't use `union(bb, _bb)` here. GeometryBasics considers any | ||
| # Rect with a zero-width dimension "empty" and returns the other | ||
| # operand, so accumulating the bboxes of axis-aligned segments | ||
| # (whose segment bbox is legitimately zero-width/height, e.g. a | ||
| # rectangle marker) would drop earlier segments and yield a | ||
| # degenerate path bbox. Grow the corners directly instead. |
There was a problem hiding this comment.
Also fixed by JuliaGeometry/GeometryBasics.jl#276 / GeometryBasics 0.5.11
| """ | ||
| covers_pointer(scene::Scene) -> Bool | ||
|
|
||
| True when `scene` is the topmost layer over its viewport: visible, with | ||
| `clear[] == true` (paints its own background) AND a positive world-z. A | ||
| scene with only one signal — a Legend lifted to z=10, or an Axis whose | ||
| theme sets `clear=true` at z=0 — is layered for rendering only and does | ||
| not claim pointer input. | ||
|
|
||
| Scenes that paint a translucent backdrop via plots instead of an opaque | ||
| `clear = true` background (e.g. a modal dialog's overlay) can opt in | ||
| explicitly with `scene.captures_mouse = true`. | ||
| """ | ||
| function covers_pointer(scene::Scene) | ||
| scene.visible[] || return false | ||
| scene.captures_mouse && return true | ||
| return scene.clear[] && z_world(scene) > 0 | ||
| end |
There was a problem hiding this comment.
I don't get this function. The mouse pointer/cursor is always on top of what's drawn, isn't it?
True when
sceneis the topmost layer over its viewport
It doesn't check that?
visible
Whether a scene is visible or not doesn't really matter for event processing? We could forbid it, but I don't see why we would
clear[] == true
If clear worked as expected you could say that you can't interact with a parent scene if it's covered by a child scene that clears everything the parent scene draws. But that's not how clear works atm, at least not in all backends. See #4150, #4724. It also only makes sense when you're talking about interacting with visual elements, which may not always be the case.
positive world-z
This scene having positive z does not guarantee that it covers things from it's parent. The parent can still draw at higher z, or plots in this scene could leave gaps. Negative z in this scene may also still draw over its parent. And in 3D this doesn't work, because z != depth.
Also what about sibling scenes? They could cover this scene too, if that's what this function or more generally the functions in this file are trying to figure out. Note though that the render order of scenes can be inconsistent between backends so any what-covers-what analysis is risky atm. (Again, see #4150, #4724)
interaction_record's Wait is fixed-duration, which can't handle async work whose time varies with recording load (e.g. a GPU analysis). WaitUntil records frames until pred() holds (or timeout), so a demo never advances past a still-running operation onto a half-finished result.
A scene whose viewport hasn't been solved yet carries the empty-Rect2i() sentinel
(typemax/typemin origin+widths). effective_clip's Int64 intersect then overflows to
a garbage-but-finite rect, and round(Int, ppu .* that) for glViewport/glScissor
threw InexactError('Error while rendering!') on the very first frame of a fresh
window. Clamp each pixel extent to a sane GLint range via gl_extent so a transient
pre-layout viewport degrades to a valid rect instead of killing the render loop.
…into sd/breaking-gui
- ParamForm takes a positional `accessory` builder `(field, gridpos) -> block` to add a third widget per field row (e.g. a keyframe toggle); returned blocks are stored in pf.accessories, column width via accessorywidth. - Modal now honors its halign/valign attributes when placing the body instead of always centering, so a dialog can be docked to a side and keep important content (e.g. a video preview) visible.
…dren - text: clamp per_glyph attribute blocks to the current glyph count — growing a Menu's options while open indexed past the stale blocks - subfigure: hide!/show! walk the child blocks, so a hidden dock panel no longer keeps invisible-but-clickable widgets that steal clicks - pin the mono-repo compat versions for the dev setup
|
Super exciting! |
A NESTED subfigure (e.g. a scrollable panel inside a dock-slot subfigure) went permanently blank after hiding and re-showing the parent: the parent's child-visibility walk applies the generic hide!, which force-sets the nested subfigure's content scene invisible — overriding its reactive binding to `sf.visible`. The specialized unhide! then only restored the blockscene and deliberately left the scene to the binding, but nothing ever re-fires that binding (the NESTED subfigure's own `visible` never changed), so the scene stayed invisible and every child unhide! early-returned on the invisible parent. unhide!(::Subfigure) now re-syncs `sf.scene.visible` with `sf.visible` instead of trusting the stale binding: a nested subfigure whose own `visible` is false stays hidden, one that should show gets its scene back BEFORE the parent-first walk reaches its children.
A block's mouse machinery gates on its own `blockscene.visible` and its own bbox — and a scrollable subfigure moves content past its viewport freely, so a widget scrolled out of sight still sat somewhere in the window and swallowed presses meant for whatever is drawn there. Measured in an editor panel: a 1213 px tall dock in a 714 px viewport put five buttons BELOW it, straight over the timeline (a "Loop finder" header at y 116..149, x 90..358), where they ate every click — the timeline never saw the press, and the failure surfaced three interactions later as "the crop went to the wrong clip" and "space stopped toggling playback". Content that does not intersect the viewport is now hidden, the same rule the subfigure already applies when it is hidden as a whole. Partially visible blocks stay live.
A query with no matches emptied the per-option color vectors. The next query that DID match pushed the new strings into the option text plot, which resolves eagerly (the list height listens to its glyph bounding boxes) — before anything had resized the colors. per_glyph_block then indexed data[0] of an empty vector and threw inside the compute graph, which takes the whole GLMakie render loop with it: the window stops responding to anything from then on. Positions, rects and colors are now resized in a handler on optionstrings with priority 1, i.e. ahead of the text plot's own listener, so the order is enforced rather than incidental. per_glyph_block additionally refuses to index an empty attribute vector — an error in there is never worth a dead render loop.
|
Maybe closes #208 |
| my_gen = gen[] | ||
| @async while sp.running[] && gen[] == my_gen | ||
| sp.visible[] && (frame_idx[] = frame_idx[] + 1) | ||
| sleep(sp.frame_interval[]) |
There was a problem hiding this comment.
this kind of stuff is generally much nicer with ticks now, instead of spinning up async tasks. it's also better for generating videos at non-real-time
Rebuilding a Modal's content in place left the rows and columns that deleting blocks leaves behind, so a modal that had held a longer list stayed sized for it. replace_content! deletes the content, trims those tracks, runs the callback and resyncs the content size. max_size caps the auto-sized body so longer content scrolls instead of growing the dialog off the figure.
This merges:
#5510
#3491
#5650
#5628
While it's not amazing to have them all in one big PR, i did want to heavily test and document all of them together, and make sure we really nail the event system and polish some other features we may need for more complex GUIs.
dashboard_demo.mp4