diff --git a/CHANGELOG.md b/CHANGELOG.md index b036499f4c6..5bea37bd51d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +- `Subfigure` now anchors its content to the top-left instead of centring it. A `GridLayout` defaults to `valign = :center`, so content shorter than the scroll region floated in the middle of it; when content overflows the alignment has no effect, so this only changes the short case. +- Added a `Card` block: a titled, foldable container for building lists of settings panels. `open` folds the body away and leaves the header; `visible = false` COLLAPSES the card — unlike `hide!`, which stops a block drawing but leaves its row at full height, so a filtered list showed holes where its hidden entries used to be. A card's bottom spacing is part of the card rather than a gap in the parent layout, so hiding one leaves no double gap; build the stack with `default_rowgap = 0`. `filter_cards!(predicate, stack, cards)` applies a filter in a single relayout (measured over 100 cards, toggling 50: 147 ms one at a time, 14 ms batched, 433 ms to rebuild them instead). Header widgets go in `card_accessory(card)`, and `headerclicks` counts presses on the title bar for list selection. +- Fixed a GLMakie `InexactError` that killed the first render of a fresh window when a scene's viewport had not been laid out yet: the empty-`Rect2i()` sentinel (typemax/typemin) makes `effective_clip`'s Int64 `intersect` overflow to a garbage-but-finite rect, and rounding that to `Int` for `glViewport`/`glScissor` threw. The per-scene scissor code now clamps each pixel extent to a sane `GLint` range (`gl_extent`). +- Added a unified `colors` theme block for Block widgets, plus a `Makie.derive_colors(; accent, gray, background)` helper that produces a full scheme from a few inputs. `set_theme!(colors = Makie.derive_colors(accent = :crimson))` recolors every interactive Block consistently — see the Block colors docs. Default neutral surfaces shift very slightly from the historical literals. [#5628](https://github.com/MakieOrg/Makie.jl/pull/5628) + - The `@inherit` macro inside `@Block` now accepts a tuple of symbols (`@inherit((:colors, :accent), default)`) to walk nested theme dicts. +- Added `Tabs` and `Subfigure` blocks, a tabbed container and the scrollable, event-isolated sub-region it is built on. [#5650](https://github.com/MakieOrg/Makie.jl/pull/5650) +- Added a `Table` block for displaying tabular data, with row/cell selection, column sorting and scrolling. [#5510](https://github.com/MakieOrg/Makie.jl/pull/5510) +- Added pointer-event routing based on visual scene stacking so overlay scenes (menus, dropdowns, popups) no longer leak mouse events to widgets underneath. `receives_events(scene)` returns `false` when a scene in a different subtree visually covers the pointer, and `is_mouseinside(scene)` honors it automatically. [#5510](https://github.com/MakieOrg/Makie.jl/pull/5510) +- Added a high-level figure GUI: a `HoverMenu` toolbar (save / copy / reset) plus opt-in automatic `Legend`/`Colorbar` insertion, controlled via `Figure(; gui, legend, colorbar)` or the theme. Also added convenience constructors `Legend(ax; ...)`, `Colorbar(ax, plot; ...)` and `axiscolorbar`. [#3491](https://github.com/MakieOrg/Makie.jl/pull/3491) +- Fixed a searchable `Menu` dying on a query that matches no option: the empty option list left its per-option color vectors empty, and the next query that did match resolved the option text plot against them and threw a `BoundsError` — inside the compute graph, which takes the render loop down with it. Per-option positions, rects and colors are now resized before the text plot sees the new strings, and `per_glyph_block` no longer indexes into an empty per-string attribute vector. +- Added a `Modal` block: a theme-styled dialog with a translucent backdrop that blocks pointer input to everything underneath while open (`open!`/`close!`, × button, optional backdrop-click dismissal). The content area is a `Subfigure`, so fixed-size modals scroll. Scenes can opt into pointer capture explicitly with `scene.captures_mouse = true`. + ## Breaking - **breaking** Moved `FFMPEG_jll` from a hard dependency to a package extension to avoid pulling in GPL-licensed libraries (e.g. libx264). `record`, `VideoStream`, `convert_video`, and `extract_frames` now require `FFMPEG_jll` to be available in the active environment; Makie will load it automatically on first use. A custom ffmpeg binary can be configured via `Makie.ffmpeg_path!(path)` (or persistently via Preferences.jl). [#5588](https://github.com/MakieOrg/Makie.jl/pull/5588) diff --git a/CairoMakie/Project.toml b/CairoMakie/Project.toml index d29dcdab367..46ecdb3f061 100644 --- a/CairoMakie/Project.toml +++ b/CairoMakie/Project.toml @@ -24,7 +24,7 @@ FileIO = "1.1" FreeType = "3, 4.0" GeometryBasics = "0.5" LinearAlgebra = "1.0, 1.6" -Makie = "=0.25.0" +Makie = "=0.24.13" PrecompileTools = "1.0" julia = "1" diff --git a/CairoMakie/src/plot-primitives.jl b/CairoMakie/src/plot-primitives.jl index 2f9de784816..9b2f4e624ec 100644 --- a/CairoMakie/src/plot-primitives.jl +++ b/CairoMakie/src/plot-primitives.jl @@ -32,7 +32,7 @@ function cairo_draw(screen::Screen, scene::Scene) # only prepare for scene when it changes # this should reduce the number of unnecessary clipping masks etc. pparent = Makie.parent_scene(p)::Scene - pparent.visible[]::Bool || continue + Makie.scene_visible(pparent) || continue if pparent != last_scene Cairo.restore(screen.context) Cairo.save(screen.context) @@ -92,22 +92,27 @@ function prepare_for_scene(screen::Screen, scene::Scene) # get the root area to correct for its size when translating root_area_height = widths(Makie.root(scene))[2] - scene_area = viewport(scene)[] - scene_height = widths(scene_area)[2] - scene_x_origin, scene_y_origin = scene_area.origin - # we need to translate x by the origin, so distance from the left - # but y by the distance from the top, which is not the origin, but can - # be calculated using the parent's height, the scene's height and the y origin - # this is because y goes downwards in Cairo and upwards in Makie + # Clip to the intersection of all ancestor viewports — `effective_clip` + # — so the scene only renders within the bounds its parents share. + # The scene's own viewport is excluded from the intersection, so e.g. + # axis markers near a spine extend past the plot scene into the axis's + # decoration area (which is clipped at the figure / container edge). + clip_area = Makie.effective_clip(scene) + clip_x, clip_y_makie = origin(clip_area) + clip_w, clip_h = widths(clip_area) + clip_top = root_area_height - clip_y_makie - clip_h + Cairo.rectangle(screen.context, clip_x, clip_top, clip_w, clip_h) + Cairo.clip(screen.context) + # Translate so scene-local (0, 0) corresponds to the scene's window origin. + # Cairo's y goes down, Makie's goes up, hence the parent-height correction. + scene_area = viewport(scene)[] + scene_x_origin, scene_y_origin = scene_area.origin + scene_height = widths(scene_area)[2] top_offset = root_area_height - scene_height - scene_y_origin Cairo.translate(screen.context, scene_x_origin, top_offset) - # clip the scene to its viewport - Cairo.rectangle(screen.context, 0, 0, widths(scene_area)...) - Cairo.clip(screen.context) - return end @@ -119,7 +124,7 @@ end function draw_background(screen::Screen, scene::Scene, root_h) cr = screen.context Cairo.save(cr) - if scene.clear[] + if scene.clear[] && Makie.scene_visible(scene) bg = scene.backgroundcolor[] Cairo.set_source_rgba(cr, red(bg), green(bg), blue(bg), alpha(bg)) r = viewport(scene)[] diff --git a/GLMakie/Project.toml b/GLMakie/Project.toml index 383c1cc321d..bde4385fd2a 100644 --- a/GLMakie/Project.toml +++ b/GLMakie/Project.toml @@ -30,7 +30,7 @@ FreeTypeAbstraction = "0.10" GLFW = "3.4.3" GeometryBasics = "0.5" LinearAlgebra = "1.0, 1.6" -Makie = "=0.25.0" +Makie = "=0.24.13" Markdown = "1.0, 1.6" MeshIO = "0.5" ModernGL = "1" diff --git a/GLMakie/src/postprocessing.jl b/GLMakie/src/postprocessing.jl index 7720e7249b1..234da444a31 100644 --- a/GLMakie/src/postprocessing.jl +++ b/GLMakie/src/postprocessing.jl @@ -286,6 +286,15 @@ end on_resize(stage::RenderPlots, w, h) = resize!(stage.framebuffer, w, h) +# glViewport / glScissor take GLint pixel extents. A scene whose viewport hasn't +# been laid out yet carries the empty-`Rect2i()` sentinel (typemax/typemin), which +# makes `effective_clip`'s Int64 `intersect` overflow to a garbage-but-finite rect; +# `round(Int, ppu .* that)` then throws `InexactError` and kills the whole frame +# (seen as "Error while rendering!" on the very first render of a fresh window). +# Clamp each component to a sane pixel range so a transient pre-layout viewport +# degrades to a valid rect instead of crashing the render loop. +@inline gl_extent(x::Real) = isfinite(x) ? round(GLint, clamp(x, -1.0f8, 1.0f8)) : GLint(0) + function run_stage(screen, glscene, stage::RenderPlots) # Somehow errors in here get ignored silently!? try @@ -301,23 +310,31 @@ function run_stage(screen, glscene, stage::RenderPlots) set_draw_buffers(stage.framebuffer) + glEnable(GL_SCISSOR_TEST) for (zindex, screenid, elem) in screen.renderlist elem.visible && haskey(elem.variants, stage.target) || continue found, scene = id2scene(screen, screenid) - (found && scene.visible[]) || continue + (found && Makie.scene_visible(scene)) || continue ppu = screen.px_per_unit[] a = viewport(scene)[] + # Scissor to the intersection of all ancestor viewports (excluding + # the scene's own) so each scene draws only within the bounds its + # parents share; lets markers near a scene edge extend past it + # while still being cut off at the enclosing container / window. + sa = Makie.effective_clip(scene) require_context(screen.glscreen) - glViewport(round.(Int, ppu .* minimum(a))..., round.(Int, ppu .* widths(a))...) + glViewport(gl_extent.(ppu .* minimum(a))..., gl_extent.(ppu .* widths(a))...) + glScissor(gl_extent.(ppu .* minimum(sa))..., gl_extent.(ppu .* widths(sa))...) elem[:px_per_unit] = ppu stage.prerender(elem[:overdraw]::UInt8) render(elem, elem.variants[stage.target]) end + glDisable(GL_SCISSOR_TEST) catch e @error "Error while rendering!" exception = e rethrow(e) diff --git a/GLMakie/src/rendering.jl b/GLMakie/src/rendering.jl index 1f3fb36bdc4..780fc80f1f9 100644 --- a/GLMakie/src/rendering.jl +++ b/GLMakie/src/rendering.jl @@ -12,11 +12,11 @@ function setup!(screen::Screen, fb) glEnable(GL_SCISSOR_TEST) ppu = screen.px_per_unit[] for (id, scene) in screen.screens - if scene.visible[] && scene.clear[] + if Makie.scene_visible(scene) && scene.clear[] a = viewport(scene)[] - rt = (round.(Int, ppu .* minimum(a))..., round.(Int, ppu .* widths(a))...) - glViewport(rt...) - glScissor(rt...) + sa = Makie.effective_clip(scene) + glViewport(round.(Int, ppu .* minimum(a))..., round.(Int, ppu .* widths(a))...) + glScissor(round.(Int, ppu .* minimum(sa))..., round.(Int, ppu .* widths(sa))...) c = scene.backgroundcolor[] glClearColor(red(c), green(c), blue(c), alpha(c)) glClear(GL_COLOR_BUFFER_BIT) diff --git a/Makie/Project.toml b/Makie/Project.toml index 8caee54f7fe..a131e7ecec4 100644 --- a/Makie/Project.toml +++ b/Makie/Project.toml @@ -1,7 +1,7 @@ name = "Makie" uuid = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" authors = ["Simon Danisch", "Julius Krumbiegel"] -version = "0.25.0" +version = "0.24.13" [deps] Animations = "27a7e980-b3e6-11e9-2bcd-0b925532e340" @@ -27,6 +27,7 @@ FreeTypeAbstraction = "663a7486-cb36-511b-a19d-713bb74d65c9" GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" GridLayoutBase = "3955a311-db13-416c-9275-1d80ed98e5e9" ImageBase = "c817782e-172a-44cc-b673-b171935fbb9e" +ImageClipboard = "6db54171-f50f-4661-a74f-bc514ef16cee" ImageIO = "82e4d734-157c-48bb-816b-45c225c6df19" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" @@ -39,6 +40,7 @@ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MathTeXEngine = "0a4f8689-d25c-4efe-a92b-7142dfc1aa53" +NativeFileDialog_jll = "94d9ae2c-efc7-56f8-9a02-54c47b797961" Observables = "510215fc-4207-5dde-b226-833fc4488ee2" OffsetArrays = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" PNGFiles = "f57f5aa1-a3ce-4bc8-8ab9-96f992907883" @@ -95,9 +97,10 @@ FixedPointNumbers = "0.6, 0.7, 0.8" Format = "1.3" FreeType = "3.0, 4.0" FreeTypeAbstraction = "0.10.3" -GeometryBasics = "0.5.9" +GeometryBasics = "0.5.11" GridLayoutBase = "0.11" ImageBase = "0.1.7" +ImageClipboard = "1.0.0" ImageIO = "0.5, 0.6" InteractiveUtils = "1.0, 1.6" Interpolations = "0.14, 0.15.1, 0.16" @@ -110,6 +113,7 @@ LinearAlgebra = "1.0, 1.6" MacroTools = "0.5" Markdown = "1.0, 1.6" MathTeXEngine = "0.5, 0.6" +NativeFileDialog_jll = "1.1.6" Observables = "0.5.5" OffsetArrays = "1" PNGFiles = "0.4.4" diff --git a/Makie/src/GUI/file-dialogue.jl b/Makie/src/GUI/file-dialogue.jl new file mode 100644 index 00000000000..5aaadd9551c --- /dev/null +++ b/Makie/src/GUI/file-dialogue.jl @@ -0,0 +1,35 @@ +using NativeFileDialog_jll + +function choose_file_dialogue(filter = C_NULL) + path = Ref(Ptr{UInt8}()) + r = @ccall libnfd.NFD_OpenDialog( + filter::Ptr{Cchar}, C_NULL::Ptr{Cchar}, + path::Ref{Ptr{UInt8}} + )::Cint + if r == 2 + # User clicked "Cancel" + out = nothing + elseif r == 1 + out = unsafe_string(path[]) + else + error() + end + return out +end + +function save_file_dialogue(filter = C_NULL) + path = Ref(Ptr{UInt8}()) + r = @ccall libnfd.NFD_SaveDialog( + filter::Ptr{Cchar}, C_NULL::Ptr{Cchar}, + path::Ref{Ptr{UInt8}} + )::Cint + if r == 2 + # User clicked "Cancel" + out = nothing + elseif r == 1 + out = unsafe_string(path[]) + else + error() + end + return out +end diff --git a/Makie/src/GUI/gui.jl b/Makie/src/GUI/gui.jl new file mode 100644 index 00000000000..381f3bdb5d1 --- /dev/null +++ b/Makie/src/GUI/gui.jl @@ -0,0 +1,207 @@ +using ImageClipboard + +include("file-dialogue.jl") +include("hovermenu.jl") + +# GUIState is defined in scenes.jl before Figure + +""" + create_gui_legend!(fig::Figure, ax, plot::AbstractPlot, options::Dict{Symbol,Any}) + +Create a legend for the plot if it has labeled elements. + +## Options (in Dict) +- `position`: Position - either Symbol (`:rt`, `:lt`, etc) for overlay or `[row, col]` for grid (default: `:rt`) +- `margin`: Margin around the legend for overlay positioning (default: `(6, 6, 6, 6)`) - only valid with Symbol position +- `unique`: Only show unique labels (default: `false`) +- `merge`: Merge plots with the same label (default: `false`) +- `title`: Legend title (default: `nothing`) +- Any other options are passed to `Legend` + +Returns the Legend object or `nothing` if no labeled plots exist. +""" +function create_gui_legend!(fig::Figure, ax, plot::AbstractPlot, options::Dict{Symbol, Any}) + position = get(options, :position, :rt) + + # Check if there are any labeled plots + unique_labels = get(options, :unique, false) + merge_labels = get(options, :merge, false) + plots, labels = Makie.get_labeled_plots(ax; unique = unique_labels, merge = merge_labels) + isempty(plots) && return nothing + + # Validate margin usage + if haskey(options, :margin) && !(position isa Symbol) + error("`margin` is only valid for overlay positioning (Symbol like :rt). For grid positions, use padding on the layout instead.") + end + + # Use Legend(ax; ...) for overlay, Legend(fig[pos...], ...) for grid + if position isa Symbol + return Legend(ax; options...) + else + legend_options = filter(kv -> kv.first != :position, options) + title = get(options, :title, nothing) + return Legend(fig[position...], plots, labels, title; legend_options...) + end +end + +""" + create_gui_colorbar!(fig::Figure, ax, plot::AbstractPlot, options::Dict{Symbol,Any}) + +Create a colorbar for the plot if it has a colormap. + +## Options (in Dict) +- `position`: Position - either Symbol (`:rt`, `:lt`, etc) for overlay or `[row, col]` for grid (default: `[1, 2]`) +- `margin`: Margin around the colorbar for overlay positioning (default: `(6, 6, 6, 6)`) - only valid with Symbol position +- Any other options are passed to `Colorbar` + +Returns the Colorbar object or `nothing` if no colormap exists. +""" +function create_gui_colorbar!(fig::Figure, ax, plot::AbstractPlot, options::Dict{Symbol, Any}) + position = get(options, :position, [1, 2]) + options = filter(((name, _),) -> name != :position, options) + + # Check if plot has a colormap + cmap = nothing + try + cmap = Makie.extract_colormap_recursive(plot) + catch + return nothing + end + isnothing(cmap) && return nothing + # Validate margin usage + if haskey(options, :margin) && !(position isa Symbol) + error("`margin` is only valid for overlay positioning (Symbol like :rt). For grid positions, use padding on the layout instead.") + end + # Use Colorbar(ax, plot; position=...) for overlay, Colorbar(fig[pos...], plot; ...) for grid + if position isa Symbol + return Colorbar(ax, plot; position = position, options...) + else + return Colorbar(fig[position...], plot; options...) + end +end + +""" + add_gui!(faxpl::FigureAxisPlot) -> FigureAxisPlot + add_gui!(fig::Figure, ax, plot::Union{AbstractPlot, Nothing}) -> nothing + +Add GUI elements (hover bar, legend, colorbar) to a figure. +When `plot` is `nothing`, only the hover bar is added (legend and colorbar require a plot). +The GUI is only added once per figure - subsequent calls are ignored. + +Options are read from: +1. Figure attributes: `Figure(; gui=true, legend=(...), colorbar=(...))` +2. Plot call: `scatter(...; figure=(; gui=true, legend=(...)))` +3. Theme: `set_theme!(Figure=(; gui=true, legend=(...)))` + +## Arguments +- `faxpl`: A `FigureAxisPlot` returned from a plotting function +- Or: `fig::Figure`, `ax` (axis), `plot::AbstractPlot` + +## Figure/Theme Options +- `gui`: Enable hover menu. Can be `true`, `false`, or a NamedTuple with: + - `style`: NamedTuple with hover bar styling options +- `legend`: Legend configuration passed to `Legend`. Common options: + - `position`: Position symbol (`:rt`, `:lt`, etc.) or grid position `[row, col]` + - `margin`, `title`, `unique`, `merge` + - Set to `false` to disable +- `colorbar`: Colorbar configuration passed to `Colorbar`. Common options: + - `position`: Position symbol (`:rt`, `:lt`, etc.) or grid position `[row, col]` + - `label`, `vertical`, `margin` + - Set to `false` to disable + +## Returns +- For `FigureAxisPlot`: Returns the same `FigureAxisPlot` +- For other inputs: Returns `nothing` + +## Examples + +```julia +# Via figure keyword +f, ax, pl = scatter(rand(10), color=1:10, label="Points"; + figure=(; gui=true, legend=(position=:lt,))) + +# Via Figure constructor +f = Figure(; gui=true, legend=(position=:rt,)) +ax = Axis(f[1,1]) +scatter!(ax, rand(10), label="Data") + +# Via theme (GUI added automatically on display) +set_theme!(Figure=(; gui=true)) +scatter(rand(10), label="Auto GUI") + +# Theme with legend/colorbar options +set_theme!(Figure=(; legend=(position=:lt,), colorbar=(position=:rt,))) +``` +""" +function add_gui!(faxpl::FigureAxisPlot) + f, ax, plot = faxpl + add_gui!(f, ax, plot) + return faxpl +end + +function add_gui!(fig::Figure) + ax = current_axis(fig) + plot = (isnothing(ax) || isempty(ax.scene.plots)) ? nothing : first(ax.scene.plots) + add_gui!(fig, ax, plot) + return fig +end + +function add_gui!(fig::Figure, ax::Union{AbstractAxis, Nothing}, plot::Union{AbstractPlot, Nothing}) + gui_state = fig.gui_state + + # Add HoverMenu block if enabled and not already added + if !isnothing(gui_state.hovermenu_options) && isnothing(gui_state.hovermenu) + # Create HoverMenu block with user options + opts = copy(gui_state.hovermenu_options) + opts[:target_axis] = ax + gui_state.hovermenu = HoverMenu(fig[:, :]; opts...) + end + + # Add legend if enabled, not already added, and we have a plot + if !isnothing(gui_state.legend_options) && isnothing(gui_state.legend) && !isnothing(plot) && ax !== nothing + gui_state.legend = create_gui_legend!(fig, ax, plot, gui_state.legend_options) + end + + # Add colorbar if enabled, not already added, and we have a plot + if !isnothing(gui_state.colorbar_options) && isnothing(gui_state.colorbar) && !isnothing(plot) && ax !== nothing + gui_state.colorbar = create_gui_colorbar!(fig, ax, plot, gui_state.colorbar_options) + end + + return nothing +end + +# Convenience method for tuple input +function add_gui!(fig_ax_plot::Tuple{Figure, Any, AbstractPlot}) + f, ax, plot = fig_ax_plot + add_gui!(f, ax, plot) + return fig_ax_plot +end + +""" + remove_gui!(fig::Figure) + +Remove all GUI elements from a figure. +""" +function remove_gui!(fig::Figure) + gui_state = fig.gui_state + + # Delete HoverMenu block if it exists + if !isnothing(gui_state.hovermenu) + delete!(gui_state.hovermenu) + gui_state.hovermenu = nothing + end + + # Delete legend if it exists + if !isnothing(gui_state.legend) + delete!(gui_state.legend) + gui_state.legend = nothing + end + + # Delete colorbar if it exists + if !isnothing(gui_state.colorbar) + delete!(gui_state.colorbar) + gui_state.colorbar = nothing + end + + return nothing +end diff --git a/Makie/src/GUI/hovermenu.jl b/Makie/src/GUI/hovermenu.jl new file mode 100644 index 00000000000..7b13ed601da --- /dev/null +++ b/Makie/src/GUI/hovermenu.jl @@ -0,0 +1,103 @@ +# HoverMenu block type is defined in makielayout/types.jl + +function free(g::HoverMenu) + !isnothing(g.box) && delete!(g.box) + !isnothing(g.save_button) && delete!(g.save_button) + !isnothing(g.copy_button) && delete!(g.copy_button) + !isnothing(g.reset_button) && delete!(g.reset_button) + return +end + +function initialize_block!(g::HoverMenu) + fig = g.parent + if !(fig isa Figure) + # TODO, when does this actually happen? + error("HoverMenu block must be a child of a Figure.") + end + visible = Observable(false) + + # Background box spanning the three buttons. `g[1, 1:3]` indexes the + # block's forwarded layout directly. + g.box = Box( + g[1, 1:3]; + height = g.height, + width = g.width, + color = g.bar_color, + cornerradius = g.corner_radius, + strokewidth = 1, + strokecolor = g.bar_strokecolor + ) + + # Button styling from block attributes (themed like every other Button, + # so the bar is consistent with the rest of the widgets by default). + bstyle = ( + buttoncolor = g.button_color, + buttoncolor_hover = g.button_color_hover, + buttoncolor_active = g.button_color_active, + labelcolor = g.label_color, + labelcolor_hover = g.label_color, + labelcolor_active = g.label_color_active, + font = g.font, + fontsize = g.fontsize, + cornerradius = 4, + ) + + g.save_button = Button(g[1, 1]; label = "Save", width = 60, bstyle...) + g.copy_button = Button(g[1, 2]; label = "Copy", width = 60, bstyle...) + g.reset_button = Button(g[1, 3]; label = "Reset", width = 60, bstyle...) + + colgap!(g.layout, 8) + + # Visibility toggle + on(g.blockscene, visible; update = true) do v + g.box.blockscene.visible[] = v + g.save_button.blockscene.visible[] = v + g.copy_button.blockscene.visible[] = v + g.reset_button.blockscene.visible[] = v + end + + # Reset button + on(g.blockscene, g.reset_button.clicks) do _ + visible[] = false + ax = g.target_axis[] + if !isnothing(ax) + reset_limits!(ax) + end + end + + # Save button + on(g.blockscene, g.save_button.clicks) do _ + visible[] = false + if !isnothing(fig) + file = save_file_dialogue() + if !isnothing(file) + save(file, fig; update = false) + end + end + end + + # Copy button + on(g.blockscene, g.copy_button.clicks) do _ + visible[] = false + if !isnothing(fig) + img = colorbuffer(fig; update = false) + ImageClipboard.clipboard_img(img) + end + end + + # Mouse hover detection - show bar when mouse is near top + if !isnothing(fig) + on(g.blockscene, fig.scene.events.mouseposition) do mp + vp = fig.scene.viewport[] + hover_height = 60 + rect = Rect2f(0, widths(vp)[2] - hover_height, widths(vp)[1], hover_height) + if mp in rect + visible[] = true + else + visible[] = false + end + end + end + + return +end diff --git a/Makie/src/Makie.jl b/Makie/src/Makie.jl index e2aa7f94b8f..103a70056f8 100644 --- a/Makie/src/Makie.jl +++ b/Makie/src/Makie.jl @@ -221,6 +221,7 @@ include("shorthands.jl") # camera types + functions include("camera/projection_math.jl") include("camera/camera.jl") +include("event_routing.jl") include("camera/camera2d.jl") include("camera/camera3d.jl") include("camera/old_camera3d.jl") @@ -483,6 +484,7 @@ export to_world # picking + interactive use cases + events export mouseover, onpick, pick, Events, Keyboard, Mouse, is_mouseinside export ispressed, Exclusively +export content_scene, add_tab!, remove_tab!, set_tab! export connect_screen export window_area, window_open, mouse_buttons, mouse_position, mouseposition_px, scroll, keyboard_buttons, unicode_input, dropped_files, hasfocus, entered_window @@ -602,6 +604,7 @@ include("basic_recipes/pathtext.jl") include("basic_recipes/raincloud.jl") include("deprecated.jl") +include("GUI/gui.jl") export Heatmap, Image, Lines, LineSegments, Mesh, MeshScatter, Poly, Scatter, Surface, Text, Volume, Wireframe, Voxels export heatmap, image, lines, linesegments, mesh, meshscatter, poly, scatter, surface, text, volume, wireframe, voxels diff --git a/Makie/src/basic_recipes/editabletext.jl b/Makie/src/basic_recipes/editabletext.jl index 2a130f57757..2630e47b0bf 100644 --- a/Makie/src/basic_recipes/editabletext.jl +++ b/Makie/src/basic_recipes/editabletext.jl @@ -601,7 +601,7 @@ function plot!(plot::EditableText) # Hide selections when the editor isn't focused — matches typical # 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, ) # The selection rects need to render *behind* the text. Plot order in # `plot.plots` is rendering order, and the text plot was created first @@ -656,7 +656,7 @@ function plot!(plot::EditableText) plot, cursor_segments_obs; color = plot.cursor_color, linewidth = plot.cursor_width, visible = cursor_visible_obs, - space = :pixel, transformation = :nothing, inspectable = false, + space = plot.space, transformation = :nothing, inspectable = false, ) # ── Event handling ─────────────────────────────────────────────────────── @@ -716,7 +716,7 @@ function attach_editabletext_events!( event.button == Mouse.left || return Consume(false) if event.action == Mouse.press - mpos = mouseposition_px(parent) + mpos = Point2f(events(parent).mouseposition[]) if plot.manage_focus[] # Auto-focus on clicks within the text bbox; auto-defocus # on clicks outside. @@ -787,8 +787,8 @@ function attach_editabletext_events!( anchor = drag_anchor[] anchor === nothing && return Consume(false) Mouse.left in events(parent).mousebuttonstate || return Consume(false) - mpos = mouseposition_px(parent) - head = _mouse_to_offset(Point2f(mpos)) + mpos = Point2f(events(parent).mouseposition[]) + head = _mouse_to_offset(mpos) cursors = plot.cursors[] # Only rewrite the last cursor (the one created by the press) so prior # multi-cursors stay intact. Skip if its head hasn't moved — mouseposition diff --git a/Makie/src/basic_recipes/hvlines.jl b/Makie/src/basic_recipes/hvlines.jl index 66db48a1828..045656c00a6 100644 --- a/Makie/src/basic_recipes/hvlines.jl +++ b/Makie/src/basic_recipes/hvlines.jl @@ -80,11 +80,13 @@ function Makie.plot!(p::Union{HLines, VLines}) end function data_limits(p::HLines) + isempty(p[1][]) && return Rect3d(Point3d(NaN, NaN, 0), Vec3d(0, 0, 0)) ymin, ymax = extrema(p[1][]) return Rect3d(Point3d(NaN, ymin, 0), Vec3d(NaN, ymax - ymin, 0)) end function data_limits(p::VLines) + isempty(p[1][]) && return Rect3d(Point3d(NaN, NaN, 0), Vec3d(0, 0, 0)) xmin, xmax = extrema(p[1][]) return Rect3d(Point3d(xmin, NaN, 0), Vec3d(xmax - xmin, NaN, 0)) end diff --git a/Makie/src/basic_recipes/text.jl b/Makie/src/basic_recipes/text.jl index 88438100ed2..3627d1a5acb 100644 --- a/Makie/src/basic_recipes/text.jl +++ b/Makie/src/basic_recipes/text.jl @@ -176,14 +176,27 @@ end ##################################### # New stuff +# a stand-in for a per-string attribute that has no value to sample yet +blockfallback(::Type{T}) where {T} = zero(T) +blockfallback(::Type{<:Quaternion}) = Quaternionf(0, 0, 0, 1) + function per_glyph_block(data, block_idx, N_blocks, block::UnitRange) block_length = length(block) if isscalar(data) return fill(data, block_length) elseif length(data) == N_blocks return fill(data[block_idx], block_length) - else + elseif checkbounds(Bool, data, block) return view(data, block) + else + # Transiently inconsistent update: the text grew before its per-string + # attribute vector did (updating a Menu's options resolves the text plot + # eagerly mid-cascade, before the recolor lands). Clamp instead of + # erroring — the consistent state resolves right after and re-renders. + # An error here is thrown inside the compute graph and takes the whole + # render loop down with it, so an empty vector must not throw either. + isempty(data) && return fill(blockfallback(eltype(data)), block_length) + return fill(data[min(block_idx, length(data))], block_length) end end diff --git a/Makie/src/camera/camera.jl b/Makie/src/camera/camera.jl index eda419e9c39..ea86817ec88 100644 --- a/Makie/src/camera/camera.jl +++ b/Makie/src/camera/camera.jl @@ -119,11 +119,9 @@ Returns true if the current mouseposition is inside the given scene. """ is_mouseinside(x) = is_mouseinside(get_scene(x)) function is_mouseinside(scene::Scene) - return scene.visible[] && in(Vec(scene.events.mouseposition[]), viewport(scene)[]) - # Check that mouse is not inside any other screen - # for child in scene.children - # is_mouseinside(child) && return false - # end + scene.visible[] || return false + in(Vec(scene.events.mouseposition[]), viewport(scene)[]) || return false + return receives_events(scene) end diff --git a/Makie/src/camera/camera2d.jl b/Makie/src/camera/camera2d.jl index b5512be50d3..4476b580e1c 100644 --- a/Makie/src/camera/camera2d.jl +++ b/Makie/src/camera/camera2d.jl @@ -326,26 +326,43 @@ struct UpdatePixelCam camera::Camera near::Float64 far::Float64 + absolute::Bool end function (cam::UpdatePixelCam)(window_size) w, h = Float64.(widths(window_size)) - projection = orthographicprojection(0.0, w, 0.0, h, cam.near, cam.far) + # `absolute = false` (default): project the viewport-local range + # `(0..w, 0..h)`, so plot coordinates are relative to the scene's own + # viewport wherever it sits in the window. + # `absolute = true`: project absolute window pixels `(vx..vx+w, vy..vy+h)`, + # so a coordinate `(X, Y)` renders at window pixel `(X, Y)`. For a viewport + # at the window origin the two are identical. + vx, vy = cam.absolute ? Float64.(minimum(window_size)) : (0.0, 0.0) + projection = orthographicprojection(vx, vx + w, vy, vy + h, cam.near, cam.far) return set_proj_view!(cam.camera, projection, Mat4d(I)) end """ - campixel!(scene; nearclip=-1000.0, farclip=1000.0) + campixel!(scene; nearclip=-10_000.0, farclip=10_000.0, absolute=false) -Creates a pixel camera for the given `scene`. This means that the positional -data of a plot will be interpreted in pixel units. This camera does not feature -controls. +Pixel camera: positional data of a plot is interpreted in pixel units. This +camera has no controls. + +With `absolute = false` (the default) coordinates are viewport-local — a plot at +`(x, y)` is drawn `x` pixels from the left and `y` pixels from the bottom of the +scene's *own* viewport, wherever that viewport sits in the window. + +With `absolute = true` coordinates are window-absolute — a plot at `(x, y)` is +drawn at window pixel `(x, y)` regardless of the scene's viewport offset. This is +what Makie's Block scenes use, since their content is positioned in the +window-absolute coordinates of the layout `computedbbox`. For a viewport at the +window origin the two modes are identical. """ -function campixel!(scene::Scene; nearclip = -10_000.0, farclip = 10_000.0) +function campixel!(scene::Scene; nearclip = -10_000.0, farclip = 10_000.0, absolute::Bool = false) disconnect!(camera(scene)) camera(scene).view_direction[] = Vec3f(0, 0, -1) update_once = Observable(false) - closure = UpdatePixelCam(camera(scene), nearclip, farclip) + closure = UpdatePixelCam(camera(scene), nearclip, farclip, absolute) on(closure, camera(scene), viewport(scene)) cam = PixelCamera() # update once @@ -355,6 +372,7 @@ function campixel!(scene::Scene; nearclip = -10_000.0, farclip = 10_000.0) return cam end + struct RelativeCamera <: AbstractCamera end get_space(::RelativeCamera) = :relative diff --git a/Makie/src/event_routing.jl b/Makie/src/event_routing.jl new file mode 100644 index 00000000000..44ef456dc4c --- /dev/null +++ b/Makie/src/event_routing.jl @@ -0,0 +1,83 @@ +""" + covers_pointer(scene::Scene) -> Bool + +Whether `scene` claims the pointer, blocking input to the scenes it overlaps. + +A scene claims the pointer only by opting in explicitly with +`scene.captures_mouse = true` (e.g. a modal dialog's translucent overlay). +Coverage is deliberately *not* inferred from rendering properties like +`clear` or world-z: those are unreliable across backends (`clear` behaves +differently per backend) and meaningless in 3D (z ≠ depth), so a scene that +merely paints on top does not silently swallow input. +""" +function covers_pointer(scene::Scene) + return scene.visible[] && scene.captures_mouse +end + +"World z = accumulated z-translation along the ancestor chain." +function z_world(scene::Scene) + z = translation(scene)[][3] + p = parent(scene) + return p === nothing ? z : z + z_world(p) +end + +function depth_in_tree(scene::Scene) + d = 0 + s = scene + while !isroot(s) + s = parent(s) + d += 1 + end + return d +end + +"Ordering for `find_topmost_cover`: higher world-z, then deeper subtree." +function wins_over(a::Scene, b::Scene) + za, zb = z_world(a), z_world(b) + za != zb && return za > zb + return depth_in_tree(a) > depth_in_tree(b) +end + +""" + find_topmost_cover(scene::Scene, mp) -> Union{Nothing, Scene} + +Deepest visible covering scene in `scene`'s subtree whose viewport contains +the mouse position `mp`, or `nothing` if none exists. +""" +function find_topmost_cover(scene::Scene, mp)::Union{Nothing, Scene} + scene.visible[] || return nothing + (Vec(mp) in scene.viewport[]) || return nothing + best::Union{Nothing, Scene} = covers_pointer(scene) ? scene : nothing + for child in scene.children + cand = find_topmost_cover(child, mp) + cand === nothing && continue + if best === nothing || wins_over(cand, best) + best = cand + end + end + return best +end + +function is_ancestor_or_equal(ancestor::Scene, scene::Scene) + s::Union{Nothing, Scene} = scene + while s !== nothing + s === ancestor && return true + s = parent(s) + end + return false +end + +""" + receives_events(scene::Scene) -> Bool + +Whether pointer-event handlers attached to `scene` should fire right now. +Returns `true` when `scene` is visible AND either no scene currently covers +the mouse, or the covering scene shares a root-to-leaf path with `scene`. +""" +function receives_events(scene::Scene) + scene.visible[] || return false + active = find_topmost_cover(root(scene), scene.events.mouseposition[]) + active === nothing && return true + return is_ancestor_or_equal(active, scene) || + is_ancestor_or_equal(scene, active) +end diff --git a/Makie/src/figureplotting.jl b/Makie/src/figureplotting.jl index afc7078f561..99bfa44d2e1 100644 --- a/Makie/src/figureplotting.jl +++ b/Makie/src/figureplotting.jl @@ -403,13 +403,31 @@ figurelike_return(ax::AbstractAxis, plot::AbstractPlot) = AxisPlot(ax, plot) figurelike_return!(::AbstractAxis, plot::AbstractPlot) = plot figurelike_return!(::Union{Plot, Scene}, plot::AbstractPlot) = plot -update_state_before_display!(f::FigureAxisPlot) = update_state_before_display!(f.figure) +function update_state_before_display!(f::FigureAxisPlot) + update_state_before_display!(f.figure) + # Apply GUI from theme if enabled (add_gui! handles the theme check internally) + add_gui!(f) + return +end update_state_before_display!(f::FigureBlock) = update_state_before_display!(f.figure) function update_state_before_display!(f::Figure) for c in f.content update_state_before_display!(c) end + add_gui!(f) + return +end + +# Recurse into nested layouts so blocks placed inside another block's +# `GridLayout` — e.g. an `Axis` inside a `Tabs` tab — also get their +# pre-display state updates (auto limits etc.). The default fallback for +# non-axis content stays a no-op, so this only adds work for things that +# care. +function update_state_before_display!(layout::GridLayout) + for gc in layout.content + update_state_before_display!(gc.content) + end return end diff --git a/Makie/src/figures.jl b/Makie/src/figures.jl index 174ce1b94c2..f69c92fff9a 100644 --- a/Makie/src/figures.jl +++ b/Makie/src/figures.jl @@ -107,19 +107,89 @@ to_rectsides(n::Number) = to_rectsides((n, n, n, n)) to_rectsides(t::Tuple{Any, Any, Any, Any}) = GridLayoutBase.RectSides{Float32}(t...) """ - Figure(; [figure_padding,] kwargs...) + Figure(; kwargs...) Construct a `Figure` which allows to place `Block`s like [`Axis`](@ref), [`Colorbar`](@ref) and [`Legend`](@ref) inside. -The outer padding of the figure (the distance of the content to the edges) can be set by passing either -one number or a tuple of four numbers for left, right, bottom and top paddings via the `figure_padding` keyword. -All other keyword arguments such as `size` and `backgroundcolor` are forwarded to the -[`Scene`](@ref) owned by the figure which acts as the container for all other visual objects. +## Keyword Arguments + +- `size`: Figure size as `(width, height)` tuple (default: `(800, 600)`) +- `figure_padding`: Padding around the figure content. Either a single number or tuple `(left, right, bottom, top)` +- `backgroundcolor`: Background color of the figure + +### Automatic Legend and Colorbar + +- `legend`: Automatic legend from labeled plots. `true`, `false`, or `NamedTuple` with options like `(position=:lt, title="Legend")`. + Position can be a Symbol (`:lt`, `:rt`, `:lb`, `:rb`) for overlay or grid position like `[1, 2]`. +- `colorbar`: Automatic colorbar from colormapped plots. `true`, `false`, or `NamedTuple` with options like `(position=[1,2], label="Values")`. + +### Hover Bar + +- `gui`: Enable hover bar with save/copy/reset buttons. `true`, `false`, or `NamedTuple` with style options. + +## Examples + +```julia +f = Figure(size=(800, 600)) +f = Figure(; legend=(position=:lt,)) +f = Figure(; colorbar=(position=[1, 2], label="Values")) +f = Figure(; gui=true) +``` """ -function Figure(; kwargs...) +function Figure end + +""" + normalize_gui_option(value, name::Symbol) -> Union{Nothing, Dict{Symbol,Any}} - kwargs_dict = Dict(kwargs) +Normalize GUI options (gui/hovermenu, legend, colorbar) to a consistent format. +- `false` or `nothing` returns `nothing` (disabled) +- `true` returns empty Dict (enabled with defaults) +- NamedTuple/Attributes/Dict are converted to Dict{Symbol,Any} +""" +function normalize_gui_option(value, name::Symbol) + if value === false || isnothing(value) + return nothing + elseif value === true + return Dict{Symbol, Any}() + elseif value isa NamedTuple + return Dict{Symbol, Any}(pairs(value)) + elseif value isa Attributes + return Dict{Symbol, Any}(k => to_value(v) for (k, v) in value) + elseif value isa Dict + return Dict{Symbol, Any}(value) + else + error("Invalid `$name` option: expected Bool, NamedTuple, Attributes, or Dict, got $(typeof(value))") + end +end + +""" + get_gui_options(kwargs_dict, figure_theme, name::Symbol) -> Union{Nothing, Dict{Symbol,Any}} + +Extract and normalize a GUI option from kwargs (with fallback to Figure theme). +Pops the option from kwargs_dict if present. +""" +function get_gui_options(kwargs_dict::Dict{Symbol, Any}, figure_theme::Attributes, name::Symbol) + kwarg_opt = pop!(kwargs_dict, name, nothing) + theme_opt = haskey(figure_theme, name) ? to_value(figure_theme[name]) : nothing + opt = !isnothing(kwarg_opt) ? kwarg_opt : theme_opt + return isnothing(opt) ? nothing : normalize_gui_option(opt, name) +end + +function Figure(; kwargs...) + kwargs_dict = Dict{Symbol, Any}(kwargs) padding = pop!(kwargs_dict, :figure_padding, theme(:figure_padding)) + + # Check Figure theme for GUI options (set_theme!(Figure=(; gui=true, ...))) + figure_theme = theme(:Figure; default = Attributes())::Attributes + + # Extract and normalize GUI options: kwargs > Figure theme + hovermenu_options = get_gui_options(kwargs_dict, figure_theme, :gui) + legend_options = get_gui_options(kwargs_dict, figure_theme, :legend) + colorbar_options = get_gui_options(kwargs_dict, figure_theme, :colorbar) + + # Create GUIState with normalized options + gui_state = GUIState(; hovermenu_options, legend_options, colorbar_options) + scene = Scene(; camera = campixel!, clear = true, kwargs_dict...) padding = convert(Observable{Any}, padding) alignmode = lift(Outside ∘ to_rectsides, padding) @@ -137,8 +207,10 @@ function Figure(; kwargs...) layout, [], Attributes(), - Ref{Any}(nothing) + Ref{Any}(nothing), + gui_state ) + current_figure!(f) # set figure as layout parent so GridPositions can refer to the figure # if connected correctly layout.parent = f diff --git a/Makie/src/makielayout/MakieLayout.jl b/Makie/src/makielayout/MakieLayout.jl index 003b4720e94..4130ab7a81a 100644 --- a/Makie/src/makielayout/MakieLayout.jl +++ b/Makie/src/makielayout/MakieLayout.jl @@ -4,8 +4,6 @@ using GridLayoutBase using GridLayoutBase: GridSubposition const FPS = Observable(30) -const COLOR_ACCENT = Ref(RGBf(((79, 122, 214) ./ 255)...)) -const COLOR_ACCENT_DIMMED = Ref(RGBf(((174, 192, 230) ./ 255)...)) include("blocks.jl") include("geometrybasics_extension.jl") @@ -20,6 +18,7 @@ include("blocks/axis3d.jl") include("blocks/polaraxis.jl") include("blocks/colorbar.jl") include("blocks/label.jl") +include("blocks/spinner.jl") include("blocks/slider.jl") include("blocks/slidergrid.jl") include("blocks/intervalslider.jl") @@ -30,11 +29,21 @@ include("blocks/toggle.jl") include("blocks/legend.jl") include("blocks/scene.jl") include("blocks/menu.jl") +include("blocks/table.jl") include("blocks/textbox.jl") include("blocks/container.jl") +include("blocks/subfigure.jl") +include("blocks/card.jl") +include("blocks/tabs.jl") +include("blocks/modal.jl") +include("blocks/paramform.jl") +# HoverMenu type is defined in types.jl; its implementation is in GUI/hovermenu.jl export @Block, Block -export axislegend +export axislegend, axiscolorbar +export open!, close!, replace_content!, refresh_contentsize! +export card_accessory, filter_cards! +export Between, OneOf, FilePath, convert_form_input export LegendEntry, MarkerElement, PolyElement, LineElement, LegendElement export linkxaxes!, linkyaxes!, linkaxes! export AxisAspect, DataAspect diff --git a/Makie/src/makielayout/blocks.jl b/Makie/src/makielayout/blocks.jl index d62b384dcb7..d90594c2763 100644 --- a/Makie/src/makielayout/blocks.jl +++ b/Makie/src/makielayout/blocks.jl @@ -593,7 +593,12 @@ function _block(T::Type{<:Block}, fig_or_scene::Union{Figure, Scene}, args, kwdi # create base block with otherwise undefined fields b = T(fig_or_scene, lobservables, graph) - b.blockscene = Scene(topscene, clear = false, camera = campixel!) + # Block content is positioned in the window-absolute coordinates of the + # layout `computedbbox`, so the blockscene uses `campixel!(; absolute = true)` + # (identical to the default at the window origin, but correct when the block + # lives inside an offset scene such as a `Subfigure`/`Tabs` content area). + b.blockscene = Scene(topscene, clear = false) + campixel!(b.blockscene; absolute = true) if has_forwarded_layout(T) init_layout!(b) @@ -916,6 +921,12 @@ function Base.delete!(block::Block) end function unhide!(block::Block) + # Only unhide if the parent scene is visible — otherwise we'd + # override the parent → child visibility cascade and leave the + # blockscene rendering inside a hidden parent (e.g. a popup that + # hasn't been opened yet). + pv = parent(block.blockscene) + pv === nothing || pv.visible[] || return if !block.blockscene.visible[] block.blockscene.visible[] = true end diff --git a/Makie/src/makielayout/blocks/button.jl b/Makie/src/makielayout/blocks/button.jl index 7da708b61da..108745ea0d9 100644 --- a/Makie/src/makielayout/blocks/button.jl +++ b/Makie/src/makielayout/blocks/button.jl @@ -7,11 +7,13 @@ function initialize_block!(b::Button) subarea = lift(scene, b.layoutobservables.computedbbox) do bbox round_to_IRect2D(bbox) end - subscene = Scene(scene, subarea, camera = campixel!) + subscene = Scene(scene, subarea) + campixel!(subscene; absolute = true) - # buttonrect is without the left bottom offset of the bbox + # the subscene camera is in absolute window coords, so the button background + # just echoes the computed bbox. buttonrect = lift(scene, b.layoutobservables.computedbbox) do bbox - BBox(0, width(bbox), 0, height(bbox)) + Rect2f(bbox) end on(scene, buttonrect) do rect diff --git a/Makie/src/makielayout/blocks/card.jl b/Makie/src/makielayout/blocks/card.jl new file mode 100644 index 00000000000..40b185d2727 --- /dev/null +++ b/Makie/src/makielayout/blocks/card.jl @@ -0,0 +1,408 @@ +""" +Everything the card draws, in the order the layers stack: the card body's +rounded rect, the header bar on top of it, then the selection outline. Kept +together so `initialize_block!` reads as geometry-then-behaviour. +""" +struct CardVisuals + card::Observable{Vector{Point2f}} + header::Observable{Vector{Point2f}} + outline::Observable{Vector{Point2f}} +end + +""" +Recursively `hide!`/`unhide!` every block placed inside `gl`. + +Blocks that CONTAIN blocks (`ParamForm`, `Container`, another `Card`) keep them +in a layout of their own, and every one of those has its own blockscene — so +hiding the container's scene leaves its children drawing. A hidden card whose +sliders kept painting over the card below it is what this recursion is for. + +A nested `Card` is left to its own `hide!`, which knows whether that card is +folded or filtered out and must not be overridden from outside. +""" +function set_content_visible!(gl::GridLayout, visible::Bool) + for gc in gl.content + c = gc.content + if c isa GridLayout + set_content_visible!(c, visible) + elseif c isa Card + visible ? unhide!(c) : hide!(c) + elseif c isa Block + visible ? unhide!(c) : hide!(c) + inner = getfield(c, :layout) + inner isa GridLayout && set_content_visible!(inner, visible) + end + end + return +end + +""" + Card(fig_or_scene; title = "Card", kwargs...) + +A titled, foldable container: a header bar with a fold arrow, a title and an +accessory cell, over a body you fill like any layout — `Slider(card[1, 1]; +...)`, `card[2, 1] = GridLayout()`, and so on. Put widgets in the header with +[`card_accessory`](@ref). + +Two independent pieces of state, and the difference between them is the point +of this block: + + * `open` folds the BODY away. The header stays, so the card is still there to + click on. This is disclosure. + * `visible` takes the whole card out. Unlike `hide!`, which only stops a block + from drawing and leaves its row sitting there at full height, a card with + `visible = false` reports zero height to its parent layout, so the cards + below it move up and close the hole. This is filtering. + +The spacing BELOW a card is part of the card (`spacing`), not a gap in the +parent layout — so a hidden card takes its spacing with it and a filtered list +has no double gaps. Build the stack with `default_rowgap = 0` and let the cards +space themselves — `rowgap!(stack, 0)` only sets the gaps that exist when it is +called, so a card added later comes back with the default gap above it and one +hidden card's hole reopens. + +```julia +fig = Figure() +stack = GridLayout(fig[1, 1]; valign = :top, default_rowgap = 0) +cards = [Card(stack[i, 1]; title = "Effect \$i") for i in 1:5] +Slider(cards[1][1, 1]; range = 0:0.01:1) +cards[3].visible = false # rows 4 and 5 move up +cards[2].open = false # header stays, body folds away +``` + +`headerclicks` counts presses on the header (after the fold arrow has had its +chance), which is what a list selects on. +""" +@Block Card begin + # The card sizes to its content: header + (body when open) + spacing, all + # forwarded to the parent layout through the block's own layout. + @forwarded_layout + scene::Scene # the whole card's scene — see `initialize_block!` + bodyscene::Scene # nested under it, visible only while unfolded + body::GridLayout # what `card[i, j]` indexes + header::GridLayout # the header's accessory cell + headerclicks::Observable{Int} # presses on the header bar, for selection + userheight::Base.RefValue{Any} # the `height` the user asked for, kept across a collapse + @attributes begin + "The card's title, drawn in the header bar." + title = "Card" + "Whether the body is unfolded. Folding leaves the header in place." + open = true + "Whether the card is shown AT ALL. `false` collapses it: no height, no spacing, no events." + visible = true + "Whether clicking the header folds and unfolds the card." + foldable = true + "Draw the card as selected — tinted header and an accent outline." + selected = false + "Background color of the card body." + backgroundcolor = RGBf(0.16, 0.16, 0.18) + "Background color of the header bar." + headercolor = RGBf(0.22, 0.22, 0.25) + "Background color of the header bar while selected." + headercolor_selected = RGBf(0.26, 0.30, 0.40) + "Color of the card's border." + strokecolor = RGBf(0.30, 0.30, 0.34) + "Width of the card's border." + strokewidth = 1 + "Color of the outline drawn when `selected`." + selectioncolor = RGBf(0.40, 0.62, 1.00) + "Width of the selection outline." + selectionwidth = 2 + "Corner radius of the card and its header." + cornerradius = 6 + "Height of the header bar in pixels." + headerheight = 26 + "Color of the title text." + titlecolor = RGBf(0.92, 0.92, 0.94) + "Font of the title text." + titlefont = :bold + "Size of the title text." + titlesize = 13 + "Left inset of the fold arrow and title, in pixels." + titleoffset = 9 + "Color of the fold arrow." + arrowcolor = RGBf(0.72, 0.72, 0.76) + "Padding inside the body, as a number or a (left, right, bottom, top) tuple." + bodypadding = (10, 10, 10, 8) + "Vertical space below the card. Part of the card, so hiding it removes the space too." + spacing = 8 + "Controls if the parent layout can adjust to this element's width." + tellwidth = false + "Controls if the parent layout can adjust to this element's height." + tellheight = true + "The width setting of the card." + width = nothing + "The height setting of the card. `Auto()` sizes it to its content; a + card that reported `nothing` here would tell its parent layout nothing + at all, and the stack would have no height to give it." + height = Auto() + end +end + +""" + filter_cards!(predicate, stack::GridLayout, cards) + +Set each card's `visible` to `predicate(card)`, relayouting `stack` ONCE +instead of once per card. This is how a filter box over a card list should +run: cards whose state does not change are not touched at all. + +Measured on a stack of 100 cards, toggling 50: 147 ms one at a time, 14 ms +through here — and 433 ms to throw the cards away and build them again, which +is what makes rebuilding on every keystroke the wrong shape. + +```julia +filter_cards!(stack, cards) do card + occursin(query[], lowercase(card.title[])) +end +``` +""" +function filter_cards!(predicate, stack::GridLayout, cards) + GridLayoutBase.with_updates_suspended(stack) do + for c in cards + want = predicate(c)::Bool + c.visible[] == want || (c.visible = want) + end + end + return +end + +""" + card_accessory(card) -> GridPosition + +Where a header widget goes: `Button(card_accessory(card); label = "×")`. The +accessory cell is right-aligned in the header bar and grows to the left, so a +row of them stays clear of the title. +""" +card_accessory(c::Card) = c.header[1, 1] + +function initialize_block!(c::Card) + blockscene = c.blockscene + + # `@forwarded_layout` had `_block` create this and connect its autosize to + # the block's, so what the card reports upward is what its content measures. + layout = c.layout + c.body = GridLayout(layout[2, 1]) + c.headerclicks = Observable(0) + c.userheight = Base.RefValue{Any}(c.height[]) + + is_visible = lift(identity, blockscene, c.visible) + + # The header is a Fixed row so the bar has the same height whether or not + # anything is in its accessory cell. The card's bottom margin is padding on + # the card's own layout rather than a gap in the parent's — that is what + # makes the spacing travel with the card when it is hidden. + rowsize!(layout, 1, Fixed(c.headerheight[])) + on(blockscene, c.headerheight) do h + rowsize!(layout, 1, Fixed(h)) + return + end + on(blockscene, c.spacing; update = true) do s + layout.alignmode[] = Outside(0.0f0, 0.0f0, Float32(s), 0.0f0) + GridLayoutBase.update!(layout) + return + end + rowgap!(layout, 0) + colsize!(layout, 1, Relative(1.0)) + + # THE BODY GETS ITS OWN SCENE, for the same reason `Subfigure` has one: the + # blocks a caller puts in `card[i, j]` are separate blocks with separate + # scenes, and a container that only hides ITS scene leaves them drawing. + # Parenting them here makes the card's state win — `unhide!` returns early on + # a block whose parent scene is invisible, so a `Subfigure` culling its + # scrolled-out content cannot un-hide a card that is folded or filtered out. + # (That bug looked like four cards' sliders painted on top of the one card + # the filter had left.) + contentarea = lift(blockscene, c.layoutobservables.computedbbox, c.spacing) do bb, sp + s = min(Float32(sp), bb.widths[2]) + return round_to_IRect2D(Rect2f(Point2f(bb.origin[1], bb.origin[2] + s), + Vec2f(bb.widths[1], bb.widths[2] - s))) + end + c.scene = Scene(blockscene; camera = campixel!, viewport = contentarea, + visible = is_visible, clear = false) + # A SECOND scene for the body, nested in the first: hiding the card hides + # both, and FOLDING hides only this one. Without the nesting, folding had the + # same defect hiding did — the body's widgets kept drawing over the card + # below, because the cull walk found them under a visible parent. + bodyarea = lift(blockscene, contentarea, c.headerheight) do ca, hh + h = max(Float32(ca.widths[2]) - Float32(hh), 0.0f0) + return round_to_IRect2D(Rect2f(Point2f(ca.origin), Vec2f(ca.widths[1], h))) + end + c.bodyscene = Scene(c.scene; camera = campixel!, viewport = bodyarea, + visible = lift(identity, blockscene, c.open), clear = false) + c.body.parent = c.bodyscene + + # The header's own grid: [ arrow | title | accessory ]. The arrow and title + # are drawn as text (they are decoration, and a Label here would fight the + # header's fixed height), so the layout only has to hold the accessory cell + # out of the title's way. + headergl = GridLayout(layout[1, 1]) + Box(headergl[1, 1]; color = (:transparent, 0.0), strokewidth = 0, width = Auto(), height = 1, tellheight = false) + # inset from the rounded corner, so an accessory button is not flush with it + c.header = GridLayout(headergl[1, 2]; halign = :right, valign = :center, + alignmode = Outside(0.0f0, 6.0f0, 0.0f0, 0.0f0)) + c.header.parent = c.scene + colsize!(headergl, 1, Auto(true, 1.0f0)) + colgap!(headergl, 0) + + # ---------------------------------------------------------------- geometry + # The card's rect is its bbox minus the spacing row at the bottom: the + # spacing is layout, not paint. + cardrect = lift(blockscene, c.layoutobservables.computedbbox, c.spacing) do bb, sp + s = min(Float32(sp), bb.widths[2]) + # y grows upward, so the spacing below the card is at the bottom of the + # bbox and the paint starts above it. + return Rect2f(Point2f(bb.origin[1], bb.origin[2] + s), Vec2f(bb.widths[1], bb.widths[2] - s)) + end + headerrect = lift(blockscene, cardrect, c.headerheight) do r, hh + h = min(Float32(hh), r.widths[2]) + return Rect2f(Point2f(r.origin[1], r.origin[2] + r.widths[2] - h), Vec2f(r.widths[1], h)) + end + + cardpoly = lift(blockscene, cardrect, c.cornerradius) do r, cr + return roundedrectvertices(r, min(Float32(cr), min(r.widths...) / 2), 12) + end + # The header shares the card's top corners and is SQUARE at the bottom, so + # it meets the body without a seam — which `roundedrectvertices` cannot do, + # its corners being all or nothing. + headerpoly = lift(blockscene, headerrect, c.cornerradius) do r, cr + rr = min(Float32(cr), min(r.widths...) / 2) + x0, y0 = Float32.(r.origin) + x1, y1 = x0 + Float32(r.widths[1]), y0 + Float32(r.widths[2]) + pts = [Point2f(x0, y0)] + for t in LinRange(Float32(pi), Float32(pi / 2), 12) # top-left arc + push!(pts, Point2f(x0 + rr + rr * cos(t), y1 - rr + rr * sin(t))) + end + for t in LinRange(Float32(pi / 2), 0.0f0, 12) # top-right arc + push!(pts, Point2f(x1 - rr + rr * cos(t), y1 - rr + rr * sin(t))) + end + push!(pts, Point2f(x1, y0)) + return pts + end + + poly!(blockscene, cardpoly; color = c.backgroundcolor, strokecolor = c.strokecolor, + strokewidth = c.strokewidth, visible = is_visible, inspectable = false) + headerfill = lift(blockscene, c.selected, c.headercolor, c.headercolor_selected) do sel, plain, chosen + return to_color(sel ? chosen : plain) + end + poly!(blockscene, headerpoly; color = headerfill, strokewidth = 0, + visible = is_visible, inspectable = false) + # The selection outline is drawn last so it sits over both fills. + poly!(blockscene, cardpoly; color = (:transparent, 0.0), strokecolor = c.selectioncolor, + strokewidth = lift((s, w) -> s ? Float32(w) : 0.0f0, blockscene, c.selected, c.selectionwidth), + visible = is_visible, inspectable = false) + + arrowpos = lift(blockscene, headerrect, c.titleoffset) do r, off + return Point2f(r.origin[1] + off, r.origin[2] + r.widths[2] / 2) + end + arrowtext = lift(o -> o ? "▾" : "▸", blockscene, c.open) + arrowvis = lift(&, blockscene, is_visible, c.foldable) + text!(blockscene, arrowpos; text = arrowtext, align = (:left, :center), + color = c.arrowcolor, fontsize = c.titlesize, visible = arrowvis, inspectable = false) + + titlepos = lift(blockscene, headerrect, c.titleoffset, c.foldable) do r, off, fold + return Point2f(r.origin[1] + off + (fold ? 15 : 0), r.origin[2] + r.widths[2] / 2) + end + text!(blockscene, titlepos; text = c.title, align = (:left, :center), color = c.titlecolor, + font = c.titlefont, fontsize = c.titlesize, visible = is_visible, inspectable = false) + + # ---------------------------------------------------------------- folding + on(blockscene, c.bodypadding; update = true) do pad + sides = pad isa Number ? (pad, pad, pad, pad) : pad + c.body.alignmode[] = Outside(to_rectsides(sides)) + GridLayoutBase.update!(c.body) + return + end + + function apply_open!(isopen::Bool) + # A folded body is BOTH inert (its blocks stop drawing and stop taking + # clicks) and zero-height, so the card shrinks to its header. + set_content_visible!(c.body, isopen && c.visible[]) + c.body.height[] = isopen ? Auto(true, 1.0f0) : Fixed(0) + return + end + on(blockscene, c.open) do isopen + apply_open!(isopen) + return + end + + # ------------------------------------------------------------- collapsing + # `visible = false` is not `hide!`: the card reports zero height, which is + # what makes a filtered list close up instead of showing gaps. The user's + # own `height` is remembered so restoring does not clobber it. + function apply_visible!(vis::Bool) + set_content_visible!(c.body, vis && c.open[]) + set_content_visible!(c.header, vis) + if vis + c.height = c.userheight[] + else + c.userheight[] = c.height[] + c.height = 0 + end + return + end + on(blockscene, c.visible) do vis + apply_visible!(vis) + return + end + + # ------------------------------------------------------------ interaction + on(blockscene, blockscene.events.mousebutton; priority = 55) do ev + (ev.button === Mouse.left && ev.action === Mouse.press) || return Consume(false) + c.visible[] || return Consume(false) + receives_events(blockscene) || return Consume(false) + pos = Point2f(blockscene.events.mouseposition[]) + pos in headerrect[] || return Consume(false) + # The accessory cell belongs to whatever the user put there — a press + # over it is that widget's, not the card's. An EMPTY layout reports the + # default 0..100 box, which would swallow presses on the whole header. + if !isempty(c.header.content) + pos in c.header.layoutobservables.computedbbox[] && return Consume(false) + end + c.headerclicks[] = c.headerclicks[] + 1 + c.foldable[] && (c.open = !c.open[]) + return Consume(true) + end + + apply_open!(c.open[]) + c.visible[] || apply_visible!(false) + return +end + +# `card[i, j]` is the BODY — the header has its own accessory cell, reached +# through `card_accessory`. +function Base.getindex(c::Card, i::Union{Integer, Colon, AbstractRange}, + j::Union{Integer, Colon, AbstractRange}, side = GridLayoutBase.Inner()) + return c.body[i, j, side] +end +Base.firstindex(c::Card, dim) = firstindex(c.body, dim) +Base.lastindex(c::Card, dim) = lastindex(c.body, dim) + +# `hide!` is the SCROLL-CULLING path (a Subfigure hides content that scrolled +# out of view) and it must not change the layout — a card that collapsed +# because it scrolled away would change the very content size that decides +# what is scrolled away. Collapsing is `visible`, and only `visible`. +function hide!(c::Card) + c.blockscene.visible[] && (c.blockscene.visible[] = false) + # `_block` hides every block once before `initialize_block!` runs, so the + # sub-layouts are not there yet on that first call. + isdefined(c, :body) || return + set_content_visible!(c.body, false) + set_content_visible!(c.header, false) + return +end + +function unhide!(c::Card) + pv = parent(c.blockscene) + pv === nothing || pv.visible[] || return + c.blockscene.visible[] || (c.blockscene.visible[] = true) + isdefined(c, :body) || return + # Re-sync with the bound state rather than forcing `true`: a card that is + # filtered out stays gone when it scrolls back into view. + set_content_visible!(c.body, c.visible[] && c.open[]) + set_content_visible!(c.header, c.visible[]) + return +end + +function update_state_before_display!(c::Card) + return update_state_before_display!(c.layout) +end diff --git a/Makie/src/makielayout/blocks/colorbar.jl b/Makie/src/makielayout/blocks/colorbar.jl index a0883279f64..f25a3b7cd8f 100644 --- a/Makie/src/makielayout/blocks/colorbar.jl +++ b/Makie/src/makielayout/blocks/colorbar.jl @@ -196,14 +196,18 @@ end function initialize_block!(cb::Colorbar) blockscene = cb.blockscene - map!(cb, [:size, :vertical], :autosize) do sz, vertical - return vertical ? (sz, nothing) : (nothing, sz) + # `margin` (l, r, b, t) insets the drawable colorbar from its layout cell, + # so it also has to be added to the space the block requests. + map!(cb, [:size, :vertical, :margin], :autosize) do sz, vertical, margin + return vertical ? (sz + sum(margin[1:2]), nothing) : (nothing, sz + sum(margin[3:4])) end ComputePipeline.set_type!(cb.autosize, Any) map!(identity, blockscene, cb.layoutobservables.autosize, cb.autosize) add_input!(cb, :computedbbox, cb.layoutobservables.computedbbox) - map!(round_to_IRect2D, cb, :computedbbox, :framebox) + map!(cb, [:computedbbox, :margin], :framebox) do bbox, margin + return round_to_IRect2D(enlarge(bbox, -margin[1], -margin[2], -margin[3], -margin[4])) + end # Run the normal color(map) processing. This either uses the inputs given # to `Colorbar()` explicitly, or the inputs extracted from a plot. @@ -488,3 +492,100 @@ function scaled_steps(steps, scale, lims) # then rescale to 0 to 1 return @. (steps_lim_scaled - steps_lim_scaled[begin]) / (steps_lim_scaled[end] - steps_lim_scaled[begin]) end + +""" + Colorbar(ax::Axis, plot::AbstractPlot; position = :rt, kwargs...) + Colorbar(ax::Axis; position = :rt, kwargs...) + +Create a colorbar positioned inside an Axis's plot area. + +This is a convenience constructor that automatically extracts the colormap from the plot +and positions the colorbar using the `position` argument. + +## Arguments +- `ax`: The axis to place the colorbar in +- `plot`: The plot to extract colormap from (defaults to first plot in axis) + +## Keyword Arguments +- `position`: Position symbol (`:rt`, `:lt`, `:rb`, `:lb`, `:ct`, `:cb`, `:lc`, `:rc`, `:cc`) + or tuple `(halign, valign)`. Default: `:rt` +- `margin`: Margin around the colorbar. Default: `(6, 50, 6, 6)` for `(left, right, bottom, top)` to leave space for tick labels +- All other keyword arguments are passed to `Colorbar` + +## Examples +```julia +fig, ax, pl = heatmap(rand(10, 10)) +Colorbar(ax, pl) # Creates colorbar at default position :rt + +Colorbar(ax, pl; position=:lt, label="Temperature") +``` +""" +function Colorbar( + ax::AbstractAxis, plot::AbstractPlot; + position = :rt, margin = (6, 50, 6, 6), kwargs... + ) + pos_kw = legend_position_to_aligns(position) + # Extract colormap from the plot + cmap = extract_colormap_recursive(plot) + func = plotfunc(plot) + if isnothing(cmap) + error("Neither $(func) nor any of its children use a colormap. Cannot create a Colorbar from this plot, please create it manually.") + end + if !(cmap isa ColorMapping) + error("extract_colormap(::$(Plot{func})) returned an invalid value: $cmap. Needs to return a `Makie.ColorMapping`.") + end + return Colorbar( + ax.parent; + colormap = cmap, + bbox = ax.scene.viewport, + margin = margin, + pos_kw..., + kwargs... + ) +end + +# Version that uses the first plot in the axis +function Colorbar(ax::AbstractAxis; kwargs...) + plots = ax.scene.plots + isempty(plots) && error("No plots in axis to extract colormap from") + return Colorbar(ax, first(plots); kwargs...) +end + +# convenience constructor for axis colorbar (analogous to axislegend) +axiscolorbar(ax = current_axis(); kwargs...) = Colorbar(ax; kwargs...) + +axiscolorbar(ax, plot::AbstractPlot; kwargs...) = Colorbar(ax, plot; kwargs...) + +""" + axiscolorbar(ax, plot::AbstractPlot; position = :rt, kwargs...) + axiscolorbar(ax = current_axis(); kwargs...) + +Create a colorbar that sits inside an Axis's plot area. + +The position can be a Symbol where the first letter controls the horizontal +alignment and can be l, r or c, and the second letter controls the vertical +alignment and can be t, b or c. Or it can be a tuple where the first +element is set as the Colorbar's halign and the second element as its valign. + +## Arguments +- `ax`: The axis to place the colorbar in +- `plot`: The plot to extract colormap from (defaults to first plot in axis) + +## Keyword Arguments +- `position`: Position symbol (`:rt`, `:lt`, etc.) or tuple `(halign, valign)`. Default: `:rt` +- `margin`: Margin around the colorbar. Default: `(6, 50, 6, 6)` for `(left, right, bottom, top)` to leave space for tick labels +- All other keyword arguments are passed to `Colorbar` + +Note: This is equivalent to `Colorbar(ax, plot; position, kwargs...)`. + +## Examples +```julia +fig, ax, pl = heatmap(rand(10, 10)) +axiscolorbar(ax, pl, position = :rt) + +# Or with the current axis +heatmap!(rand(10, 10)) +axiscolorbar(position = :lt) +``` +""" +axiscolorbar diff --git a/Makie/src/makielayout/blocks/legend.jl b/Makie/src/makielayout/blocks/legend.jl index 935fd3616d9..5671cc10f9e 100644 --- a/Makie/src/makielayout/blocks/legend.jl +++ b/Makie/src/makielayout/blocks/legend.jl @@ -84,7 +84,8 @@ function initialize_block!(leg::Legend; entrygroups) legend_area = lift(round_to_IRect2D, blockscene, leg.layoutobservables.computedbbox) - scene = Scene(blockscene, blockscene.viewport, camera = campixel!) + scene = Scene(blockscene, blockscene.viewport) + campixel!(scene; absolute = true) leg.scene = scene # the rectangle in which the legend is drawn when margins are removed legendrect = lift(blockscene, legend_area, leg.margin, ignore_equal_values = true) do la, lm @@ -397,6 +398,7 @@ function initialize_block!(leg::Legend; entrygroups) # Process hide/show events sevents = events(blockscene) on(scene, sevents.mousebutton, priority = 1) do event + Makie.receives_events(blockscene) || return Consume(false) mpos = sevents.mouseposition[] if (event.action == Mouse.release) && in(mpos, legend_area[]) if event.button == Mouse.left @@ -1110,11 +1112,58 @@ function get_plots(scene::Scene) return plots end +""" + Legend(ax::Axis; position = :rt, kwargs...) + Legend(ax::Axis, title; position = :rt, kwargs...) + +Create a legend positioned inside an Axis's plot area. + +This is a convenience constructor that automatically extracts labeled plots from the axis +and positions the legend using the `position` argument. + +## Arguments +- `ax`: The axis to place the legend in and extract plots from +- `title`: Optional title for the legend + +## Keyword Arguments +- `position`: Position symbol (`:rt`, `:lt`, `:rb`, `:lb`, `:ct`, `:cb`, `:lc`, `:rc`, `:cc`) + or tuple `(halign, valign)`. Default: `:rt` +- `margin`: Margin around the legend. Default: `(6, 6, 6, 6)` for `(left, right, bottom, top)` +- `merge`: If `true`, merge plots with the same label. Default: `false` +- `unique`: If `true`, only show unique label/plot-type combinations. Default: `false` +- All other keyword arguments are passed to `Legend` + +## Examples +```julia +fig, ax, pl = scatter(rand(10), label="Points") +Legend(ax) # Creates legend at default position :rt + +lines!(ax, rand(10), label="Line") +Legend(ax; position=:lt, title="My Legend") +``` +""" +function Legend( + ax::Union{Axis, Axis3}, _title = nothing; + position = :rt, margin = (6, 6, 6, 6), + merge = false, unique = false, title = _title, kwargs... + ) + plots, labels = get_labeled_plots(ax, merge = merge, unique = unique) + isempty(plots) && error("There are no plots with labels in the given axis that can be put in the legend. Supply labels to plotting functions like `plot(args...; label = \"My label\")`") + pos_kw = legend_position_to_aligns(position) + return Legend( + ax.parent, plots, labels, title; + bbox = ax.scene.viewport, + margin = margin, + pos_kw..., + kwargs... + ) +end + # convenience constructor for axis legend -axislegend(ax = current_axis(); kwargs...) = axislegend(ax, ax; kwargs...) +axislegend(ax = current_axis(); kwargs...) = Legend(ax; kwargs...) -axislegend(title::AbstractString; kwargs...) = axislegend(current_axis(), current_axis(), title; kwargs...) -axislegend(ax, title::AbstractString; kwargs...) = axislegend(ax, ax, title; kwargs...) +axislegend(title::AbstractString; kwargs...) = Legend(current_axis(), title; kwargs...) +axislegend(ax, title::AbstractString; kwargs...) = Legend(ax, title; kwargs...) """ axislegend(ax, args...; position = :rt, kwargs...) @@ -1135,16 +1184,14 @@ same labels are treated. If merge is true, all plot objects with the same label will be layered on top of each other into one legend entry. If unique is true, all plot objects with the same plot type and label will be reduced to one occurrence. + +Note: This is equivalent to `Legend(ax; position, kwargs...)`. """ -function axislegend(ax, args...; position = :rt, kwargs...) +function axislegend(ax, args...; position = :rt, margin = (6, 6, 6, 6), kwargs...) return Legend( ax.parent, args...; bbox = ax.scene.viewport, - margin = get( - kwargs, - :margin, - get(something(theme(:Legend), NamedTuple()), :margin, (6, 6, 6, 6)) - ), + margin = margin, legend_position_to_aligns(position)..., kwargs... ) diff --git a/Makie/src/makielayout/blocks/menu.jl b/Makie/src/makielayout/blocks/menu.jl index 585b46c51bb..735993e12de 100644 --- a/Makie/src/makielayout/blocks/menu.jl +++ b/Makie/src/makielayout/blocks/menu.jl @@ -1,20 +1,31 @@ -function _update_option_colors!(hovered, optionstrings, optionpolycolors, m) +# `hovered` is an index into the VISIBLE (filtered) options; `m.i_selected` always +# references the ORIGINAL options, so the selection highlight maps through +# `filtered_indices` (search support, API as in MakieOrg/Makie.jl#5642). +function _update_option_colors!(hovered, optionstrings, optionpolycolors, optiontextcolors, m, + filtered_indices) n = length(optionstrings[]) resize!(optionpolycolors.val, n) - map!(optionpolycolors.val, 1:n) do idx - if idx == m.i_selected[] - return m.cell_color_active[] + resize!(optiontextcolors.val, n) + base_textcolor = to_color(m.textcolor[]) + active_textcolor = to_color(m.textcolor_active[]) + for idx in 1:n + global_idx = idx <= length(filtered_indices[]) ? filtered_indices[][idx] : idx + if global_idx == m.i_selected[] + optionpolycolors.val[idx] = m.cell_color_active[] + optiontextcolors.val[idx] = active_textcolor elseif idx == hovered - return m.cell_color_hover[] + optionpolycolors.val[idx] = m.cell_color_hover[] + optiontextcolors.val[idx] = base_textcolor else - if iseven(idx) - to_color(m.cell_color_inactive_even[]) - else + optionpolycolors.val[idx] = iseven(idx) ? + to_color(m.cell_color_inactive_even[]) : to_color(m.cell_color_inactive_odd[]) - end + optiontextcolors.val[idx] = base_textcolor end end - return notify(optionpolycolors) + notify(optionpolycolors) + notify(optiontextcolors) + return end function _pick_entry(y, menuscene, list_y_bounds) @@ -90,7 +101,8 @@ function initialize_block!(m::Menu; default = 1) end end - menuscene = Scene(blockscene, scenearea, camera = campixel!, clear = true, visible = m.is_open) + menuscene = Scene(blockscene, scenearea, clear = true, visible = m.is_open) + campixel!(menuscene; absolute = true) translate!(menuscene, 0, 0, 200) onany(blockscene, scenearea, listheight) do area, listheight @@ -100,13 +112,66 @@ function initialize_block!(m::Menu; default = 1) translate!(menuscene, t[1], new_y, t[3]) end - optionstrings = lift(o -> optionlabel.(o), blockscene, m.options; ignore_equal_values = true) + # search support (API of MakieOrg/Makie.jl#5642): typing while the dropdown + # is open filters the VISIBLE options; `i_selected`/`selection` always refer + # to the original options via `filtered_indices`. + is_searchable = m.searchable[] + search_text = Observable(""; ignore_equal_values = true) + optionstrings_all = lift(o -> optionlabel.(o), blockscene, m.options; ignore_equal_values = true) + filtered_indices = lift(blockscene, optionstrings_all, search_text, m.filter; + ignore_equal_values = true) do strings, query, filter_fn + isempty(query) ? collect(eachindex(strings)) : + [i for (i, s) in enumerate(strings) if filter_fn(query, s)] + end + optionstrings = lift(blockscene, optionstrings_all, filtered_indices) do strings, idx + strings[idx] + end - selected_text = lift(blockscene, m.prompt, m.i_selected; ignore_equal_values = true) do prompt, i_selected - if i_selected == 0 + selected_text = lift(blockscene, m.prompt, m.i_selected, search_text, m.is_open; + ignore_equal_values = true) do prompt, i_selected, query, open + if open && is_searchable + isempty(query) ? m.search_placeholder[] : query * "▏" + elseif i_selected == 0 prompt else - optionstrings[][i_selected] + optionstrings_all[][i_selected] + end + end + + if is_searchable + # typing goes into the query while the dropdown is open; keys are consumed + # so application shortcuts (single-letter editor keys!) don't fire mid-search + on(blockscene, blockscene.events.unicode_input) do chars + (m.is_open[] && Makie.receives_events(blockscene)) || return Consume(false) + s = chars isa AbstractVector ? String(collect(chars)) : string(chars) + isempty(s) && return Consume(false) + search_text[] = search_text[] * s + return Consume(true) + end + on(blockscene, blockscene.events.keyboardbutton; priority = 10) do ev + (m.is_open[] && Makie.receives_events(blockscene)) || return Consume(false) + ev.action in (Keyboard.press, Keyboard.repeat) || return Consume(false) + if ev.key == Keyboard.backspace + isempty(search_text[]) || (search_text[] = String(chop(search_text[]))) + return Consume(true) + elseif ev.key == Keyboard.enter + idx = filtered_indices[] + isempty(idx) || (m.i_selected[] = first(idx)) + m.is_open[] = false + return Consume(true) + elseif ev.key == Keyboard.escape + m.is_open[] = false + return Consume(true) + end + # swallow plain typing keys (they arrive as unicode_input); modified + # chords (Ctrl+…) stay application shortcuts + mods = blockscene.events.keyboardstate + ctrl = Keyboard.left_control in mods || Keyboard.right_control in mods + return Consume(!ctrl) + end + on(blockscene, m.is_open) do open + open || (search_text[] = "") + return end end @@ -143,19 +208,40 @@ function initialize_block!(m::Menu; default = 1) textpositions = Observable(zeros(Point2f, length(optionstrings[])); ignore_equal_values = true) - # band-aid fix for resizing before display - on(optionstrings) do strings + optionrects = Observable([Rect2d(0, 0, 0, 0)]; ignore_equal_values = true) + optionpolycolors = Observable(RGBAf[RGBAf(0.5, 0.5, 0.5, 1)]; ignore_equal_values = true) + optiontextcolors = Observable(fill(to_color(m.textcolor[]), length(optionstrings[])); ignore_equal_values = true) + + # The option list is one text plot with one entry per option, so positions + # and colors are per-string vectors that MUST have the length of the new + # strings by the time the text plot sees them: it resolves eagerly (the list + # height below listens to its glyph boundingboxes), long before the geometry + # handler gets to resize anything. A filter that first matches nothing and + # then matches again used to leave an EMPTY color vector behind and the text + # plot indexed out of bounds — which killed the render loop. The priority + # puts this ahead of the plot's own listener. + on(blockscene, optionstrings; priority = 1) do strings N = length(strings) if N != length(textpositions[]) + old = length(textpositions[]) resize!(textpositions[], N) + for i in (old + 1):N + textpositions[][i] = Point2f(0) # placed by the geometry handler + end notify(textpositions) end + if N != length(optionrects[]) || N != length(optiontextcolors[]) + old = length(optionrects[]) + resize!(optionrects.val, N) + for i in (old + 1):N + optionrects.val[i] = Rect2d(0, 0, 0, 0) + end + _update_option_colors!(0, optionstrings, optionpolycolors, optiontextcolors, m, filtered_indices) + notify(optionrects) + end return end - optionrects = Observable([Rect2d(0, 0, 0, 0)]; ignore_equal_values = true) - optionpolycolors = Observable(RGBAf[RGBAf(0.5, 0.5, 0.5, 1)]; ignore_equal_values = true) - # the y boundaries of the list rectangles list_y_bounds = Ref(Float32[]) @@ -163,7 +249,7 @@ function initialize_block!(m::Menu; default = 1) optiontexts = text!( menuscene, textpositions, text = optionstrings, align = (:left, :center), - fontsize = m.fontsize, inspectable = false + fontsize = m.fontsize, color = optiontextcolors, inspectable = false ) # listheight needs to be up to date before showing the menuscene so that its @@ -179,22 +265,22 @@ function initialize_block!(m::Menu; default = 1) # No need to update when the scene is hidden widths(bbox) == Vec2i(0) && return - pad = m.textpadding[] # gc_heights triggers on padding, so we don't need to react to it - # listheight[] = h - + pad = m.textpadding[] + # campixel is absolute, so anchor the list at the menuscene viewport + # origin. `list_y_bounds` are likewise in absolute window y. + ox, oy = Float32(left(bbox)), Float32(bottom(bbox)) heights_cumsum = [zero(eltype(heights)); cumsum(heights)] - list_y_bounds[] = h .- heights_cumsum + list_y_bounds[] = oy .+ (h .- heights_cumsum) texts_y = @views h .- 0.5 .* (heights_cumsum[1:(end - 1)] .+ heights_cumsum[2:end]) - textpositions[] = Point2f.(pad[1], texts_y) + textpositions[] = Point2f.(ox + pad[1], oy .+ texts_y) w_bbox = width(bbox) - # need to manipulate the vectors themselves, otherwise update errors when lengths change resize!(optionrects.val, length(heights)) optionrects.val .= map(eachindex(heights)) do i - BBox(0, w_bbox, h - heights_cumsum[i + 1], h - heights_cumsum[i]) + BBox(ox, ox + w_bbox, oy + h - heights_cumsum[i + 1], oy + h - heights_cumsum[i]) end - _update_option_colors!(0, optionstrings, optionpolycolors, m) + _update_option_colors!(0, optionstrings, optionpolycolors, optiontextcolors, m, filtered_indices) notify(optionrects) return end @@ -215,24 +301,37 @@ function initialize_block!(m::Menu; default = 1) was_pressed_button = Ref(false) onany(blockscene, e.mouseposition, e.mousebutton; priority = 64) do position, butt - mp = screen_relative(menuscene, position) - # track if we have been inside menu/options to clean up if we haven't been + # Inert when hidden or when another scene covers the pointer. + Makie.receives_events(blockscene) || return Consume(false) + # optionrects and list_y_bounds are in absolute window coords (campixel + # is absolute); offset by the menuscene's translation for the scroll + # state when hit-testing. + mp = Point2f(position) .- Vec2f(translation(menuscene)[][1], translation(menuscene)[][2]) is_over_options = false is_over_button = false if Makie.is_mouseinside(menuscene) # the whole scene containing all options - # Is inside the expanded menu selection (the polys cover the whole - # selectable area and are in pixel space relative to menuscene) + # We entered the dropdown — the button cleanup below is short-circuited + # by the early return, so reset the button's hover indicator here. + if was_inside_button[] + was_inside_button[] = false + button_hovered[] = false + end + # Is inside the expanded menu selection (optionrects cover the whole + # selectable area, hit-tested with the translation-adjusted `mp`) if any(r -> mp in r, optionpolys[1][]) is_over_options = true was_inside_options[] = true - # we either clicked on an item or hover it if _mouse_up(butt, was_pressed_options) # PRESSED - m.i_selected[] = _pick_entry(mp[2], menuscene, list_y_bounds) + picked = _pick_entry(position[2], menuscene, list_y_bounds) + # the picked row is a VISIBLE index — map to the original option + if picked in eachindex(filtered_indices[]) + m.i_selected[] = filtered_indices[][picked] + end m.is_open[] = false else # HOVER - idx_hovered = _pick_entry(mp[2], menuscene, list_y_bounds) - _update_option_colors!(idx_hovered, optionstrings, optionpolycolors, m) + idx_hovered = _pick_entry(position[2], menuscene, list_y_bounds) + _update_option_colors!(idx_hovered, optionstrings, optionpolycolors, optiontextcolors, m, filtered_indices) end else # If not inside anymore, invalidate was_pressed @@ -271,7 +370,7 @@ function initialize_block!(m::Menu; default = 1) # clean up hovers if we're outside if !is_over_options && was_inside_options[] # going from being inside to outside was_inside_options[] = false - _update_option_colors!(0, optionstrings, optionpolycolors, m) + _update_option_colors!(0, optionstrings, optionpolycolors, optiontextcolors, m, filtered_indices) end if !is_over_button && was_inside_button[] was_inside_button[] = false @@ -285,6 +384,7 @@ function initialize_block!(m::Menu; default = 1) end on(blockscene, menuscene.events.scroll; priority = 61) do (x, y) + Makie.receives_events(blockscene) || return Consume(false) if is_mouseinside(menuscene) t = translation(menuscene)[] # Hack to differentiate mousewheel and trackpad scrolling diff --git a/Makie/src/makielayout/blocks/modal.jl b/Makie/src/makielayout/blocks/modal.jl new file mode 100644 index 00000000000..4ca2a17696a --- /dev/null +++ b/Makie/src/makielayout/blocks/modal.jl @@ -0,0 +1,180 @@ +function initialize_block!(m::Modal) + blockscene = m.blockscene + + # Modal floats above the whole figure; it is constructed as `Modal(fig)` + # without a grid position, so the blockscene's viewport is the full figure. + is_open = lift(identity, blockscene, m.open) + + # Overlay scene: full-figure, translucent backdrop drawn as a plot, lifted + # far above regular content. `captures_mouse = true` makes it the pointer + # cover for the whole window while visible, so only the modal's own + # subtree receives events (see `covers_pointer`). + overlay = Scene( + blockscene; clear = false, + viewport = blockscene.viewport, visible = is_open + ) + campixel!(overlay; absolute = true) + translate!(overlay, 0, 0, 1000) + overlay.captures_mouse = true + m.overlay = overlay + + backdrop_rect = lift(vp -> Rect2f(vp), blockscene, blockscene.viewport) + backdrop = poly!(overlay, backdrop_rect; color = m.backdrop_color, strokewidth = 0, inspectable = false) + translate!(backdrop, 0, 0, -2) + + # Body sizing: content-driven (floored at min_size) unless width/height + # are fixed numbers. The content autosize comes from the inner Subfigure. + content_autosize = Observable(Vec2f(0, 0); ignore_equal_values = true) + # place the body along a viewport axis by `halign`/`valign` (`:left`/`:center`/ + # `:right`, `:bottom`/`:center`/`:top`, or a 0..1 fraction), inset by `margin`. + modaloffset(a::Symbol, avail, sz, margin) = + a in (:left, :bottom) ? Float32(margin) : + a in (:right, :top) ? max(avail - sz - margin, margin) : + round((avail - sz) / 2) + modaloffset(a::Real, avail, sz, margin) = clamp(round(Float32(a) * (avail - sz)), 0.0f0, max(avail - sz, 0.0f0)) + body_rect = lift( + blockscene, blockscene.viewport, content_autosize, + m.width, m.height, m.min_size, m.max_size, m.contentpadding, m.header_height, + m.halign, m.valign + ) do vp, asz, w, h, msz, xsz, pad, hh, ha, va + bw = w isa Number ? Float32(w) : max(Float32(msz[1]), asz[1] + 2pad) + bh = h isa Number ? Float32(h) : max(Float32(msz[2]), asz[2] + hh + 2pad) + bw = min(bw, Float32(xsz[1])) + bh = min(bh, Float32(xsz[2])) + bw = min(bw, Float32(widths(vp)[1])) + bh = min(bh, Float32(widths(vp)[2])) + x = vp.origin[1] + modaloffset(ha, widths(vp)[1], bw, 2pad) + y = vp.origin[2] + modaloffset(va, widths(vp)[2], bh, 2pad) + return Rect2f(x, y, bw, bh) + end + + body_poly = lift(blockscene, body_rect, m.cornerradius, m.cornersegments) do r, cr, cs + return roundedrectvertices(r, min(Float32(cr), minimum(widths(r)) / 2), cs) + end + body = poly!( + overlay, body_poly; + color = m.color, strokecolor = m.strokecolor, strokewidth = m.strokewidth, + inspectable = false + ) + translate!(body, 0, 0, -1) + + # Header: title, separator line, close × + title_pos = lift(blockscene, body_rect, m.contentpadding, m.header_height) do r, pad, hh + return Point2f(left(r) + pad, top(r) - hh / 2) + end + titleplot = text!( + overlay, title_pos; text = m.title, font = m.titlefont, + fontsize = m.titlesize, color = m.titlecolor, + align = (:left, :center), inspectable = false + ) + translate!(titleplot, 0, 0, 2) + + separator = lift(blockscene, body_rect, m.header_height) do r, hh + y = top(r) - hh + return Point2f[(left(r), y), (right(r), y)] + end + sep = lines!(overlay, separator; color = m.separator_color, linewidth = 1, inspectable = false) + translate!(sep, 0, 0, 2) + + close_rect = lift(blockscene, body_rect, m.header_height) do r, hh + inset = hh / 4 + sz = hh - 2inset + return Rect2f(right(r) - hh + inset, top(r) - hh + inset, sz, sz) + end + close_segments = lift(blockscene, close_rect) do r + pad = widths(r)[1] / 4 + x0, y0 = minimum(r) .+ pad + x1, y1 = maximum(r) .- pad + return Point2f[(x0, y0), (x1, y1), (x0, y1), (x1, y0)] + end + close_hovered = Observable(false; ignore_equal_values = true) + close_color = lift(blockscene, close_hovered, m.closecolor, m.closecolor_hover) do h, c, ch + return to_color(h ? ch : c) + end + closeplot = linesegments!(overlay, close_segments; color = close_color, linewidth = 1.5, inspectable = false) + translate!(closeplot, 0, 0, 2) + + # Content: a Subfigure parented under the overlay, so it shares the + # cover's subtree and keeps receiving events while the modal is open. + # It also gives us scrolling for free when width/height are fixed. + content_bbox = lift(blockscene, body_rect, m.contentpadding, m.header_height) do r, pad, hh + return round_to_IRect2D( + Rect2f(left(r) + pad, bottom(r) + pad, widths(r)[1] - 2pad, widths(r)[2] - hh - 2pad) + ) + end + sf = Subfigure(overlay; bbox = content_bbox, visible = is_open, contentpadding = 0) + m.subfigure = sf + # Forward `modal[row, col]` to the content layout. + m.layout = sf.layout + on(blockscene, sf.contentsize) do cs + content_autosize[] = cs + return + end + + # Interaction: close on ×, optionally close on backdrop click. Registered + # on the shared events; inert while closed. + e = events(blockscene) + on(blockscene, e.mouseposition; priority = 90) do mp + is_open[] || return Consume(false) + close_hovered[] = Point2f(mp) in close_rect[] + return Consume(false) + end + on(blockscene, e.mousebutton; priority = 90) do ev + (is_open[] && ev.button == Mouse.left && ev.action == Mouse.press) || return Consume(false) + mp = Point2f(e.mouseposition[]) + if mp in close_rect[] + close!(m) + return Consume(true) + elseif m.dismiss_on_backdrop_click[] && !(mp in body_rect[]) + close!(m) + return Consume(true) + end + return Consume(false) + end + + return +end + +content_scene(m::Modal) = content_scene(m.subfigure) + +""" + replace_content!(f, m::Modal) + +Clear the modal's content and rebuild it by calling `f(subfigure)`, then +resize the modal to the new content. + +Deleting blocks leaves their rows and columns behind, so rebuilding +content in place accumulates empty tracks that push the remaining content +around. This removes the old content, trims those tracks, and refreshes +the size that `width`/`height = Auto()` depends on. + +```julia +replace_content!(modal) do sf + for (i, name) in enumerate(names) + Button(sf.layout[i, 1]; label = name) + end +end +``` +""" +function replace_content!(f, m::Modal) + sf = m.subfigure + layout = sf.layout + for c in copy(contents(layout)) + delete_layoutable!(c) + end + trim!(layout) + f(sf) + refresh_contentsize!(sf) + return m +end + +"Show the modal." +open!(m::Modal) = (m.open = true; nothing) +"Hide the modal." +close!(m::Modal) = (m.open = false; nothing) +"Whether the modal is currently shown." +Base.isopen(m::Modal) = m.open[] + +function update_state_before_display!(m::Modal) + return update_state_before_display!(m.subfigure) +end diff --git a/Makie/src/makielayout/blocks/paramform.jl b/Makie/src/makielayout/blocks/paramform.jl new file mode 100644 index 00000000000..cd4874bb73f --- /dev/null +++ b/Makie/src/makielayout/blocks/paramform.jl @@ -0,0 +1,352 @@ +""" + Between(lo, hi) + +Constraint for a numeric field: valid values must satisfy `lo ≤ value ≤ hi`. +`ParamForm` renders such fields as [`Slider`](@ref)s. +""" +struct Between{T} + lo::T + hi::T + Between{T}(lo, hi) where {T} = new{T}(lo, hi) +end +Between(lo::T, hi::T) where {T} = Between{T}(lo, hi) +Between(lo, hi) = Between(promote(lo, hi)...) +(c::Between)(v) = c.lo <= v <= c.hi +Base.show(io::IO, c::Between) = print(io, "Between(", c.lo, ", ", c.hi, ")") + +""" + OneOf(options) + +Constraint for a field whose value must be one of a fixed set of options. +`ParamForm` renders such fields as [`Menu`](@ref)s. +""" +struct OneOf + options::Vector +end +(c::OneOf)(v) = v in c.options +Base.show(io::IO, c::OneOf) = print(io, "OneOf(", c.options, ")") + +""" + FilePath(; extension = nothing) + +Constraint for a string-valued field that holds a file path. `ParamForm` renders +it as a text entry with a "…" browse button backed by [`choose_file_dialogue`](@ref). +Pass `extension` (e.g. `"csv,tsv"`) to filter the native file picker. +""" +struct FilePath + extension::Union{String, Nothing} + FilePath(; extension = nothing) = new(extension) +end +(::FilePath)(v) = true +Base.show(io::IO, c::FilePath) = print(io, "FilePath(", c.extension === nothing ? "" : c.extension, ")") + +""" + convert_form_input(spec::NamedTuple) -> Vector{NamedTuple} + +Normalise a `NamedTuple` form specification to one +`(; field, type, default, constraint)` per field. Each key maps to a +`(default, constraint)` pair; `type` is inferred from `typeof(default)`. + +```julia +spec = ( + alpha = (0.5, Between(0.0, 1.0)), + mode = ("fast", OneOf(["fast", "slow"])), + notes = ("", nothing), +) +fields = convert_form_input(spec) +``` +""" +convert_form_input(spec::NamedTuple) = + [(; field = k, type = typeof(v[1]), default = v[1], constraint = v[2]) for (k, v) in pairs(spec)] + +""" + widget_for(gridpos, T::Type, constraint, default, width) -> AbstractBlock + +Create the input widget for a field of declared type `T` with the given +constraint, seeded with `default` and sized to `width` pixels. Dispatches +on the constraint type: + +- [`OneOf`](@ref) → [`Menu`](@ref) +- [`Between`](@ref) → [`Slider`](@ref) +- [`FilePath`](@ref) → [`Textbox`](@ref) with a browse button +- `Bool` → [`Toggle`](@ref) +- anything else → [`Textbox`](@ref) +""" +widget_for(gridpos, ::Type, c::OneOf, default, width) = + Menu(gridpos; options = c.options, default = default, width = width) + +function widget_for(gridpos, ::Type{<:Real}, c::Between, default, width) + step = (c.hi - c.lo) / 100 + return Slider(gridpos; range = c.lo:step:c.hi, startvalue = default, width = width) +end + +function widget_for(gridpos, ::Type, c::FilePath, default, width) + sub = GridLayout(gridpos) + default_str = string(default) + tb = Textbox(sub[1, 1]; stored_string = isempty(default_str) ? nothing : default_str, + placeholder = default_str, width = width - 30) + # Textbox editing is disabled; the browse button is the only write path. + on(tb.focused) do focused + focused && defocus!(tb) + end + btn = Button(sub[1, 2]; label = "…") + colgap!(sub, 5) + on(btn.clicks) do _ + path = choose_file_dialogue(c.extension) + isnothing(path) || set!(tb, path) + end + return tb +end + +widget_for(gridpos, T::Type, constraint, default, width) = + scalar_widget(gridpos, T, constraint, default, width) + +""" + scalar_widget(gridpos, T::Type, constraint, default, width) -> AbstractBlock + +Fallback widget: a [`Toggle`](@ref) for `Bool` fields, otherwise a +[`Textbox`](@ref) pre-filled with `string(default)`. The textbox's `validator` +rejects input that would fail [`try_field_value`](@ref), so invalid values can +never be committed at the widget level. +""" +scalar_widget(gridpos, ::Type{Bool}, constraint, default, width) = + Toggle(gridpos; active = default) + +function scalar_widget(gridpos, ::Type{T}, constraint, default, width) where {T} + tb = Textbox(gridpos; stored_string = string(default), placeholder = string(default), width = width) + tb.validator[] = s -> try_field_value(tb, s, T, constraint) !== nothing + return tb +end + +""" + value_observable(widget) -> Observable + +The observable carrying a widget's current value, wired into the form's +compute graph as an input node. +""" +value_observable(m::Menu) = m.selection +value_observable(s::Slider) = s.value +value_observable(t::Toggle) = t.active +value_observable(tb::Textbox) = tb.stored_string + +""" + raw_value(widget, raw, T::Type) + +Convert the widget's raw observable value toward field type `T`. Menu, Slider +and Toggle values are already typed; Textbox values are parsed via `parse`. +""" +raw_value(::Union{Menu, Slider, Toggle}, raw, ::Type) = raw +raw_value(::Textbox, s::AbstractString, ::Type{T}) where {T <: Number} = parse(T, s) +raw_value(::Textbox, s::AbstractString, ::Type) = s +raw_value(::Textbox, ::Nothing, ::Type{<:AbstractString}) = "" +raw_value(::Textbox, ::Nothing, ::Type) = throw(ArgumentError("empty input")) + +""" + validate_value(T::Type, constraint, parsed) + +Convert `parsed` to `T` and check it against `constraint` (a callable, or +`nothing`), throwing `ArgumentError` if the constraint is violated. +""" +function validate_value(::Type{T}, constraint, parsed) where {T} + v = convert(T, parsed) + if constraint !== nothing && !constraint(v) + throw(ArgumentError("$v does not satisfy constraint $constraint")) + end + return v +end + +""" + try_field_value(widget, raw, T::Type, constraint) -> Union{T, Nothing} + +Return the validated value for `widget`'s raw input, or `nothing` if the input +is invalid. Only `ArgumentError` (bad parse or violated constraint) and +`InexactError` (value doesn't fit the numeric type) are treated as invalid; +other exceptions propagate so genuine bugs surface rather than silently +going dead. +""" +function try_field_value(w, raw, ::Type{T}, constraint) where {T} + try + return validate_value(T, constraint, raw_value(w, raw, T)) + catch e + e isa Union{ArgumentError, InexactError} || rethrow() + return nothing + end +end + +""" + ParamForm(gridpos, spec; title = nothing, kwargs...) -> ParamForm + +A themeable Makie `Block` that builds a labelled form of input widgets. + +`spec` is a `NamedTuple` of `field = (default, constraint)` pairs, processed +by [`convert_form_input`](@ref): + +```julia +pf = ParamForm(fig[1, 1], ( + alpha = (0.5, Between(0.0, 1.0)), + mode = ("fast", OneOf(["fast", "slow"])), + notes = ("", nothing), +)) +``` + +Each field renders as a right-aligned label and a widget chosen by +[`widget_for`](@ref). `pf.graph[:values]` is the live `NamedTuple` of +validated current values; read it with `pf.graph[:values][]`. The individual +widget blocks are available by field name in `pf.widgets` (e.g. +`pf.widgets[:alpha]`). + +With a `title` keyword, a bold section header is added above the fields. + +Pass a second positional argument `accessory` — a builder +`(field::Symbol, gridpos) -> block` — to add a third widget per row (e.g. a +keyframe toggle or reset button). It is called for each field with that row's +third-column cell; any returned block is stored in `pf.accessories[field]`. +Rows whose builder returns `nothing` get no accessory. The column is sized by +the `accessorywidth` attribute. + +```julia +pf = ParamForm(fig[1, 1], (gain = (1.0, Between(0.0, 4.0)),), + (field, pos) -> Button(pos; label = "◆")) +``` +""" +@Block ParamForm begin + @forwarded_layout + # Compute graph filled by initialize_block!; pf.graph[:values][] is the + # current validated NamedTuple. + graph::ComputeGraph + # Field name => the widget block created for it, e.g. pf.widgets[:alpha]. + widgets::Dict{Symbol, Any} + # Field name => the block returned by the `accessory` builder, if any. + accessories::Dict{Symbol, Any} + @attributes begin + "The horizontal alignment of the block in its suggested bounding box." + halign = :center + "The vertical alignment of the block in its suggested bounding box." + valign = :center + "The width setting of the block." + width = Auto() + "The height setting of the block." + height = Auto() + "Controls if the parent layout can adjust to this block's width." + tellwidth = false + "Controls if the parent layout can adjust to this block's height." + tellheight = true + "The align mode of the block in its parent GridLayout." + alignmode = Inside() + "Optional section title drawn above the fields (spans both columns). `nothing` = no title." + title = nothing + "Colour of the field-name labels." + labelcolor = @inherit((:colors, :text)) + "Font of the field-name labels." + labelfont = :bold + "Colour of the section title." + titlecolor = @inherit((:colors, :text)) + "Fixed pixel width of the right-aligned field-name column." + labelwidth = 88 + "Fixed pixel width of the widget column." + widgetwidth = 175 + "Fixed pixel width of the optional per-field accessory column (see the `accessory` constructor keyword). Only used when an accessory is built for at least one field." + accessorywidth = 28 + "Vertical gap between rows in pixels." + rowgap = 6 + "Horizontal gap between the label and widget columns in pixels." + colgap = 8 + end +end + +""" + build_field!(pf, field, T, constraint, default, row, accessory) + +Build one form row (label + widget) at `row` in `pf.layout` and wire the +widget into `pf.graph`: the widget's value observable becomes input +`Symbol(field, "__raw")`, and a node named `field` converts + validates it via +[`try_field_value`](@ref), returning `nothing` on invalid input so the graph +keeps its last valid value. + +If `accessory` is not `nothing` it is called as `accessory(field, gridpos)` +with `gridpos` the third-column cell of this row; a returned block (anything +but `nothing`) is stored in `pf.accessories[field]`. +""" +function build_field!(pf::ParamForm, field, ::Type{T}, constraint, default, row, accessory) where {T} + Label(pf.layout[row, 1], string(field); halign = :right, + font = pf.labelfont[], color = pf.labelcolor) + w = widget_for(pf.layout[row, 2], T, constraint, default, pf.widgetwidth[]) + pf.widgets[field] = w + raw = Symbol(field, "__raw") + add_input!(pf.graph, raw, value_observable(w)) + register_computation!(pf.graph, [raw], [field]) do inputs, _, _ + v = try_field_value(w, inputs[1], T, constraint) + return v === nothing ? nothing : (v,) + end + if accessory !== nothing + a = accessory(field, pf.layout[row, 3]) + a === nothing || (pf.accessories[field] = a) + end + return +end + +function initialize_block!(pf::ParamForm, spec, accessory = nothing) + fields = convert_form_input(spec) + pf.graph = ComputeGraph() + pf.widgets = Dict{Symbol, Any}() + pf.accessories = Dict{Symbol, Any}() + row = 1 + if pf.title[] !== nothing + Label(pf.layout[row, 1:2], pf.title[]; font = :bold, halign = :left, color = pf.titlecolor) + row += 1 + end + for f in fields + build_field!(pf, f.field, f.type, f.constraint, f.default, row, accessory) + row += 1 + end + if isempty(fields) + add_constant!(pf.graph, :values, NamedTuple()) + else + register_computation!(pf.graph, [f.field for f in fields], [:values]) do inputs, _, _ + return (inputs,) + end + end + if row == 1 + # Nothing was added: pin the single default cell to zero size so an + # empty form reports a determinate height instead of an indeterminate + # flexible row. + rowsize!(pf.layout, 1, Fixed(0)) + colsize!(pf.layout, 1, Fixed(0)) + else + colsize!(pf.layout, 1, Fixed(pf.labelwidth[])) + colsize!(pf.layout, 2, Fixed(pf.widgetwidth[])) + # only size the accessory column if a field actually got one + isempty(pf.accessories) || colsize!(pf.layout, 3, Fixed(pf.accessorywidth[])) + rowgap!(pf.layout, pf.rowgap[]) + colgap!(pf.layout, pf.colgap[]) + end + return +end + +free(pf::ParamForm) = clear!(pf.layout) + +""" + clear!(x) + +Remove GUI content. A Makie `Block` is deleted from its figure; a `GridLayout` +is cleared recursively (blocks and nested layouts removed). `nothing` is +ignored. Replaces ad-hoc `hasmethod` reflection with plain dispatch. +""" +clear!(block::Block) = (delete!(block); nothing) + +function clear!(gl::GridLayout) + for gc in reverse(copy(gl.content)) + obj = gc.content + if obj isa GridLayout + clear!(obj) + GridLayoutBase.remove_from_gridlayout!(gc) + else + clear!(obj) + end + end + GridLayoutBase.trim!(gl) + return nothing +end + +clear!(::Nothing) = nothing +clear!(::Any) = nothing diff --git a/Makie/src/makielayout/blocks/spinner.jl b/Makie/src/makielayout/blocks/spinner.jl new file mode 100644 index 00000000000..ece6e0198d3 --- /dev/null +++ b/Makie/src/makielayout/blocks/spinner.jl @@ -0,0 +1,57 @@ +function initialize_block!(sp::Spinner) + topscene = sp.blockscene + layoutobservables = sp.layoutobservables + + frame_idx = Observable(1) + displayed = lift( + topscene, frame_idx, sp.message, sp.frames, sp.running + ) do i, msg, frames, running + running || return "" + return "$(frames[mod1(i, length(frames))]) $msg" + end + + textpos = Observable(Point3f(0, 0, 0)) + t = text!( + topscene, textpos; text = displayed, fontsize = sp.fontsize, + color = sp.color, visible = sp.visible, + align = (:center, :center), markerspace = :data, + inspectable = false + ) + + textbb = Ref(BBox(0, 1, 0, 1)) + onany(topscene, displayed, sp.fontsize) do _, _ + textbb[] = Rect2f(boundingbox(t, :data)) + layoutobservables.autosize[] = (width(textbb[]), height(textbb[])) + return + end + + onany(topscene, layoutobservables.computedbbox) do bbox + tw = width(textbb[]) + th = height(textbb[]) + tx = bbox.origin[1] + 0.5 * width(bbox) + ty = bbox.origin[2] + 0.5 * height(bbox) + if all(isfinite, (tx, ty)) + textpos[] = Point3f(tx, ty, 0) + end + return + end + + # Advance the frame on ticks (matches record framerate; no async). + accum = Ref(0.0) + on(events(topscene).tick) do tick + if sp.running[] + accum[] += tick.delta_time + if accum[] >= sp.frame_interval[] + accum[] = 0.0 + frame_idx[] = frame_idx[] + 1 + end + else + accum[] = 0.0 + end + return + end + + notify(displayed) + layoutobservables.suggestedbbox[] = layoutobservables.suggestedbbox[] + return sp +end diff --git a/Makie/src/makielayout/blocks/subfigure.jl b/Makie/src/makielayout/blocks/subfigure.jl new file mode 100644 index 00000000000..c6a09481054 --- /dev/null +++ b/Makie/src/makielayout/blocks/subfigure.jl @@ -0,0 +1,318 @@ +""" + refresh_contentsize!(sf::Subfigure) + +Recompute `sf.contentsize` from the content layout's intrinsic size and +return it. Normally driven by the layout's `computedbbox`; call it +directly to resynchronise after rebuilding content in place. +""" +function refresh_contentsize!(sf::Subfigure) + layout = sf.layout + dw = GridLayoutBase.determinedirsize(layout, GridLayoutBase.Col()) + dh = GridLayoutBase.determinedirsize(layout, GridLayoutBase.Row()) + cw = dw === nothing ? 0.0f0 : Float32(dw) + ch = dh === nothing ? 0.0f0 : Float32(dh) + sf.contentsize[] = Vec2f(cw, ch) + return sf.contentsize[] +end + +function initialize_block!(sf::Subfigure) + blockscene = sf.blockscene + + content_area = lift(round_to_IRect2D, blockscene, sf.layoutobservables.computedbbox) + + # Unwrap the Compute graph node into a plain Observable{Bool} for the + # parts of the API that expect one (Scene's `visible`). + is_visible = lift(identity, blockscene, sf.visible) + + # Share the parent's `Events`: the scene-stacking event router + # (`receives_events`, honored by `is_mouseinside`/`addmouseevents!`) keeps + # hidden subtrees inert, so no separate `Events`/event forwarding is needed. + scene = Scene(blockscene; camera = campixel!, viewport = content_area, visible = is_visible, clear = false) + sf.scene = scene + + poly!( + blockscene, content_area; + color = sf.backgroundcolor, visible = is_visible, inspectable = false + ) + + sf.scroll = Observable(Vec2f(0, 0); ignore_equal_values = true) + sf.contentsize = Observable(Vec2f(0, 0); ignore_equal_values = true) + + layout_bbox = Observable(Rect2f(0, 0, 1, 1); ignore_equal_values = true) + # TOP-LEFT, not centred. A GridLayout defaults to `valign = :center`, so + # content that does not fill a scroll region floated in the middle of it — + # and the taller the region, the further from where the user is reading. When + # content DOES overflow, the alignment makes no difference, so this only ever + # affects the short case, where centring was never the intent. + layout = GridLayout(; bbox = layout_bbox, valign = :top, halign = :left) + layout.parent = scene + sf.layout = layout + + on(blockscene, sf.contentpadding; update = true) do pad + sides = pad isa Number ? (pad, pad, pad, pad) : pad + layout.alignmode[] = Outside(to_rectsides(sides)) + GridLayoutBase.update!(layout) + return + end + + # Scrollbar primitives drawn in the parent's blockscene so they sit above + # the content area; sized in `update_scrollbars!`. + vbar_rect = Observable(Rect2f(0, 0, 0, 0)) + vthumb_rect = Observable(Rect2f(0, 0, 0, 0)) + vthumb_color = Observable(to_color(sf.scrollbar_thumb_color[])) + vvis = Observable(false; ignore_equal_values = true) + hbar_rect = Observable(Rect2f(0, 0, 0, 0)) + hthumb_rect = Observable(Rect2f(0, 0, 0, 0)) + hthumb_color = Observable(to_color(sf.scrollbar_thumb_color[])) + hvis = Observable(false; ignore_equal_values = true) + # Rounded thumbs (capsule-shaped) computed from the bar rects. The track + # rectangles aren't drawn — `sf.scrollbar_color` is exposed for users who + # want a track, but the default is transparent. + poly!(blockscene, vbar_rect; color = sf.scrollbar_color, visible = lift(&, is_visible, vvis), inspectable = false) + poly!(blockscene, hbar_rect; color = sf.scrollbar_color, visible = lift(&, is_visible, hvis), inspectable = false) + vthumb_poly = lift(blockscene, vthumb_rect) do r + roundedrectvertices(r, min(widths(r)...) / 2, 8) + end + hthumb_poly = lift(blockscene, hthumb_rect) do r + roundedrectvertices(r, min(widths(r)...) / 2, 8) + end + poly!(blockscene, vthumb_poly; color = vthumb_color, visible = lift(&, is_visible, vvis), inspectable = false) + poly!(blockscene, hthumb_poly; color = hthumb_color, visible = lift(&, is_visible, hvis), inspectable = false) + + function update_scrollbars!() + ca = scene.viewport[] + cs = sf.contentsize[] + sc = sf.scroll[] + sbsize = Float32(sf.scrollbar_size[]) + vw, vh = Float32.(widths(ca)) + cw, ch = max(cs[1], vw), max(cs[2], vh) + max_sx, max_sy = max(0.0f0, cw - vw), max(0.0f0, ch - vh) + vvis[] = max_sy > 0 + hvis[] = max_sx > 0 + if max_sy > 0 + tx = right(ca) - sbsize + vbar_rect[] = Rect2f((tx, bottom(ca)), (sbsize, vh)) + thumb_h = max(20.0f0, vh * vh / ch) + usable = vh - thumb_h + ty = top(ca) - thumb_h - (sc[2] / max_sy) * usable + vthumb_rect[] = Rect2f((tx, ty), (sbsize, thumb_h)) + end + if max_sx > 0 + by = bottom(ca) + hbar_rect[] = Rect2f((left(ca), by), (vw, sbsize)) + thumb_w = max(20.0f0, vw * vw / cw) + usable = vw - thumb_w + tx = left(ca) + (sc[1] / max_sx) * usable + hthumb_rect[] = Rect2f((tx, by), (thumb_w, sbsize)) + end + return + end + + onany(blockscene, content_area, sf.scroll, sf.contentsize) do ca, sc, cs + vw, vh = Float32.(widths(ca)) + cw, ch = max(cs[1], vw), max(cs[2], vh) + max_sx, max_sy = max(0.0f0, cw - vw), max(0.0f0, ch - vh) + sx = clamp(sc[1], 0.0f0, max_sx) + sy = clamp(sc[2], 0.0f0, max_sy) + if sx != sc[1] || sy != sc[2] + sf.scroll[] = Vec2f(sx, sy) + return + end + top_edge = top(ca) + sy + left_edge = left(ca) - sx + layout_bbox[] = Rect2f(Point2f(left_edge, top_edge - ch), Vec2f(cw, ch)) + update_scrollbars!() + return + end + + on(blockscene, layout.layoutobservables.computedbbox) do _ + refresh_contentsize!(sf) + return + end + + # Wheel scrolling runs below the default priority so an inner block (e.g. + # an Axis zoom-on-scroll handler at priority 0) gets the event first; the + # subfigure only scrolls when nothing else consumed. Since the events are + # shared with the parent, it also checks `is_mouseinside(scene)` so stacked + # subfigures don't all scroll on every wheel event. + on(blockscene, scene.events.scroll; priority = -1) do (dx, dy) + sf.scrollable[] || return Consume(false) + is_mouseinside(scene) || return Consume(false) + cs = sf.contentsize[] + ca = scene.viewport[] + vw, vh = Float32.(widths(ca)) + cw, ch = max(cs[1], vw), max(cs[2], vh) + (cw <= vw && ch <= vh) && return Consume(false) + step = Float32(sf.scroll_speed[]) + sf.scroll[] = Vec2f(sf.scroll[][1] + step * dx, sf.scroll[][2] - step * dy) + return Consume(true) + end + + drag_state = Ref{Tuple{Symbol, Float32, Vec2f}}((:none, 0.0f0, Vec2f(0, 0))) + # If the subfigure becomes invisible mid-drag (e.g. a Tab switch), or the + # backend ever drops the mouse-release, the drag must be cleared so the + # next mouse-move doesn't keep scrolling. + on(blockscene, is_visible) do v + v || (drag_state[] = (:none, 0.0f0, Vec2f(0, 0))) + return + end + # Keep child blocks' visibility in sync with the subfigure — RECURSIVELY + # (nested Subfigures/blocks carry their own layouts). Show: unhide any child + # whose `scene.visible` was kept `false` because `unhide!` saw the parent + # scene as invisible during block construction. Hide: `hide!` every child, + # because a block's mouse machinery gates on its OWN blockscene.visible — + # without this, widgets of a hidden subfigure keep consuming clicks meant + # for whatever is shown in the same place (e.g. two panels sharing a dock + # slot: the hidden panel's buttons steal the visible panel's clicks). + on(blockscene, is_visible) do v + stack = flatten_layout_content(sf.layout) + while !isempty(stack) + block = pop!(stack) + append!(stack, flatten_layout_content(block)) + v ? unhide!(block) : hide!(block) + end + v && clip_content_to_viewport!() + return + end + + """ + Scrolled-out content must also stop RECEIVING clicks. A block's mouse + machinery gates on its own `blockscene.visible` and its own bbox — and a + scrollable subfigure moves content freely past its viewport, so a widget + scrolled out of sight still sits somewhere in the window and swallows + presses meant for whatever is drawn there (measured: a panel 1213 px tall in + a 714 px viewport put its buttons over the timeline underneath, where they + ate the clicks). Hide what does not intersect the viewport. + """ + function clip_content_to_viewport!() + is_visible[] || return + vp = scene.viewport[] + (widths(vp)[1] <= 0 || widths(vp)[2] <= 0) && return + stack = flatten_layout_content(sf.layout) + while !isempty(stack) + block = pop!(stack) + append!(stack, flatten_layout_content(block)) + bb = block.layoutobservables.computedbbox[] + all(isfinite, bb.origin) && all(isfinite, bb.widths) || continue + outside = bb.origin[1] + bb.widths[1] < left(vp) || bb.origin[1] > right(vp) || + bb.origin[2] + bb.widths[2] < bottom(vp) || bb.origin[2] > top(vp) + outside ? hide!(block) : unhide!(block) + end + return + end + onany(blockscene, layout_bbox, scene.viewport) do _, _ + clip_content_to_viewport!() + return + end + on(blockscene, blockscene.events.mousebutton) do ev + if ev.action == Mouse.release && drag_state[][1] !== :none + drag_state[] = (:none, 0.0f0, Vec2f(0, 0)) + end + return Consume(false) + end + function bar_at(pos) + is_visible[] || return :none + pt = Point2f(pos) + if vvis[] + pt in vthumb_rect[] && return :vthumb + pt in vbar_rect[] && return :vtrack + end + if hvis[] + pt in hthumb_rect[] && return :hthumb + pt in hbar_rect[] && return :htrack + end + return :none + end + + on(blockscene, blockscene.events.mouseposition; priority = 60) do pos + is_visible[] || return Consume(false) + kind = bar_at(pos) + st = drag_state[][1] + v_highlight = st === :vdrag || kind in (:vthumb, :vtrack) + h_highlight = st === :hdrag || kind in (:hthumb, :htrack) + vthumb_color[] = to_color(v_highlight ? sf.scrollbar_thumb_color_active[] : sf.scrollbar_thumb_color[]) + hthumb_color[] = to_color(h_highlight ? sf.scrollbar_thumb_color_active[] : sf.scrollbar_thumb_color[]) + + st, anchor, anchor_scroll = drag_state[] + if st === :vdrag + ca = scene.viewport[] + vh = Float32(widths(ca)[2]) + ch = max(sf.contentsize[][2], vh) + max_sy = max(0.0f0, ch - vh) + max_sy == 0 && return Consume(false) + thumb_h = max(20.0f0, vh * vh / ch) + usable = vh - thumb_h + dy = anchor - Float32(pos[2]) + new_sy = clamp(anchor_scroll[2] + dy * max_sy / usable, 0.0f0, max_sy) + sf.scroll[] = Vec2f(sf.scroll[][1], new_sy) + return Consume(true) + elseif st === :hdrag + ca = scene.viewport[] + vw = Float32(widths(ca)[1]) + cw = max(sf.contentsize[][1], vw) + max_sx = max(0.0f0, cw - vw) + max_sx == 0 && return Consume(false) + thumb_w = max(20.0f0, vw * vw / cw) + usable = vw - thumb_w + dx = Float32(pos[1]) - anchor + new_sx = clamp(anchor_scroll[1] + dx * max_sx / usable, 0.0f0, max_sx) + sf.scroll[] = Vec2f(new_sx, sf.scroll[][2]) + return Consume(true) + end + return Consume(false) + end + + on(blockscene, blockscene.events.mousebutton; priority = 60) do ev + is_visible[] || return Consume(false) + if ev.button == Mouse.left && ev.action == Mouse.press + pos = blockscene.events.mouseposition[] + kind = bar_at(pos) + if kind === :vthumb + drag_state[] = (:vdrag, Float32(pos[2]), sf.scroll[]) + return Consume(true) + elseif kind === :hthumb + drag_state[] = (:hdrag, Float32(pos[1]), sf.scroll[]) + return Consume(true) + elseif kind === :vtrack + vh = Float32(widths(scene.viewport[])[2]) + dir = Float32(pos[2]) < (vthumb_rect[].origin[2] + vthumb_rect[].widths[2] / 2) ? 1 : -1 + sf.scroll[] = Vec2f(sf.scroll[][1], sf.scroll[][2] + dir * vh) + return Consume(true) + elseif kind === :htrack + vw = Float32(widths(scene.viewport[])[1]) + dir = Float32(pos[1]) > (hthumb_rect[].origin[1] + hthumb_rect[].widths[1] / 2) ? 1 : -1 + sf.scroll[] = Vec2f(sf.scroll[][1] + dir * vw, sf.scroll[][2]) + return Consume(true) + end + elseif ev.button == Mouse.left && ev.action == Mouse.release + drag_state[] = (:none, 0.0f0, Vec2f(0, 0)) + end + return Consume(false) + end + + notify(sf.layoutobservables.suggestedbbox) + return +end + +content_scene(sf::Subfigure) = sf.scene + +# The generic `hide!` (e.g. a parent Subfigure's child walk hiding a NESTED +# subfigure) force-sets `sf.scene.visible[] = false`, overriding the reactive +# binding from `sf.visible` — and nothing re-fires that binding on re-show, so +# the content scene would stay invisible forever and every child `unhide!` +# early-returns on the invisible parent. Re-SYNC the scene with its bound state +# here instead of blindly forcing `true`: a nested subfigure whose own +# `visible` is false stays hidden, one that should show gets its scene back +# BEFORE the walk reaches its children (the walk is parent-first). +function unhide!(sf::Subfigure) + sf.blockscene.visible[] || (sf.blockscene.visible[] = true) + want = sf.visible[] + sf.scene.visible[] == want || (sf.scene.visible[] = want) + return +end + +# Recurse into the subfigure's layout so blocks placed inside get their +# pre-display updates (auto axis limits etc.). +function update_state_before_display!(sf::Subfigure) + return update_state_before_display!(sf.layout) +end diff --git a/Makie/src/makielayout/blocks/table.jl b/Makie/src/makielayout/blocks/table.jl new file mode 100644 index 00000000000..7823e6a2efa --- /dev/null +++ b/Makie/src/makielayout/blocks/table.jl @@ -0,0 +1,664 @@ +# ===== TABLE PLOT RECIPE ===== +# A recipe that renders tabular data efficiently with one poly, one text, and one linesegments plot + +@recipe TablePlot (data::Dict{Symbol, Any},) begin + # Data + column_names = automatic + column_widths = :auto # :auto (equal), :fit (auto-size), or Vector of widths + row_heights = automatic # automatic (uniform) or Vector of heights + + # Selection & sorting (inputs that can change) + i_selected = 0 # Selected row (0 = none) + i_selected_cell = (0, 0) # Selected cell as (row, col), (0,0) = none + hovered_row = 0 + hovered_cell = (0, 0) # Hovered cell as (row, col) + sort_column = 0 + sort_direction = :ascending + scroll_offset = 0 + + # Geometry + bbox = Rect2d(0, 0, 200, 200) + header_height = 30.0 + row_height = 25.0 + cell_padding = Vec4f(8, 8, 4, 4) # left, right, top, bottom + max_visible_rows = nothing + + # Header styling + header_color = RGBf(0.2, 0.2, 0.2) + header_textcolor = :white + header_fontsize = 14.0f0 + show_sort_indicator = true + + # Cell styling - can be single color, or matrix for per-cell colors + cell_color = automatic # automatic uses even/odd, or Matrix{<:Colorant} for per-cell + cell_color_even = RGBf(0.98, 0.98, 0.98) + cell_color_odd = RGBf(0.94, 0.94, 0.94) + cell_color_hover = @inherit((:colors, :accent_subtle)) + cell_color_selected = @inherit((:colors, :accent)) + cell_textcolor = :black # single color or Matrix for per-cell + cell_fontsize = 12.0f0 + + # Grid + show_grid = true + grid_color = RGBf(0.8, 0.8, 0.8) + grid_linewidth = 1.0 + show_vertical_lines = true + show_horizontal_lines = true + + mixin_generic_plot_attributes()... +end + +# Convert any tabular data to Dict{Symbol, Any} for type stability +function convert_arguments(::Type{<:TablePlot}, data::NamedTuple) + return (Dict{Symbol, Any}(pairs(data)),) +end + +function convert_arguments(::Type{<:TablePlot}, data::Dict{Symbol}) + return (Dict{Symbol, Any}(data),) +end + +function plot!(p::TablePlot) + # The recipe's attributes IS a ComputeGraph, so we use it directly + attr = p.attributes + + # ===== COMPUTED NODES ===== + # All computations use map! on the attribute graph + + # Basic dimensions from data + map!(attr, :data, :n_cols) do data + length(keys(data)) + end + + map!(attr, :data, :n_rows) do data + cols = values(data) + isempty(cols) ? 0 : length(first(cols)) + end + + # Column keys (for consistent ordering with Dict) + map!(attr, [:data, :column_names], :col_keys) do data, names + names === automatic ? collect(keys(data)) : collect(Symbol.(names)) + end + + # Column names for display + map!(attr, :col_keys, :col_names) do col_keys + String.(col_keys) + end + + # Sort permutation - recomputed when sort settings or data change + map!(attr, [:sort_column, :sort_direction, :data, :n_rows, :col_keys], :sort_perm) do col, dir, data, nr, col_keys + if col == 0 || nr == 0 || col > length(col_keys) + collect(1:nr) + else + col_data = data[col_keys[col]] + sortperm(col_data; rev = (dir == :descending)) + end + end + + # Visible rows based on max setting + map!(attr, [:n_rows, :max_visible_rows], :visible_rows) do nr, mvr + mvr === nothing ? nr : min(nr, mvr) + end + + # Column widths - computed from bbox and settings + # Note: :fit mode requires calling auto_fit_columns! after first render + map!(attr, [:bbox, :n_cols, :column_widths], :col_widths) do bbox, nc, cw + w = width(bbox) + if nc == 0 + Float32[] + elseif cw === :auto || cw === :fit + # :auto and :fit both start with equal widths + # :fit is updated by auto_fit_columns! after text is rendered + fill(Float32(w / nc), nc) + elseif cw isa Number + fill(Float32(cw), nc) + else + Float32.(cw) + end + end + + # Row heights - can be uniform or per-row + map!(attr, [:visible_rows, :row_height, :row_heights], :row_height_vec) do vr, default_rh, rh + if rh === automatic + fill(Float32(default_rh), vr) + elseif rh isa Number + fill(Float32(rh), vr) + else + # Vector provided - pad or truncate to visible rows + n = length(rh) + if n >= vr + Float32.(rh[1:vr]) + else + vcat(Float32.(rh), fill(Float32(default_rh), vr - n)) + end + end + end + + # Table dimensions (for layout feedback) + map!(attr, [:visible_rows, :header_height, :col_widths, :row_height_vec], [:table_width, :table_height]) do vr, hh, cw, rhv + w = isempty(cw) ? 200.0f0 : sum(cw) + h = Float32(hh + (isempty(rhv) ? 0.0f0 : sum(rhv))) + (w, h) + end + + # ===== GEOMETRY - single computation for all positions ===== + # Using scatter with BezierPath rectangle marker is more efficient than poly for many rects + map!( + attr, [ + :bbox, :data, :col_widths, :col_names, :col_keys, :sort_perm, + :n_cols, :n_rows, :visible_rows, :scroll_offset, + :row_height_vec, :header_height, :cell_padding, + :show_grid, :show_horizontal_lines, :show_vertical_lines, + :sort_column, :sort_direction, :show_sort_indicator, + ], [:cell_positions, :cell_sizes, :text_positions, :text_strings, :line_points] + ) do bbox, data, cw, col_names, col_keys, perm, nc, nr, vr, offset, rhv, hh, pad, show_grid, show_h, show_v, sort_col, sort_dir, show_indicator + + # Empty table + (nc == 0 || isempty(cw)) && return (Point2f[], Vec2f[], Point2f[], String[], Point2f[]) + + n_rects = nc + nc * vr + cell_pos = Vector{Point2f}(undef, n_rects) # top-left corner positions + cell_sz = Vector{Vec2f}(undef, n_rects) # (width, height) for markersize + text_pos = Vector{Point2f}(undef, n_rects) + text_str = Vector{String}(undef, n_rects) + + x0, y_top = left(bbox), top(bbox) + + # Column x positions (cumulative) + col_x = zeros(Float32, nc) + nc > 1 && cumsum!(@view(col_x[2:end]), @view(cw[1:(end - 1)])) + + # Row y positions (cumulative from top, going down) + row_y = zeros(Float32, vr + 1) + row_y[1] = y_top - hh + for i in 1:vr + row_y[i + 1] = row_y[i] - rhv[i] + end + + # Header - position at top-left of each header cell + for col in 1:nc + x, w = x0 + col_x[col], cw[col] + cell_pos[col] = Point2f(x, y_top) # top-left + cell_sz[col] = Vec2f(w, hh) + text_pos[col] = Point2f(x + pad[1], y_top - hh / 2) + hdr = col_names[col] + show_indicator && sort_col == col && (hdr *= sort_dir == :ascending ? " ↑" : " ↓") + text_str[col] = hdr + end + + # Cells - use col_keys to access data consistently + for row in 1:vr + actual_row = row + offset <= length(perm) ? perm[row + offset] : row + offset + rh = rhv[row] + y_cell_top = row_y[row] # top of this row + for col in 1:nc + idx = nc + (row - 1) * nc + col + x, w = x0 + col_x[col], cw[col] + cell_pos[idx] = Point2f(x, y_cell_top) # top-left + cell_sz[idx] = Vec2f(w, rh) + text_pos[idx] = Point2f(x + pad[1], y_cell_top - rh / 2) + text_str[idx] = actual_row <= nr ? string(data[col_keys[col]][actual_row]) : "" + end + end + + # Grid lines + lines = Point2f[] + if show_grid + total_w = sum(cw) + total_h = hh + sum(rhv) + if show_h + # Header bottom line + push!(lines, Point2f(x0, y_top - hh), Point2f(x0 + total_w, y_top - hh)) + # Row bottom lines + for row in 1:vr + y = row_y[row + 1] + push!(lines, Point2f(x0, y), Point2f(x0 + total_w, y)) + end + end + if show_v + for col in 1:(nc + 1) + x = x0 + (col <= nc ? col_x[col] : total_w) + push!(lines, Point2f(x, y_top), Point2f(x, y_top - total_h)) + end + end + end + + (cell_pos, cell_sz, text_pos, text_str, lines) + end + + # ===== COLORS - separate from geometry for efficient hover/selection updates ===== + # Supports both row-level and cell-level selection/hover + map!( + attr, [ + :n_cols, :visible_rows, :scroll_offset, :sort_perm, + :i_selected, :i_selected_cell, :hovered_row, :hovered_cell, + :header_color, :cell_color, :cell_color_even, :cell_color_odd, :cell_color_hover, :cell_color_selected, + ], :rect_colors + ) do nc, vr, offset, perm, selected_row, selected_cell, hovered_row, hovered_cell, hdr_c, cell_c, even_c, odd_c, hover_c, sel_c + + n_rects = nc + nc * vr + colors = Vector{RGBAf}(undef, n_rects) + nc == 0 && return colors + + hdr_color = to_color(hdr_c) + for i in 1:nc + colors[i] = hdr_color + end + + # Check if cell_color is a matrix (per-cell coloring) + use_matrix = cell_c isa AbstractMatrix + sel_cell_row, sel_cell_col = selected_cell + hov_cell_row, hov_cell_col = hovered_cell + + for row in 1:vr + actual_row = row + offset <= length(perm) ? perm[row + offset] : row + offset + for col in 1:nc + idx = nc + (row - 1) * nc + col + # Priority: cell selection > row selection > cell hover > row hover > matrix color > alternating + colors[idx] = if sel_cell_row == actual_row && sel_cell_col == col + # Cell-level selection (highest priority) + to_color(sel_c) + elseif actual_row == selected_row + # Row-level selection + to_color(sel_c) + elseif hov_cell_row == row && hov_cell_col == col + # Cell-level hover + to_color(hover_c) + elseif row == hovered_row + # Row-level hover + to_color(hover_c) + elseif use_matrix && actual_row <= size(cell_c, 1) && col <= size(cell_c, 2) + to_color(cell_c[actual_row, col]) + elseif iseven(row) + to_color(even_c) + else + to_color(odd_c) + end + end + end + colors + end + + # Text styling - supports per-cell text colors via matrix + map!( + attr, [ + :n_cols, :visible_rows, :scroll_offset, :sort_perm, + :header_textcolor, :cell_textcolor, :header_fontsize, :cell_fontsize, + ], + [:text_colors, :text_fontsizes] + ) do nc, vr, offset, perm, hdr_tc, cell_tc, hdr_fs, cell_fs + + n = nc + nc * vr + colors = Vector{RGBAf}(undef, n) + sizes = Vector{Float32}(undef, n) + + hdr_c = to_color(hdr_tc) + for i in 1:nc + colors[i], sizes[i] = hdr_c, hdr_fs + end + + # Check if cell_textcolor is a matrix + use_matrix = cell_tc isa AbstractMatrix + default_cell_c = use_matrix ? to_color(:black) : to_color(cell_tc) + + for row in 1:vr + actual_row = row + offset <= length(perm) ? perm[row + offset] : row + offset + for col in 1:nc + idx = nc + (row - 1) * nc + col + sizes[idx] = cell_fs + if use_matrix && actual_row <= size(cell_tc, 1) && col <= size(cell_tc, 2) + colors[idx] = to_color(cell_tc[actual_row, col]) + else + colors[idx] = default_cell_c + end + end + end + (colors, sizes) + end + + # ===== CHILD PLOTS - pass computed nodes directly ===== + + # Rectangle marker: draws from (0,0) going right and down + # Position is top-left, markersize is (width, height) + rect_marker = BezierPath( + [ + MoveTo(0, 0), + LineTo(1, 0), + LineTo(1, -1), + LineTo(0, -1), + ClosePath(), + ] + ) + + # Single scatter for all cell rectangles (more efficient than poly for many rects) + scatter!( + p, attr[:cell_positions]; + marker = rect_marker, + markersize = attr[:cell_sizes], + color = attr[:rect_colors], + markerspace = :data, + inspectable = false + ) + + # Single text for all labels + tp = text!( + p, attr[:text_positions]; + text = attr[:text_strings], + color = attr[:text_colors], + fontsize = attr[:text_fontsizes], + align = (:left, :center), + inspectable = false + ) + translate!(tp, 0, 0, 1) + + # Single linesegments for grid + lp = linesegments!( + p, attr[:line_points]; + color = attr[:grid_color], + linewidth = attr[:grid_linewidth], + inspectable = false + ) + translate!(lp, 0, 0, 0.5) + + return p +end + +# ===== HELPER FUNCTIONS FOR INTERACTION ===== +# These work with the plot's ComputeGraph + +function table_row_at_position(p::TablePlot, mp::Point2f) + attr = p.attributes + bbox = attr[:bbox][] + hh = attr[:header_height][] + rh = attr[:row_height][] + y_top = top(bbox) + + mp[2] > y_top - hh && return :header + + y_offset = y_top - hh - mp[2] + row = floor(Int, y_offset / rh) + 1 + vr = attr[:visible_rows][] + return (row >= 1 && row <= vr) ? row : 0 +end + +function table_col_at_position(p::TablePlot, mp::Point2f) + attr = p.attributes + cw = attr[:col_widths][] + isempty(cw) && return 0 + + x_offset = mp[1] - left(attr[:bbox][]) + cumw = cumsum(cw) + for (i, cx) in enumerate(cumw) + x_offset <= cx && return i + end + return 0 +end + +function is_inside_table(p::TablePlot, mp::Point2f) + attr = p.attributes + bbox = attr[:bbox][] + th = attr[:table_height][] + return mp[1] >= left(bbox) && mp[1] <= right(bbox) && mp[2] <= top(bbox) && mp[2] >= top(bbox) - th +end + +# Get actual data row from visual row (accounting for sort and scroll) +function get_actual_row(p::TablePlot, visual_row::Int) + attr = p.attributes + perm = attr[:sort_perm][] + offset = attr[:scroll_offset][] + idx = visual_row + offset + return idx <= length(perm) ? perm[idx] : 0 +end + +# Get row data as NamedTuple +function get_row_data(p::TablePlot, row_idx::Int) + attr = p.attributes + data = attr[:data][] + nr = attr[:n_rows][] + col_keys = attr[:col_keys][] + return row_idx <= nr ? NamedTuple{Tuple(col_keys)}(Tuple(data[k][row_idx] for k in col_keys)) : nothing +end + +# Get cell data at (row, col) +function get_cell_data(p::TablePlot, row_idx::Int, col_idx::Int) + attr = p.attributes + data = attr[:data][] + nr = attr[:n_rows][] + col_keys = attr[:col_keys][] + return (row_idx <= nr && col_idx <= length(col_keys)) ? data[col_keys[col_idx]][row_idx] : nothing +end + +""" + auto_fit_columns!(p::TablePlot) + +Automatically resize columns to fit their content based on text bounding boxes. +Call this after the table has been rendered at least once. +""" +function auto_fit_columns!(p::TablePlot) + # Get the text plot (third child: poly, lines, text) + text_plot = p.plots[2]::Text + + attr = p.attributes + nc = attr[:n_cols][] + vr = attr[:visible_rows][] + pad = attr[:cell_padding][] + + nc == 0 && return p + + # Get bounding boxes of all text elements + bbs = string_boundingboxes(text_plot) + + # Compute max width per column (including header) + max_widths = zeros(Float32, nc) + + # Header widths (first nc entries) + for col in 1:nc + if col <= length(bbs) + max_widths[col] = max(max_widths[col], bbs[col].widths[1]) + end + end + + # Cell widths + for row in 1:vr + for col in 1:nc + idx = nc + (row - 1) * nc + col + if idx <= length(bbs) + max_widths[col] = max(max_widths[col], bbs[idx].widths[1]) + end + end + end + + # Add padding + max_widths .+= pad[1] + pad[2] + + # Update column widths + update!(attr; column_widths = max_widths) + + return p +end + +""" + auto_fit_rows!(p::TablePlot) + +Automatically resize rows to fit their content based on text bounding boxes. +Call this after the table has been rendered at least once. +""" +function auto_fit_rows!(p::TablePlot) + text_plot = p.plots[2]::Text + + attr = p.attributes + nc = attr[:n_cols][] + vr = attr[:visible_rows][] + pad = attr[:cell_padding][] + + vr == 0 && return p + + # Get bounding boxes + bbs = string_boundingboxes(text_plot) + + # Compute max height per row + max_heights = zeros(Float32, vr) + + for row in 1:vr + for col in 1:nc + idx = nc + (row - 1) * nc + col + if idx <= length(bbs) + max_heights[row] = max(max_heights[row], bbs[idx].widths[2]) + end + end + end + + # Add padding + max_heights .+= pad[3] + pad[4] + + # Update row heights + update!(attr; row_heights = max_heights) + + return p +end + + +# ===== TABLE BLOCK INITIALIZATION ===== + +function initialize_block!(t::Table) + scene = t.blockscene + + # Seed the plot's ComputeGraph straight from the block's attributes: the + # recipe picks up every key it defines (reactively linked, so later + # `update!(t.attributes; ...)` propagates) and ignores block-only ones + # (selection, sortable, layout attrs, …). `t.data` is the positional arg. + plot = tableplot!(scene, t.attributes, t.data; inspectable = false) + + attr = plot.attributes + last_autosize = Ref((0.0f0, 0.0f0)) + + # Sync bbox from layout → plot's ComputeGraph (update existing input) + on(scene, t.layoutobservables.computedbbox) do cbb + update!(attr; bbox = Rect2d(origin(cbb), widths(cbb))) + # Sync autosize back to layout, but only if changed (prevents infinite loop) + w = attr[:table_width][] + h = attr[:table_height][] + new_size = (w, h) + if new_size != last_autosize[] + last_autosize[] = new_size + t.layoutobservables.autosize[] = new_size + end + end + + # ===== EVENT HANDLING ===== + e = scene.events + last_click_time = Ref(0.0) + last_click_cell = Ref((0, 0)) + was_inside = Ref(false) + + # Mouse position + button handler (priority to consume events) + onany(scene, e.mouseposition, e.mousebutton; priority = 63) do position, butt + # Inert when hidden or when another scene covers the pointer. + Makie.receives_events(scene) || return Consume(false) + mp = screen_relative(scene, position) + + if !is_inside_table(plot, mp) + if was_inside[] + was_inside[] = false + update!(attr; hovered_row = 0, hovered_cell = (0, 0)) + end + return Consume(false) + end + + was_inside[] = true + row = table_row_at_position(plot, mp) + col = table_col_at_position(plot, mp) + + # Update hover state (both row and cell level) + if row isa Int && row > 0 && col > 0 + update!(attr; hovered_row = row, hovered_cell = (row, col)) + else + update!(attr; hovered_row = row isa Int ? row : 0, hovered_cell = (0, 0)) + end + + # Handle left clicks + if butt.button == Mouse.left && butt.action == Mouse.press + if row == :header && col > 0 && t.sortable[] + # Sort by column + current_col = attr[:sort_column][] + if current_col == col + new_dir = attr[:sort_direction][] == :ascending ? :descending : :ascending + # sort_column/sort_direction are inputs of the block, linked + # into the plot — update them on the block so the change + # propagates (updating the plot graph directly errors). + update!(t.attributes; sort_direction = new_dir) + else + update!(t.attributes; sort_column = col, sort_direction = :ascending) + end + + cb = t.on_sort_change[] + cb !== nothing && cb(t, col, attr[:sort_direction][]) + return Consume(true) + + elseif row isa Int && row > 0 && col > 0 + actual_row = get_actual_row(plot, row) + current_time = time() + + # Double click check (same cell) + if current_time - last_click_time[] < 0.3 && last_click_cell[] == (actual_row, col) + cb = t.on_row_doubleclick[] + cb !== nothing && cb(t, actual_row, get_row_data(plot, actual_row)) + else + # Single click - select row and cell (block inputs -> update + # on the block so the selection propagates into the plot) + update!(t.attributes; i_selected = actual_row, i_selected_cell = (actual_row, col)) + t.selection[] = get_row_data(plot, actual_row) + t.cell_selection[] = get_cell_data(plot, actual_row, col) + + # Row click callback + cb = t.on_row_click[] + cb !== nothing && cb(t, actual_row, t.selection[]) + + # Cell click callback + cb_cell = t.on_cell_click[] + cb_cell !== nothing && cb_cell(t, actual_row, col, t.cell_selection[]) + end + + last_click_time[] = current_time + last_click_cell[] = (actual_row, col) + return Consume(true) + end + + # Handle right clicks + elseif butt.button == Mouse.right && butt.action == Mouse.press + if row isa Int && row > 0 && col > 0 + actual_row = get_actual_row(plot, row) + cell_data = get_cell_data(plot, actual_row, col) + + cb = t.on_cell_rightclick[] + cb !== nothing && cb(t, actual_row, col, cell_data) + return Consume(true) + end + end + + return Consume(false) + end + + # Scroll handling + on(scene, e.scroll; priority = 62) do (x, y) + Makie.receives_events(scene) || return Consume(false) + mp = screen_relative(scene, e.mouseposition[]) + + if is_inside_table(plot, mp) + nr = attr[:n_rows][] + vr = attr[:visible_rows][] + max_offset = max(0, nr - vr) + + step = round(Int, t.scroll_speed[] * sign(y)) + current = attr[:scroll_offset][] + new_offset = clamp(current - step, 0, max_offset) + + if new_offset != current + update!(t.attributes; scroll_offset = new_offset) + return Consume(true) + end + end + return Consume(false) + end + + return nothing +end diff --git a/Makie/src/makielayout/blocks/tabs.jl b/Makie/src/makielayout/blocks/tabs.jl new file mode 100644 index 00000000000..faada821d77 --- /dev/null +++ b/Makie/src/makielayout/blocks/tabs.jl @@ -0,0 +1,379 @@ +block_kwargs(::Type{Tabs}) = Set([:closable]) + +function initialize_block!(t::Tabs, labels::AbstractVector = ["Tab 1", "Tab 2"]; closable = true) + blockscene = t.blockscene + + t.tabs = TabData[] + t.hovered = Observable(0; ignore_equal_values = true) + t.close_hovered = Observable(0; ignore_equal_values = true) + t.headerheight = Observable(0.0; ignore_equal_values = true) + t.separator_path = Observable(Point2f[]) + # Defaults are sane for most sans-serif fonts so the first frame doesn't draw + # the × at (0, 0); replaced once a label plot resolves its font. + t.font_metrics = Observable(TabFontMetrics(0.95f0, -0.21f0, 0.52f0)) + t.font_metrics_captured = false + + t.content_area = lift(blockscene, t.layoutobservables.computedbbox, t.headerheight) do cbb, hh + return round_to_IRect2D(BBox(left(cbb), right(cbb), bottom(cbb), top(cbb) - hh)) + end + + # A single continuous separator line under the header that detours up and + # around the active tab — its left/top/right edges replace the straight + # line, so the active tab visually connects to the content area below. + sep_plot = lines!( + blockscene, t.separator_path; + color = t.separator_color, linewidth = t.separator_thickness, inspectable = false + ) + # raise above the tab polys (z = 0) so the line isn't covered along their + # shared bottom edge + translate!(sep_plot, 0, 0, 2) + + closable_for(i) = closable isa AbstractVector ? (1 <= i <= length(closable) && Bool(closable[i])) : Bool(closable) + for (i, label) in enumerate(labels) + add_tab!(t, label; closable = closable_for(i)) + end + + onany( + blockscene, t.layoutobservables.computedbbox, t.active, t.hovered, t.close_hovered, + t.tabheight, t.tabpadding, t.tabgap, t.tabcolor_active, t.tabcolor_inactive, + t.tabcolor_hover, t.labelcolor_active, t.labelcolor_inactive, + t.closecolor, t.closecolor_hover, t.font_metrics, + ) do args... + recompute_layout!(t) + end + + on(_ -> refresh_visibility!(t), blockscene, t.active) + + on(blockscene, blockscene.events.mouseposition) do pos + t.hovered[] = tab_at(t, pos) + t.close_hovered[] = close_at(t, pos) + return Consume(false) + end + + on(blockscene, blockscene.events.mousebutton; priority = 60) do ev + if ev.button == Mouse.left && ev.action == Mouse.press + pos = blockscene.events.mouseposition[] + # Close button takes precedence over the tab body click: clicking + # the × removes the tab without first making it active. + ci = close_at(t, pos) + if ci != 0 + remove_tab!(t, ci) + return Consume(true) + end + i = tab_at(t, pos) + if i != 0 + t.active[] = i + return Consume(true) + end + end + return Consume(false) + end + + notify(t.layoutobservables.suggestedbbox) + return +end + +""" + add_tab!(tabs::Tabs, label = "Tab N"; activate = false, kwargs...) -> Subfigure + +Append a tab to `tabs` and return its [`Subfigure`](@ref). Plot into it via +`tabs[end][row, col] = Axis(...)` or `content_scene(tabs, length(tabs))`. Pass +`activate = true` to switch to the new tab immediately. The first tab added to an +empty `Tabs` becomes active automatically. + +`label` accepts anything `text!` does — a plain `String`, a `rich(...)` for +colored / styled spans, or a `LaTeXString` (`L"..."`). Any further keywords +(e.g. `closable`) are forwarded to [`set_tab!`](@ref) to set the new tab's +properties. +""" +function add_tab!( + t::Tabs, label = "Tab $(length(t.tabs) + 1)"; + activate::Bool = false, kwargs... + ) + blockscene = t.blockscene + + visible = Observable(false; ignore_equal_values = true) + # An inactive tab is hidden (`visible = false`), and the scene-stacking + # event router (`receives_events`, honored by `is_mouseinside` / + # `addmouseevents!`) keeps hidden subtrees inert — so the shared-events + # Subfigure needs nothing extra to isolate a tab's input. + sf = Subfigure( + blockscene; + bbox = t.content_area, + visible = visible, contentpadding = t.contentpadding, + ) + + label_obs = Observable{Any}(label) + closable_obs = Observable(true) + rect = Observable(Rect2f(0, 0, 0, 0)) + bgcolor = Observable(to_color(t.tabcolor_inactive[])) + labelpos = Observable(Point2f(0, 0)) + labelcolor = Observable(to_color(t.labelcolor_inactive[])) + close_segments = Observable(Point2f[]) + close_color = Observable(to_color(t.closecolor[])) + close_rect = Observable(Rect2f(0, 0, 0, 0)) + close_visible = Observable(false) + + poly_pts = lift(roundedrectvertices, blockscene, rect, t.cornerradius, t.cornersegments) + polyplot = poly!(blockscene, poly_pts; color = bgcolor, inspectable = false) + labelplot = text!( + blockscene, labelpos; text = label_obs, fontsize = t.fontsize, font = t.font, + color = labelcolor, align = (:center, :center), markerspace = :data, inspectable = false + ) + translate!(labelplot, 0, 0, 1) + labelboundingboxes = fast_string_boundingboxes_obs(labelplot) + + # Close "×" drawn as two diagonal line segments, sized to x-height. + close_lw = lift(fs -> max(1.0f0, Float32(fs) * 0.08f0), blockscene, t.fontsize) + closeplot = linesegments!( + blockscene, close_segments; + color = close_color, linewidth = close_lw, visible = close_visible, inspectable = false, + ) + translate!(closeplot, 0, 0, 1) + + td = TabData( + sf, label_obs, closable_obs, visible, rect, bgcolor, labelpos, labelcolor, + labelboundingboxes, close_segments, close_color, close_rect, close_visible, + (polyplot, labelplot, closeplot), + ) + push!(t.tabs, td) + + # Changing the label text (→ new measured size) or the closability needs a + # relayout; binding the text plot to `label_obs` already redraws the glyphs. + on(_ -> recompute_layout!(t), blockscene, labelboundingboxes) + on(_ -> recompute_layout!(t), blockscene, closable_obs) + + # Capture the resolved label font's metrics once, so the × is sized to the + # x-height and sits on the label's baseline. + if !t.font_metrics_captured + t.font_metrics_captured = true + on(blockscene, labelplot.selected_font; update = true) do f + try + asc = Float32(Makie.FreeTypeAbstraction.ascender(f)) + des = Float32(Makie.FreeTypeAbstraction.descender(f)) + ext = Makie.FreeTypeAbstraction.get_extent(f, 'x') + bb = Makie.FreeTypeAbstraction.inkboundingbox(ext) + xh = Float32(widths(bb)[2]) + t.font_metrics[] = TabFontMetrics(asc, des, xh) + catch + # keep defaults + end + return + end + end + + isempty(kwargs) || set_tab!(t, length(t.tabs); kwargs...) + + if activate || t.active[] < 1 + t.active[] = length(t.tabs) + end + refresh_visibility!(t) + recompute_layout!(t) + return sf +end + +""" + remove_tab!(tabs::Tabs, i::Integer) + +Remove tab `i`, freeing its [`Subfigure`](@ref) content and header plots. The +remaining tabs shift down and `active` is updated to keep pointing at the same +tab (or its neighbour if the active tab itself was removed). +""" +function remove_tab!(t::Tabs, i::Integer) + n = length(t.tabs) + (1 <= i <= n) || return + + td = t.tabs[i] + delete!(td.subfigure) + for p in td.plots + delete!(t.blockscene, p) + end + deleteat!(t.tabs, i) + + new_n = n - 1 + old_active = t.active[] + new_active = if new_n == 0 + 0 + elseif old_active < i + old_active + elseif old_active == i + min(i, new_n) + else + old_active - 1 + end + t.active[] = new_active + refresh_visibility!(t) + recompute_layout!(t) + return +end + +""" + set_tab!(tabs::Tabs, i::Integer; label, closable) + +Change properties of tab `i`. Omitted keywords are left unchanged: `label` +sets the tab's text, `closable` whether it shows a close (×) button. +""" +function set_tab!(t::Tabs, i::Integer; label = nothing, closable = nothing) + td = t.tabs[i] + label === nothing || (td.label[] = label) + closable === nothing || (td.closable[] = Bool(closable)) + return +end + +function refresh_visibility!(t::Tabs) + a = t.active[] + for (i, td) in enumerate(t.tabs) + td.visible[] = (i == a) + end + return +end + +function tab_at(t::Tabs, pos) + p = Point2f(pos) + for (i, td) in enumerate(t.tabs) + p in td.rect[] && return i + end + return 0 +end + +function close_at(t::Tabs, pos) + p = Point2f(pos) + for (i, td) in enumerate(t.tabs) + td.closable[] || continue + p in td.close_rect[] && return i + end + return 0 +end + +function recompute_layout!(t::Tabs) + n = length(t.tabs) + if n == 0 + t.headerheight[] = 0.0 + t.separator_path[] = Point2f[] + return + end + + pad = t.tabpadding[] + gap = t.tabgap[] + fs = Float32(t.fontsize[]) + fm = t.font_metrics[] + x_height_px = fm.x_height * fs + widths_ = Vector{Float32}(undef, n) + labelws = Vector{Float32}(undef, n) + close_ws = Vector{Float32}(undef, n) + close_gaps = Vector{Float32}(undef, n) + hmax = 0.0 + for (i, td) in enumerate(t.tabs) + bbs = td.labelboundingboxes[] + w = isempty(bbs) ? 0.0 : width(bbs[1]) + h = isempty(bbs) ? 0.0 : height(bbs[1]) + labelws[i] = w + hmax = max(hmax, h) + cl = td.closable[] + close_ws[i] = cl ? x_height_px : 0.0f0 + close_gaps[i] = cl ? x_height_px * 1.6f0 : 0.0f0 + widths_[i] = w + pad[1] + pad[2] + close_ws[i] + close_gaps[i] + end + th = t.tabheight[] + t.headerheight[] = th === automatic ? hmax + pad[3] + pad[4] : Float64(th) + + cbb = t.layoutobservables.computedbbox[] + hh = t.headerheight[] + l, top_ = left(cbb), top(cbb) + active = t.active[] + hov = t.hovered[] + chov = t.close_hovered[] + x = l + for (i, td) in enumerate(t.tabs) + x0 = x + x1 = x + widths_[i] + td.rect[] = BBox(x0, x1, top_ - hh, top_) + # label sits left-aligned in the tab, centered vertically + td.labelpos[] = Point2f(x0 + pad[1] + labelws[i] / 2, top_ - hh / 2) + # The × is drawn as two diagonal line segments, baseline-aligned with + # the label (so it sits where a lowercase 'x' would) and sized to the + # font's x-height. Non-closable tabs get empty segments and a zero-size + # hit rect so nothing draws or hit-tests. + td.close_visible[] = close_ws[i] > 0 + if close_ws[i] > 0 + close_cx = x0 + pad[1] + labelws[i] + close_gaps[i] + close_ws[i] / 2 + baseline_y = top_ - hh / 2 - (fm.ascent + fm.descent) / 2 * fs + half = x_height_px / 2 + y_top = baseline_y + x_height_px + y_bot = baseline_y + td.close_segments[] = Point2f[ + Point2f(close_cx - half, y_top), Point2f(close_cx + half, y_bot), # \ + Point2f(close_cx + half, y_top), Point2f(close_cx - half, y_bot), # / + ] + # hit-test region a bit larger than the glyph, centred on the tab + hit_half = max(close_ws[i], fs) / 2 + 2 + td.close_rect[] = Rect2f(close_cx - hit_half, top_ - hh / 2 - hit_half, 2hit_half, 2hit_half) + td.close_color[] = to_color(i == chov ? t.closecolor_hover[] : t.closecolor[]) + else + td.close_segments[] = Point2f[] + td.close_rect[] = Rect2f(0, 0, 0, 0) + end + + td.bgcolor[] = to_color( + i == active ? t.tabcolor_active[] : + i == hov ? t.tabcolor_hover[] : t.tabcolor_inactive[] + ) + td.labelcolor[] = to_color(i == active ? t.labelcolor_active[] : t.labelcolor_inactive[]) + x = x1 + gap + end + + # Separator path: a horizontal line under the header that detours up and + # over the active tab so the active tab "merges" into the panel. + sep_y = Float32(top_ - hh) + if 1 <= active <= n + ar = t.tabs[active].rect[] + ax0, ax1 = left(ar), right(ar) + aytop = top(ar) + t.separator_path[] = Point2f[ + (Float32(l), sep_y), + (ax0, sep_y), + (ax0, aytop), + (ax1, aytop), + (ax1, sep_y), + (Float32(right(cbb)), sep_y), + ] + else + t.separator_path[] = Point2f[(Float32(l), sep_y), (Float32(right(cbb)), sep_y)] + end + return +end + +Base.getindex(t::Tabs, i::Integer) = t.tabs[i].subfigure +Base.length(t::Tabs) = length(t.tabs) + +# Generic `Block` indexing would lazy-init a fresh, disconnected `GridLayout` +# on `t.layout` — a silent foot-gun: `Axis(tabs[1, 1])` would create an +# orphan that never displays. Force users to specify which tab. +function Base.getindex( + ::Tabs, + ::Union{Integer, Colon, AbstractRange}, + ::Union{Integer, Colon, AbstractRange}, + side = GridLayoutBase.Inner() + ) + error( + "`Tabs` doesn't have a top-level grid layout — index into a specific " * + "tab first: `tabs[i][row, col]` (or `tabs[i].layout[row, col]`)." + ) +end + +""" + content_scene(tabs::Tabs, i::Integer) + +Return the content `Scene` of tab `i`, for plotting directly into a tab. +""" +content_scene(t::Tabs, i::Integer) = content_scene(t.tabs[i].subfigure) + +# Hand pre-display state updates to each tab's Subfigure so blocks placed inside a +# tab (which the Figure can't see because they have a Scene parent, not a +# Figure parent) still get e.g. auto axis limits. +function update_state_before_display!(t::Tabs) + for td in t.tabs + update_state_before_display!(td.subfigure) + end + return +end diff --git a/Makie/src/makielayout/blocks/textbox.jl b/Makie/src/makielayout/blocks/textbox.jl index 16465ed12b5..ac9debaf2ae 100644 --- a/Makie/src/makielayout/blocks/textbox.jl +++ b/Makie/src/makielayout/blocks/textbox.jl @@ -5,7 +5,8 @@ function initialize_block!(tbox::Textbox) Rect(round.(Int, bb.origin), round.(Int, bb.widths)) end - scene = Scene(topscene, scenearea, camera = campixel!) + scene = Scene(topscene, scenearea) + campixel!(scene; absolute = true) roundedrectpoints = lift(roundedrectvertices, topscene, scenearea, tbox.cornerradius, tbox.cornersegments) @@ -53,10 +54,10 @@ function initialize_block!(tbox::Textbox) realtextcolor = Observable(to_color(:red)) # Position the editor at the top-left of the inner area, accounting for textpadding. + # campixel is in absolute window coords, so we anchor at the window-coord top-left. text_origin = lift(topscene, scenearea, tbox.textpadding) do area, padding - # padding = (left, right, bottom, top); text uses (:left, :top) align so we anchor at the top-left l, _r, _b, t = padding - return Point2f(l, widths(area)[2] - t) + return Point2f(left(area) + l, top(area) - t) end # Placeholder is rendered as a separate text! that's visible whenever @@ -81,7 +82,6 @@ function initialize_block!(tbox::Textbox) font = tbox.font, fontsize = tbox.fontsize, cursor_color = tbox.cursorcolor, - space = :pixel, multiline = false, manage_focus = false, # Textbox drives focus (see below) input_filter = c -> is_allowed(c, tbox.restriction[]), diff --git a/Makie/src/makielayout/mousestatemachine.jl b/Makie/src/makielayout/mousestatemachine.jl index a3b252a9043..1652a793fba 100644 --- a/Makie/src/makielayout/mousestatemachine.jl +++ b/Makie/src/makielayout/mousestatemachine.jl @@ -121,12 +121,24 @@ end ``` """ function addmouseevents!(scene, elements...; priority = 1) - is_mouse_over_relevant_area() = isempty(elements) ? Makie.is_mouseinside(scene) : mouseover(scene, elements...) - return _addmouseevents!(scene, is_mouse_over_relevant_area, priority) + # A hidden scene shouldn't generate mouse events for anything anchored + # to it — without this check, Block widgets (Button, Checkbox, …) keep + # firing clicks/hover on their bbox even after the block has been + # visually hidden by `block.blockscene.visible[] = false`. + is_mouse_over_relevant_area() = scene.visible[] && + (isempty(elements) ? Makie.is_mouseinside(scene) : mouseover(scene, elements...)) + return _addmouseevents!(scene, is_mouse_over_relevant_area, Makie.mouseposition_px, priority) end function addmouseevents!(scene, bbox::Observables.AbstractObservable{<:Rect2}; priority = 1) - is_mouse_over_relevant_area() = Makie.mouseposition_px(scene) in bbox[] - return _addmouseevents!(scene, is_mouse_over_relevant_area, priority) + # Block geometry (`computedbbox`, slider endpoints, …) lives in absolute + # window-pixel coordinates and blockscene cameras are window-absolute + # (campixel!), so hit-test and report event positions in that same frame. + # `mouseposition_px` is viewport-relative and stops matching the absolute + # bboxes as soon as the blockscene inherits an offset viewport (e.g. for + # a widget placed inside a Subfigure/Tabs content scene). + to_px(scene) = Point2f(events(scene).mouseposition[]) + is_mouse_over_relevant_area() = scene.visible[] && (to_px(scene) in bbox[]) + return _addmouseevents!(scene, is_mouse_over_relevant_area, to_px, priority) end @@ -185,7 +197,7 @@ function to_click_event(b::Mouse.Button) return error("No recognized mouse button $b") end -function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) +function _addmouseevents!(scene, is_mouse_over_relevant_area, to_px, priority) Mouse = Makie.Mouse dblclick_max_interval = 0.2 @@ -194,8 +206,8 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) ) # initialize state variables last_mouseevent = Ref{Mouse.Action}(Mouse.release) - prev_data = Ref(mouseposition(scene)) - prev_px = Ref(Makie.mouseposition_px(scene)) + prev_px = Ref(Point2f(to_px(scene))) + prev_data = Ref(to_world(scene, prev_px[])) mouse_downed_inside = Ref(false) mouse_downed_button = Ref{Optional{Mouse.Button}}(nothing) drag_ongoing = Ref(false) @@ -211,8 +223,8 @@ function _addmouseevents!(scene, is_mouse_over_relevant_area, priority) mousepos_observerfunc = on(scene, events(scene).mouseposition; priority = priority) do mp consumed = false t = time() - data = mouseposition(scene) - px = mouseposition_px(scene) + px = Point2f(to_px(scene)) + data = to_world(scene, px) mouse_inside = is_mouse_over_relevant_area() # last_mouseevent can only be up or down diff --git a/Makie/src/makielayout/types.jl b/Makie/src/makielayout/types.jl index 60e47138414..ee1a5d55497 100644 --- a/Makie/src/makielayout/types.jl +++ b/Makie/src/makielayout/types.jl @@ -981,6 +981,8 @@ Colorbar(fig_or_scene, contourf::Makie.Contourf; kwargs...) minorticks = IntervalsBetween(5) "The width or height of the colorbar, depending on if it's vertical or horizontal, unless overridden by `width` / `height`" size = 12 + "The additional space between the colorbar content and its suggested boundingbox. Only used for overlay positioning." + margin = (0.0f0, 0.0f0, 0.0f0, 0.0f0) end end @@ -1030,6 +1032,29 @@ end end end +""" + Spinner(figposition; message = "Working…", running = false) + +Braille-frame busy indicator. Set `sp.running = true`/`false` to +start/stop the frame animation. +""" +@Block Spinner begin + @attributes begin + message = "Working…" + running::Bool = false + fontsize::Float32 = @inherit(:fontsize, 20.0f0) + color::RGBAf = @inherit((:colors, :accent), :black) + frames::Vector{String} = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + frame_interval::Float32 = 0.09f0 + halign = :center + valign = :center + tellwidth::Bool = false + tellheight::Bool = false + alignmode = Inside() + visible::Bool = true + end +end + @Block Box begin @attributes begin "Controls if the rectangle is visible." @@ -1089,11 +1114,11 @@ end "The width of the slider line" linewidth::Float32 = 10 "The color of the slider when the mouse hovers over it." - color_active_dimmed::RGBAf = COLOR_ACCENT_DIMMED[] + color_active_dimmed::RGBAf = @inherit((:colors, :accent_subtle)) "The color of the slider when the mouse clicks and drags the slider." - color_active::RGBAf = COLOR_ACCENT[] + color_active::RGBAf = @inherit((:colors, :accent)) "The color of the slider when it is not interacted with." - color_inactive::RGBAf = RGBf(0.94, 0.94, 0.94) + color_inactive::RGBAf = @inherit((:colors, :surface)) "Controls if the slider has a horizontal orientation or not." horizontal::Bool = true "The align mode of the slider in its parent GridLayout." @@ -1191,11 +1216,11 @@ end "The width of the slider line" linewidth::Float64 = 10.0 "The color of the slider when the mouse hovers over it." - color_active_dimmed::RGBAf = COLOR_ACCENT_DIMMED[] + color_active_dimmed::RGBAf = @inherit((:colors, :accent_subtle)) "The color of the slider when the mouse clicks and drags the slider." - color_active::RGBAf = COLOR_ACCENT[] + color_active::RGBAf = @inherit((:colors, :accent)) "The color of the slider when it is not interacted with." - color_inactive::RGBAf = RGBf(0.94, 0.94, 0.94) + color_inactive::RGBAf = @inherit((:colors, :surface)) "Controls if the slider has a horizontal orientation or not." horizontal::Bool = true "The align mode of the slider in its parent GridLayout." @@ -1236,17 +1261,17 @@ end "The color of the button border." strokecolor = :transparent "The color of the button." - buttoncolor = RGBf(0.94, 0.94, 0.94) + buttoncolor = @inherit((:colors, :surface)) "The color of the label." labelcolor = @inherit(:textcolor, :black) "The color of the label when the mouse hovers over the button." - labelcolor_hover = :black + labelcolor_hover = @inherit((:colors, :text)) "The color of the label when the mouse clicks the button." - labelcolor_active = :white + labelcolor_active = @inherit((:colors, :text_on_accent)) "The color of the button when the mouse clicks the button." - buttoncolor_active = COLOR_ACCENT[] + buttoncolor_active = @inherit((:colors, :accent)) "The color of the button when the mouse hovers over the button." - buttoncolor_hover = COLOR_ACCENT_DIMMED[] + buttoncolor_hover = @inherit((:colors, :accent_subtle)) "The number of clicks that have been registered by the button." clicks = 0 "The align mode of the button in its parent GridLayout." @@ -1287,17 +1312,17 @@ const CHECKMARK_BEZIER = scale( "The strokewidth of the checkbox poly." checkboxstrokewidth = 1.5 "The color of the checkbox background when checked." - checkboxcolor_checked = COLOR_ACCENT[] + checkboxcolor_checked = @inherit((:colors, :accent)) "The color of the checkbox background when unchecked." checkboxcolor_unchecked = @inherit(:backgroundcolor, :white) "The strokecolor of the checkbox background when checked." - checkboxstrokecolor_checked = COLOR_ACCENT[] + checkboxstrokecolor_checked = @inherit((:colors, :accent)) "The strokecolor of the checkbox background when unchecked." - checkboxstrokecolor_unchecked = COLOR_ACCENT[] + checkboxstrokecolor_unchecked = @inherit((:colors, :accent)) "The color of the checkmark when unchecked." checkmarkcolor_unchecked = :transparent "The color of the checkmark when the mouse clicks the checkbox." - checkmarkcolor_checked = :white + checkmarkcolor_checked = @inherit((:colors, :text_on_accent)) "The align mode of the checkbox in its parent GridLayout." alignmode = Inside() "If the checkbox is currently checked. This value should not be modified directly." @@ -1347,12 +1372,12 @@ end # strokewidth = 2f0 # strokecolor = :transparent "The color of the border when the toggle is inactive." - framecolor_inactive = RGBf(0.94, 0.94, 0.94) + framecolor_inactive = @inherit((:colors, :surface)) "The color of the border when the toggle is hovered." - framecolor_active = COLOR_ACCENT_DIMMED[] + framecolor_active = @inherit((:colors, :accent_subtle)) # buttoncolor = RGBf(0.2, 0.2, 0.2) "The color of the toggle button." - buttoncolor = COLOR_ACCENT[] + buttoncolor = @inherit((:colors, :accent)) "Indicates if the toggle is active or not." active = false "The duration of the toggle animation." @@ -1439,17 +1464,17 @@ end "Is the menu showing the available options" is_open = false "Cell color when hovered" - cell_color_hover = COLOR_ACCENT_DIMMED[] + cell_color_hover = @inherit((:colors, :accent_subtle)) "Cell color when active" - cell_color_active = COLOR_ACCENT[] + cell_color_active = @inherit((:colors, :accent)) "Cell color when inactive even" - cell_color_inactive_even = RGBf(0.97, 0.97, 0.97) + cell_color_inactive_even = @inherit((:colors, :surface_subtle)) "Cell color when inactive odd" - cell_color_inactive_odd = RGBf(0.97, 0.97, 0.97) + cell_color_inactive_odd = @inherit((:colors, :surface_subtle)) "Selection cell color when inactive" - selection_cell_color_inactive = RGBf(0.94, 0.94, 0.94) + selection_cell_color_inactive = @inherit((:colors, :surface)) "Color of the dropdown arrow" - dropdown_arrow_color = (:black, 0.2) + dropdown_arrow_color = @inherit((:colors, :text_muted)) "Size of the dropdown arrow" dropdown_arrow_size = 10 "The list of options selectable in the menu. This can be any iterable of a mixture of strings and containers with one string and one other value. If an entry is just a string, that string is both label and selection. If an entry is a container with one string and one other value, the string is the label and the other value is the selection." @@ -1459,13 +1484,311 @@ end "Padding of entry texts" textpadding = (8, 10, 8, 8) "Color of entry texts" - textcolor = :black + textcolor = @inherit(:textcolor, :black) + "Color of entry text in the currently selected row of the open dropdown." + textcolor_active = @inherit((:colors, :text_on_accent)) "The opening direction of the menu (:up or :down)" direction = automatic "The default message prompting a selection when i == 0" prompt = "Select..." "Speed of scrolling in large Menu lists." scroll_speed = 15.0 + "If `true`, typing while the dropdown is open filters the options by `filter(query, label)` (API as in MakieOrg/Makie.jl#5642). Honored only at construction time." + searchable = false + "Placeholder shown in place of the prompt while the searchable dropdown is open and the query is empty." + search_placeholder = "Search…" + "Predicate `(query::String, label::String) -> Bool` deciding whether an option matches the search. Used only when `searchable = true`." + filter = (q, s) -> occursin(lowercase(q), lowercase(s)) + end +end + + +""" + Subfigure(fig_or_scene; kwargs...) + +A clipped, 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). + +The `visible::Observable{Bool}` attribute controls whether the subfigure is +shown. When hidden it renders nothing and, because the scene-stacking event +router (`receives_events`) treats hidden subtrees as inert, receives no +mouse or keyboard input either. + +Content larger than the subfigure scrolls vertically and horizontally; +scrollbars appear only when there is overflow. Content size is derived +from the inner `GridLayout`'s determinable size, so setting fixed row / +column sizes makes the subfigure overflow and scroll. + +`Tabs` is built on `Subfigure` — one per tab, with +`visible = (tabs.active == i)`. Use `Subfigure` directly for scrollable +scientific-figure panels, sidebars, dialog regions, etc. +""" +@Block Subfigure begin + scene::Scene + scroll::Observable{Vec2f} + contentsize::Observable{Vec2f} + @attributes begin + "Whether the subfigure is shown. When `false` it renders nothing and receives no input." + visible = true + "Padding (in pixels) around the content, as a number or a (left, right, bottom, top) tuple." + contentpadding = 10 + "Background color of the content area." + backgroundcolor = :transparent + "Whether wheel/trackpad scroll inside the subfigure scrolls its content." + scrollable = true + "Speed of wheel/trackpad scrolling." + scroll_speed = 15.0 + "Thickness in pixels of the scrollbar thumbs shown when content overflows." + scrollbar_size = 6 + "Background color of the scrollbar track (default transparent — only the thumb is drawn)." + scrollbar_color = RGBAf(0, 0, 0, 0) + "Color of the scrollbar thumb (the draggable handle)." + scrollbar_thumb_color = RGBAf(0, 0, 0, 0.3) + "Color of the scrollbar thumb when hovered or dragged." + scrollbar_thumb_color_active = RGBAf(0, 0, 0, 0.5) + # halign / valign / alignmode match the block layout mixin defaults and + # are added automatically; only the ones that differ are set here. + "The height setting of the subfigure." + height = nothing + "The width setting of the subfigure." + width = nothing + "Controls if the parent layout can adjust to this element's width." + tellwidth = false + "Controls if the parent layout can adjust to this element's height." + tellheight = false + end +end + + +""" +Resolved metrics of a tab label's font, in EM units (multiply by fontsize for +pixels). Used to size and baseline-align the close `×`. +""" +struct TabFontMetrics + ascent::Float32 + descent::Float32 + x_height::Float32 +end + +""" +Per-tab state for [`Tabs`](@ref): the tab's [`Subfigure`](@ref), its label and +closability, and the observables backing its header rendering. One `TabData` +exists per live tab, so closing a tab in the middle is a single `deleteat!` with +nothing to keep in sync. Mutate via [`add_tab!`](@ref), [`remove_tab!`](@ref), +[`set_tab!`](@ref) rather than directly. +""" +struct TabData + subfigure::Subfigure + label::Observable{Any} + closable::Observable{Bool} + visible::Observable{Bool} + rect::Observable{Rect2f} + bgcolor::Observable{RGBAf} + labelpos::Observable{Point2f} + labelcolor::Observable{RGBAf} + labelboundingboxes::Observable + close_segments::Observable{Vector{Point2f}} + close_color::Observable{RGBAf} + close_rect::Observable{Rect2f} + close_visible::Observable{Bool} + plots::Tuple{Any, Any, Any} # (poly, label, close) for deletion +end + +""" + Tabs(fig_or_scene, labels = ["Tab 1", "Tab 2"]; closable = true, kwargs...) + +A tabbed container. Each tab is backed by a [`Subfigure`](@ref): place blocks +with `tabs[i][row, col] = Axis(...)` or plot directly into +`content_scene(tabs, i)`. Only the active tab is visible, and because hidden +scenes are inert to the event router, only the active tab receives mouse / +keyboard events. Content larger than the visible area scrolls vertically and +horizontally. + +`labels` (a positional argument) and the `closable` keyword seed the initial +tabs; they are not reactive attributes. Change the set of tabs afterwards with +the setter functions [`add_tab!`](@ref), [`remove_tab!`](@ref) and +[`set_tab!`](@ref). `closable` may be a single `Bool` (applied to every initial +tab) or a `Vector{Bool}` (one entry per tab; tabs beyond its length are not +closable). The active tab is the scalar `active` attribute. +""" +@Block Tabs begin + tabs::Vector{TabData} + content_area::Observable{Rect2i} + headerheight::Observable{Float64} + separator_path::Observable{Vector{Point2f}} + hovered::Observable{Int} + close_hovered::Observable{Int} + font_metrics::Observable{TabFontMetrics} + font_metrics_captured::Bool + @attributes begin + "Index of the active (visible) tab, or `0` when there are no tabs." + active = 1 + "Height of the tab header strip in pixels, or `automatic` to derive it from the label size." + tabheight = automatic + "Font size of the tab labels." + fontsize = @inherit(:fontsize, 16.0f0) + "Font of the tab labels." + font = :regular + "Padding (left, right, bottom, top) around each tab label in pixels." + tabpadding = (12, 12, 8, 8) + "Corner radius of the tab header backgrounds." + cornerradius = 0 + "Number of vertices used to render rounded tab corners." + cornersegments = 10 + "Background color of the active tab header." + tabcolor_active = :white + "Background color of inactive tab headers." + tabcolor_inactive = :white + "Background color of a hovered, inactive tab header (brief gray feedback while pointing/clicking)." + tabcolor_hover = RGBf(0.92, 0.92, 0.92) + "Color of the active tab label." + labelcolor_active = :black + "Color of inactive tab labels." + labelcolor_inactive = RGBf(0.4, 0.4, 0.4) + "Color of the close (×) icon when idle." + closecolor = RGBf(0.5, 0.5, 0.5) + "Color of the close (×) icon when hovered." + closecolor_hover = RGBf(0, 0, 0) + "Gap in pixels between adjacent tab headers." + tabgap = 0 + "Color of the thin separator line drawn under the header strip (broken under the active tab)." + separator_color = RGBf(0.82, 0.82, 0.82) + "Thickness in pixels of the header bottom separator." + separator_thickness = 1 + "Padding (in pixels) forwarded to each tab's content area." + contentpadding = 10 + "The height setting of the tabs block." + height = nothing + "The width setting of the tabs block." + width = nothing + "Controls if the parent layout can adjust to this element's width." + tellwidth = false + "Controls if the parent layout can adjust to this element's height." + tellheight = false + "The horizontal alignment of the block in its suggested bounding box." + halign = :center + "The vertical alignment of the block in its suggested bounding box." + valign = :center + "The alignment of the block in its suggested bounding box." + alignmode = Inside() + end +end + + +""" + Table(fig_or_scene; kwargs...) + +A table widget for displaying tabular data with interactive features like row selection, +column sorting, and scrolling. + +## Example + +```julia +fig = Figure() +data = (name = ["Alice", "Bob", "Charlie"], age = [25, 30, 35], city = ["NYC", "LA", "Chicago"]) +t = Table(fig[1,1]; data = data) + +# Listen to selection changes +on(t.selection) do sel + println("Selected: ", sel) +end +``` + +## Attributes +""" +@Block Table begin + @attributes begin + # Only `width` differs from the block layout mixin defaults; the rest + # (height, tellwidth, tellheight, halign, valign, alignmode) are added + # automatically with the same values. + "The width setting of the table." + width = nothing + + "The tabular data to display. Can be a NamedTuple or Dict{Symbol, Any} where each value is a column vector." + data = (a = [1, 2, 3], b = ["x", "y", "z"]) + "Custom column names to display. If `automatic`, uses the data's keys." + column_names = automatic + "Column widths. Can be `:auto` (equal widths), `:fit` (auto-fit to content), a number (all same width), or a vector of widths." + column_widths = :auto + "Row heights. Can be `automatic` (uniform using row_height), a number, or a vector of heights per row." + row_heights = automatic + + "Index of the currently selected row. 0 means no selection." + i_selected = 0 + "The data of the currently selected row as a NamedTuple. This is the output observable to listen to." + selection = nothing + "Selected cell as (row, col) tuple. (0, 0) means no cell selection." + i_selected_cell = (0, 0) + "The data of the currently selected cell. This is the output observable for cell-level selection." + cell_selection = nothing + + "Index of the column to sort by. 0 means no sorting." + sort_column = 0 + "Sort direction, either `:ascending` or `:descending`." + sort_direction = :ascending + "Whether clicking column headers enables sorting." + sortable = true + + "Maximum number of visible rows. If `nothing`, shows all rows." + max_visible_rows = nothing + "Current scroll offset (row index to start displaying from)." + scroll_offset = 0 + "Scroll speed multiplier for mouse wheel scrolling." + scroll_speed = 3.0 + + "Background color of the header row." + header_color = RGBf(0.2, 0.2, 0.2) + "Text color of the header row." + header_textcolor = :white + "Font size of the header text." + header_fontsize = 14.0f0 + "Height of the header row in pixels." + header_height = 30.0 + "Whether to show sort direction indicator (↑/↓) in the header." + show_sort_indicator = true + + "Per-cell background colors as a Matrix. If `automatic`, uses even/odd coloring." + cell_color = automatic + "Background color of even-numbered data rows (when cell_color is automatic)." + cell_color_even = RGBf(0.98, 0.98, 0.98) + "Background color of odd-numbered data rows (when cell_color is automatic)." + cell_color_odd = RGBf(0.94, 0.94, 0.94) + "Background color of the hovered row." + cell_color_hover = @inherit((:colors, :accent_subtle)) + "Background color of the selected row." + cell_color_selected = @inherit((:colors, :accent)) + "Text color of data cells." + cell_textcolor = :black + "Font size of data cell text." + cell_fontsize = 12.0f0 + "Height of each data row in pixels." + row_height = 25.0 + "Padding inside cells as (left, right, bottom, top)." + cell_padding = (8, 8, 4, 4) + + "Whether to show grid lines." + show_grid = true + "Color of grid lines." + grid_color = RGBf(0.8, 0.8, 0.8) + "Width of grid lines." + grid_linewidth = 1.0 + "Whether to show vertical grid lines." + show_vertical_lines = true + "Whether to show horizontal grid lines." + show_horizontal_lines = true + + "Callback function `(table, row_index, row_data) -> nothing` called on row click." + on_row_click = nothing + "Callback function `(table, row_index, row_data) -> nothing` called on row double-click." + on_row_doubleclick = nothing + "Callback function `(table, column_index, direction) -> nothing` called when sort changes." + on_sort_change = nothing + "Callback function `(table, row, col, cell_data) -> nothing` called on cell click." + on_cell_click = nothing + "Callback function `(table, row, col, cell_data) -> nothing` called on cell right-click." + on_cell_rightclick = nothing end end @@ -1780,7 +2103,7 @@ end "Text color." textcolor = @inherit(:textcolor, :black) "Text color for the placeholder." - textcolor_placeholder = RGBf(0.5, 0.5, 0.5) + textcolor_placeholder = @inherit((:colors, :text_muted)) "Font family." font = :regular "Color of the box." @@ -1792,11 +2115,11 @@ end "Color of the box when hovered." boxcolor_hover = :transparent "Color of the box border." - bordercolor = RGBf(0.8, 0.8, 0.8) + bordercolor = @inherit((:colors, :border)) "Color of the box border when hovered." - bordercolor_hover = COLOR_ACCENT_DIMMED[] + bordercolor_hover = @inherit((:colors, :accent_subtle)) "Color of the box border when focused." - bordercolor_focused = COLOR_ACCENT[] + bordercolor_focused = @inherit((:colors, :accent)) "Color of the box border when focused and invalid." bordercolor_focused_invalid = RGBf(1, 0, 0) "Width of the box border." @@ -1814,7 +2137,7 @@ end "Restricts the allowed unicode input via is_allowed(char, restriction)." restriction = nothing "The color of the cursor." - cursorcolor = COLOR_ACCENT[] + cursorcolor = @inherit((:colors, :accent)) end end @@ -2500,3 +2823,124 @@ SomeBlock(fig[i, j][1, 1], ...) ``` """ @Block Container + +@Block HoverMenu begin + @forwarded_layout + box::Box + save_button::Button + copy_button::Button + reset_button::Button + @attributes begin + "The horizontal alignment of the HoverMenu bar" + halign = :center + "The vertical alignment of the HoverMenu bar" + valign = :top + "The width of the HoverMenu bar" + width = 220 + "The height of the HoverMenu bar" + height = 40 + "Controls if the parent layout can adjust to this element's width" + tellwidth = false + "Controls if the parent layout can adjust to this element's height" + tellheight = false + "The background color of the bar" + bar_color = @inherit((:colors, :surface)) + "The stroke color of the bar" + bar_strokecolor = @inherit((:colors, :border)) + "The corner radius of the bar" + corner_radius = 8 + "The button background color" + button_color = @inherit((:colors, :surface)) + "The button hover color" + button_color_hover = @inherit((:colors, :accent_subtle)) + "The button color while pressed" + button_color_active = @inherit((:colors, :accent)) + "The button label color" + label_color = @inherit((:colors, :text)) + "The button label color while pressed" + label_color_active = @inherit((:colors, :text_on_accent)) + "The button font" + font = :regular + "The button font size" + fontsize = 12 + "The axis to reset when clicking the reset button" + target_axis = nothing + end +end + +""" + Modal(fig; title = "", kwargs...) + +A modal dialog floating above the figure: a translucent backdrop dims and +blocks pointer input to everything underneath, while a centered body holds a +`GridLayout` for content. Place content with `modal[row, col] = ...` (or +`Axis(modal[1, 1])` etc.), then show it with `open!(modal)` and hide it with +`close!(modal)` (also triggered by the × button and — unless +`dismiss_on_backdrop_click = false` — by clicking the backdrop). + +The body auto-sizes to its content (floored at `min_size`); pass numbers for +`width`/`height` to fix the body size instead, in which case overflowing +content scrolls (the content area is a [`Subfigure`](@ref)). + +Pointer isolation uses the scene-stacking event router: the overlay scene is +marked `captures_mouse = true`, so while the modal is open only its own +subtree receives events. +""" +@Block Modal begin + overlay::Scene + subfigure::Subfigure + @attributes begin + "Title shown in the modal's header." + title = "" + "Whether the modal is currently shown. Use `open!`/`close!` or set directly." + open = false + "Color of the backdrop dimming the figure behind the modal." + backdrop_color = (:black, 0.35) + "Background color of the modal body." + color = @inherit((:colors, :background)) + "Border color of the modal body." + strokecolor = @inherit((:colors, :border)) + "Border line width of the modal body." + strokewidth = 1.0 + "Corner radius of the modal body." + cornerradius = 8 + "Number of vertices used per rounded corner." + cornersegments = 10 + "Font size of the title." + titlesize = 16.0f0 + "Font of the title." + titlefont = :bold + "Color of the title." + titlecolor = @inherit((:colors, :text)) + "Color of the separator line under the header." + separator_color = @inherit((:colors, :border)) + "Color of the close (×) icon when idle." + closecolor = @inherit((:colors, :text_muted)) + "Color of the close (×) icon when hovered." + closecolor_hover = @inherit((:colors, :text)) + "Whether clicking the backdrop (outside the body) closes the modal." + dismiss_on_backdrop_click = true + "Padding in pixels between the body border and the content layout." + contentpadding = 16 + "Height in pixels of the title header strip." + header_height = 40 + "Minimum body size (width, height) in pixels when auto-sizing." + min_size = (280, 140) + "Maximum body size (width, height) in pixels when auto-sizing; content beyond this scrolls." + max_size = (Inf, Inf) + "Body width in pixels, or `Auto()` to size to the content." + width = Auto() + "Body height in pixels, or `Auto()` to size to the content." + height = Auto() + "The horizontal alignment of the block in its suggested bounding box." + halign = :center + "The vertical alignment of the block in its suggested bounding box." + valign = :center + "Controls if the parent layout can adjust to this element's width." + tellwidth = false + "Controls if the parent layout can adjust to this element's height." + tellheight = false + "The alignment of the block in its suggested bounding box." + alignmode = Inside() + end +end diff --git a/Makie/src/scenes.jl b/Makie/src/scenes.jl index a3e4e93cd81..05c1d123ff8 100644 --- a/Makie/src/scenes.jl +++ b/Makie/src/scenes.jl @@ -119,6 +119,14 @@ mutable struct Scene <: AbstractScene # Can't type this, don't have the type yet data_inspector::Any + """ + Pointer-routing opt-in: when `true`, this scene claims the pointer for its + subtree while visible (see `covers_pointer`/`receives_events`), regardless + of `clear`/z. For overlays that paint a translucent backdrop via plots + (e.g. a modal dialog) instead of an opaque `clear = true` background. + """ + captures_mouse::Bool + function Scene( parent::Union{Nothing, Scene}, events::Events, @@ -158,7 +166,8 @@ mutable struct Scene <: AbstractScene ComputeGraph(), DimConversions(), false, - nothing + nothing, + false ) add_camera_computation!(scene.compute, scene) add_light_computation!(scene.compute, scene, lights) @@ -437,6 +446,54 @@ function root(scene::Scene) end parent_or_self(scene::Scene) = isroot(scene) ? scene : parent(scene) +""" + scene_visible(scene::Scene) + +Effective visibility of `scene`: `true` only if `scene` and all of its ancestors +are visible. Unlike `scene.visible[]`, this cascades down the scene tree, so a +scene nested under an invisible ancestor counts as hidden even when its own +`visible[]` is `true` (as happens when a `Block` force-shows its own scene). +""" +function scene_visible(scene::Scene) + while true + scene.visible[] || return false + isroot(scene) && return true + scene = parent(scene) + end + return +end + +""" + effective_clip(scene::Scene)::Rect2i + +Intersection of `scene`'s own viewport with every ancestor's viewport. +Backends use this as the scissor rectangle, so a scene is rendered only within +the bounds shared by itself and all of its parents. Including the scene's own +viewport keeps the scissor a subset of it (some drivers require `scissor ⊆ +viewport`), while intersecting the ancestors is what clips content that has +scrolled or been positioned outside an enclosing region (e.g. a `Subfigure`). + +For the root scene this is just its own viewport, so the scissor matches the +window. +""" +function effective_clip(scene::Scene) + rect = viewport(scene)[] + s = scene + while !isroot(s) + s = parent(s) + rect = intersect(rect, viewport(s)[]) + end + # `intersect` on `Rect2i` returns negative widths for disjoint rects, which + # turns `glScissor` into `GL_INVALID_VALUE` (and Cairo's clip into a no-op). + # Clamp to an empty rect at the intersection origin so callers get a sane + # "draw nothing" instead. + w = widths(rect) + if w[1] < 0 || w[2] < 0 + return Rect2i(minimum(rect), Vec2i(0, 0)) + end + return rect +end + GeometryBasics.widths(scene::Scene) = widths(to_value(viewport(scene))) Base.size(scene::Scene) = Tuple(widths(scene)) @@ -737,18 +794,45 @@ is2d(lims::Rect3) = widths(lims)[3] == 0.0 ##### Figure type ##### +""" + GUIState + +Stores the configuration and state of GUI elements for a Figure. +Created during Figure construction with normalized options from figure attributes and theme. + +## Fields +Options (nothing = disabled, Dict = enabled with options): +- `hovermenu_options::Union{Nothing, Dict{Symbol,Any}}`: Options for the hover menu bar +- `legend_options::Union{Nothing, Dict{Symbol,Any}}`: Options for the legend overlay +- `colorbar_options::Union{Nothing, Dict{Symbol,Any}}`: Options for the colorbar overlay + +Created elements (nothing until added): +- `hovermenu::Union{Nothing, Any}`: Reference to the hover menu elements if created +- `legend::Union{Nothing, Block}`: Reference to the legend if created +- `colorbar::Union{Nothing, Block}`: Reference to the colorbar if created +""" +mutable struct GUIState + # Options (nothing = disabled) + hovermenu_options::Union{Nothing, Dict{Symbol, Any}} + legend_options::Union{Nothing, Dict{Symbol, Any}} + colorbar_options::Union{Nothing, Dict{Symbol, Any}} + # Created elements + hovermenu::Union{Nothing, Any} + legend::Union{Nothing, Block} + colorbar::Union{Nothing, Block} +end + +function GUIState(; hovermenu_options = nothing, legend_options = nothing, colorbar_options = nothing) + return GUIState(hovermenu_options, legend_options, colorbar_options, nothing, nothing, nothing) +end + struct Figure scene::Scene layout::GridLayoutBase.GridLayout content::Vector attributes::Attributes current_axis::Ref{Any} - - function Figure(args...) - f = new(args...) - current_figure!(f) - return f - end + gui_state::GUIState end struct FigureAxisPlot diff --git a/Makie/src/theming.jl b/Makie/src/theming.jl index 5aa49387632..6a02f6dff41 100644 --- a/Makie/src/theming.jl +++ b/Makie/src/theming.jl @@ -28,6 +28,68 @@ end const DEFAULT_PALETTES = generate_default_palette() +const DEFAULT_ACCENT_COLOR = RGBf((79, 122, 214) ./ 255...) + +# Rec. 709 / sRGB luminance coefficients. +_relative_luminance(c) = 0.2126f0 * red(c) + 0.7152f0 * green(c) + 0.0722f0 * blue(c) + +""" + derive_colors(; accent = Makie.DEFAULT_ACCENT_COLOR, + gray = automatic, + background = :white) + +Derive a complete set of UI role colors from a small set of user inputs. The +result is a `NamedTuple` of nine `RGBf` values that Block defaults can pull +from via the theme's nested `colors` block. + +Inputs: +- `accent`: primary accent color used for active, checked, and focused states. +- `gray`: the "contrast pole" mixed with `background` to produce neutrals. + `automatic` picks pure black for light backgrounds and pure white for dark + ones. Pass a tinted color (e.g. a slightly warm dark) to bias all neutrals + toward that hue. +- `background`: the canvas background. Its luminance also selects the default + contrast pole when `gray = automatic`. + +Returns roles: `background`, `surface`, `surface_subtle`, `border`, `text`, +`text_muted`, `text_on_accent`, `accent`, `accent_subtle`. State conventions +across Blocks: idle uses `surface`, hover uses `accent_subtle`, and +active/checked/focused uses `accent`. + +Mixing happens in Oklab so equal weights between `background` and `gray`/ +`accent` give perceptually-even steps. The weights are picked to give a +familiar light-mode appearance (≈92% surface, ≈74% border, ≈50% muted text) +while remaining well-balanced when `gray` or `background` is tinted. +""" +function derive_colors(; + accent = DEFAULT_ACCENT_COLOR, + gray = automatic, + background = :white, + ) + bg = RGBf(to_color(background)) + a = RGBf(to_color(accent)) + auto_pole = _relative_luminance(bg) >= 0.5f0 ? RGBf(0, 0, 0) : RGBf(1, 1, 1) + g = RGBf(to_color(default_automatic(gray, auto_pole))) + + # Text on an accent fill must contrast with the accent itself, not with the + # page background — pick by accent luminance regardless of `gray`. + text_on_accent = _relative_luminance(a) >= 0.5f0 ? RGBf(0, 0, 0) : RGBf(1, 1, 1) + + return ( + background = bg, + surface = lerp_oklab(bg, g, 0.06), + surface_subtle = lerp_oklab(bg, g, 0.03), + border = lerp_oklab(bg, g, 0.2), + text = g, + text_muted = lerp_oklab(bg, g, 0.4), + text_on_accent = text_on_accent, + accent = a, + accent_subtle = lerp_oklab(bg, a, 0.45), + ) +end + +const DEFAULT_COLORS = derive_colors() + const MAKIE_DEFAULT_THEME = Attributes( palette = DEFAULT_PALETTES, font = :regular, @@ -37,6 +99,7 @@ const MAKIE_DEFAULT_THEME = Attributes( italic = "TeX Gyre Heros Makie Italic", bold_italic = "TeX Gyre Heros Makie Bold Italic", ), + colors = Attributes(DEFAULT_COLORS), fontsize = 14, textcolor = :black, padding = Vec3f(0.05), @@ -64,6 +127,7 @@ const MAKIE_DEFAULT_THEME = Attributes( visible = true, Axis = Attributes(), Axis3 = Attributes(), + Figure = Attributes(), legend = Attributes(), axis_type = automatic, camera = automatic, @@ -147,7 +211,7 @@ const MAKIE_DEFAULT_THEME = Attributes( resource = automatic, plugin = automatic, max_recursion = 10 - ) + ), ) const CURRENT_DEFAULT_THEME = deepcopy(MAKIE_DEFAULT_THEME) diff --git a/Makie/src/utilities/utilities.jl b/Makie/src/utilities/utilities.jl index 60fbde37b6f..83f6e17a01a 100644 --- a/Makie/src/utilities/utilities.jl +++ b/Makie/src/utilities/utilities.jl @@ -319,6 +319,20 @@ lerp(a::T, b::T, val::AbstractFloat) where {T} = a .+ val * (b .- a) lerp(a::RGBAf, b::RGBAf, val::AbstractFloat) = a .+ val * (b .- a) lerp(a::Colorant, b::Colorant, val::AbstractFloat) = lerp(RGBAf(a), RGBAf(b), val) +# Perceptually-uniform interpolation between two colors via Oklab, so equal +# values of `t` produce equal perceived steps even when one pole is a saturated +# accent or a non-neutral gray. +function lerp_oklab(a::Colorant, b::Colorant, t::Real) + oa = convert(Colors.Oklab, a) + ob = convert(Colors.Oklab, b) + m = Colors.Oklab( + (1 - t) * oa.l + t * ob.l, + (1 - t) * oa.a + t * ob.a, + (1 - t) * oa.b + t * ob.b, + ) + return RGBf(convert(Colors.RGB, m)) +end + function merged_get!(defaults::Function, key, scene, input::Vector{Any}) return merged_get!(defaults, key, scene, Attributes(input)) end diff --git a/Makie/test/SceneLike/card.jl b/Makie/test/SceneLike/card.jl new file mode 100644 index 00000000000..ae094fb0b1d --- /dev/null +++ b/Makie/test/SceneLike/card.jl @@ -0,0 +1,156 @@ +using Makie: Card, card_accessory, filter_cards! +const GLB = Makie.GridLayoutBase + +"Stack height as the parent layout sees it — the number a scroll panel is sized from." +stackheight(gl) = GLB.determinedirsize(gl, GLB.Row()) + +"A stack of `n` cards, each with a 30px body, spaced only by the cards themselves." +function cardstack(n; kwargs...) + fig = Figure(size = (400, 900)) + # `default_rowgap`, not `rowgap!` afterwards: the latter only sets the gaps + # that exist at the time, so a card added later would come back with the + # default 18px gap above it. (`rowgap` is not a kwarg here — it lands in + # `kwargs...` and does nothing at all.) + stack = GridLayout(fig[1, 1]; valign = :top, default_rowgap = 0) + cards = [Card(stack[i, 1]; title = "Card $i", kwargs...) for i in 1:n] + for (i, c) in enumerate(cards) + Label(c[1, 1], "body $i"; tellwidth = false, height = 30) + end + Makie.update_state_before_display!(fig) + return fig, stack, cards +end + +# 26 header + 30 body + 18 body padding (8 top, 10 bottom) + 8 spacing +const CARDHEIGHT = 82 +const FOLDEDHEIGHT = 26 + 8 + +@testset "Card" begin + @testset "sizes to its content" begin + fig, stack, cards = cardstack(6) + @test stackheight(stack) == 6 * CARDHEIGHT + @test GLB.determinedirsize(cards[1].layout, GLB.Row()) == CARDHEIGHT + end + + @testset "hiding COLLAPSES, and restores" begin + fig, stack, cards = cardstack(6) + for i in (2, 4, 6) + cards[i].visible = false + end + Makie.update_state_before_display!(fig) + # The whole point: no holes where the hidden cards were, and no leftover + # gaps either — the spacing belongs to the card, not to the stack. + @test stackheight(stack) == 3 * CARDHEIGHT + for i in (2, 4, 6) + cards[i].visible = true + end + Makie.update_state_before_display!(fig) + @test stackheight(stack) == 6 * CARDHEIGHT + end + + @testset "folding keeps the header" begin + fig, stack, cards = cardstack(6) + cards[1].open = false + Makie.update_state_before_display!(fig) + @test stackheight(stack) == 5 * CARDHEIGHT + FOLDEDHEIGHT + cards[1].open = true + Makie.update_state_before_display!(fig) + @test stackheight(stack) == 6 * CARDHEIGHT + end + + @testset "a hidden card's content is inert" begin + fig, stack, cards = cardstack(3) + label = contents(cards[2][1, 1])[1] + @test label.blockscene.visible[] + cards[2].visible = false + @test !label.blockscene.visible[] + cards[2].visible = true + @test label.blockscene.visible[] + # Folding hides the body too, without collapsing the card away. + cards[2].open = false + @test !label.blockscene.visible[] + end + + @testset "hide! does NOT collapse" begin + fig, stack, cards = cardstack(3) + h = stackheight(stack) + # `hide!` is the scroll-culling path: a card that collapsed because it + # scrolled out of view would change the content size that decides what + # is out of view. + Makie.hide!(cards[2]) + Makie.update_state_before_display!(fig) + @test stackheight(stack) == h + Makie.unhide!(cards[2]) + @test stackheight(stack) == h + end + + @testset "unhide! re-syncs with visible, it does not force" begin + fig, stack, cards = cardstack(3) + cards[2].visible = false + label = contents(cards[2][1, 1])[1] + Makie.unhide!(cards[2]) # e.g. scrolled back into view + @test !label.blockscene.visible[] # still filtered out + @test stackheight(stack) == 2 * CARDHEIGHT + end + + @testset "filter_cards! batches" begin + fig, stack, cards = cardstack(8) + filter_cards!(stack, cards) do c + iseven(parse(Int, split(c.title[])[2])) + end + Makie.update_state_before_display!(fig) + @test stackheight(stack) == 4 * CARDHEIGHT + @test [c.visible[] for c in cards] == [false, true, false, true, false, true, false, true] + filter_cards!(_ -> true, stack, cards) + Makie.update_state_before_display!(fig) + @test stackheight(stack) == 8 * CARDHEIGHT + end + + @testset "header click folds; the accessory cell is not the card's" begin + fig, stack, cards = cardstack(2) + c = cards[1] + Button(card_accessory(c); label = "×", width = 20, height = 18) + Makie.update_state_before_display!(fig) + @test c.open[] + # A press on the header bar, away from the accessory. The BLOCK's + # computedbbox is the laid-out one — `init_layout!` disconnects the + # inner layout's, so reading that gives a stale default box. + head = c.layoutobservables.computedbbox[] + pos = Point2f(head.origin[1] + 30, head.origin[2] + head.widths[2] - 13) + events = c.blockscene.events + events.mouseposition[] = Tuple(pos) + events.mousebutton[] = Makie.MouseButtonEvent(Mouse.left, Mouse.press) + @test !c.open[] + @test c.headerclicks[] == 1 + end + + @testset "a card that starts hidden takes no space" begin + fig, stack, cards = cardstack(3) + c = Card(stack[4, 1]; title = "Hidden", visible = false) + Label(c[1, 1], "body"; tellwidth = false, height = 30) + Makie.update_state_before_display!(fig) + @test stackheight(stack) == 3 * CARDHEIGHT + end +end + +@testset "Card scenes" begin + # The card owns a scene for itself and a nested one for its body. Both matter: + # a container that only hides its OWN scene leaves the blocks a caller placed + # in it drawing, because those are separate blocks with separate scenes — and + # a Subfigure culling its scrolled-out content will happily `unhide!` them + # again. Parenting makes the card's state win, because `unhide!` returns early + # on a block whose parent scene is invisible. + fig, stack, cards = cardstack(3) + c = cards[2] + label = contents(c[1, 1])[1] + @test parent(label.blockscene) === c.bodyscene + @test parent(c.bodyscene) === c.scene + + b = Button(card_accessory(c); label = "×", width = 20, height = 18) + @test parent(b.blockscene) === c.scene # the header lives in the card's scene… + c.open = false + @test !c.bodyscene.visible[] # …so folding does not take it with it + @test c.scene.visible[] + c.open = true + c.visible = false + @test !c.scene.visible[] # hiding takes everything +end diff --git a/Makie/test/SceneLike/subfigure.jl b/Makie/test/SceneLike/subfigure.jl new file mode 100644 index 00000000000..9d302b14178 --- /dev/null +++ b/Makie/test/SceneLike/subfigure.jl @@ -0,0 +1,58 @@ +@testset "refresh_contentsize! is idempotent" begin + f = Figure() + sf = Subfigure(f.scene; bbox = Observable(Rect2f(0, 0, 300, 300))) + Button(sf.layout[1, 1]; label = "x", width = 80, height = 20) + Makie.update_state_before_display!(f) + + first = refresh_contentsize!(sf) + @test refresh_contentsize!(sf) == first + @test sf.contentsize[] == first +end + +@testset "Modal auto-sizes to content and honours min/max_size" begin + f = Figure() + m = Modal(f; min_size = (200, 60), max_size = (200, 200), title = "t") + open!(m) + + add_rows!(n) = replace_content!(m) do sf + for i in 1:n + Button(sf.layout[i, 1]; label = "b$i", width = 100, height = 20) + end + end + + add_rows!(2) + Makie.update_state_before_display!(f) + small = m.subfigure.layoutobservables.computedbbox[].widths[2] + + add_rows!(5) + Makie.update_state_before_display!(f) + mid = m.subfigure.layoutobservables.computedbbox[].widths[2] + @test mid > small + + # Back to the smaller list: no stale rows, so it shrinks back exactly. + add_rows!(2) + Makie.update_state_before_display!(f) + @test m.subfigure.layoutobservables.computedbbox[].widths[2] ≈ small + + # Well past max_size: body is clamped and the content scrolls. + add_rows!(40) + Makie.update_state_before_display!(f) + @test m.subfigure.layoutobservables.computedbbox[].widths[2] <= 200 +end + +@testset "replace_content! leaves no empty tracks behind" begin + f = Figure() + m = Modal(f; title = "t") + open!(m) + + for n in (6, 2, 9, 1) + replace_content!(m) do sf + for i in 1:n + Button(sf.layout[i, 1]; label = "b$i", width = 60, height = 18) + end + end + Makie.update_state_before_display!(f) + @test length(contents(m.layout)) == n + @test size(m.layout) == (n, 1) + end +end diff --git a/Makie/test/events_isolation.jl b/Makie/test/events_isolation.jl new file mode 100644 index 00000000000..e6443fa4b0e --- /dev/null +++ b/Makie/test/events_isolation.jl @@ -0,0 +1,92 @@ +using Makie +using Makie: MouseButtonEvent, Mouse, receives_events +using Test + +@testset "Tabs event isolation (shared events + receives_events)" begin + f = Figure() + t = Tabs(f[1, 1], ["A", "B"]) + Axis(t[1][1, 1]) + Axis(t[2][1, 1]) + s1, s2 = content_scene(t, 1), content_scene(t, 2) + + # Tabs share the figure's Events (no per-tab Events / event forwarding). + @test events(s1) === events(f.scene) + @test events(s2) === events(f.scene) + + # Isolation is by visibility: only the active tab is visible, and a hidden + # scene is inert to the event router (`receives_events` short-circuits on + # `visible[] == false`). Handlers that guard on `receives_events` / + # `is_mouseinside` therefore fire only for the active tab. + t.active[] = 1 + @test s1.visible[] == true + @test s2.visible[] == false + @test receives_events(s1) == true + @test receives_events(s2) == false + + t.active[] = 2 + @test s1.visible[] == false + @test s2.visible[] == true + @test receives_events(s1) == false + @test receives_events(s2) == true +end + +labels_of(t) = [td.label[] for td in t.tabs] +closable_of(t) = [td.closable[] for td in t.tabs] + +@testset "Tabs closable" begin + f = Figure() + t = Tabs(f[1, 1], ["A", "B", "C"]; closable = [true, false, true]) + scenes0 = [content_scene(t, i) for i in 1:3] + + # click the center of tab `slot`'s close glyph (the LineSegments plots are + # the close ×s, in tab order; the separator is a `Lines`, not LineSegments) + function click_close(slot) + cps = filter(p -> p isa Makie.LineSegments, t.blockscene.plots) + seg = cps[slot].positions[] + cx = sum(p -> p[1], seg) / length(seg) + cy = sum(p -> p[2], seg) / length(seg) + e = t.blockscene.events + e.mouseposition[] = (Float64(cx), Float64(cy)) + e.mousebutton[] = MouseButtonEvent(Mouse.left, Mouse.press) + return e.mousebutton[] = MouseButtonEvent(Mouse.left, Mouse.release) + end + + click_close(1) # close "A" + @test labels_of(t) == ["B", "C"] + @test closable_of(t) == [false, true] # per-tab state stays aligned + # reindex: remaining tabs map to their original content scenes + @test content_scene(t, 1) === scenes0[2] + @test content_scene(t, 2) === scenes0[3] +end + +@testset "Tabs setter API" begin + f = Figure() + t = Tabs(f[1, 1], ["A", "B"]) + @test length(t) == 2 + @test t.active[] == 1 + + sf = add_tab!(t, "C"; activate = true, closable = false) # closable forwarded to set_tab! + @test length(t) == 3 + @test labels_of(t) == ["A", "B", "C"] + @test t.active[] == 3 + @test content_scene(t, 3) === sf.scene + @test closable_of(t) == [true, true, false] + + set_tab!(t, 3; label = "Z", closable = true) + @test labels_of(t) == ["A", "B", "Z"] + + set_tab!(t, 1; closable = false) + @test closable_of(t) == [false, true, true] + + remove_tab!(t, 3) + @test length(t) == 2 + @test t.active[] == 2 # clamped down from removed tab + + remove_tab!(t, 1) + remove_tab!(t, 1) + @test length(t) == 0 + @test t.active[] == 0 # no tabs -> no active tab + + add_tab!(t, "back") + @test t.active[] == 1 # first tab on empty becomes active +end diff --git a/Makie/test/gui.jl b/Makie/test/gui.jl new file mode 100644 index 00000000000..5c02891ceee --- /dev/null +++ b/Makie/test/gui.jl @@ -0,0 +1,375 @@ +@testset "GUIState creation and management" begin + # GUI requires gui=true in figure attributes or theme + f = Figure(; gui = true) + ax = Axis(f[1, 1]) + pl = scatter!(ax, rand(10), label = "test") + + # Initially no GUI elements (not yet displayed) + @test isnothing(f.gui_state.hovermenu) + + # Add GUI + Makie.add_gui!(f, ax, pl) + + # Now has hovermenu + @test !isnothing(f.gui_state.hovermenu) + + # Adding GUI again should not duplicate + hovermenu_before = f.gui_state.hovermenu + Makie.add_gui!(f, ax, pl) + @test f.gui_state.hovermenu === hovermenu_before + + # Remove GUI + Makie.remove_gui!(f) + @test isnothing(f.gui_state.hovermenu) +end + +@testset "add_gui! with FigureAxisPlot" begin + # Test with FigureAxisPlot and figure=(; gui=true) + fap = scatter(rand(10), rand(10), label = "Points"; figure = (; gui = true)) + result = Makie.add_gui!(fap) + + # Should return same FigureAxisPlot + @test result === fap + @test result isa Makie.FigureAxisPlot + + # Figure should have hovermenu + @test !isnothing(fap.figure.gui_state.hovermenu) +end + +@testset "Legend attributes" begin + # Test that legend gets correct labels from plots + f, ax, pl = scatter( + rand(10), label = "My Scatter"; + figure = (; legend = (position = :lt, title = "Test Legend")) + ) + lines!(ax, rand(10), label = "My Line") + Makie.add_gui!(f, ax, pl) + + legend = f.gui_state.legend + @test !isnothing(legend) + @test legend isa Legend + + # Check title was passed through (stored in entrygroups) + @test legend.entrygroups[][1][1] == "Test Legend" + + # Check position (overlay at left-top means halign=:left, valign=:top) + @test legend.halign[] == :left + @test legend.valign[] == :top + + # Test legend with grid position + f2, ax2, pl2 = scatter( + rand(10), label = "Grid Legend"; + figure = (; legend = (position = [1, 2],)) + ) + Makie.add_gui!(f2, ax2, pl2) + legend2 = f2.gui_state.legend + @test !isnothing(legend2) + # Grid position legend should be in layout, not overlay + # For grid positions, tellwidth defaults to Automatic (which behaves as true) + @test legend2.tellwidth[] isa Makie.Automatic + + # Test legend with margin (overlay only) + f3, ax3, pl3 = scatter( + rand(10), label = "Margin Test"; + figure = (; legend = (position = :rt, margin = (20, 20, 20, 20))) + ) + Makie.add_gui!(f3, ax3, pl3) + legend3 = f3.gui_state.legend + @test !isnothing(legend3) + @test legend3.margin[] == (20, 20, 20, 20) + + # Test unique and merge options + f4 = Figure(; legend = (position = :lt, unique = true, merge = true)) + ax4 = Axis(f4[1, 1]) + scatter!(ax4, rand(10), label = "Same") + scatter!(ax4, rand(10), label = "Same") # duplicate label + lines!(ax4, rand(10), label = "Same") # same label, different plot type + Makie.add_gui!(f4, ax4, first(ax4.scene.plots)) + legend4 = f4.gui_state.legend + @test !isnothing(legend4) + # With unique=true and merge=true, should have fewer entries +end + +@testset "Colorbar attributes" begin + # Test colorbar with label + f, ax, pl = heatmap( + rand(10, 10); + figure = (; colorbar = (position = [1, 2], label = "Heat Values")) + ) + Makie.add_gui!(f, ax, pl) + + colorbar = f.gui_state.colorbar + @test !isnothing(colorbar) + @test colorbar isa Colorbar + @test colorbar.label[] == "Heat Values" + + # Test colorbar overlay position + f2, ax2, pl2 = scatter( + rand(10), color = 1:10; + figure = (; colorbar = (position = :rt,)) + ) + Makie.add_gui!(f2, ax2, pl2) + colorbar2 = f2.gui_state.colorbar + @test !isnothing(colorbar2) + @test colorbar2.halign[] == :right + @test colorbar2.valign[] == :top + + # Test colorbar with margin (overlay only) + f3, ax3, pl3 = scatter( + rand(10), color = 1:10; + figure = (; colorbar = (position = :lt, margin = (10, 60, 10, 10))) + ) + Makie.add_gui!(f3, ax3, pl3) + colorbar3 = f3.gui_state.colorbar + @test !isnothing(colorbar3) + @test colorbar3.margin[] == (10, 60, 10, 10) + + # Test that colorbar inherits colormap from plot + f4, ax4, pl4 = heatmap( + rand(10, 10), colormap = :viridis; + figure = (; colorbar = (position = [1, 2],)) + ) + Makie.add_gui!(f4, ax4, pl4) + colorbar4 = f4.gui_state.colorbar + @test !isnothing(colorbar4) + # Colorbar should have a colormap (derived from plot) + @test length(to_value(colorbar4.colormap)) > 0 +end + +@testset "Hover bar attributes" begin + # Test hover bar with default style + f = Figure(; gui = true) + ax = Axis(f[1, 1]) + pl = scatter!(ax, rand(10)) + Makie.add_gui!(f, ax, pl) + + gui = f.gui_state.hovermenu + @test !isnothing(gui) + @test gui isa HoverMenu + + # Test hover bar with custom style (styling params directly in gui=) + f2 = Figure(; + gui = ( + bar_color = :red, + height = 50, + width = 300, + ) + ) + ax2 = Axis(f2[1, 1]) + pl2 = scatter!(ax2, rand(10)) + Makie.add_gui!(f2, ax2, pl2) + + gui2 = f2.gui_state.hovermenu + @test !isnothing(gui2) + @test gui2 isa HoverMenu + # Check that custom style was applied + @test gui2.height[] == 50 + @test gui2.width[] == 300 +end + +@testset "Figure constructor with GUI options" begin + # Test Figure(; gui=true) - options should be empty dict (HoverMenu block handles defaults) + f = Figure(; gui = true) + @test !isnothing(f.gui_state.hovermenu_options) + @test f.gui_state.hovermenu_options == Dict{Symbol, Any}() + + ax = Axis(f[1, 1]) + pl = scatter!(ax, rand(10), label = "test") + Makie.add_gui!(f, ax, pl) + @test !isnothing(f.gui_state.hovermenu) + + # Test Figure(; gui=true, legend=(...)) + f2 = Figure(; gui = true, legend = (position = :lt, title = "My Title")) + @test !isnothing(f2.gui_state.legend_options) + @test f2.gui_state.legend_options[:position] == :lt + @test f2.gui_state.legend_options[:title] == "My Title" + + ax2 = Axis(f2[1, 1]) + pl2 = scatter!(ax2, rand(10), label = "test") + Makie.add_gui!(f2, ax2, pl2) + @test !isnothing(f2.gui_state.legend) + @test f2.gui_state.legend.entrygroups[][1][1] == "My Title" + + # Test Figure(; gui=false) - should not add GUI + f3 = Figure(; gui = false) + @test isnothing(f3.gui_state.hovermenu_options) + ax3 = Axis(f3[1, 1]) + pl3 = scatter!(ax3, rand(10), label = "test") + Makie.add_gui!(f3, ax3, pl3) + @test isnothing(f3.gui_state.hovermenu) +end + +@testset "figure keyword in plot calls" begin + # Test figure=(; gui=true, legend=(...)) + f, ax, pl = scatter( + rand(10), rand(10), label = "Test"; + figure = (; gui = true, legend = (position = :lt,)) + ) + Makie.add_gui!(f, ax, pl) + @test !isnothing(f.gui_state.hovermenu) + @test !isnothing(f.gui_state.legend) + + # Test figure=(; gui=true, colorbar=false) + f2, ax2, pl2 = scatter( + rand(10), rand(10), color = 1:10; + figure = (; gui = true, colorbar = false) + ) + Makie.add_gui!(f2, ax2, pl2) + @test isnothing(f2.gui_state.colorbar) + + # Test figure=(; legend=true) without gui - legend should still be created + f3, ax3, pl3 = scatter( + rand(10), label = "test"; + figure = (; legend = true) + ) + Makie.add_gui!(f3, ax3, pl3) + @test !isnothing(f3.gui_state.legend) + @test isnothing(f3.gui_state.hovermenu) # gui not enabled +end + +@testset "GUI theming with Figure theme" begin + # Test that Figure theme options are picked up + fap1 = with_theme(Figure = (; gui = true)) do + scatter(rand(10), label = "test") + end + @test !isnothing(fap1.figure.gui_state.hovermenu_options) + + # Add GUI and verify it works + Makie.add_gui!(fap1) + @test !isnothing(fap1.figure.gui_state.hovermenu) + + # Test Figure theme with legend options including attributes + fap2 = with_theme(Figure = (; legend = (position = :lt, title = "Theme Title"))) do + scatter(rand(10), label = "test") + end + Makie.add_gui!(fap2) + @test !isnothing(fap2.figure.gui_state.legend) + @test fap2.figure.gui_state.legend.entrygroups[][1][1] == "Theme Title" + + # Test figure attrs override Figure theme + fap3 = with_theme(Figure = (; gui = true, legend = (position = :lt, title = "Theme"))) do + # Figure attribute overrides theme + scatter(rand(10), label = "test"; figure = (; legend = false)) + end + Makie.add_gui!(fap3) + @test isnothing(fap3.figure.gui_state.legend) # legend disabled by figure attr + @test !isnothing(fap3.figure.gui_state.hovermenu) # hovermenu from theme + + # Test that GUI is NOT added when theme has gui=false (default) + fap4 = with_theme(Figure = (; gui = false)) do + scatter(rand(10), label = "test") + end + Makie.add_gui!(fap4) + @test isnothing(fap4.figure.gui_state.hovermenu) + + # Test colorbar options from theme + fap5 = with_theme(Figure = (; colorbar = (position = [1, 2], label = "Theme CB"))) do + heatmap(rand(10, 10)) + end + Makie.add_gui!(fap5) + @test !isnothing(fap5.figure.gui_state.colorbar) + @test fap5.figure.gui_state.colorbar.label[] == "Theme CB" +end + +@testset "GUI with various plot types" begin + # Lines with legend - verify labels are correct + f1, ax1, pl1 = lines(rand(10), label = "My Line"; figure = (; legend = true)) + Makie.add_gui!(f1, ax1, pl1) + @test !isnothing(f1.gui_state.legend) + + # Heatmap with colorbar (grid position) - verify colormap inheritance + f2, ax2, pl2 = heatmap( + rand(10, 10), colormap = :heat; + figure = (; colorbar = (position = [1, 2],)) + ) + Makie.add_gui!(f2, ax2, pl2) + @test !isnothing(f2.gui_state.colorbar) + # Colorbar should have a colormap (derived from plot) + @test length(to_value(f2.gui_state.colorbar.colormap)) > 0 + + # Series with legend - multiple labels + f3, ax3, pl3 = series( + rand(5, 10), labels = ["a", "b", "c", "d", "e"]; + figure = (; legend = true) + ) + Makie.add_gui!(f3, ax3, pl3) + @test !isnothing(f3.gui_state.legend) + + # Scatter with both legend and colorbar + f4, ax4, pl4 = scatter( + rand(10), color = 1:10, label = "Colored Points"; + figure = (; legend = (position = :lt,), colorbar = (position = :rt,)) + ) + Makie.add_gui!(f4, ax4, pl4) + @test !isnothing(f4.gui_state.legend) + @test !isnothing(f4.gui_state.colorbar) + @test f4.gui_state.legend.halign[] == :left + @test f4.gui_state.colorbar.halign[] == :right +end + +@testset "remove_gui! cleans up state" begin + f, ax, pl = scatter( + rand(10), color = 1:10, label = "test"; + figure = (; gui = true, legend = true, colorbar = true) + ) + Makie.add_gui!(f, ax, pl) + + @test !isnothing(f.gui_state.hovermenu) + @test !isnothing(f.gui_state.legend) + @test !isnothing(f.gui_state.colorbar) + + Makie.remove_gui!(f) + @test isnothing(f.gui_state.hovermenu) + @test isnothing(f.gui_state.legend) + @test isnothing(f.gui_state.colorbar) + + # Calling remove on figure without GUI should not error + f2 = Figure() + @test isnothing(Makie.remove_gui!(f2)) +end + +@testset "Legend and Colorbar position options" begin + # Test all overlay positions for legend + for pos in [:lt, :rt, :lb, :rb, :lc, :rc, :ct, :cb] + f, ax, pl = scatter(rand(10), label = "test"; figure = (; legend = (position = pos,))) + Makie.add_gui!(f, ax, pl) + legend = f.gui_state.legend + @test !isnothing(legend) + # Verify halign/valign based on position + halign, valign = Makie.legend_position_to_aligns(pos) + @test legend.halign[] == halign + @test legend.valign[] == valign + end + + # Test grid position (Vector) + f2, ax2, pl2 = scatter(rand(10), color = 1:10; figure = (; colorbar = (position = [1, 2],))) + Makie.add_gui!(f2, ax2, pl2) + @test !isnothing(f2.gui_state.colorbar) + + # Test colorbar overlay positions + for pos in [:lt, :rt, :lb, :rb] + f, ax, pl = scatter(rand(10), color = 1:10; figure = (; colorbar = (position = pos,))) + Makie.add_gui!(f, ax, pl) + colorbar = f.gui_state.colorbar + @test !isnothing(colorbar) + halign, valign = Makie.legend_position_to_aligns(pos) + @test colorbar.halign[] == halign + @test colorbar.valign[] == valign + end +end + +@testset "No labeled plots or colormap" begin + # Legend with no labeled plots should return nothing + f1 = Figure(; legend = true) + ax1 = Axis(f1[1, 1]) + scatter!(ax1, rand(10)) # no label + Makie.add_gui!(f1, ax1, first(ax1.scene.plots)) + @test isnothing(f1.gui_state.legend) + + # Colorbar with no colormap should return nothing + f2 = Figure(; colorbar = true) + ax2 = Axis(f2[1, 1]) + pl2 = lines!(ax2, rand(10)) # no colormap + Makie.add_gui!(f2, ax2, pl2) + @test isnothing(f2.gui_state.colorbar) +end diff --git a/Makie/test/isolated/derive_colors.jl b/Makie/test/isolated/derive_colors.jl new file mode 100644 index 00000000000..c92b8172377 --- /dev/null +++ b/Makie/test/isolated/derive_colors.jl @@ -0,0 +1,73 @@ +@testset "derive_colors" begin + using Makie: derive_colors, DEFAULT_ACCENT_COLOR, RGBf + using Makie.Colors: red, green, blue + + @testset "default inputs produce the expected light-mode scheme" begin + c = derive_colors() + # Black/white poles are returned unchanged in any mix space. + @test c.background == RGBf(1, 1, 1) + @test c.text == RGBf(0, 0, 0) + @test c.accent == DEFAULT_ACCENT_COLOR + @test c.text_on_accent == RGBf(1, 1, 1) + # Oklab-mixed neutrals are perceptually-even steps between white and + # black. Values quoted are what the function produces today; if they + # shift more than ~1 luminance unit either the algorithm or the + # weights changed and we want to know about it. + @test red(c.surface) ≈ 0.92 atol = 0.01 + @test red(c.surface_subtle) ≈ 0.96 atol = 0.01 + @test red(c.border) ≈ 0.74 atol = 0.01 + @test red(c.text_muted) ≈ 0.5 atol = 0.01 + # Neutrals must be gray (no chromaticity) when both poles are neutral. + for role in (c.surface, c.surface_subtle, c.border, c.text_muted) + @test red(role) ≈ green(role) atol = 1.0e-3 + @test green(role) ≈ blue(role) atol = 1.0e-3 + end + @test red(c.accent_subtle) ≈ 0.68 atol = 0.02 + @test green(c.accent_subtle) ≈ 0.77 atol = 0.02 + @test blue(c.accent_subtle) ≈ 0.94 atol = 0.02 + end + + @testset "dark background flips gray pole and inverts neutrals" begin + # Pure-black background is a corner case: Oklab L just above 0 still + # maps to a very small sRGB value because of sRGB's gamma curve. The + # ordering should still be background < surface_subtle < surface + # < border < text_muted < text, which is what we test here. + c = derive_colors(background = :black) + @test c.background == RGBf(0, 0, 0) + @test c.text == RGBf(1, 1, 1) + order = ( + c.background, c.surface_subtle, c.surface, c.border, + c.text_muted, c.text, + ) + @test issorted(red.(order)) + + # A realistic dark theme has a slight lift above pure black, which + # makes the perceptual mix produce visibly distinct surfaces. + dark = RGBf(0.12, 0.12, 0.14) + c2 = derive_colors(background = dark) + @test red(c2.surface) ≈ 0.16 atol = 0.02 + @test red(c2.border) ≈ 0.27 atol = 0.02 + @test red(c2.text_muted) ≈ 0.44 atol = 0.02 + end + + @testset "explicit gray override biases neutrals toward its hue" begin + warm = RGBf(0.5, 0.3, 0.2) + c = derive_colors(gray = warm) + @test c.text == warm + # The Oklab mix at weight 0.06 pulls slightly off pure neutral toward + # the warm pole; check that the result is biased correctly (R > G > B). + @test red(c.surface) > green(c.surface) > blue(c.surface) + end + + @testset "text_on_accent picks black for a bright accent" begin + c = derive_colors(accent = :yellow) + @test c.text_on_accent == RGBf(0, 0, 0) + end + + @testset "all returned values are RGBf" begin + c = derive_colors() + for (k, v) in pairs(c) + @test v isa RGBf + end + end +end diff --git a/Makie/test/issues.jl b/Makie/test/issues.jl index b4dc88951ed..a332281f1fe 100644 --- a/Makie/test/issues.jl +++ b/Makie/test/issues.jl @@ -122,4 +122,31 @@ p.sdf_uv[] @test true end + + @testset "Menu search filter matching nothing" begin + # A query that matched nothing emptied the per-option color vectors. The + # next query that DID match then resolved the option text plot against + # them and threw a BoundsError inside the compute graph, which takes the + # whole render loop down with it — the menu was dead from then on. + fig = Figure() + menu = Menu( + fig[1, 1]; options = ["Brightness", "Color balance", "Stabilization"], + searchable = true, default = nothing + ) + Makie.update_state_before_display!(fig) + menu.is_open[] = true + texts = menu.blockscene.children[1].plots[2] + + foreach(c -> events(fig).unicode_input[] = c, "zq") # matches nothing + @test isempty(texts.text[]) + @test isempty(texts.color[]) + + events(fig).keyboardbutton[] = Makie.KeyEvent(Keyboard.backspace, Keyboard.press) + @test texts.text[] == ["Stabilization"] # one match again + @test length(texts.color[]) == 1 # …and it has a color + texts.positions_transformed_f32c[] # resolves: must not throw + + events(fig).keyboardbutton[] = Makie.KeyEvent(Keyboard.backspace, Keyboard.press) + @test length(texts.text[]) == length(texts.color[]) == 3 + end end diff --git a/Makie/test/runtests.jl b/Makie/test/runtests.jl index e30d7006789..e017354f792 100644 --- a/Makie/test/runtests.jl +++ b/Makie/test/runtests.jl @@ -38,6 +38,7 @@ end include("isolated/argument_docs.jl") include("isolated/recipes.jl") # @recipe, @Block generated code include("isolated/showoff.jl") + include("isolated/derive_colors.jl") end @testset "Plots" begin @@ -56,7 +57,14 @@ end include("SceneLike/scenes.jl") include("SceneLike/figures.jl") include("SceneLike/makielayout.jl") + include("SceneLike/card.jl") + include("SceneLike/subfigure.jl") include("SceneLike/PolarAxis.jl") + include("events_isolation.jl") + end + + @testset "GUI" begin + include("gui.jl") end @testset "Conversion & Projection Pipeline" begin diff --git a/RPRMakie/Project.toml b/RPRMakie/Project.toml index abc5c8084f0..122a1f4922c 100644 --- a/RPRMakie/Project.toml +++ b/RPRMakie/Project.toml @@ -17,7 +17,7 @@ Colors = "0.9, 0.10, 0.11, 0.12, 0.13" FileIO = "1.6" GeometryBasics = "0.5" LinearAlgebra = "1.0, 1.6" -Makie = "=0.25.0" +Makie = "=0.24.13" Printf = "1.0, 1.6" RadeonProRender = "0.3.2" julia = "1" diff --git a/ReferenceTests/src/tests/figures_and_makielayout.jl b/ReferenceTests/src/tests/figures_and_makielayout.jl index 44a32ac4af4..06ebf6277f8 100644 --- a/ReferenceTests/src/tests/figures_and_makielayout.jl +++ b/ReferenceTests/src/tests/figures_and_makielayout.jl @@ -64,6 +64,29 @@ end fig end +@reference_test "table" begin + fig = Figure(size = (600, 400)) + + data = ( + name = ["Alice", "Bob", "Charlie", "Diana", "Eve"], + age = [28, 35, 42, 31, 25], + city = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"], + ) + + t = Table( + fig[1, 1]; + data = data, + header_color = RGBf(0.2, 0.4, 0.6), + header_textcolor = :white, + cell_color_even = RGBf(0.95, 0.95, 1.0), + cell_color_odd = RGBf(0.9, 0.9, 0.95), + cell_color_selected = RGBf(0.7, 0.85, 1.0), + i_selected = 3 + ) + + fig +end + @reference_test "Label with text wrapping" begin lorem_ipsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." fig = Figure(size = (1000, 660)) diff --git a/WGLMakie/Project.toml b/WGLMakie/Project.toml index ceede9ea3c8..cbad3f73c79 100644 --- a/WGLMakie/Project.toml +++ b/WGLMakie/Project.toml @@ -27,7 +27,7 @@ FreeTypeAbstraction = "0.10" GeometryBasics = "0.5" Hyperscript = "0.0.3, 0.0.4, 0.0.5" LinearAlgebra = "1.0, 1.6" -Makie = "=0.25.0" +Makie = "=0.24.13" Observables = "0.5.1" PNGFiles = "0.3, 0.4" PrecompileTools = "1.0" diff --git a/docs/fake_interaction.jl b/docs/fake_interaction.jl index 4d12852b239..f4a252e7606 100644 --- a/docs/fake_interaction.jl +++ b/docs/fake_interaction.jl @@ -15,6 +15,7 @@ export KeyPress export KeyDown, KeyUp export Lazy export Wait +export Scroll export relative_pos export textbox_offset_pos @@ -97,6 +98,16 @@ end duration(w::Wait, prev_position) = w.duration +# Wait until `pred()` returns true (records frames meanwhile), up to `timeout` +# seconds. Use for load-dependent async work (e.g. a GPU analysis whose duration +# varies with recording overhead) instead of guessing a fixed Wait. +struct WaitUntil + pred::Function + timeout::Float64 +end +WaitUntil(pred; timeout = 60.0) = WaitUntil(pred, Float64(timeout)) +duration(w::WaitUntil, prev_position) = w.timeout + struct LeftClick end duration(::LeftClick, _) = 0.15 @@ -187,6 +198,40 @@ duration(::KeyUp, _) = 0.0 keyboardevents_start(k::KeyUp) = [Makie.KeyEvent(k.key, Keyboard.release)] +""" + Scroll(delta; duration = 0.8) + +Emits `events.scroll` events over `duration` seconds so that the totals sum to +`delta` (a `(dx, dy)` tuple in scroll units). Use to animate a wheel/trackpad +scroll over a scrollable container. +""" +struct Scroll + delta::Tuple{Float64, Float64} + duration::Float64 +end +Scroll(delta; duration = 0.8) = Scroll(Tuple(Float64.(delta)), duration) +duration(s::Scroll, _) = s.duration + +function scrollevents_frame(s::Scroll, time, prev_time) + s.duration <= 0 && return [] + frac = (time - prev_time) / s.duration + frac <= 0 && return [] + return [Tuple(frac .* s.delta)] +end + +""" + DropFiles(paths...) + +Delivers `paths` as an `events.dropped_files` event — what GLFW emits when +the user drags files from a file manager onto the window. +""" +struct DropFiles + files::Vector{String} +end +DropFiles(paths::AbstractString...) = DropFiles(collect(String, paths)) +duration(::DropFiles, _) = 0.1 +droppedfiles_start(d::DropFiles) = d.files + mouseevents_start(obj) = [] mouseevents_end(obj) = [] mouseevents_frame(obj, t) = [] @@ -196,6 +241,8 @@ mousepositions_frame(obj, startpos, t) = [] keyboardevents_start(obj) = [] keyboardevents_end(obj) = [] unicode_inputs_frame(obj, time, prev_time) = [] +scrollevents_frame(obj, time, prev_time) = [] +droppedfiles_start(obj) = String[] function alpha_blend(fg::Makie.RGBA, bg::Makie.RGB) r = (fg.r * fg.alpha + bg.r * (1 - fg.alpha)) @@ -279,6 +326,8 @@ function interaction_record(func, figlike, filepath, events::AbstractVector; fps for keyevent in keyboardevents_start(event) content_scene.events.keyboardbutton[] = keyevent end + dropped = droppedfiles_start(event) + isempty(dropped) || (content_scene.events.dropped_files[] = dropped) mousepositions = mousepositions_start(event, event_startposition) for mouseposition in mousepositions content_scene.events.mouseposition[] = tuple(mouseposition...) @@ -287,6 +336,8 @@ function interaction_record(func, figlike, filepath, events::AbstractVector; fps prev_t_in_event = 0.0 while t < t_event + current_duration + # WaitUntil ends as soon as its predicate holds (timeout = current_duration) + event isa WaitUntil && event.pred() && break t_in_event = t - t_event mouseevents = mouseevents_frame(event, t_in_event) for mouseevent in mouseevents @@ -300,6 +351,9 @@ function interaction_record(func, figlike, filepath, events::AbstractVector; fps for c in unicode_inputs_frame(event, t_in_event, prev_t_in_event) content_scene.events.unicode_input[] = c end + for sc in scrollevents_frame(event, t_in_event, prev_t_in_event) + content_scene.events.scroll[] = sc + end prev_t_in_event = t_in_event func(i_frame, t) diff --git a/docs/makedocs.jl b/docs/makedocs.jl index d330821a781..7b230573110 100644 --- a/docs/makedocs.jl +++ b/docs/makedocs.jl @@ -78,9 +78,11 @@ pages = [ "legend.md", "lscene.md", "menu.md", + "paramform.md", "polaraxis.md", "slider.md", "slidergrid.md", + "table.md", "textbox.md", "toggle.md", ] @@ -150,6 +152,7 @@ pages = [ "explanations", "theming", [ "themes.md", "predefined_themes.md", + "block_colors.md", ] ), joinpath.( diff --git a/docs/src/explanations/figure.md b/docs/src/explanations/figure.md index f4da0a175ee..77d2ae5d27f 100644 --- a/docs/src/explanations/figure.md +++ b/docs/src/explanations/figure.md @@ -105,6 +105,123 @@ scatter!(1:10) f ``` +## Automatic Legend and Colorbar + +Figures can automatically create legends and colorbars from your plots using the `legend` and `colorbar` keyword arguments. + +### Automatic Legend + +Enable automatic legend creation with the `legend` keyword: + +```@figure +f = Figure(; legend=(position=:lt,)) +ax = Axis(f[1, 1]) +scatter!(ax, rand(10), label="Points A") +scatter!(ax, rand(10), label="Points B") +f +``` + +Legend options: +- `position`: Symbol (`:lt`, `:rt`, `:lb`, `:rb`, etc.) for overlay, or grid position like `[1, 2]` +- `margin`: Margin around the legend as `(left, right, bottom, top)` - only for overlay positions +- `title`: Legend title +- `unique`: Only show unique labels (default: `false`) +- `merge`: Merge plots with the same label (default: `false`) +- All other options are passed to [`Legend`](@ref) + +### Automatic Colorbar + +Enable automatic colorbar creation with the `colorbar` keyword: + +```@figure +# Grid position colorbar - best for heatmaps +f, ax, pl = heatmap(rand(20, 20); + figure=(; + colorbar=( + position=[1, 2], + label="Values", + ), + ) +) +``` + +```@figure +# Overlay colorbar - works well with 3D plots +f, ax, pl = surface(0:0.5:10, 0:0.5:10, (x, y) -> sin(x) * cos(y); + figure=(; + colorbar=( + position=:rt, + label="Values", + ), + ), + axis=(; type=Axis3) +) +``` + +Colorbar options: +- `position`: Symbol (`:rt`, `:lt`, etc.) for overlay, or grid position like `[1, 2]` (default) +- `margin`: Margin around the colorbar as `(left, right, bottom, top)` - only for overlay positions +- All other options are passed to [`Colorbar`](@ref) + +### Using the `figure` keyword + +You can also pass these options via the `figure` keyword in plotting functions: + +```@figure +f, ax, pl = scatter(rand(10), rand(10), color=1:10, label="Data"; + figure=(; + legend=(position=:lt,), + colorbar=(position=:rt,), + )) +f +``` + +### Theme Integration + +Enable these options globally using themes: + +```julia +set_theme!(Figure=(; + legend=(position=:lt,), + colorbar=(position=[1, 2],), +)) + +# Now all figures will have these defaults +scatter(rand(10), label="Auto legend") +``` + +## Hover Bar + +The `gui` option enables a hover bar that appears at the top of the figure with buttons for common actions: + +- **Save**: Opens a file dialog to save the figure +- **Copy**: Copies the figure to the system clipboard +- **Reset**: Resets axis limits to automatic values + +```julia +f = Figure(; gui=true) +``` + +Customize the hover bar appearance: + +```julia +f = Figure(; + gui=( + bar_color=(:gray90, 0.95), + bar_height=45, + button_color=RGBf(0.2, 0.4, 0.6), + ), +) +``` + +Enable globally via theme: + +```julia +set_theme!(Figure=(; gui=true)) +``` + + + ## Retrieving Objects From A Figure Sometimes users are surprised that indexing into a figure does not retrieve the object placed at that position. diff --git a/docs/src/explanations/theming/block_colors.md b/docs/src/explanations/theming/block_colors.md new file mode 100644 index 00000000000..2cc8c0805cd --- /dev/null +++ b/docs/src/explanations/theming/block_colors.md @@ -0,0 +1,138 @@ +# Block colors + +The interactive `Block` widgets (`Button`, `Checkbox`, `Toggle`, `Slider`, +`IntervalSlider`, `Menu`, `Textbox`) all read their fill, border, and text +colors from a small set of named roles in the theme. This means you can +recolour every widget at once by adjusting one nested theme block, instead of +overriding individual attributes on each Block. + +## The `colors` theme block + +`MAKIE_DEFAULT_THEME[:colors]` is a nested `Attributes` block — like +`fonts` — containing nine role tokens: + +| Role | Where it shows up | +|---|---| +| `background` | Canvas background, unchecked Checkbox fill | +| `surface` | Idle interactive fills (Button, Slider track, Toggle frame) | +| `surface_subtle` | Menu dropdown rows | +| `border` | Idle Textbox border | +| `text` | Default text (Labels, Axis titles, Button label) | +| `text_muted` | Placeholder text, dropdown arrow | +| `text_on_accent` | Text rendered over an accent-coloured fill | +| `accent` | Active, checked, focused, pressed states | +| `accent_subtle` | Hover states | + +State conventions across all interactive Blocks are uniform: idle = `surface`, +hover = `accent_subtle`, active/checked/focused = `accent`. + +## Deriving a scheme from a few colours + +In practice, you don't set all nine roles by hand. The helper +[`derive_colors`](@ref) takes three inputs — an accent colour, a +"contrast pole" gray, and the background — and computes the full nine-role +scheme so every neutral and accent step is consistent with your choices. + +```julia +Makie.derive_colors(; accent, gray = automatic, background = :white) +``` + +- `accent` — the primary accent colour, used for active/checked/focused states. +- `gray` — the colour that gets mixed with `background` to produce neutrals. + `automatic` picks pure black on light backgrounds and pure white on dark + ones; pass a tinted near-black (or near-white) to bias every neutral towards + that hue. +- `background` — the canvas background; its luminance also selects the default + contrast pole when `gray = automatic`. + +With default inputs the function reproduces Makie's traditional Block defaults +within rounding, so existing figures don't shift. + +## Applying a scheme + +Wrap the derived scheme into `set_theme!` (or `with_theme`). Setting +`backgroundcolor` and `textcolor` at the top level keeps the rest of Makie +(figure background, axis titles, plot text) in sync with the widget scheme. + +## A light, warm-brown theme + +```@example block_colors +using CairoMakie +CairoMakie.activate!(type = "svg") # hide + +function widget_showcase() + fig = Figure(figure_padding = 70) + Label(fig[0, 1:3], "Block showcase", fontsize = 18, tellwidth = false) + + btn = Button(fig[1, 1], label = "Idle") + Button(fig[1, 2], label = "Hover preview", buttoncolor = btn.buttoncolor_hover) + Button(fig[1, 3], label = "Active preview", + buttoncolor = btn.buttoncolor_active, + labelcolor = btn.labelcolor_active) + + Checkbox(fig[2, 1], checked = false) + Checkbox(fig[2, 2], checked = true) + Label(fig[2, 3], "Checkboxes", tellwidth = false) + + Toggle(fig[3, 1], active = false) + Toggle(fig[3, 2], active = true) + Label(fig[3, 3], "Toggles", tellwidth = false) + + Slider(fig[4, 1:3], range = 0:0.1:10, startvalue = 5) + IntervalSlider(fig[5, 1:3], range = 0:0.1:10, startvalues = (2, 7)) + + Menu(fig[6, 1], options = ["alpha", "beta", "gamma"], width = 100) + Textbox(fig[6, 2], placeholder = "type here") + Textbox(fig[6, 3], stored_string = "with text") + + resize_to_layout!(fig) + fig +end + +bg = RGBf(0.96, 0.92, 0.84) # cream +warm = RGBf(0.20, 0.13, 0.08) # warm dark pole +acc = RGBf(0.70, 0.36, 0.18) # terracotta + +with_theme( + backgroundcolor = bg, + textcolor = warm, + colors = Makie.derive_colors(accent = acc, gray = warm, background = bg), +) do + widget_showcase() +end +``` + +Because `gray` is set to a brown-leaning near-black, every derived neutral +(`surface`, `surface_subtle`, `border`, `text_muted`) is also brown-tinted — +the whole UI carries the warmth, not just the accent. + +## A dark, greenish theme + +```@example block_colors +bg = RGBf(0.08, 0.11, 0.09) # near-black, slight green tint +light = RGBf(0.86, 0.96, 0.87) # light pole, slightly green +acc = RGBf(0.40, 0.85, 0.55) # vibrant green accent + +with_theme( + backgroundcolor = bg, + textcolor = light, + colors = Makie.derive_colors(accent = acc, gray = light, background = bg), +) do + widget_showcase() +end +``` + +When the background is dark, `gray = automatic` already picks a near-white +contrast pole; here we override it to a pale green so the neutral steps pull +slightly green rather than pure gray. + +## Overriding a single role + +You can target an individual role instead of swapping the whole scheme: + +```julia +set_theme!(colors = (text = :navy,)) +``` + +Only `text` changes; the other eight roles stay at their current values. This +also works inside `with_theme(...)` and `update_theme!`. diff --git a/docs/src/reference/blocks/colorbar.md b/docs/src/reference/blocks/colorbar.md index 0086c1b3140..29aecb4d012 100644 --- a/docs/src/reference/blocks/colorbar.md +++ b/docs/src/reference/blocks/colorbar.md @@ -61,6 +61,28 @@ fig ``` +## Colorbar Inside An Axis + +You can place a colorbar inside an axis using the `Colorbar(ax, plot; position=...)` constructor or the `axiscolorbar` function. This is useful for overlay positioning, especially with 3D plots. + +```@figure +fig, ax, pl = scatter(rand(30), rand(30), color=rand(30), markersize=12) + +# Create colorbar inside axis at right-top +Colorbar(ax, pl; position=:rt, label="Values") +fig +``` + +Position symbols follow the same convention as legend: `:lt` (left-top), `:rt` (right-top), `:lb` (left-bottom), `:rb` (right-bottom), etc. + +The `axiscolorbar` function provides the same functionality: + +```@figure +fig, ax, pl = scatter(rand(30), rand(30), color=rand(30), markersize=12) +axiscolorbar(ax, pl; position=:lt, label="Values") +fig +``` + ### Experimental Categorical support !!! warning diff --git a/docs/src/reference/blocks/legend.md b/docs/src/reference/blocks/legend.md index aab535c5008..de1788cf78d 100644 --- a/docs/src/reference/blocks/legend.md +++ b/docs/src/reference/blocks/legend.md @@ -109,6 +109,10 @@ f ## Legend Inside An Axis +There are two ways to place a legend inside an axis: `axislegend` and `Legend(ax; position=...)`. + +### Using axislegend + The `axislegend` function is a quick way to add a legend to an Axis. You can pass a selected axis plus arguments which are forwarded to the `Legend` constructor, or the current axis is used by default. If you pass only a string, it's used as the title with the current axis. @@ -137,6 +141,21 @@ axislegend(ax, [sc1, sc2], ["One", "Two"], "Selected Dots", position = :rb, f ``` +### Using Legend(ax; position=...) + +You can also use the `Legend(ax; position=...)` constructor to place a legend inside an axis. This is equivalent to `axislegend` but uses position symbols like `:lt` (left-top), `:rt` (right-top), `:lb` (left-bottom), `:rb` (right-bottom), etc. + +```@figure +fig, ax, pl = scatter(rand(10), label="Points") +lines!(ax, rand(10), label="Line") + +# Create legend inside axis at left-top +Legend(ax; position=:lt) +fig +``` + +### Manual positioning + Alternatively, you can simply add a Legend to the same layout slot that an axis lives in. As long as the axis is bigger than the legend you can set the legend's `tellheight` and `tellwidth` to `false` and position it using the align diff --git a/docs/src/reference/blocks/paramform.md b/docs/src/reference/blocks/paramform.md new file mode 100644 index 00000000000..fda0e3beb74 --- /dev/null +++ b/docs/src/reference/blocks/paramform.md @@ -0,0 +1,73 @@ +# ParamForm + +`ParamForm` builds a labelled, themed form of input widgets from a `NamedTuple` +specification. Each field maps to a `(default, constraint)` pair; the constraint +selects the widget type and validation rule. All validated values are always +available as a live `NamedTuple` from the block's compute graph — no read-back +step needed. + +```@example paramform +using GLMakie +GLMakie.activate!() # hide + +fig = Figure(size = (500, 300)) + +pf = ParamForm(fig[1, 1], ( + iterations = (50, Between(1, 200)), + method = ("LBFGS", OneOf(["LBFGS", "Newton", "GradDesc"])), + tolerance = (1e-6, nothing), + verbose = (false, nothing), +); title = "Solver settings") + +Label(fig[2, 1], lift(vals -> "tolerance = $(vals.tolerance)", + pf.graph[:values]); tellwidth = false) + +fig +nothing # hide +``` + +## Constraint types + +| Constraint | Widget | Description | +|:-----------|:-------|:------------| +| `Between(lo, hi)` | `Slider` | Numeric range — value must satisfy `lo ≤ v ≤ hi` | +| `OneOf(options)` | `Menu` | Fixed option set — value must be one of `options` | +| `FilePath(; extension)` | `Textbox` + browse button | File path, optionally filtered by extension | +| `nothing` | `Toggle` (Bool) or `Textbox` | No constraint; widget chosen by field type | + +## Reading values + +`pf.graph[:values][]` returns the current validated `NamedTuple`. Wire it to +other observables with `on` or `lift`: + +```julia +on(pf.graph[:values]) do vals + run_solver(vals.method; tol = vals.tolerance, iters = vals.iterations) +end +``` + +## Inside a Modal + +`ParamForm` composes naturally with [`Modal`](@ref) for settings dialogs: + +```julia +modal = Modal(fig; title = "Settings") +pf = ParamForm(modal[1, 1], (alpha = (0.5, Between(0.0, 1.0)),)) +on(pf.graph[:values]) do vals; update_plot!(vals) end +on(_ -> open!(modal), settings_button.clicks) +``` + +## Clearing a form + +[`clear!`](@ref) removes all blocks from a `GridLayout` recursively, which is +useful when rebuilding a form in place: + +```julia +clear!(pf.layout) # or delete!(pf) to remove the whole block +``` + +## Attributes + +```@attrdocs +ParamForm +``` diff --git a/docs/src/reference/blocks/table.md b/docs/src/reference/blocks/table.md new file mode 100644 index 00000000000..315db50f15e --- /dev/null +++ b/docs/src/reference/blocks/table.md @@ -0,0 +1,193 @@ +# Table + +The `Table` block displays tabular data with interactive features like row selection, column sorting, and scrolling. + +```@example table +using GLMakie +GLMakie.activate!() # hide + +fig = Figure() + +# Sample data +data = ( + name = ["Alice", "Bob", "Charlie", "Diana", "Eve"], + age = [28, 35, 42, 31, 25], + city = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"] +) + +t = Table(fig[1, 1]; data = data) + +# Listen to selection changes +on(t.selection) do sel + if sel !== nothing + println("Selected: ", sel) + end +end + +fig +nothing # hide +``` + +## Styling + +You can customize the appearance of the table with various styling attributes: + +```@figure backend=GLMakie + +fig = Figure() + +data = ( + id = 1:10, + name = ["Item $i" for i in 1:10], + value = rand(10) .* 100 +) + +t = Table(fig[1, 1]; + data = data, + header_color = RGBf(0.2, 0.4, 0.6), + header_textcolor = :white, + cell_color_even = RGBf(0.95, 0.95, 1.0), + cell_color_odd = RGBf(0.9, 0.9, 0.95), + cell_color_selected = RGBf(0.7, 0.85, 1.0), + row_height = 30.0, + header_height = 35.0 +) + +# Select row 3 by default +t.i_selected[] = 3 + +fig +``` + +## Scrolling + +When `max_visible_rows` is set, the table becomes scrollable: + +```@figure backend=GLMakie + +fig = Figure() + +# Large dataset +data = ( + row = 1:50, + name = ["Entry $i" for i in 1:50], + value = rand(50) +) + +t = Table(fig[1, 1]; + data = data, + max_visible_rows = 10 +) + +fig +``` + +## Per-Cell Colors + +You can provide a matrix for `cell_color` and/or `cell_textcolor` to color individual cells: + +```@figure backend=GLMakie + +fig = Figure() + +data = ( + name = ["Alice", "Bob", "Charlie", "Diana"], + score = [95, 82, 78, 91], + status = ["Pass", "Pass", "Fail", "Pass"] +) + +# Create color matrix based on data +colors = [ + RGBf(0.9, 1.0, 0.9) RGBf(0.9, 1.0, 0.9) RGBf(0.9, 1.0, 0.9); + RGBf(0.9, 1.0, 0.9) RGBf(1.0, 1.0, 0.8) RGBf(0.9, 1.0, 0.9); + RGBf(0.9, 1.0, 0.9) RGBf(1.0, 0.8, 0.8) RGBf(1.0, 0.8, 0.8); + RGBf(0.9, 1.0, 0.9) RGBf(0.9, 1.0, 0.9) RGBf(0.9, 1.0, 0.9) +] + +t = Table(fig[1, 1]; + data = data, + cell_color = colors +) + +fig +``` + + +## Sorting + +Click on column headers to sort by that column. Click again to toggle between ascending and descending order. + +```@figure backend=GLMakie + +fig = Figure() + +data = ( + name = ["Zebra", "Apple", "Mango", "Banana", "Cherry"], + count = [5, 12, 8, 15, 3] +) + +t = Table(fig[1, 1]; + data = data, + sortable = true, + show_sort_indicator = true +) + +# Sort by the second column (count), descending +t.sort_column[] = 2 +t.sort_direction[] = :descending + +fig +``` + + +## Callbacks + +You can register callbacks for various interactions: + +```julia +# Single click callback +t.on_row_click[] = (table, row_index, row_data) -> begin + println("Clicked row $row_index: $row_data") +end + +# Double click callback +t.on_row_doubleclick[] = (table, row_index, row_data) -> begin + println("Double-clicked row $row_index") +end + +# Sort change callback +t.on_sort_change[] = (table, column_index, direction) -> begin + println("Sorted by column $column_index ($direction)") +end +``` + +## Auto-Fit Columns + +You can automatically resize columns to fit their content using `auto_fit_columns!`: + +```julia +using GLMakie + +fig = Figure() +data = ( + name = ["Short", "A much longer name here", "Medium"], + value = [1, 2, 3] +) + +t = Table(fig[1, 1]; data = data) + +# Render once, then auto-fit columns to content +display(fig) +auto_fit_columns!(t.plots[1]) # Access the TablePlot + +fig +``` + +Similarly, `auto_fit_rows!` adjusts row heights to fit text content. + + +## Attributes + +```@attrdocs +Table +``` diff --git a/docs/src/reference/blocks/tabs.md b/docs/src/reference/blocks/tabs.md new file mode 100644 index 00000000000..d80a7e09495 --- /dev/null +++ b/docs/src/reference/blocks/tabs.md @@ -0,0 +1,161 @@ +# Tabs + +A tabbed container. Each tab is backed by a [`Subfigure`](@ref) with isolated +events: only the active tab is visible, and only the active tab receives +mouse and keyboard input. Place blocks with `tabs[i][row, col] = Axis(...)` +or plot directly into `content_scene(tabs, i)`. Content larger than the +visible area scrolls vertically and horizontally; scrollbars appear only when +there is overflow. + +```@example tabs +using GLMakie +import Makie.GridLayoutBase as GLB +GLMakie.activate!() # hide + +fig = Figure(size = (700, 450)) + +tabs = Tabs(fig[1, 1], ["Single axis", "Wide", "Tall"]) + +# Tab 1 — a single axis +scatter!( + Axis(tabs[1][1, 1], title = "Drag to pan, scroll to zoom"), + randn(150), randn(150), color = 1:150, colormap = :viridis +) + +# Tab 2 — three Fixed-width axes side by side → horizontal scroll +for (col, sym) in enumerate((:tomato, :steelblue, :seagreen)) + ax = Axis(tabs[2][1, col], title = "col $col") + scatter!(ax, randn(60), randn(60), color = sym) +end +GLB.colsize!(tabs[2].layout, 1, GLB.Fixed(300)) +GLB.colsize!(tabs[2].layout, 2, GLB.Fixed(300)) +GLB.colsize!(tabs[2].layout, 3, GLB.Fixed(300)) + +# Tab 3 — three Fixed-height axes stacked → vertical scroll +for (row, sym) in enumerate((:tomato, :steelblue, :seagreen)) + ax = Axis(tabs[3][row, 1], title = "row $row") + lines!(ax, 1:50, cumsum(randn(50)), color = sym) +end +GLB.rowsize!(tabs[3].layout, 1, GLB.Fixed(180)) +GLB.rowsize!(tabs[3].layout, 2, GLB.Fixed(180)) +GLB.rowsize!(tabs[3].layout, 3, GLB.Fixed(180)) + +fig +nothing # hide +``` + +```@setup tabs +using ..FakeInteraction + +# Find a tab's label center and close-button center by inspecting the plots +# that `Tabs` adds to its blockscene (one `Text` per label, one `LineSegments` +# per close ×, in tab order). This stays in sync with the actual rendered +# layout without poking new fields into `Tabs`. +function tab_label_center(tabs, i) + plots = filter(p -> p isa Makie.Text, tabs.blockscene.plots) + return Point2f(plots[i].positions[][1]) +end +function tab_close_center(tabs, i) + plots = filter(p -> p isa Makie.LineSegments, tabs.blockscene.plots) + seg = plots[i].positions[] + return Point2f(sum(p -> p[1], seg) / length(seg), sum(p -> p[2], seg) / length(seg)) +end + +function vbar_thumb_center(sf) + ca = sf.scene.viewport[] + cs = sf.contentsize[][2] + vh = widths(ca)[2] + ch = max(cs, vh) + thumb_h = max(20.0, vh^2 / ch) + max_sy = max(0.0, ch - vh) + usable = vh - thumb_h + sc_y = sf.scroll[][2] + ty = top(ca) - thumb_h - (sc_y / max_sy) * usable + return Point2f(right(ca) - 4, ty + thumb_h / 2) +end + +events = [ + Wait(1.0), + Lazy() do fig MouseTo(tab_label_center(tabs, 2)) end, # over tab 2 ("Wide") + LeftClick(), + Wait(1.0), + Lazy() do fig MouseTo(tab_label_center(tabs, 3)) end, # over tab 3 ("Tall") + LeftClick(), + Wait(0.4), + # Grab the vertical scrollbar thumb and drag it down. + Lazy() do fig MouseTo(vbar_thumb_center(tabs[3])) end, + LeftDown(), + Lazy() do fig + sf = tabs[3] + ca = sf.scene.viewport[] + MouseTo(Point2f(right(ca) - 4, bottom(ca) + 60), 1.5) + end, + LeftUp(), + Wait(0.5), + # Move into the (now-visible) bottom row's axis and zoom it with the + # wheel — Tabs lets the axis consume scroll first when the cursor is + # inside, so this demonstrates per-tab interactivity. + Lazy() do fig + sf = tabs[3] + ca = sf.scene.viewport[] + MouseTo(Point2f(left(ca) + 0.45 * widths(ca)[1], bottom(ca) + 0.2 * widths(ca)[2])) + end, + Scroll((0.0, -3.0); duration = 0.5), # zoom in + Wait(0.5), + # Close the "Wide" tab via its × button. Small pause after landing so + # the click doesn't feel instantaneous. + Lazy() do fig MouseTo(tab_close_center(tabs, 2)) end, + Wait(0.3), + LeftClick(), + Wait(1.5), +] + +interaction_record(fig, "tabs_example.mp4", events) +``` + +```@raw html +