1use std::io::Read;
4
5use crate::{
6 backend::{Window, WindowEvent, create_window},
7 error::Error,
8 render::{Canvas, Font, rgb},
9 ui::{
10 BASE_BUTTON_HEIGHT, BASE_BUTTON_SPACING, BASE_CORNER_RADIUS, Colors, KEY_DOWN, KEY_END,
11 KEY_ESCAPE, KEY_HOME, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RETURN, KEY_UP,
12 widgets::{Widget, button::Button},
13 },
14};
15
16const BASE_PADDING: u32 = 16;
17const BASE_LINE_HEIGHT: u32 = 20;
18const BASE_CHECKBOX_SIZE: u32 = 16;
19const BASE_MIN_WIDTH: u32 = 400;
20const BASE_MIN_HEIGHT: u32 = 300;
21const BASE_DEFAULT_WIDTH: u32 = 500;
22const BASE_DEFAULT_HEIGHT: u32 = 400;
23
24#[derive(Debug, Clone)]
26pub enum TextInfoResult {
27 Ok { checkbox_checked: bool },
29 Cancelled,
31 Closed,
33}
34
35impl TextInfoResult {
36 pub fn exit_code(&self) -> i32 {
37 match self {
38 TextInfoResult::Ok {
39 checkbox_checked,
40 } => {
41 if *checkbox_checked {
42 0
43 } else {
44 1
45 }
46 }
47 TextInfoResult::Cancelled => 1,
48 TextInfoResult::Closed => 1,
49 }
50 }
51}
52
53pub struct TextInfoBuilder {
55 title: String,
56 filename: Option<String>,
57 checkbox_text: Option<String>,
58 width: Option<u32>,
59 height: Option<u32>,
60 colors: Option<&'static Colors>,
61}
62
63impl TextInfoBuilder {
64 pub fn new() -> Self {
65 Self {
66 title: String::new(),
67 filename: None,
68 checkbox_text: None,
69 width: None,
70 height: None,
71 colors: None,
72 }
73 }
74
75 pub fn title(mut self, title: &str) -> Self {
76 self.title = title.to_string();
77 self
78 }
79
80 pub fn filename(mut self, filename: &str) -> Self {
82 self.filename = Some(filename.to_string());
83 self
84 }
85
86 pub fn checkbox(mut self, text: &str) -> Self {
88 self.checkbox_text = Some(text.to_string());
89 self
90 }
91
92 pub fn colors(mut self, colors: &'static Colors) -> Self {
93 self.colors = Some(colors);
94 self
95 }
96
97 pub fn width(mut self, width: u32) -> Self {
98 self.width = Some(width);
99 self
100 }
101
102 pub fn height(mut self, height: u32) -> Self {
103 self.height = Some(height);
104 self
105 }
106
107 pub fn show(self) -> Result<TextInfoResult, Error> {
108 let colors = self.colors.unwrap_or_else(|| crate::ui::detect_theme());
109
110 let content = if let Some(ref filename) = self.filename {
112 std::fs::read_to_string(filename).map_err(Error::Io)?
113 } else {
114 let mut buf = String::new();
115 std::io::stdin()
116 .read_to_string(&mut buf)
117 .map_err(Error::Io)?;
118 buf
119 };
120
121 let has_checkbox = self.checkbox_text.is_some();
122
123 let logical_width = self.width.unwrap_or(BASE_DEFAULT_WIDTH).max(BASE_MIN_WIDTH);
125 let logical_height = self
126 .height
127 .unwrap_or(BASE_DEFAULT_HEIGHT)
128 .max(BASE_MIN_HEIGHT);
129
130 let mut window = create_window(logical_width as u16, logical_height as u16)?;
132 window.set_title(if self.title.is_empty() {
133 "Text"
134 } else {
135 &self.title
136 })?;
137
138 let scale = window.scale_factor();
140
141 let font = Font::load(scale);
143
144 let padding = (BASE_PADDING as f32 * scale) as u32;
146 let line_height = (BASE_LINE_HEIGHT as f32 * scale) as u32;
147 let checkbox_size = (BASE_CHECKBOX_SIZE as f32 * scale) as u32;
148
149 let physical_width = (logical_width as f32 * scale) as u32;
151 let physical_height = (logical_height as f32 * scale) as u32;
152
153 let mut ok_button = Button::new("OK", &font, scale);
155 let mut cancel_button = Button::new("Cancel", &font, scale);
156
157 let title_height = if self.title.is_empty() {
159 0
160 } else {
161 line_height + (8.0 * scale) as u32
162 };
163 let button_height = (BASE_BUTTON_HEIGHT as f32 * scale) as u32;
164 let checkbox_row_height = if has_checkbox {
165 checkbox_size + (8.0 * scale) as u32
166 } else {
167 0
168 };
169 let button_spacing = (24.0 * scale) as u32;
170 let button_y = (physical_height - padding - button_height) as i32;
171 let checkbox_y = if has_checkbox {
172 button_y - checkbox_row_height as i32 - (8.0 * scale) as i32
173 } else {
174 button_y
175 };
176
177 let text_area_x = padding as i32;
179 let text_area_y = padding as i32 + title_height as i32;
180 let text_area_w = physical_width - padding * 2;
181 let text_area_bottom = if has_checkbox {
182 checkbox_y as u32 - button_spacing
183 } else {
184 button_y as u32 - button_spacing
185 };
186 let text_area_h = text_area_bottom - padding - (8.0 * scale) as u32;
187
188 let max_text_width = text_area_w - (16.0 * scale) as u32; let mut wrapped_lines: Vec<String> = Vec::new();
191
192 for line in content.lines() {
193 if line.is_empty() {
194 wrapped_lines.push(String::new());
195 } else {
196 let mut remaining = line;
198 while !remaining.is_empty() {
199 let (line_w, _) = font.render(remaining).measure();
200 if line_w as u32 <= max_text_width {
201 wrapped_lines.push(remaining.to_string());
202 break;
203 }
204
205 let mut break_at = remaining.len();
207 for (i, _) in remaining.char_indices().rev() {
208 let test = &remaining[..i];
209 let (w, _) = font.render(test).measure();
210 if w as u32 <= max_text_width {
211 if let Some(space_pos) = test.rfind(|c: char| c.is_whitespace()) {
213 break_at = space_pos + 1;
214 } else {
215 break_at = i;
216 }
217 break;
218 }
219 }
220
221 if break_at == 0 {
222 break_at = 1; }
224
225 wrapped_lines.push(remaining[..break_at].trim_end().to_string());
226 remaining = remaining[break_at..].trim_start();
227 }
228 }
229 }
230
231 let total_lines = wrapped_lines.len();
232 let visible_lines = (text_area_h / line_height) as usize;
233
234 let mut bx = physical_width as i32 - padding as i32;
236 bx -= cancel_button.width() as i32;
237 cancel_button.set_position(bx, button_y);
238 bx -= (BASE_BUTTON_SPACING as f32 * scale) as i32 + ok_button.width() as i32;
239 ok_button.set_position(bx, button_y);
240
241 let mut scroll_offset = 0usize;
243 let mut checkbox_checked = false;
244 let mut checkbox_hovered = false;
245 let mut scrollbar_hovered = false;
246
247 let mut canvas = Canvas::new(physical_width, physical_height);
249
250 let draw = |canvas: &mut Canvas,
252 colors: &Colors,
253 font: &Font,
254 title: &str,
255 wrapped_lines: &[String],
256 scroll_offset: usize,
257 visible_lines: usize,
258 checkbox_text: &Option<String>,
259 checkbox_checked: bool,
260 checkbox_hovered: bool,
261 ok_button: &Button,
262 cancel_button: &Button,
263 padding: u32,
265 line_height: u32,
266 checkbox_size: u32,
267 text_area_x: i32,
268 text_area_y: i32,
269 text_area_w: u32,
270 text_area_h: u32,
271 checkbox_y: i32,
272 scale: f32,
273 scrollbar_hovered: bool| {
274 let width = canvas.width() as f32;
275 let height = canvas.height() as f32;
276 let radius = BASE_CORNER_RADIUS * scale;
277
278 canvas.fill_dialog_bg(
279 width,
280 height,
281 colors.window_bg,
282 colors.window_border,
283 colors.window_shadow,
284 radius,
285 );
286
287 if !title.is_empty() {
289 let title_font_size = 18.0 * 1.5 * scale;
291 let title_font = Font::load_with_size(title_font_size);
292 let title_rendered = title_font.render(title).with_color(colors.text).finish();
293 let title_x = (width as i32 - title_rendered.width() as i32) / 2;
294 let title_y = padding as i32;
295 canvas.draw_canvas(&title_rendered, title_x, title_y);
296 }
297
298 canvas.fill_rounded_rect(
300 text_area_x as f32,
301 text_area_y as f32,
302 text_area_w as f32,
303 text_area_h as f32,
304 6.0 * scale,
305 colors.input_bg,
306 );
307
308 let text_padding = (8.0 * scale) as i32;
310 for (i, line_idx) in
311 (scroll_offset..wrapped_lines.len().min(scroll_offset + visible_lines)).enumerate()
312 {
313 let line = &wrapped_lines[line_idx];
314 if !line.is_empty() {
315 let tc = font.render(line).with_color(colors.text).finish();
316 let y = text_area_y + text_padding + (i as u32 * line_height) as i32;
317 canvas.draw_canvas(&tc, text_area_x + text_padding, y);
318 }
319 }
320
321 if wrapped_lines.len() > visible_lines {
323 let scrollbar_width = if scrollbar_hovered {
324 12.0 * scale
325 } else {
326 8.0 * scale
327 };
328 let sb_x = text_area_x + text_area_w as i32 - scrollbar_width as i32;
329 let sb_y = text_area_y as f32 + 4.0 * scale;
330 let sb_h = text_area_h as f32 - 8.0 * scale;
331 let thumb_h =
332 (visible_lines as f32 / wrapped_lines.len() as f32 * sb_h).max(20.0 * scale);
333 let max_scroll = wrapped_lines.len().saturating_sub(visible_lines);
334 let thumb_y = if max_scroll > 0 {
335 scroll_offset as f32 / max_scroll as f32 * (sb_h - thumb_h)
336 } else {
337 0.0
338 };
339
340 canvas.fill_rounded_rect(
342 sb_x as f32,
343 sb_y,
344 scrollbar_width - 2.0 * scale,
345 sb_h,
346 3.0 * scale,
347 darken(colors.input_bg, 0.05),
348 );
349 canvas.fill_rounded_rect(
351 sb_x as f32,
352 sb_y + thumb_y,
353 scrollbar_width - 2.0 * scale,
354 thumb_h,
355 3.0 * scale,
356 if scrollbar_hovered {
357 colors.input_border_focused
358 } else {
359 colors.input_border
360 },
361 );
362 }
363
364 canvas.stroke_rounded_rect(
366 text_area_x as f32,
367 text_area_y as f32,
368 text_area_w as f32,
369 text_area_h as f32,
370 6.0 * scale,
371 colors.input_border,
372 1.0,
373 );
374
375 if let Some(cb_text) = checkbox_text {
377 let cb_x = padding as i32;
378 let cb_y = checkbox_y;
379
380 let cb_bg = if checkbox_hovered {
382 darken(colors.input_bg, 0.06)
383 } else {
384 colors.input_bg
385 };
386 canvas.fill_rounded_rect(
387 cb_x as f32,
388 cb_y as f32,
389 checkbox_size as f32,
390 checkbox_size as f32,
391 3.0 * scale,
392 cb_bg,
393 );
394 canvas.stroke_rounded_rect(
395 cb_x as f32,
396 cb_y as f32,
397 checkbox_size as f32,
398 checkbox_size as f32,
399 3.0 * scale,
400 colors.input_border,
401 1.0,
402 );
403
404 if checkbox_checked {
406 let inset = (3.0 * scale) as i32;
407 canvas.fill_rounded_rect(
408 (cb_x + inset) as f32,
409 (cb_y + inset) as f32,
410 (checkbox_size as i32 - inset * 2) as f32,
411 (checkbox_size as i32 - inset * 2) as f32,
412 2.0 * scale,
413 colors.input_border_focused,
414 );
415 }
416
417 let label_x = cb_x + checkbox_size as i32 + (8.0 * scale) as i32;
419 let tc = font.render(cb_text).with_color(colors.text).finish();
420 canvas.draw_canvas(&tc, label_x, cb_y);
421 }
422
423 ok_button.draw_to(canvas, colors, font);
425 cancel_button.draw_to(canvas, colors, font);
426 };
427
428 let mut window_dragging = false;
429
430 let mut thumb_drag = false;
432 let mut thumb_drag_offset: Option<i32> = None;
433 let mut last_cursor_pos: Option<(i32, i32)> = None;
434 let mut clicking_scrollbar: bool;
435
436 draw(
438 &mut canvas,
439 colors,
440 &font,
441 &self.title,
442 &wrapped_lines,
443 scroll_offset,
444 visible_lines,
445 &self.checkbox_text,
446 checkbox_checked,
447 checkbox_hovered,
448 &ok_button,
449 &cancel_button,
450 padding,
451 line_height,
452 checkbox_size,
453 text_area_x,
454 text_area_y,
455 text_area_w,
456 text_area_h,
457 checkbox_y,
458 scale,
459 scrollbar_hovered,
460 );
461 window.set_contents(&canvas)?;
462 window.show()?;
463
464 loop {
466 let event = window.wait_for_event()?;
467 let mut needs_redraw = false;
468
469 match &event {
470 WindowEvent::CloseRequested => return Ok(TextInfoResult::Closed),
471 WindowEvent::RedrawRequested => needs_redraw = true,
472 WindowEvent::CursorEnter(pos) | WindowEvent::CursorMove(pos) => {
473 if window_dragging {
474 let _ = window.start_drag();
475 window_dragging = false;
476 }
477
478 let mx = pos.x as i32;
479 let my = pos.y as i32;
480
481 last_cursor_pos = Some((mx, my));
483
484 if thumb_drag && total_lines > visible_lines {
486 let text_area_my = my - text_area_y;
487
488 let sb_y_f32 = 4.0 * scale;
489 let sb_y = sb_y_f32 as i32;
490 let sb_h_f32 = text_area_h as f32 - 8.0 * scale;
491 let sb_h = sb_h_f32 as i32;
492
493 let max_scroll = total_lines.saturating_sub(visible_lines);
494 if max_scroll > 0 {
495 let thumb_h_f32 = (visible_lines as f32 / total_lines as f32
496 * sb_h_f32)
497 .max(20.0 * scale);
498 let thumb_h = thumb_h_f32 as i32;
499 let max_thumb_y = sb_h - thumb_h;
500
501 let offset = thumb_drag_offset.unwrap_or(thumb_h / 2);
502 let thumb_y = (text_area_my - sb_y - offset).clamp(0, max_thumb_y);
503 let scroll_ratio = if max_thumb_y > 0 {
504 thumb_y as f32 / max_thumb_y as f32
505 } else {
506 0.0
507 };
508 scroll_offset =
509 ((scroll_ratio * max_scroll as f32) as usize).clamp(0, max_scroll);
510 needs_redraw = true;
511 }
512 } else {
513 let scrollbar_width = if scrollbar_hovered {
515 12.0 * scale
516 } else {
517 8.0 * scale
518 };
519 let scrollbar_x = text_area_x + text_area_w as i32 - scrollbar_width as i32;
520
521 scrollbar_hovered = total_lines > visible_lines
522 && mx >= scrollbar_x
523 && mx < text_area_x + text_area_w as i32
524 && my >= text_area_y
525 && my < text_area_y + text_area_h as i32;
526
527 if has_checkbox {
528 let cb_x = padding as i32;
530 let cb_row_width = checkbox_size as i32 + (8.0 * scale) as i32 + 200; let old_hovered = checkbox_hovered;
532 checkbox_hovered = !scrollbar_hovered
533 && mx >= cb_x
534 && mx < cb_x + cb_row_width
535 && my >= checkbox_y
536 && my < checkbox_y + checkbox_size as i32;
537
538 if old_hovered != checkbox_hovered {
539 needs_redraw = true;
540 }
541 }
542 }
543 }
544 WindowEvent::ButtonPress(crate::backend::MouseButton::Left, _) => {
545 window_dragging = true;
546 clicking_scrollbar = false;
547
548 if let Some((mx, my)) = last_cursor_pos {
550 if total_lines > visible_lines {
551 let scrollbar_width = if scrollbar_hovered {
552 12.0 * scale
553 } else {
554 8.0 * scale
555 };
556 let scrollbar_x =
557 text_area_x + text_area_w as i32 - scrollbar_width as i32;
558
559 if mx >= scrollbar_x
561 && mx < text_area_x + text_area_w as i32
562 && my >= text_area_y
563 && my < text_area_y + text_area_h as i32
564 {
565 clicking_scrollbar = true;
566
567 let text_area_mx = mx - text_area_x;
569 let text_area_my = my - text_area_y;
570
571 let sb_x = text_area_w as i32 - scrollbar_width as i32;
572 let sb_y_f32 = 4.0 * scale;
573 let sb_y = sb_y_f32 as i32;
574 let sb_h_f32 = text_area_h as f32 - 8.0 * scale;
575 let sb_h = sb_h_f32 as i32;
576
577 let thumb_h_f32 = (visible_lines as f32 / total_lines as f32
578 * sb_h_f32)
579 .max(20.0 * scale);
580 let thumb_h = thumb_h_f32 as i32;
581
582 let max_scroll = total_lines.saturating_sub(visible_lines);
583 let thumb_y = if max_scroll > 0 {
584 let max_thumb_y = sb_h - thumb_h;
585 ((scroll_offset as f32 / max_scroll as f32)
586 * max_thumb_y as f32)
587 as i32
588 } else {
589 0
590 };
591
592 if text_area_mx >= sb_x
593 && text_area_mx < sb_x + scrollbar_width as i32
594 && text_area_my >= sb_y + thumb_y
595 && text_area_my < sb_y + thumb_y + thumb_h
596 {
597 thumb_drag = true;
598 thumb_drag_offset = Some(text_area_my - (sb_y + thumb_y));
599 }
600 }
601 }
602 }
603
604 if !clicking_scrollbar && checkbox_hovered {
606 checkbox_checked = !checkbox_checked;
607 needs_redraw = true;
608 }
609 }
610 WindowEvent::ButtonRelease(_, _) => {
611 window_dragging = false;
612 thumb_drag = false;
613 thumb_drag_offset = None;
614 }
615 WindowEvent::Scroll(direction) => {
616 match direction {
617 crate::backend::ScrollDirection::Up => {
618 if scroll_offset > 0 {
619 scroll_offset = scroll_offset.saturating_sub(3);
620 needs_redraw = true;
621 }
622 }
623 crate::backend::ScrollDirection::Down => {
624 let max_scroll = total_lines.saturating_sub(visible_lines);
625 if scroll_offset < max_scroll {
626 scroll_offset = (scroll_offset + 3).min(max_scroll);
627 needs_redraw = true;
628 }
629 }
630 _ => {}
631 }
632 }
633 WindowEvent::TextInput(c) => {
634 if *c == ' ' && has_checkbox {
636 checkbox_checked = !checkbox_checked;
637 needs_redraw = true;
638 }
639 }
640 WindowEvent::KeyPress(key_event) => {
641 let max_scroll = total_lines.saturating_sub(visible_lines);
642
643 match key_event.keysym {
644 KEY_UP => {
645 if scroll_offset > 0 {
646 scroll_offset = scroll_offset.saturating_sub(1);
647 needs_redraw = true;
648 }
649 }
650 KEY_DOWN => {
651 if scroll_offset < max_scroll {
652 scroll_offset = (scroll_offset + 1).min(max_scroll);
653 needs_redraw = true;
654 }
655 }
656 KEY_PAGE_UP => {
657 scroll_offset = scroll_offset.saturating_sub(visible_lines);
658 needs_redraw = true;
659 }
660 KEY_PAGE_DOWN => {
661 scroll_offset = (scroll_offset + visible_lines).min(max_scroll);
662 needs_redraw = true;
663 }
664 KEY_HOME => {
665 if scroll_offset > 0 {
666 scroll_offset = 0;
667 needs_redraw = true;
668 }
669 }
670 KEY_END => {
671 if scroll_offset < max_scroll {
672 scroll_offset = max_scroll;
673 needs_redraw = true;
674 }
675 }
676 KEY_RETURN => {
677 return Ok(TextInfoResult::Ok {
678 checkbox_checked,
679 });
680 }
681 KEY_ESCAPE => {
682 return Ok(TextInfoResult::Cancelled);
683 }
684 _ => {}
685 }
686 }
687 _ => {}
688 }
689
690 needs_redraw |= ok_button.process_event(&event);
691 needs_redraw |= cancel_button.process_event(&event);
692
693 if ok_button.was_clicked() {
694 return Ok(TextInfoResult::Ok {
695 checkbox_checked,
696 });
697 }
698 if cancel_button.was_clicked() {
699 return Ok(TextInfoResult::Cancelled);
700 }
701
702 while let Some(ev) = window.poll_for_event()? {
704 match &ev {
705 WindowEvent::CloseRequested => {
706 return Ok(TextInfoResult::Closed);
707 }
708 WindowEvent::CursorEnter(pos) | WindowEvent::CursorMove(pos) => {
709 last_cursor_pos = Some((pos.x as i32, pos.y as i32));
710 }
711 WindowEvent::ButtonPress(button, _modifiers)
712 if *button == crate::backend::MouseButton::Left =>
713 {
714 if let Some((mx, my)) = last_cursor_pos {
715 if total_lines > visible_lines {
716 let sb_x = text_area_w as i32 - (10.0 * scale) as i32;
717 let sb_y_f32 = 4.0 * scale;
718 let sb_y = sb_y_f32 as i32;
719 let sb_h_f32 = text_area_h as f32 - 8.0 * scale;
720 let sb_h = sb_h_f32 as i32;
721
722 let thumb_h_f32 = (visible_lines as f32 / total_lines as f32
723 * sb_h_f32)
724 .max(20.0 * scale);
725 let thumb_h = thumb_h_f32 as i32;
726
727 let max_scroll = total_lines.saturating_sub(visible_lines);
728 let max_thumb_y = sb_h - thumb_h;
729 let thumb_y = if max_scroll > 0 {
730 ((scroll_offset as f32 / max_scroll as f32)
731 * max_thumb_y as f32)
732 as i32
733 } else {
734 0
735 };
736
737 if mx >= text_area_x + sb_x
738 && mx < text_area_x + sb_x + (6.0 * scale) as i32
739 && my >= text_area_y + sb_y + thumb_y
740 && my < text_area_y + sb_y + thumb_y + thumb_h
741 {
742 thumb_drag = true;
743 thumb_drag_offset = Some(my - (text_area_y + sb_y + thumb_y));
744 }
745 }
746 }
747 }
748 WindowEvent::ButtonRelease(_, _) => {
749 thumb_drag = false;
750 thumb_drag_offset = None;
751 }
752 _ => {}
753 }
754
755 needs_redraw |= ok_button.process_event(&ev);
756 needs_redraw |= cancel_button.process_event(&ev);
757 }
758
759 if needs_redraw {
760 draw(
761 &mut canvas,
762 colors,
763 &font,
764 &self.title,
765 &wrapped_lines,
766 scroll_offset,
767 visible_lines,
768 &self.checkbox_text,
769 checkbox_checked,
770 checkbox_hovered,
771 &ok_button,
772 &cancel_button,
773 padding,
774 line_height,
775 checkbox_size,
776 text_area_x,
777 text_area_y,
778 text_area_w,
779 text_area_h,
780 checkbox_y,
781 scale,
782 scrollbar_hovered,
783 );
784 window.set_contents(&canvas)?;
785 }
786 }
787 }
788}
789
790impl Default for TextInfoBuilder {
791 fn default() -> Self {
792 Self::new()
793 }
794}
795
796fn darken(color: crate::render::Rgba, amount: f32) -> crate::render::Rgba {
797 rgb(
798 (color.r as f32 * (1.0 - amount)) as u8,
799 (color.g as f32 * (1.0 - amount)) as u8,
800 (color.b as f32 * (1.0 - amount)) as u8,
801 )
802}