Rust · 26597 bytes Raw Blame History
1 //! Picker toolbar component with Accept/Cancel buttons.
2 //!
3 //! This toolbar replaces the normal toolbar when garfield runs in picker mode.
4 //! In save mode, also includes a filename textbox.
5
6 use anyhow::Result;
7 use gartk_core::{Key, Point, Rect};
8 use gartk_render::{Renderer, TextStyle};
9
10 /// Height of the picker toolbar (same as normal toolbar).
11 pub const PICKER_TOOLBAR_HEIGHT: u32 = 36;
12
13 /// Button width.
14 const BUTTON_WIDTH: u32 = 100;
15
16 /// Button height.
17 const BUTTON_HEIGHT: u32 = 28;
18
19 /// Padding from edges.
20 const PADDING: i32 = 8;
21
22 /// Gap between buttons.
23 const BUTTON_GAP: i32 = 12;
24
25 /// Filename textbox minimum width.
26 const FILENAME_MIN_WIDTH: u32 = 200;
27
28 /// Picker toolbar click result.
29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 pub enum PickerToolbarClick {
31 /// Accept button clicked.
32 Accept,
33 /// Cancel button clicked.
34 Cancel,
35 /// Nothing clicked.
36 None,
37 }
38
39 /// Picker toolbar with Accept and Cancel buttons.
40 pub struct PickerToolbar {
41 /// Toolbar bounds.
42 bounds: Rect,
43 /// Accept button label.
44 accept_label: String,
45 /// Cancel button label.
46 cancel_label: String,
47 /// Accept button bounds.
48 accept_bounds: Rect,
49 /// Cancel button bounds.
50 cancel_bounds: Rect,
51 /// Filter text bounds (for hover detection).
52 filter_bounds: Rect,
53 /// Hovered button (0 = accept, 1 = cancel, 2 = filename).
54 hovered: Option<usize>,
55 /// Whether filter text is hovered.
56 filter_hovered: bool,
57 /// Focused button for keyboard navigation (0 = accept, 1 = cancel, 2 = filename).
58 focused: usize,
59 /// Whether accept button is enabled (has valid selection).
60 accept_enabled: bool,
61 /// Filter description shown in toolbar (full text).
62 filter_description: Option<String>,
63 /// Whether this is save mode (shows filename textbox).
64 save_mode: bool,
65 /// Filename for save mode.
66 filename: String,
67 /// Filename textbox bounds.
68 filename_bounds: Rect,
69 /// Whether filename textbox is being edited.
70 filename_editing: bool,
71 /// Cursor position in filename (character index).
72 filename_cursor: usize,
73 /// Selection start in filename (if different from cursor, text is selected).
74 filename_selection_start: Option<usize>,
75 }
76
77 impl PickerToolbar {
78 /// Create a new picker toolbar.
79 pub fn new(bounds: Rect, accept_label: String) -> Self {
80 let mut toolbar = Self {
81 bounds,
82 accept_label,
83 cancel_label: "Cancel".to_string(),
84 accept_bounds: Rect::default(),
85 cancel_bounds: Rect::default(),
86 filter_bounds: Rect::default(),
87 hovered: None,
88 filter_hovered: false,
89 focused: 0,
90 accept_enabled: false,
91 filter_description: None,
92 save_mode: false,
93 filename: String::new(),
94 filename_bounds: Rect::default(),
95 filename_editing: false,
96 filename_cursor: 0,
97 filename_selection_start: None,
98 };
99 toolbar.layout();
100 toolbar
101 }
102
103 /// Create a new picker toolbar for save mode with suggested filename.
104 pub fn new_save_mode(bounds: Rect, accept_label: String, suggested_filename: String) -> Self {
105 let cursor_pos = suggested_filename.len();
106 let mut toolbar = Self {
107 bounds,
108 accept_label,
109 cancel_label: "Cancel".to_string(),
110 accept_bounds: Rect::default(),
111 cancel_bounds: Rect::default(),
112 filter_bounds: Rect::default(),
113 hovered: None,
114 filter_hovered: false,
115 focused: 2, // Start focused on filename
116 accept_enabled: true, // Enable by default in save mode
117 filter_description: None,
118 save_mode: true,
119 filename: suggested_filename,
120 filename_bounds: Rect::default(),
121 filename_editing: true, // Start editing
122 filename_cursor: cursor_pos,
123 filename_selection_start: Some(0), // Select all
124 };
125 toolbar.layout();
126 toolbar
127 }
128
129 /// Set bounds.
130 pub fn set_bounds(&mut self, bounds: Rect) {
131 self.bounds = bounds;
132 self.layout();
133 }
134
135 /// Layout buttons and filter area.
136 fn layout(&mut self) {
137 // Cancel button (rightmost)
138 let cancel_x = self.bounds.x + self.bounds.width as i32 - BUTTON_WIDTH as i32 - PADDING;
139 let button_y = self.bounds.y + (self.bounds.height as i32 - BUTTON_HEIGHT as i32) / 2;
140 self.cancel_bounds = Rect::new(cancel_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT);
141
142 // Accept button (to the left of cancel)
143 let accept_x = cancel_x - BUTTON_WIDTH as i32 - BUTTON_GAP;
144 self.accept_bounds = Rect::new(accept_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT);
145
146 if self.save_mode {
147 // Filename textbox (left side, takes available space)
148 let filename_x = self.bounds.x + PADDING;
149 let available_width = (accept_x - BUTTON_GAP - filename_x).max(FILENAME_MIN_WIDTH as i32) as u32;
150 self.filename_bounds = Rect::new(filename_x, button_y, available_width, BUTTON_HEIGHT);
151 // No filter area in save mode
152 self.filter_bounds = Rect::default();
153 } else {
154 // Filter text area (left side, up to accept button)
155 let filter_x = self.bounds.x + PADDING;
156 let filter_width = (accept_x - BUTTON_GAP - filter_x).max(0) as u32;
157 self.filter_bounds = Rect::new(filter_x, self.bounds.y, filter_width, self.bounds.height);
158 self.filename_bounds = Rect::default();
159 }
160 }
161
162 /// Get max width available for filter text.
163 fn max_filter_width(&self) -> u32 {
164 self.filter_bounds.width.saturating_sub(8) // Small padding
165 }
166
167 /// Set whether accept button is enabled.
168 pub fn set_accept_enabled(&mut self, enabled: bool) {
169 self.accept_enabled = enabled;
170 }
171
172 /// Set filter description shown in toolbar.
173 pub fn set_filter_description(&mut self, desc: Option<String>) {
174 self.filter_description = desc;
175 }
176
177 /// Handle mouse move. Returns true if hovered state changed.
178 pub fn on_mouse_move(&mut self, pos: Point) -> bool {
179 let mut changed = false;
180
181 if !self.bounds.contains_point(pos) {
182 if self.hovered.is_some() {
183 self.hovered = None;
184 changed = true;
185 }
186 if self.filter_hovered {
187 self.filter_hovered = false;
188 changed = true;
189 }
190 return changed;
191 }
192
193 let new_hovered = if self.accept_bounds.contains_point(pos) {
194 Some(0)
195 } else if self.cancel_bounds.contains_point(pos) {
196 Some(1)
197 } else if self.save_mode && self.filename_bounds.contains_point(pos) {
198 Some(2)
199 } else {
200 None
201 };
202
203 if new_hovered != self.hovered {
204 self.hovered = new_hovered;
205 changed = true;
206 }
207
208 // Check if hovering over filter text area
209 let new_filter_hovered = self.filter_bounds.contains_point(pos) && self.filter_description.is_some();
210 if new_filter_hovered != self.filter_hovered {
211 self.filter_hovered = new_filter_hovered;
212 changed = true;
213 }
214
215 changed
216 }
217
218 /// Get tooltip text if hovering over truncated filter.
219 pub fn get_tooltip(&self) -> Option<&str> {
220 if self.filter_hovered {
221 self.filter_description.as_deref()
222 } else {
223 None
224 }
225 }
226
227 /// Check if filter is currently hovered.
228 pub fn is_filter_hovered(&self) -> bool {
229 self.filter_hovered
230 }
231
232 /// Handle click. Returns the action if a button was clicked.
233 pub fn on_click(&mut self, pos: Point) -> PickerToolbarClick {
234 if self.accept_bounds.contains_point(pos) && self.accept_enabled {
235 self.filename_editing = false;
236 PickerToolbarClick::Accept
237 } else if self.cancel_bounds.contains_point(pos) {
238 self.filename_editing = false;
239 PickerToolbarClick::Cancel
240 } else if self.save_mode && self.filename_bounds.contains_point(pos) {
241 // Click on filename textbox - start editing
242 self.filename_editing = true;
243 self.focused = 2;
244 // Position cursor at click point (simplified: just move to end)
245 self.filename_cursor = self.filename.len();
246 self.filename_selection_start = None;
247 PickerToolbarClick::None
248 } else {
249 // Click elsewhere stops editing
250 self.filename_editing = false;
251 PickerToolbarClick::None
252 }
253 }
254
255 /// Cycle focus between elements.
256 pub fn cycle_focus(&mut self) {
257 if self.save_mode {
258 // Cycle: filename (2) -> accept (0) -> cancel (1) -> filename
259 self.focused = match self.focused {
260 2 => 0,
261 0 => 1,
262 _ => 2,
263 };
264 self.filename_editing = self.focused == 2;
265 } else {
266 self.focused = 1 - self.focused;
267 }
268 }
269
270 /// Activate focused element.
271 pub fn activate_focused(&self) -> PickerToolbarClick {
272 if self.focused == 0 && self.accept_enabled {
273 PickerToolbarClick::Accept
274 } else if self.focused == 1 {
275 PickerToolbarClick::Cancel
276 } else {
277 // Focused on filename - Enter should accept if valid
278 if self.save_mode && !self.filename.is_empty() {
279 PickerToolbarClick::Accept
280 } else {
281 PickerToolbarClick::None
282 }
283 }
284 }
285
286 /// Whether filename textbox is being edited.
287 pub fn is_editing_filename(&self) -> bool {
288 self.save_mode && self.filename_editing
289 }
290
291 /// Get the current filename.
292 pub fn filename(&self) -> &str {
293 &self.filename
294 }
295
296 /// Set the filename (e.g., when clicking a file in save mode).
297 pub fn set_filename(&mut self, filename: &str) {
298 self.filename = filename.to_string();
299 self.filename_cursor = self.filename.len();
300 self.filename_selection_start = None;
301 }
302
303 /// Handle keyboard input for filename editing. Returns true if handled.
304 pub fn handle_key(&mut self, key: &Key) -> bool {
305 if !self.filename_editing {
306 return false;
307 }
308
309 match key {
310 Key::Char(c) => {
311 // Don't allow path separators in filename
312 if *c != '/' && *c != '\\' && *c != '\0' {
313 // Delete selection first if any
314 self.delete_selection();
315 self.filename.insert(self.filename_cursor, *c);
316 self.filename_cursor += 1;
317 }
318 true
319 }
320 Key::Backspace => {
321 if self.filename_selection_start.is_some() {
322 self.delete_selection();
323 } else if self.filename_cursor > 0 {
324 self.filename_cursor -= 1;
325 self.filename.remove(self.filename_cursor);
326 }
327 true
328 }
329 Key::Delete => {
330 if self.filename_selection_start.is_some() {
331 self.delete_selection();
332 } else if self.filename_cursor < self.filename.len() {
333 self.filename.remove(self.filename_cursor);
334 }
335 true
336 }
337 Key::Left => {
338 if self.filename_cursor > 0 {
339 self.filename_cursor -= 1;
340 }
341 self.filename_selection_start = None;
342 true
343 }
344 Key::Right => {
345 if self.filename_cursor < self.filename.len() {
346 self.filename_cursor += 1;
347 }
348 self.filename_selection_start = None;
349 true
350 }
351 Key::Home => {
352 self.filename_cursor = 0;
353 self.filename_selection_start = None;
354 true
355 }
356 Key::End => {
357 self.filename_cursor = self.filename.len();
358 self.filename_selection_start = None;
359 true
360 }
361 _ => false,
362 }
363 }
364
365 /// Delete selected text.
366 fn delete_selection(&mut self) {
367 if let Some(start) = self.filename_selection_start.take() {
368 let (from, to) = if start < self.filename_cursor {
369 (start, self.filename_cursor)
370 } else {
371 (self.filename_cursor, start)
372 };
373 self.filename.drain(from..to);
374 self.filename_cursor = from;
375 }
376 }
377
378 /// Select all text in filename.
379 pub fn select_all(&mut self) {
380 if self.save_mode {
381 self.filename_selection_start = Some(0);
382 self.filename_cursor = self.filename.len();
383 self.filename_editing = true;
384 self.focused = 2;
385 }
386 }
387
388 /// Check if point is within toolbar bounds.
389 pub fn contains_point(&self, pos: Point) -> bool {
390 self.bounds.contains_point(pos)
391 }
392
393 /// Render the toolbar.
394 pub fn render(&self, renderer: &Renderer) -> Result<()> {
395 let theme = renderer.theme();
396
397 // Toolbar background
398 renderer.fill_rect(self.bounds, theme.item_background)?;
399
400 // Save mode: filename textbox
401 if self.save_mode {
402 self.render_filename_textbox(renderer)?;
403 } else if let Some(desc) = &self.filter_description {
404 // Filter description (left side, truncated with ellipsis)
405 let text_style = TextStyle::new()
406 .font_family(&theme.font_family)
407 .font_size(theme.font_size - 1.0)
408 .color(if self.filter_hovered { theme.foreground } else { theme.item_foreground });
409
410 let max_width = self.max_filter_width() as f64;
411 let display_text = self.truncate_with_ellipsis(desc, max_width, renderer, &text_style)?;
412
413 let text_x = (self.bounds.x + PADDING + 4) as f64;
414 let text_y = (self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2) as f64;
415 renderer.text(&display_text, text_x, text_y, &text_style)?;
416
417 // Show tooltip if hovered
418 if self.filter_hovered {
419 self.render_tooltip(renderer, desc)?;
420 }
421 }
422
423 // Accept button
424 let accept_hovered = self.hovered == Some(0);
425 let accept_focused = self.focused == 0;
426 let (accept_bg, accept_fg) = if !self.accept_enabled {
427 // Disabled state - use input_background for visibility
428 (theme.input_background.with_alpha(0.5), theme.item_foreground.with_alpha(0.5))
429 } else if accept_hovered || accept_focused {
430 // Active state - use accent color
431 (theme.selection_background, theme.foreground)
432 } else {
433 // Normal state - accent color slightly dimmed
434 (theme.selection_background.with_alpha(0.8), theme.foreground)
435 };
436
437 renderer.fill_rounded_rect(self.accept_bounds, 4.0, accept_bg)?;
438 if accept_focused && self.accept_enabled {
439 renderer.stroke_rounded_rect(self.accept_bounds, 4.0, theme.foreground, 2.0)?;
440 }
441
442 let button_style = TextStyle::new()
443 .font_family(&theme.font_family)
444 .font_size(theme.font_size)
445 .color(accept_fg);
446
447 let accept_metrics = renderer.measure_text(&self.accept_label, &button_style)?;
448 let accept_text_x = self.accept_bounds.x + (self.accept_bounds.width as i32 - accept_metrics.width as i32) / 2;
449 let accept_text_y = self.accept_bounds.y + (self.accept_bounds.height as i32 - accept_metrics.height as i32) / 2;
450 renderer.text(&self.accept_label, accept_text_x as f64, accept_text_y as f64, &button_style)?;
451
452 // Cancel button - use input_background for visibility
453 let cancel_hovered = self.hovered == Some(1);
454 let cancel_focused = self.focused == 1;
455 let cancel_bg = if cancel_hovered || cancel_focused {
456 theme.item_hover_background
457 } else {
458 theme.input_background
459 };
460
461 renderer.fill_rounded_rect(self.cancel_bounds, 4.0, cancel_bg)?;
462 if cancel_focused {
463 renderer.stroke_rounded_rect(self.cancel_bounds, 4.0, theme.foreground, 2.0)?;
464 }
465 renderer.stroke_rounded_rect(self.cancel_bounds, 4.0, theme.border, 1.0)?;
466
467 let cancel_style = TextStyle::new()
468 .font_family(&theme.font_family)
469 .font_size(theme.font_size)
470 .color(theme.foreground);
471
472 let cancel_metrics = renderer.measure_text(&self.cancel_label, &cancel_style)?;
473 let cancel_text_x = self.cancel_bounds.x + (self.cancel_bounds.width as i32 - cancel_metrics.width as i32) / 2;
474 let cancel_text_y = self.cancel_bounds.y + (self.cancel_bounds.height as i32 - cancel_metrics.height as i32) / 2;
475 renderer.text(&self.cancel_label, cancel_text_x as f64, cancel_text_y as f64, &cancel_style)?;
476
477 // Bottom border
478 let border_y = self.bounds.y + self.bounds.height as i32 - 1;
479 renderer.fill_rect(
480 Rect::new(self.bounds.x, border_y, self.bounds.width, 1),
481 theme.border,
482 )?;
483
484 Ok(())
485 }
486
487 /// Render the filename textbox for save mode.
488 fn render_filename_textbox(&self, renderer: &Renderer) -> Result<()> {
489 let theme = renderer.theme();
490 let filename_focused = self.focused == 2;
491 let filename_hovered = self.hovered == Some(2);
492
493 // Textbox background - brighter when editing/focused
494 let bg_color = if self.filename_editing {
495 theme.background
496 } else if filename_focused || filename_hovered {
497 theme.item_hover_background
498 } else {
499 theme.input_background
500 };
501
502 renderer.fill_rounded_rect(self.filename_bounds, 4.0, bg_color)?;
503
504 // Border - thick accent color when editing, thinner when just focused
505 if self.filename_editing {
506 // Editing: prominent accent border
507 renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.selection_background, 2.0)?;
508 // Inner glow effect
509 let inner = Rect::new(
510 self.filename_bounds.x + 1,
511 self.filename_bounds.y + 1,
512 self.filename_bounds.width.saturating_sub(2),
513 self.filename_bounds.height.saturating_sub(2),
514 );
515 renderer.stroke_rounded_rect(inner, 3.0, theme.selection_background.with_alpha(0.3), 1.0)?;
516 } else if filename_focused {
517 // Focused but not editing: white/foreground border
518 renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.foreground, 2.0)?;
519 } else {
520 // Normal: subtle border
521 renderer.stroke_rounded_rect(self.filename_bounds, 4.0, theme.border, 1.0)?;
522 }
523
524 // "Filename:" label
525 let label = "Filename:";
526 let label_style = TextStyle::new()
527 .font_family(&theme.font_family)
528 .font_size(theme.font_size - 1.0)
529 .color(theme.item_foreground);
530
531 let label_metrics = renderer.measure_text(label, &label_style)?;
532 let label_x = self.filename_bounds.x + 8;
533 let label_y = self.filename_bounds.y + (self.filename_bounds.height as i32 - label_metrics.height as i32) / 2;
534 renderer.text(label, label_x as f64, label_y as f64, &label_style)?;
535
536 // Text content area (after label)
537 let text_padding = 8;
538 let text_x_start = label_x + label_metrics.width as i32 + text_padding;
539 let text_max_width = (self.filename_bounds.x + self.filename_bounds.width as i32 - text_x_start - text_padding) as u32;
540
541 let text_style = TextStyle::new()
542 .font_family(&theme.font_family)
543 .font_size(theme.font_size)
544 .color(theme.foreground);
545
546 // Selection highlight
547 if let Some(sel_start) = self.filename_selection_start {
548 if sel_start != self.filename_cursor {
549 let (from, to) = if sel_start < self.filename_cursor {
550 (sel_start, self.filename_cursor)
551 } else {
552 (self.filename_cursor, sel_start)
553 };
554
555 // Measure text up to selection start and end
556 let before_sel = &self.filename[..from];
557 let selection = &self.filename[from..to];
558
559 let before_width = if before_sel.is_empty() {
560 0
561 } else {
562 renderer.measure_text(before_sel, &text_style)?.width
563 };
564 let sel_width = renderer.measure_text(selection, &text_style)?.width;
565
566 let sel_x = text_x_start + before_width as i32;
567 let sel_rect = Rect::new(
568 sel_x,
569 self.filename_bounds.y + 4,
570 sel_width.min(text_max_width),
571 self.filename_bounds.height - 8,
572 );
573 renderer.fill_rect(sel_rect, theme.selection_background.with_alpha(0.4))?;
574 }
575 }
576
577 // Filename text
578 let text_y = self.filename_bounds.y + (self.filename_bounds.height as i32 - theme.font_size as i32) / 2;
579 renderer.text(&self.filename, text_x_start as f64, text_y as f64, &text_style)?;
580
581 // Cursor when editing
582 if self.filename_editing {
583 let cursor_text = &self.filename[..self.filename_cursor];
584 let cursor_offset = if cursor_text.is_empty() {
585 0
586 } else {
587 renderer.measure_text(cursor_text, &text_style)?.width
588 };
589
590 let cursor_x = text_x_start + cursor_offset as i32;
591 let cursor_rect = Rect::new(
592 cursor_x,
593 self.filename_bounds.y + 6,
594 2,
595 self.filename_bounds.height - 12,
596 );
597 renderer.fill_rect(cursor_rect, theme.foreground)?;
598 }
599
600 Ok(())
601 }
602
603 /// Truncate text with ellipsis if it exceeds max width.
604 fn truncate_with_ellipsis(&self, text: &str, max_width: f64, renderer: &Renderer, style: &TextStyle) -> Result<String> {
605 let metrics = renderer.measure_text(text, style)?;
606 let max_width = max_width as u32;
607 if metrics.width <= max_width {
608 return Ok(text.to_string());
609 }
610
611 // Need to truncate - binary search for the right length
612 let ellipsis = "...";
613 let ellipsis_width = renderer.measure_text(ellipsis, style)?.width;
614 let target_width = max_width.saturating_sub(ellipsis_width);
615
616 if target_width == 0 {
617 return Ok(ellipsis.to_string());
618 }
619
620 // Find the longest prefix that fits
621 let mut end = text.len();
622 for (i, _) in text.char_indices().rev() {
623 let prefix = &text[..i];
624 let prefix_width = renderer.measure_text(prefix, style)?.width;
625 if prefix_width <= target_width {
626 end = i;
627 break;
628 }
629 }
630
631 if end == 0 {
632 Ok(ellipsis.to_string())
633 } else {
634 Ok(format!("{}{}", &text[..end], ellipsis))
635 }
636 }
637
638 /// Render tooltip showing full filter text as multiline.
639 fn render_tooltip(&self, renderer: &Renderer, text: &str) -> Result<()> {
640 let theme = renderer.theme();
641
642 let tooltip_style = TextStyle::new()
643 .font_family(&theme.font_family)
644 .font_size(theme.font_size - 2.0)
645 .color(theme.foreground);
646
647 // Parse filter patterns and format as multiline
648 // Remove "Filter: " prefix if present
649 let filter_text = text.strip_prefix("Filter: ").unwrap_or(text);
650
651 // Split by semicolon or comma and clean up patterns
652 let patterns: Vec<&str> = filter_text
653 .split(|c| c == ';' || c == ',')
654 .map(|s| s.trim())
655 .filter(|s| !s.is_empty())
656 .collect();
657
658 // Format into columns (4 patterns per row max)
659 let cols = 4;
660 let mut lines: Vec<String> = Vec::new();
661 lines.push("Accepted file types:".to_string());
662
663 for chunk in patterns.chunks(cols) {
664 let line = chunk.join(" ");
665 lines.push(line);
666 }
667
668 // Measure dimensions
669 let padding = 10u32;
670 let line_height = (theme.font_size - 2.0) as u32 + 4;
671 let mut max_width = 0u32;
672
673 for line in &lines {
674 let metrics = renderer.measure_text(line, &tooltip_style)?;
675 max_width = max_width.max(metrics.width);
676 }
677
678 let tooltip_width = max_width + padding * 2;
679 let tooltip_height = (lines.len() as u32 * line_height) + padding * 2;
680
681 // Position tooltip above the filter area, but keep on screen
682 let tooltip_x = self.filter_bounds.x.max(4);
683 let tooltip_y = self.bounds.y - tooltip_height as i32 - 4;
684
685 let tooltip_bounds = Rect::new(tooltip_x, tooltip_y, tooltip_width, tooltip_height);
686
687 // Background with border and shadow effect
688 let shadow_bounds = Rect::new(tooltip_x + 2, tooltip_y + 2, tooltip_width, tooltip_height);
689 renderer.fill_rounded_rect(shadow_bounds, 6.0, theme.background.with_alpha(0.3))?;
690 renderer.fill_rounded_rect(tooltip_bounds, 6.0, theme.background)?;
691 renderer.stroke_rounded_rect(tooltip_bounds, 6.0, theme.border, 1.0)?;
692
693 // Render each line
694 let mut y = tooltip_y + padding as i32;
695 for (i, line) in lines.iter().enumerate() {
696 let style = if i == 0 {
697 // Header line slightly brighter
698 TextStyle::new()
699 .font_family(&theme.font_family)
700 .font_size(theme.font_size - 2.0)
701 .color(theme.foreground)
702 } else {
703 tooltip_style.clone()
704 };
705
706 renderer.text(
707 line,
708 (tooltip_x + padding as i32) as f64,
709 y as f64,
710 &style,
711 )?;
712 y += line_height as i32;
713 }
714
715 Ok(())
716 }
717 }
718