Skip to content

Commit c76122b

Browse files
feat: add backendhandler module
1 parent d1ed6ca commit c76122b

1 file changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""
2+
Makie backend handler for LineCableModels.
3+
4+
Design goals:
5+
- Precompile in any environment (never touch GL/WGL at load-time).
6+
- Default to CairoMakie as a safe backend.
7+
- Offer a single `set_backend!` API (no user `using` needed).
8+
- In headless (e.g., Literate → Documenter), display PNG inline.
9+
10+
How to wire it (minimal integration):
11+
12+
1) Add this helper to your package module once:
13+
14+
include(joinpath(@__DIR__, "..", "@INPROGRESS", "makie_backend_alt.jl"))
15+
using .BackendHandler
16+
17+
2) In Makie-based preview entrypoints, ensure a backend is active:
18+
19+
# Use caller keyword `backend::Union{Nothing,Symbol}` if you keep it
20+
BackendHandler.ensure_backend!(backend === nothing ? :cairo : backend)
21+
22+
3) For GL interactive windows without directly referencing GLMakie:
23+
24+
if BackendHandler.current_backend_symbol() == :gl
25+
if (scr = BackendHandler.gl_screen("Title")) !== nothing
26+
display(scr, fig)
27+
else
28+
display(fig)
29+
end
30+
else
31+
display(fig)
32+
end
33+
34+
4) For docs/headless builds (Literate/Documenter) PNG inline display:
35+
36+
# in your final display branch
37+
BackendHandler.renderfig(fig)
38+
39+
5) Let users select backends interactively (no extra imports):
40+
41+
BackendHandler.set_backend!(:gl) # or :wgl, :cairo
42+
43+
Notes:
44+
- No `@eval import` anywhere; backends are loaded via `Base.require` using PkgId.
45+
- Calls into newly loaded modules go through `Base.invokelatest` to avoid
46+
world-age issues.
47+
"""
48+
module BackendHandler
49+
50+
using Makie
51+
using UUIDs
52+
using ..Utils: is_headless
53+
54+
export set_backend!,
55+
ensure_backend!, current_backend_symbol,
56+
backend_available, gl_screen, renderfig,
57+
next_fignum, reset_fignum!
58+
59+
# ---------------------------------------------------------------------------
60+
# Backend registry
61+
# ---------------------------------------------------------------------------
62+
63+
const _BACKENDS = Dict{Symbol, Tuple{UUID, String}}(
64+
:cairo => (UUID("13f3f980-e62b-5c42-98c6-ff1f3baf88f0"), "CairoMakie"),
65+
:gl => (UUID("e9467ef8-e4e7-5192-8a1a-b1aee30e663a"), "GLMakie"),
66+
:wgl => (UUID("276b4fcb-3e11-5398-bf8b-a0c2d153d008"), "WGLMakie"),
67+
)
68+
69+
_pkgid(sym::Symbol) = begin
70+
tup = get(_BACKENDS, sym, nothing)
71+
tup === nothing && throw(
72+
ArgumentError("Unknown backend: $(sym). Valid: $(collect(keys(_BACKENDS)))"),
73+
)
74+
Base.PkgId(tup[1], tup[2])
75+
end
76+
77+
"""Return true if a backend package exists in the environment."""
78+
backend_available(backend::Symbol) = Base.find_package(last(_BACKENDS[backend])) !== nothing
79+
80+
# Track the last activated backend symbol (separate from Makie.internal state)
81+
const _active_backend = Base.RefValue{Symbol}(:none)
82+
83+
# ---------------------------------------------------------------------------
84+
# Activation core (lazy, world-age safe)
85+
# ---------------------------------------------------------------------------
86+
87+
function _activate_backend!(backend::Symbol; allow_interactive_in_headless::Bool = false)
88+
if is_headless() && backend != :cairo && !allow_interactive_in_headless
89+
@warn "Headless environment: forcing :cairo instead of $(backend)."
90+
return _activate_backend!(:cairo; allow_interactive_in_headless)
91+
end
92+
93+
pid = _pkgid(backend)
94+
# Load the backend module into Julia's module world; idempotent if already loaded
95+
mod = Base.require(pid)
96+
# Call `activate!` safely with world-age correctness
97+
Base.invokelatest(getproperty(mod, :activate!))
98+
_active_backend[] = backend
99+
return backend
100+
end
101+
102+
"""Ensure a backend is active. Defaults to :cairo the first time."""
103+
function ensure_backend!(backend::Union{Nothing, Symbol} = nothing)
104+
if backend === nothing
105+
return _active_backend[] == :none ? _activate_backend!(:cairo) : _active_backend[]
106+
else
107+
return set_backend!(backend)
108+
end
109+
end
110+
111+
"""Activate a specific backend (:cairo, :gl, :wgl).
112+
113+
In headless, :gl/:wgl requests fall back to :cairo unless `force=true`.
114+
No `using` required by callers.
115+
"""
116+
function set_backend!(backend::Symbol; force::Bool = false)
117+
haskey(_BACKENDS, backend) || throw(
118+
ArgumentError("Unknown backend: $(backend). Valid: $(collect(keys(_BACKENDS)))"),
119+
)
120+
if backend != :cairo && !backend_available(backend)
121+
@warn "Backend $(last(_BACKENDS[backend])) not in environment; using :cairo."
122+
return _activate_backend!(:cairo)
123+
end
124+
return _activate_backend!(backend; allow_interactive_in_headless = force)
125+
end
126+
127+
"""Symbol of the current Makie backend (:cairo, :gl, :wgl, :unknown, :none)."""
128+
function current_backend_symbol()
129+
try
130+
nb = nameof(Makie.current_backend())
131+
nb === :CairoMakie && return :cairo
132+
nb === :GLMakie && return :gl
133+
nb === :WGLMakie && return :wgl
134+
return :unknown
135+
catch
136+
return :none
137+
end
138+
end
139+
140+
"""Create a GLMakie screen if GL backend is active; otherwise return nothing."""
141+
function gl_screen(title::AbstractString)
142+
if current_backend_symbol() == :gl
143+
mod = Base.require(_pkgid(:gl))
144+
ctor = getproperty(mod, :Screen)
145+
return Base.invokelatest(ctor; title = String(title))
146+
end
147+
return nothing
148+
end
149+
150+
"""Display a figure appropriately in headless docs or interactive sessions.
151+
152+
- Headless: returns `DisplayAs.Text(DisplayAs.PNG(fig))` if `DisplayAs` exists;
153+
otherwise attempts to rasterize via CairoMakie and returns nothing.
154+
- Interactive: calls `display(fig)` and returns its result.
155+
"""
156+
function renderfig(fig)
157+
if is_headless()
158+
try
159+
D = Base.require(
160+
Base.PkgId(UUID("0b91fe84-8a4c-11e9-3e1d-67c38462b6d6"), "DisplayAs"),
161+
)
162+
return D.Text(D.PNG(fig))
163+
catch
164+
try
165+
ensure_backend!(:cairo)
166+
cm = Base.require(_pkgid(:cairo))
167+
savef = getproperty(cm, :save)
168+
io = IOBuffer()
169+
Base.invokelatest(savef, io, fig)
170+
return nothing
171+
catch
172+
return nothing
173+
end
174+
end
175+
else
176+
return display(fig)
177+
end
178+
end
179+
180+
const FIG_NO = Base.Threads.Atomic{Int}(1)
181+
next_fignum() = Base.Threads.atomic_add!(FIG_NO, 1)
182+
reset_fignum!(n::Int = 1) = (FIG_NO[] = n)
183+
184+
# ---------------------------------------------------------------------------
185+
# Default backend at runtime load (safe: CairoMakie only)
186+
# ---------------------------------------------------------------------------
187+
188+
function __init__()
189+
try
190+
# Only set a default if nothing looks active yet
191+
if current_backend_symbol() in (:none, :unknown)
192+
ensure_backend!(:cairo)
193+
end
194+
catch e
195+
@warn "Failed to initialize default CairoMakie" exception=(e, catch_backtrace())
196+
end
197+
end
198+
199+
end # module
200+

0 commit comments

Comments
 (0)