Skip to main content

GORBIE/
lib.rs

1//! ## Working with mutable/non-cloneable things.
2//! Sometimes when working with existing code, libraries or even std things like
3//! files, can introduce an impedance mismatch with a dataflow-style model.
4//! Often it is enough to wrap the object in question into another layer of `Arc`s
5//! and `RWLock`s in addition to what Gorby already does with its shared state
6//! store.
7//!
8//! For heavier work, `ComputedState` can run background tasks and hold the latest
9//! value. Use `Option<T>` when a value may be absent while a computation runs.
10//!
11//! But sometimes that isn't enough, e.g. when you want to display some application
12//! global state. This is why `NotebookCtx::state` and `NotebookCtx::view` are carefully
13//! designed to stay independent from any dataflow runtime. Instead they can be used,
14//! like any other mutable rust type, via the typed `StateId` handle.
15//!
16
17#![allow(non_snake_case)]
18
19/// Card context — the `&mut CardCtx` passed to card closures.
20pub mod card_ctx;
21/// Card trait and built-in card types (stateful, stateless).
22pub mod cards;
23/// Background computation with [`ComputedState`](dataflow::ComputedState).
24pub mod dataflow;
25pub(crate) mod floating;
26mod headless;
27/// Convenient glob import of common types and constants.
28pub mod prelude;
29/// Thread-safe state management via [`StateId`](state::StateId) handles.
30pub mod state;
31/// Telemetry dashboard (requires `telemetry` feature).
32#[cfg(feature = "telemetry")]
33pub mod telemetry;
34/// Visual themes, RAL colors, and widget style structs.
35pub mod themes;
36/// Built-in widgets: buttons, fields, sliders, progress bars, and more.
37pub mod widgets;
38
39pub use gorbie_macros::notebook;
40
41use crate::themes::industrial_dark;
42use crate::themes::industrial_fonts;
43use crate::themes::industrial_light;
44use eframe::egui::{self};
45use std::any::TypeId;
46use std::path::PathBuf;
47use std::process::Command;
48use std::sync::atomic::{AtomicBool, Ordering};
49use std::sync::Arc;
50use std::time::Duration;
51
52use dark_light::Mode;
53
54
55#[derive(Clone, Debug, Hash, PartialEq, Eq)]
56struct SourceLocation {
57    file: String,
58    line: u32,
59    column: u32,
60}
61
62impl SourceLocation {
63    fn from_location(location: &'static std::panic::Location<'static>) -> Self {
64        Self {
65            file: location.file().to_string(),
66            line: location.line(),
67            column: location.column(),
68        }
69    }
70
71    fn format_arg(&self, template: &str) -> String {
72        let file = &self.file;
73        let line = self.line;
74        let column = self.column;
75        template
76            .replace("{file}", file)
77            .replace("{line}", &line.to_string())
78            .replace("{column}", &column.to_string())
79    }
80
81    fn file_line_column(&self) -> String {
82        let file = &self.file;
83        let line = self.line;
84        let column = self.column;
85        format!("{file}:{line}:{column}")
86    }
87}
88
89#[derive(Clone, Debug, Hash, PartialEq, Eq)]
90enum CardIdentityKey {
91    Stateless {
92        source: Option<SourceLocation>,
93        function: TypeId,
94    },
95    Stateful {
96        source: Option<SourceLocation>,
97        state: egui::Id,
98        function: TypeId,
99    },
100    Custom,
101}
102
103/// Command template for opening source files in an external editor.
104///
105/// Argument strings may contain `{file}`, `{line}`, and `{column}` placeholders
106/// that are expanded when a card's source location is opened.
107#[derive(Clone, Debug)]
108pub struct EditorCommand {
109    program: String,
110    args: Vec<String>,
111}
112
113impl EditorCommand {
114    /// Creates a new editor command with the given program name.
115    pub fn new(program: impl Into<String>) -> Self {
116        Self {
117            program: program.into(),
118            args: Vec::new(),
119        }
120    }
121
122    /// Appends an argument to the command. Supports `{file}`, `{line}`, and
123    /// `{column}` placeholders.
124    pub fn arg(mut self, arg: impl Into<String>) -> Self {
125        self.args.push(arg.into());
126        self
127    }
128
129    fn open(&self, source: &SourceLocation) -> std::io::Result<()> {
130        let mut cmd = Command::new(&self.program);
131        if self.args.is_empty() {
132            cmd.arg(source.file_line_column());
133        } else {
134            for arg in &self.args {
135                cmd.arg(source.format_arg(arg));
136            }
137        }
138        let _child = cmd.spawn()?;
139        Ok(())
140    }
141}
142
143struct CardEntry {
144    card: Box<dyn cards::Card + 'static>,
145    source: Option<SourceLocation>,
146    identity: egui::Id,
147}
148
149#[derive(Clone, Default)]
150struct NotebookState {
151    card_detached: Vec<bool>,
152    card_placeholder_sizes: Vec<egui::Vec2>,
153    card_identities: Vec<Option<egui::Id>>,
154}
155
156impl NotebookState {
157    fn sync_len(&mut self, len: usize) {
158        self.card_detached.resize(len, false);
159        self.card_placeholder_sizes.resize(len, egui::Vec2::ZERO);
160        self.card_identities.resize(len, None);
161    }
162
163    fn ensure_card_identity(&mut self, index: usize, identity: egui::Id) {
164        let slot = self
165            .card_identities
166            .get_mut(index)
167            .expect("card_identities synced to cards");
168        if slot.map_or(true, |prev| prev != identity) {
169            *slot = Some(identity);
170            *self
171                .card_detached
172                .get_mut(index)
173                .expect("card_detached synced to cards") = false;
174            *self
175                .card_placeholder_sizes
176                .get_mut(index)
177                .expect("card_placeholder_sizes synced to cards") = egui::Vec2::ZERO;
178        }
179    }
180}
181
182/// Configuration for a notebook application.
183pub struct NotebookConfig {
184    title: String,
185    editor: Option<EditorCommand>,
186    headless_capture: Option<HeadlessCaptureConfig>,
187    headless_settle_timeout: Option<Duration>,
188}
189
190#[derive(Clone)]
191struct HeadlessCaptureConfig {
192    output_dir: PathBuf,
193    card_width: f32,
194    pixels_per_point: f32,
195    settle_timeout: Duration,
196}
197
198#[derive(Clone)]
199struct AppIcons {
200    light: Arc<egui::IconData>,
201    dark: Arc<egui::IconData>,
202}
203
204struct NotebookCore {
205    config: NotebookConfig,
206    body: Box<dyn FnMut(&mut NotebookCtx)>,
207    state_store: Arc<state::StateStore>,
208    settled: Arc<AtomicBool>,
209}
210
211struct Notebook {
212    core: NotebookCore,
213    icons: Option<AppIcons>,
214    icon_is_dark: Option<bool>,
215    #[cfg(feature = "telemetry")]
216    #[allow(dead_code)] // kept alive to flush/close the telemetry sink on shutdown
217    telemetry: Option<telemetry::Telemetry>,
218}
219
220/// Frame-scoped notebook builder used to collect cards in immediate mode.
221pub struct NotebookCtx {
222    state_id: egui::Id,
223    cards: Vec<CardEntry>,
224    state_store: Arc<state::StateStore>,
225    settled: Arc<AtomicBool>,
226}
227
228pub use card_ctx::CardCtx;
229pub use card_ctx::Grid;
230pub use card_ctx::GRID_COL_WIDTH;
231pub use card_ctx::GRID_COLUMNS;
232pub use card_ctx::GRID_GUTTER;
233
234pub(crate) const NOTEBOOK_COLUMN_WIDTH: f32 = 768.0;
235const NOTEBOOK_MIN_HEIGHT: f32 = 360.0;
236const HEADLESS_DEFAULT_PIXELS_PER_POINT: f32 = 2.0;
237const HEADLESS_DEFAULT_SETTLE_TIMEOUT: Duration = Duration::from_millis(2000);
238
239impl Default for NotebookConfig {
240    fn default() -> Self {
241        Self::new(String::new())
242    }
243}
244
245impl NotebookConfig {
246    /// Creates a new configuration with the given application title.
247    ///
248    /// The editor command is auto-detected from the `GORBIE_EDITOR` environment
249    /// variable; use [`with_editor`](Self::with_editor) to override.
250    pub fn new(name: impl Into<String>) -> Self {
251        let title = name.into();
252        Self {
253            title,
254            editor: editor_from_env(),
255            headless_capture: None,
256            headless_settle_timeout: None,
257        }
258    }
259
260    /// Overrides the editor command used for "open in editor" buttons.
261    pub fn with_editor(mut self, editor: EditorCommand) -> Self {
262        self.editor = Some(editor);
263        self
264    }
265
266    /// Enables headless capture mode, rendering each card to a PNG in `output_dir`.
267    pub fn with_headless_capture(mut self, output_dir: impl Into<PathBuf>) -> Self {
268        let settle_timeout = self
269            .headless_settle_timeout
270            .unwrap_or(HEADLESS_DEFAULT_SETTLE_TIMEOUT);
271        self.headless_capture = Some(HeadlessCaptureConfig {
272            output_dir: output_dir.into(),
273            card_width: NOTEBOOK_COLUMN_WIDTH,
274            pixels_per_point: HEADLESS_DEFAULT_PIXELS_PER_POINT,
275            settle_timeout,
276        });
277        self
278    }
279
280    /// Like [`with_headless_capture`](Self::with_headless_capture), but with a
281    /// custom `pixels_per_point` scaling factor for the rendered output.
282    pub fn with_headless_capture_scaled(
283        mut self,
284        output_dir: impl Into<PathBuf>,
285        pixels_per_point: f32,
286    ) -> Self {
287        let pixels_per_point = if pixels_per_point > 0.0 {
288            pixels_per_point
289        } else {
290            HEADLESS_DEFAULT_PIXELS_PER_POINT
291        };
292        let settle_timeout = self
293            .headless_settle_timeout
294            .unwrap_or(HEADLESS_DEFAULT_SETTLE_TIMEOUT);
295        self.headless_capture = Some(HeadlessCaptureConfig {
296            output_dir: output_dir.into(),
297            card_width: NOTEBOOK_COLUMN_WIDTH,
298            pixels_per_point,
299            settle_timeout,
300        });
301        self
302    }
303
304    /// Sets the settle timeout for headless capture. The renderer waits up to
305    /// this duration for the UI to stabilize before capturing.
306    pub fn with_headless_settle_timeout(mut self, timeout: Duration) -> Self {
307        self.headless_settle_timeout = Some(timeout);
308        if let Some(headless) = &mut self.headless_capture {
309            headless.settle_timeout = timeout;
310        }
311        self
312    }
313
314    fn state_id(&self) -> egui::Id {
315        egui::Id::new(("gorbie_notebook_state", self.title.as_str()))
316    }
317
318    /// Launches the notebook application.
319    ///
320    /// The `body` closure is called once per frame to populate cards. In headless
321    /// mode the cards are rendered to PNGs and the process exits; otherwise an
322    /// interactive window is opened.
323    pub fn run(self, body: impl FnMut(&mut NotebookCtx) + 'static) -> eframe::Result {
324        let config = self;
325        if let Some(headless) = config.headless_capture.clone() {
326            return headless::run_headless(NotebookCore::new(config, Box::new(body)), headless)
327                .map_err(eframe::Error::AppCreation);
328        }
329
330        let window_title = if config.title.is_empty() {
331            "GORBIE".to_owned()
332        } else {
333            config.title.clone()
334        };
335
336        let icons = load_app_icons();
337        let mut native_options = eframe::NativeOptions::default();
338        native_options.persist_window = true;
339        native_options.viewport = native_options
340            .viewport
341            .with_inner_size(egui::vec2(1200.0, 800.0))
342            .with_min_inner_size(egui::vec2(NOTEBOOK_COLUMN_WIDTH, NOTEBOOK_MIN_HEIGHT));
343
344        if let Some(icons) = icons.as_ref() {
345            let icon = match dark_light::detect() {
346                Ok(Mode::Light) => icons.light.clone(),
347                Ok(Mode::Dark) => icons.dark.clone(),
348                Ok(Mode::Unspecified) | Err(_) => icons.dark.clone(),
349            };
350            native_options.viewport = native_options.viewport.with_icon(icon);
351        }
352
353        let body = Box::new(body);
354        eframe::run_native(
355            &window_title,
356            native_options,
357            Box::new(|cc| {
358                let ctx = cc.egui_ctx.clone();
359                ctrlc::set_handler(move || ctx.send_viewport_cmd(egui::ViewportCommand::Close))
360                    .expect("failed to set exit signal handler");
361
362                cc.egui_ctx.set_fonts(industrial_fonts());
363
364                cc.egui_ctx
365                    .set_style_of(egui::Theme::Light, industrial_light());
366                cc.egui_ctx
367                    .set_style_of(egui::Theme::Dark, industrial_dark());
368
369                #[cfg(feature = "telemetry")]
370                let telemetry_title = config.title.clone();
371                Ok(Box::new(Notebook {
372                    core: NotebookCore::new(config, body),
373                    icons,
374                    icon_is_dark: None,
375                    #[cfg(feature = "telemetry")]
376                    telemetry: telemetry::Telemetry::install_global_from_env(&telemetry_title),
377                }))
378            }),
379        )
380    }
381}
382
383fn load_app_icons() -> Option<AppIcons> {
384    let light =
385        eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon_light.png")).ok()?;
386    let dark = eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon_dark.png")).ok()?;
387    Some(AppIcons {
388        light: Arc::new(light),
389        dark: Arc::new(dark),
390    })
391}
392
393fn editor_from_env() -> Option<EditorCommand> {
394    let gorbie_editor = std::env::var("GORBIE_EDITOR")
395        .ok()
396        .filter(|value| !value.trim().is_empty());
397    if gorbie_editor.is_none() {
398        log_missing_editor_hint();
399        return None;
400    }
401    let editor = gorbie_editor?;
402
403    let mut parts = editor.split_whitespace();
404    let program = parts.next()?.to_string();
405    let args = parts.map(str::to_string);
406    let mut command = EditorCommand::new(program);
407    for arg in args {
408        command = command.arg(arg);
409    }
410    Some(command)
411}
412
413fn log_missing_editor_hint() {
414    static ONCE: std::sync::Once = std::sync::Once::new();
415    ONCE.call_once(|| {
416        log::info!(
417            "GORBIE_EDITOR is not set. Set it to an editor command with placeholders {{file}} {{line}} {{column}} to enable open-in-editor. Example: GORBIE_EDITOR='code -g {{file}}:{{line}}:{{column}}'."
418        );
419    });
420}
421
422impl state::StateAccess for NotebookCtx {
423    fn store(&self) -> &state::StateStore {
424        &self.state_store
425    }
426}
427
428impl NotebookCtx {
429    fn new(config: &NotebookConfig, state_store: Arc<state::StateStore>, settled: Arc<AtomicBool>) -> Self {
430        Self {
431            state_id: config.state_id(),
432            cards: Vec::new(),
433            state_store,
434            settled,
435        }
436    }
437
438    /// Signal that notebook content is fully loaded and ready for capture.
439    /// In headless mode this triggers immediate capture instead of waiting
440    /// for the settle timeout. In interactive mode this is a no-op.
441    pub fn settled(&self) {
442        self.settled.store(true, Ordering::Relaxed);
443    }
444
445    /// Adds a stateless card whose content is drawn by `function` each frame.
446    #[track_caller]
447    pub fn view<F>(&mut self, function: F)
448    where
449        F: for<'a, 'b> FnMut(&'a mut CardCtx<'b>) + 'static,
450    {
451        let source = SourceLocation::from_location(std::panic::Location::caller());
452        let identity = self.card_identity(CardIdentityKey::Stateless {
453            source: Some(source.clone()),
454            function: TypeId::of::<F>(),
455        });
456        let card = cards::StatelessCard::new(function);
457        self.push_with_source(Box::new(card), Some(source), identity);
458    }
459
460    /// Adds a stateful card backed by a value of type `T` in the shared state store.
461    ///
462    /// The state is initialized with `init` on first use and persists across frames.
463    /// Returns a [`StateId`](state::StateId) handle for reading/writing the state
464    /// from other cards.
465    #[track_caller]
466    pub fn state<K, T, F>(&mut self, key: &K, init: T, function: F) -> state::StateId<T>
467    where
468        K: std::hash::Hash + ?Sized,
469        T: Send + Sync + 'static,
470        F: for<'a, 'b> FnMut(&'a mut CardCtx<'b>, &mut T) + 'static,
471    {
472        let source = SourceLocation::from_location(std::panic::Location::caller());
473        let state_id = self.state_id_for(key);
474        let identity = self.card_identity(CardIdentityKey::Stateful {
475            source: Some(source.clone()),
476            state: state_id,
477            function: TypeId::of::<F>(),
478        });
479        let state = state::StateId::new(state_id);
480        let handle = state;
481        self.state_store.get_or_insert(state, init);
482        let card = cards::StatefulCard::new(state, function);
483        self.push_with_source(Box::new(card), Some(source), identity);
484        handle
485    }
486
487    /// Adds a pre-built [`Card`](cards::Card) trait object to the notebook.
488    pub fn push(&mut self, card: Box<dyn cards::Card>) {
489        let identity = self.card_identity(CardIdentityKey::Custom);
490        self.push_with_source(card, None, identity);
491    }
492
493    pub(crate) fn state_id_for<K: std::hash::Hash + ?Sized>(&self, key: &K) -> egui::Id {
494        self.state_id.with(("state", key))
495    }
496
497    fn card_identity(&self, key: CardIdentityKey) -> egui::Id {
498        self.state_id.with(("card", key))
499    }
500
501    fn push_with_source(
502        &mut self,
503        card: Box<dyn cards::Card>,
504        source: Option<SourceLocation>,
505        identity: egui::Id,
506    ) {
507        self.cards.push(CardEntry {
508            card,
509            source,
510            identity,
511        });
512    }
513}
514
515impl NotebookCore {
516    fn new(config: NotebookConfig, body: Box<dyn FnMut(&mut NotebookCtx)>) -> Self {
517        Self {
518            config,
519            body,
520            state_store: Arc::new(state::StateStore::default()),
521            settled: Arc::new(AtomicBool::new(false)),
522        }
523    }
524
525    fn has_settled(&self) -> bool {
526        self.settled.swap(false, Ordering::Relaxed)
527    }
528
529    fn build_notebook(&mut self) -> NotebookCtx {
530        let mut notebook = NotebookCtx::new(&self.config, self.state_store.clone(), self.settled.clone());
531        (self.body)(&mut notebook);
532        notebook
533    }
534
535    fn draw_card(
536        &self,
537        ctx: &egui::Context,
538        notebook: &mut NotebookCtx,
539        index: usize,
540        card_width: f32,
541    ) -> Option<f32> {
542        if index >= notebook.cards.len() {
543            return None;
544        }
545
546        let store = notebook.state_store.clone();
547        let mut measured_height: Option<f32> = None;
548        let panel_fill = ctx.style().visuals.window_fill;
549        egui::CentralPanel::default()
550            .frame(egui::Frame::NONE.fill(panel_fill))
551            .show(ctx, |ui| {
552                ui.set_min_size(egui::vec2(card_width, 0.0));
553                if let Some(entry) = notebook.cards.get_mut(index) {
554                    let card: &mut dyn cards::Card = entry.card.as_mut();
555                    let rect = draw_card_body(ui, card_width, card, store.as_ref(), None);
556                    measured_height = Some(rect.height());
557                }
558            });
559
560        measured_height
561    }
562}
563
564impl Notebook {
565    fn update_app_icon(&mut self, ctx: &egui::Context) {
566        let Some(icons) = self.icons.as_ref() else {
567            return;
568        };
569        let is_dark = matches!(ctx.theme(), egui::Theme::Dark);
570        if self.icon_is_dark == Some(is_dark) {
571            return;
572        }
573
574        let icon = if is_dark {
575            icons.dark.clone()
576        } else {
577            icons.light.clone()
578        };
579        ctx.send_viewport_cmd(egui::ViewportCommand::Icon(Some(icon)));
580        self.icon_is_dark = Some(is_dark);
581    }
582}
583
584impl eframe::App for Notebook {
585    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
586        self.update_app_icon(ctx);
587        ctx.style_mut(|style| {
588            style.visuals.clip_rect_margin = 0.0;
589        });
590
591        #[cfg(feature = "telemetry")]
592        let _telemetry_frame = tracing::info_span!("frame").entered();
593
594        let mut notebook = {
595            #[cfg(feature = "telemetry")]
596            let _build_span = tracing::info_span!("build_notebook").entered();
597            self.core.build_notebook()
598        };
599        let config = &self.core.config;
600
601        let state_id = config.state_id();
602        let mut runtime = ctx.data_mut(|data| {
603            let slot = data.get_temp_mut_or_insert_with(state_id, NotebookState::default);
604            std::mem::take(slot)
605        });
606
607        egui::CentralPanel::default().show(ctx, |ui| {
608            egui::ScrollArea::vertical()
609                .auto_shrink([false; 2])
610                .show_viewport(ui, |ui, viewport| {
611                    let rect = ui.max_rect();
612                    let clip_rect = ui.clip_rect();
613                        let scroll_y = viewport.min.y;
614
615                    // Publish scroll info so floating cards can do anchor switching.
616                    floating::store_scroll_info(
617                        ui.ctx(),
618                        floating::NotebookScrollInfo {
619                            scroll_y,
620                            viewport_top: clip_rect.min.y,
621                            clip_rect,
622                        },
623                    );
624
625                    let column_width = NOTEBOOK_COLUMN_WIDTH;
626                    let left_margin_width = 0.0;
627                    let card_width = column_width;
628
629                    let left_margin_paint = egui::Rect::from_min_max(
630                        egui::pos2(rect.min.x, clip_rect.min.y),
631                        egui::pos2(rect.min.x + left_margin_width, clip_rect.max.y),
632                    );
633                    let left_margin = egui::Rect::from_min_max(
634                        rect.min,
635                        egui::pos2(rect.min.x + left_margin_width, rect.max.y),
636                    );
637                    let column_rect = egui::Rect::from_min_max(
638                        egui::pos2(left_margin.max.x, rect.min.y),
639                        egui::pos2(left_margin.max.x + column_width, rect.max.y),
640                    );
641                    let right_margin_paint = egui::Rect::from_min_max(
642                        egui::pos2(column_rect.max.x, clip_rect.min.y),
643                        egui::pos2(rect.max.x, clip_rect.max.y),
644                    );
645                    let right_margin = egui::Rect::from_min_max(
646                        egui::pos2(column_rect.max.x, rect.min.y),
647                        rect.max,
648                    );
649
650                    paint_dot_grid(ui, left_margin_paint, scroll_y);
651                    paint_dot_grid(ui, right_margin_paint, scroll_y);
652
653                    ui.scope_builder(egui::UiBuilder::new().max_rect(column_rect), |ui| {
654                        // Keep column/background fills from painting into the margins when
655                        // a too-wide widget forces a larger layout rect.
656                        let restore_clip_rect = ui.clip_rect();
657                        let column_clip_rect = egui::Rect::from_min_max(
658                            egui::pos2(column_rect.min.x, restore_clip_rect.min.y),
659                            egui::pos2(column_rect.max.x, restore_clip_rect.max.y),
660                        );
661                        ui.set_clip_rect(column_clip_rect);
662
663                        ui.set_min_size(column_rect.size());
664                        ui.set_max_width(column_rect.width());
665
666                        let fill = ui.visuals().window_fill;
667
668                        let card_gap = ui
669                            .visuals()
670                            .widgets
671                            .noninteractive
672                            .bg_stroke
673                            .width
674                            .max(1.0);
675                        let card_gap_i8 = card_gap
676                            .round()
677                            .clamp(0.0, i8::MAX as f32) as i8;
678                        let column_inner_margin = egui::Margin {
679                            left: 0,
680                            right: 0,
681                            top: 12,
682                            bottom: card_gap_i8,
683                        };
684                        let column_frame = egui::Frame::new()
685                            .fill(fill)
686                            .stroke(egui::Stroke::NONE)
687                            .corner_radius(0.0)
688                            .inner_margin(column_inner_margin)
689                            .show(ui, |ui| {
690                                // Theme switch is part of the page header (above the first card).
691                                ui.horizontal(|ui| {
692                                    ui.add_space(16.0);
693                                    if !config.title.is_empty() {
694                                        let header_title =
695                                            egui::RichText::new(config.title.to_uppercase())
696                                                .monospace()
697                                                .strong();
698                                        ui.add(egui::Label::new(header_title).truncate());
699                                    }
700
701                                    ui.with_layout(
702                                        egui::Layout::right_to_left(egui::Align::Center),
703                                        |ui| {
704                                            ui.add_space(16.0);
705                                            let mut preference =
706                                                ui.ctx().options(|opt| opt.theme_preference);
707                                            if ui
708                                                .add(
709                                                    widgets::ChoiceToggle::new(&mut preference)
710                                                        .choice(egui::ThemePreference::System, "◐")
711                                                        .choice(egui::ThemePreference::Dark, "●")
712                                                        .choice(egui::ThemePreference::Light, "○"),
713                                                )
714                                                .changed()
715                                            {
716                                                ui.ctx().set_theme(preference);
717                                            }
718                                        },
719                                    );
720                                });
721
722                                ui.add_space(12.0);
723
724                                // Separator between header and first card.
725                                {
726                                    let (gap_rect, _) = ui.allocate_exact_size(
727                                        egui::vec2(card_width, card_gap),
728                                        egui::Sense::hover(),
729                                    );
730                                    let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
731                                    ui.painter().rect_filled(gap_rect, 0.0, stroke.color);
732                                }
733
734                                runtime.sync_len(notebook.cards.len());
735                                let store = notebook.state_store.clone();
736
737                                let default_item_spacing = ui.style().spacing.item_spacing;
738                                ui.style_mut().spacing.item_spacing.y = 0.0;
739                                let cards_len = notebook.cards.len();
740                                for (i, entry) in notebook.cards.iter_mut().enumerate() {
741                                    let card_identity = entry.identity;
742                                    runtime.ensure_card_identity(i, card_identity);
743                                    let card_detached = runtime.card_detached
744                                        .get_mut(i)
745                                        .expect("card_detached synced to cards");
746                                    let card_placeholder_size = runtime.card_placeholder_sizes
747                                        .get_mut(i)
748                                        .expect("card_placeholder_sizes synced to cards");
749                                    ui.push_id((i, card_identity), |ui| {
750                                        let card_left = column_rect.min.x;
751                                        let card: &mut dyn cards::Card = entry.card.as_mut();
752                                        let card_rect = if *card_detached {
753                                            let placeholder_height =
754                                                crate::card_ctx::GRID_ROW_MODULE;
755                                            let placeholder_width = card_width;
756                                            let (rect, resp) = ui.allocate_exact_size(
757                                                egui::vec2(placeholder_width, placeholder_height),
758                                                egui::Sense::click(),
759                                            );
760                                            let fill = ui.visuals().window_fill;
761                                            let outline =
762                                                ui.visuals().widgets.noninteractive.bg_stroke.color;
763                                            ui.painter().rect_filled(rect, 0.0, fill);
764                                            paint_hatching(
765                                                &ui.painter().with_clip_rect(rect),
766                                                rect,
767                                                outline,
768                                            );
769                                            show_postit_tooltip(ui, &resp, "Dock card");
770                                            if resp.clicked() {
771                                                *card_detached = false;
772                                            }
773
774                                            if *card_detached {
775                                                let initial_screen_pos = egui::pos2(
776                                                    right_margin.min.x + 12.0,
777                                                    rect.top(),
778                                                );
779                                                let detached_id = ui.id().with("detached_card");
780                                                let float_resp = floating::show_floating_card(
781                                                    ui.ctx(),
782                                                    detached_id,
783                                                    initial_screen_pos,
784                                                    card_width,
785                                                    card_placeholder_size.y,
786                                                    store.as_ref(),
787                                                    "Dock card",
788                                                    &mut |ctx| {
789                                                        #[cfg(feature = "telemetry")]
790                                                        let _detached_span = tracing::info_span!(
791                                                            "detached_draw"
792                                                        )
793                                                        .entered();
794                                                        card.draw(ctx);
795                                                    },
796                                                );
797                                                if float_resp.handle_clicked {
798                                                    *card_detached = false;
799                                                }
800                                            }
801                                            rect
802                                        } else {
803                                            let clip_rect = ui.clip_rect();
804                                            let card_clip_rect = egui::Rect::from_min_max(
805                                                egui::pos2(
806                                                    column_rect.min.x,
807                                                    clip_rect.min.y,
808                                                ),
809                                                egui::pos2(
810                                                    column_rect.max.x,
811                                                    clip_rect.max.y,
812                                                ),
813                                            );
814                                            #[cfg(feature = "telemetry")]
815                                            let _card_span = {
816                                                let source = entry
817                                                    .source
818                                                    .as_ref()
819                                                    .map(|s| s.file_line_column())
820                                                    .unwrap_or_default();
821                                                tracing::info_span!(
822                                                    "card",
823                                                    source = source.as_str()
824                                                )
825                                                .entered()
826                                            };
827                                            let inner_rect = draw_card_body(
828                                                ui,
829                                                card_width,
830                                                card,
831                                                store.as_ref(),
832                                                Some(card_clip_rect),
833                                            );
834                                            *card_placeholder_size =
835                                                egui::vec2(card_width, inner_rect.height());
836                                            egui::Rect::from_min_size(
837                                                egui::pos2(card_left, inner_rect.min.y),
838                                                egui::vec2(card_width, inner_rect.height()),
839                                            )
840                                        };
841                                        if i + 1 < cards_len {
842                                            let separator_top = card_rect.bottom().ceil();
843                                            let cursor_top = ui.cursor().top();
844                                            if separator_top > cursor_top {
845                                                ui.add_space(separator_top - cursor_top);
846                                            }
847                                            let (gap_rect, _) = ui.allocate_exact_size(
848                                                egui::vec2(card_width, card_gap),
849                                                egui::Sense::hover(),
850                                            );
851                                            let stroke = ui
852                                                .visuals()
853                                                .widgets
854                                                .noninteractive
855                                                .bg_stroke;
856                                            ui.painter()
857                                                .rect_filled(gap_rect, 0.0, stroke.color);
858                                        }
859
860                                        let show_detach_button = !*card_detached;
861                                        let show_open_button = show_detach_button
862                                            && entry.source.is_some()
863                                            && config.editor.is_some();
864                                        if show_detach_button {
865                                            let tab_size = egui::vec2(20.0, 2.0 * crate::card_ctx::GRID_ROW_MODULE);
866                                            let tab_pull = 4.0;
867                                            let base_tab_gap = 4.0;
868                                            let base_top_offset = 8.0;
869                                            let min_top_offset = 0.0;
870                                            let min_visible = tab_size.y * 0.4;
871                                            let tab_fill = crate::themes::GorbieButtonStyle::from(
872                                                ui.style().as_ref(),
873                                            )
874                                            .fill;
875
876                                            let tab_count = 1 + usize::from(show_open_button);
877                                            let available = card_rect.height().max(0.0);
878                                            let mut top_offset = base_top_offset;
879                                            let mut gap = base_tab_gap;
880                                            let required = top_offset
881                                                + tab_size.y * tab_count as f32
882                                                + gap * (tab_count.saturating_sub(1) as f32);
883
884                                            if required > available {
885                                                let extra = required - available;
886                                                let max_top_reduce =
887                                                    (top_offset - min_top_offset).max(0.0);
888                                                let top_reduce = extra.min(max_top_reduce);
889                                                top_offset -= top_reduce;
890                                                let remaining = extra - top_reduce;
891
892                                                if remaining > 0.0 && tab_count > 1 {
893                                                    let min_gap =
894                                                        -(tab_size.y - min_visible);
895                                                    let max_gap_reduce =
896                                                        (gap - min_gap).max(0.0);
897                                                    let gap_reduce = remaining.min(max_gap_reduce);
898                                                    gap -= gap_reduce;
899                                                }
900                                            }
901
902                                            let tab_x = card_rect.right().round();
903                                            let top_y =
904                                                (card_rect.top() + top_offset).round();
905                                            let detach_pos = egui::pos2(tab_x, top_y);
906                                            let open_pos = show_open_button.then(|| {
907                                                egui::pos2(
908                                                    tab_x,
909                                                    (top_y + tab_size.y + gap).round(),
910                                                )
911                                            });
912
913                                            ui.push_id((i, card_identity), |ui| {
914                                                if let Some(open_pos) = open_pos {
915                                                    let open_id =
916                                                        ui.id().with("open_button");
917                                                    let open_area = egui::Area::new(open_id)
918                                                        .order(egui::Order::Middle)
919                                                        .fixed_pos(open_pos)
920                                                        .movable(false)
921                                                        .constrain_to(egui::Rect::EVERYTHING);
922                                                    let open_resp =
923                                                        open_area.show(ui.ctx(), |ui| {
924                                                            let (rect, resp) =
925                                                                ui.allocate_exact_size(
926                                                                    egui::vec2(
927                                                                        tab_size.x + tab_pull,
928                                                                        tab_size.y,
929                                                                    ),
930                                                                    egui::Sense::click(),
931                                                                );
932                                                            let tab_rect =
933                                                                egui::Rect::from_min_size(
934                                                                    rect.min,
935                                                                    tab_size,
936                                                                );
937                                                            paint_card_tab_button(
938                                                                ui,
939                                                                &resp,
940                                                                tab_rect,
941                                                                "<>",
942                                                                tab_fill,
943                                                                tab_pull,
944                                                            );
945
946                                                            if let Some(source) =
947                                                                entry.source.as_ref()
948                                                            {
949                                                                let file = &source.file;
950                                                                let line = source.line;
951                                                                let tooltip = format!(
952                                                                    "Open in editor\n{file}:{line}"
953                                                                );
954                                                                show_postit_tooltip(
955                                                                    ui,
956                                                                    &resp,
957                                                                    &tooltip,
958                                                                );
959                                                            } else {
960                                                                show_postit_tooltip(
961                                                                    ui,
962                                                                    &resp,
963                                                                    "Open in editor",
964                                                                );
965                                                            }
966                                                            resp
967                                                        });
968
969                                                    if open_resp.inner.clicked() {
970                                                        if let (Some(source), Some(editor)) =
971                                                            (
972                                                                entry.source.as_ref(),
973                                                                config.editor.as_ref(),
974                                                            )
975                                                        {
976                                                            if let Err(err) = editor.open(source) {
977                                                                log::warn!(
978                                                                    "failed to open editor: {err}"
979                                                                );
980                                                            }
981                                                        }
982                                                    }
983                                                }
984
985                                                let detach_id = ui.id().with("detach_button");
986                                                let detach_area = egui::Area::new(detach_id)
987                                                    .order(egui::Order::Middle)
988                                                    .fixed_pos(detach_pos)
989                                                    .movable(false)
990                                                    .constrain_to(egui::Rect::EVERYTHING);
991                                                let detach_resp =
992                                                    detach_area.show(ui.ctx(), |ui| {
993                                                        let (rect, resp) =
994                                                            ui.allocate_exact_size(
995                                                                egui::vec2(
996                                                                    tab_size.x + tab_pull,
997                                                                    tab_size.y,
998                                                                ),
999                                                                egui::Sense::click(),
1000                                                            );
1001                                                        let tab_rect =
1002                                                            egui::Rect::from_min_size(
1003                                                                rect.min,
1004                                                                tab_size,
1005                                                            );
1006                                                        paint_card_tab_button(
1007                                                            ui,
1008                                                            &resp,
1009                                                            tab_rect,
1010                                                            "[]",
1011                                                            tab_fill,
1012                                                            tab_pull,
1013                                                        );
1014
1015                                                        let tooltip = if *card_detached {
1016                                                            "Dock card"
1017                                                        } else {
1018                                                            "Detach card"
1019                                                        };
1020                                                        show_postit_tooltip(ui, &resp, tooltip);
1021                                                        resp
1022                                                    });
1023
1024                                                if detach_resp.inner.clicked() {
1025                                                    *card_detached = !*card_detached;
1026                                                }
1027                                            });
1028                                        }
1029
1030                                    });
1031                                }
1032
1033                                ui.style_mut().spacing.item_spacing = default_item_spacing;
1034
1035                            });
1036                        ui.set_clip_rect(restore_clip_rect);
1037                        let frame_rect = column_frame.response.rect;
1038                        let frame_rect = egui::Rect::from_min_max(
1039                            egui::pos2(column_rect.min.x, frame_rect.min.y),
1040                            egui::pos2(column_rect.max.x, frame_rect.max.y),
1041                        );
1042                        let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
1043                        ui.painter()
1044                            .rect_stroke(frame_rect, 0.0, stroke, egui::StrokeKind::Inside);
1045
1046                        // Extend scroll content to include content-anchored floating cards.
1047                        let inline_bottom = frame_rect.bottom();
1048                        let float_bottom = floating::max_float_content_bottom(ui.ctx());
1049                        if float_bottom > inline_bottom {
1050                            ui.allocate_space(egui::vec2(0.0, float_bottom - inline_bottom));
1051                        }
1052                    });
1053                });
1054        });
1055
1056        ctx.data_mut(|data| {
1057            data.insert_temp(state_id, runtime);
1058        });
1059    }
1060}
1061
1062fn draw_card_body(
1063    ui: &mut egui::Ui,
1064    card_width: f32,
1065    card: &mut dyn cards::Card,
1066    store: &state::StateStore,
1067    clip_rect: Option<egui::Rect>,
1068) -> egui::Rect {
1069    // Clamp *all* painting (including the frame fill) to the column rect.
1070    // Without this, a too-wide widget can expand the frame and paint into the
1071    // notebook margins, covering the dot grid.
1072    let restore_clip = clip_rect.map(|rect| {
1073        let restore = ui.clip_rect();
1074        ui.set_clip_rect(rect);
1075        restore
1076    });
1077
1078    let inner = egui::Frame::group(ui.style())
1079        .stroke(egui::Stroke::NONE)
1080        .corner_radius(0.0)
1081        .inner_margin(egui::Margin::ZERO)
1082        .show(ui, |ui| {
1083            ui.reset_style();
1084            ui.set_width(card_width);
1085            let mut ctx = CardCtx::new(ui, store);
1086            card.draw(&mut ctx);
1087        });
1088
1089    if let Some(restore) = restore_clip {
1090        ui.set_clip_rect(restore);
1091    }
1092    inner.response.rect
1093}
1094
1095fn paint_dot_grid(ui: &egui::Ui, rect: egui::Rect, scroll_y: f32) {
1096    if rect.width() <= 0.0 || rect.height() <= 0.0 {
1097        return;
1098    }
1099
1100    let painter = ui.painter_at(rect);
1101
1102    let spacing = 18.0;
1103    let radius = 1.2;
1104    let background = ui.visuals().window_fill;
1105    let outline = ui.visuals().widgets.noninteractive.bg_stroke.color;
1106    let color = crate::themes::blend(background, outline, 0.35);
1107
1108    let start_x = (rect.left() / spacing).floor() * spacing + spacing / 2.0;
1109    let start_y = rect.top() - scroll_y.rem_euclid(spacing) + spacing / 2.0;
1110
1111    let mut y = start_y;
1112    while y < rect.bottom() {
1113        let mut x = start_x;
1114        while x < rect.right() {
1115            painter.circle_filled(egui::pos2(x, y), radius, color);
1116            x += spacing;
1117        }
1118        y += spacing;
1119    }
1120}
1121
1122fn paint_hatching(painter: &egui::Painter, rect: egui::Rect, color: egui::Color32) {
1123    let spacing = 12.0;
1124    let stroke = egui::Stroke::new(1.0, color);
1125
1126    let h = rect.height();
1127    let mut x = rect.left() - h;
1128    while x < rect.right() + h {
1129        painter.line_segment(
1130            [egui::pos2(x, rect.top()), egui::pos2(x + h, rect.bottom())],
1131            stroke,
1132        );
1133        x += spacing;
1134    }
1135}
1136
1137pub(crate) fn show_postit_tooltip(ui: &egui::Ui, response: &egui::Response, text: &str) {
1138    let outline = ui.visuals().widgets.noninteractive.bg_stroke.color;
1139    let shadow_color = crate::themes::ral(9004);
1140    let shadow = egui::epaint::Shadow {
1141        offset: [4, 4],
1142        blur: 0,
1143        spread: 0,
1144        color: shadow_color,
1145    };
1146
1147    let frame = egui::Frame::new()
1148        .fill(crate::themes::ral(1003))
1149        .stroke(egui::Stroke::new(1.0, outline))
1150        .shadow(shadow)
1151        .corner_radius(0.0)
1152        .inner_margin(egui::Margin::same(10));
1153
1154    let mut tooltip = egui::containers::Tooltip::for_enabled(response);
1155    tooltip.popup = tooltip.popup.frame(frame);
1156    tooltip.show(|ui| {
1157        ui.set_max_width(ui.spacing().tooltip_width);
1158        ui.add(
1159            egui::Label::new(
1160                egui::RichText::new(text)
1161                    .monospace()
1162                    .color(crate::themes::ral(9011)),
1163            )
1164            .wrap_mode(egui::TextWrapMode::Extend),
1165        );
1166    });
1167}
1168
1169fn paint_card_tab_button(
1170    ui: &egui::Ui,
1171    response: &egui::Response,
1172    rect: egui::Rect,
1173    label: &str,
1174    fill: egui::Color32,
1175    pull_out: f32,
1176) {
1177    let outline = ui.visuals().widgets.noninteractive.bg_stroke.color;
1178    let stroke = egui::Stroke::new(1.0, outline);
1179    let rounding = egui::CornerRadius {
1180        nw: 0,
1181        ne: 4,
1182        sw: 0,
1183        se: 4,
1184    };
1185    let rect = if response.hovered() || response.has_focus() {
1186        egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x + pull_out, rect.max.y))
1187    } else {
1188        rect
1189    };
1190
1191    ui.painter().rect_filled(rect, rounding, fill);
1192    ui.painter()
1193        .rect_stroke(rect, rounding, stroke, egui::StrokeKind::Inside);
1194    let text_color = if response.enabled() {
1195        crate::themes::ral(9011)
1196    } else {
1197        crate::themes::blend(crate::themes::ral(9011), fill, 0.55)
1198    };
1199    ui.painter().text(
1200        rect.center(),
1201        egui::Align2::CENTER_CENTER,
1202        label,
1203        egui::FontId::monospace(10.0),
1204        text_color,
1205    );
1206}
1207
1208// notebook initialization is handled by the #[notebook] attribute macro.