1#![allow(non_snake_case)]
18
19pub mod card_ctx;
21pub mod cards;
23pub mod dataflow;
25pub(crate) mod floating;
26mod headless;
27pub mod prelude;
29pub mod state;
31#[cfg(feature = "telemetry")]
33pub mod telemetry;
34pub mod themes;
36pub 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#[derive(Clone, Debug)]
108pub struct EditorCommand {
109 program: String,
110 args: Vec<String>,
111}
112
113impl EditorCommand {
114 pub fn new(program: impl Into<String>) -> Self {
116 Self {
117 program: program.into(),
118 args: Vec::new(),
119 }
120 }
121
122 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
182pub 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)] telemetry: Option<telemetry::Telemetry>,
218}
219
220pub 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 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 pub fn with_editor(mut self, editor: EditorCommand) -> Self {
262 self.editor = Some(editor);
263 self
264 }
265
266 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 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 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 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 pub fn settled(&self) {
442 self.settled.store(true, Ordering::Relaxed);
443 }
444
445 #[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 #[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 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 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 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 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 {
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 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 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