1use std::{fmt, io, time::Duration};
28
29use omp_core::Str;
30use smallvec::SmallVec;
31use tokio_util::sync::CancellationToken;
32
33use crate::{
34 Appearance, CursorStyle, Graphics, InputEvent, Key, OverlayId, PaintStats, ProbeResults,
35 Renderer, Size, Terminal, TerminalCaps, TerminalOptions, TerminalResponse, Theme, TtyOut, Ui,
36 UiContext, UiEvent,
37 component::Slot,
38 components::ImgState,
39 detect, negotiate_async,
40 paste::{Clipboard, ClipboardRead, Pasted, PastedImage},
41 pump::{DebugOp, DebugQuery, TerminalEvent},
42};
43
44const RESIZE_SETTLE: Duration = Duration::from_millis(120);
45const RESIZE_RECHECK: Duration = Duration::from_millis(25);
46#[cfg(windows)]
47const WINDOWS_RESIZE_POLL: Duration = Duration::from_millis(100);
48const CLIPBOARD_READ_TIMEOUT: Duration = Duration::from_secs(10);
52
53pub enum Msg {
54 Update(Box<dyn FnOnce(&mut Ui) + Send>),
56 ImageDecoded { slot: Slot, state: ImgState },
58 Pasted { generation: u64, raw: bool, clipboard: Option<crate::paste::Clipboard> },
62}
63
64#[derive(Default)]
74struct ClipboardGate {
75 in_flight: Option<InFlightRead>,
76 generation: u64,
77 pending: std::collections::VecDeque<InputEvent>,
78}
79
80struct InFlightRead {
81 generation: u64,
82 deadline: tokio::time::Instant,
83}
84
85impl ClipboardGate {
86 fn begin(&mut self, now: tokio::time::Instant) -> Option<u64> {
89 if self.in_flight.is_some() {
90 return None;
91 }
92 self.generation += 1;
93 self.in_flight = Some(InFlightRead {
94 generation: self.generation,
95 deadline: now + CLIPBOARD_READ_TIMEOUT,
96 });
97 Some(self.generation)
98 }
99
100 fn admit(&mut self, event: InputEvent, quit: &[Key]) -> Option<InputEvent> {
103 if self.in_flight.is_none() {
104 return Some(event);
105 }
106 if let InputEvent::Key(key) = &event
107 && quit.contains(key)
108 {
109 return Some(event);
110 }
111 self.pending.push_back(event);
112 None
113 }
114
115 fn settle(&mut self, generation: u64) -> bool {
117 if self
118 .in_flight
119 .as_ref()
120 .is_some_and(|read| read.generation == generation)
121 {
122 self.in_flight = None;
123 return true;
124 }
125 false
126 }
127
128 const fn expire(&mut self) {
130 self.in_flight = None;
131 }
132
133 fn deadline(&self) -> Option<tokio::time::Instant> {
135 self.in_flight.as_ref().map(|read| read.deadline)
136 }
137
138 fn drain(&mut self) -> Option<InputEvent> {
140 if self.in_flight.is_some() {
141 return None;
142 }
143 self.pending.pop_front()
144 }
145}
146
147#[derive(Clone)]
149pub struct ImageLoader {
150 tx: flume::Sender<Msg>,
151 rx: flume::Receiver<Msg>,
152 rt: tokio::runtime::Handle,
153}
154
155impl ImageLoader {
156 pub fn new() -> Self {
161 let (tx, rx) = flume::unbounded();
162 Self { tx, rx, rt: tokio::runtime::Handle::current() }
163 }
164
165 pub(crate) fn request(
166 &self,
167 slot: Slot,
168 source: Str,
169 width: u16,
170 height: Option<u16>,
171 trim: bool,
172 ) {
173 let tx = self.tx.clone();
174 let _task = self.rt.spawn_blocking(move || {
175 let state = crate::components::decode_source(&source, width, height, trim);
176 let _ = tx.send(Msg::ImageDecoded { slot, state });
177 });
178 }
179}
180
181impl Default for ImageLoader {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187impl fmt::Debug for ImageLoader {
188 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189 formatter.write_str("ImageLoader")
190 }
191}
192
193#[derive(Clone)]
197pub struct UiHandle {
198 tx: flume::Sender<Msg>,
199 cancel: CancellationToken,
200}
201
202impl UiHandle {
203 pub fn update(&self, update: impl FnOnce(&mut Ui) + Send + 'static) {
205 let _ = self.tx.send(Msg::Update(Box::new(update)));
206 }
207
208 pub fn set_text(&self, id: impl Into<Str>, text: impl Into<Str>) {
210 let id = id.into();
211 let text = text.into();
212 self.update(move |ui| {
213 ui.set_text(&id, text);
214 });
215 }
216
217 pub fn invalidate(&self, id: impl Into<Str>) {
219 let id = id.into();
220 self.update(move |ui| {
221 ui.invalidate(&id);
222 });
223 }
224
225 pub fn shutdown(&self) {
227 self.cancel.cancel();
228 }
229}
230
231type GraphicsOverride = Box<dyn FnOnce(&TerminalCaps) -> Option<Graphics> + Send>;
232
233pub struct AppOptions {
239 probe: Option<Duration>,
240 graphics: Option<GraphicsOverride>,
241 cursor_style: Option<CursorStyle>,
242 quit: SmallVec<Key, 4>,
243 hotkeys: SmallVec<Key, 4>,
244 quit_on_cancel: bool,
245 mouse: bool,
246 hold_alt: bool,
247}
248
249impl AppOptions {
250 pub fn new() -> Self {
252 let mut quit = SmallVec::new();
253 quit.push(Key::Ctrl('c'));
254 Self {
255 probe: None,
256 graphics: None,
257 cursor_style: None,
258 quit,
259 hotkeys: SmallVec::new(),
260 quit_on_cancel: true,
261 mouse: false,
262 hold_alt: false,
263 }
264 }
265
266 pub const fn probe(mut self, timeout: Duration) -> Self {
268 self.probe = Some(timeout);
269 self
270 }
271
272 pub fn graphics_with(
274 mut self,
275 forced: impl FnOnce(&TerminalCaps) -> Option<Graphics> + Send + 'static,
276 ) -> Self {
277 self.graphics = Some(Box::new(forced));
278 self
279 }
280
281 pub const fn cursor_style(mut self, style: CursorStyle) -> Self {
283 self.cursor_style = Some(style);
284 self
285 }
286
287 pub const fn mouse(mut self) -> Self {
294 self.mouse = true;
295 self
296 }
297
298 pub fn quit(mut self, chords: impl IntoIterator<Item = Key>) -> Self {
300 self.quit = chords.into_iter().collect();
301 self
302 }
303
304 pub fn hotkeys(mut self, chords: impl IntoIterator<Item = Key>) -> Self {
313 self.hotkeys = chords.into_iter().collect();
314 self
315 }
316
317 pub const fn hold_alt(mut self) -> Self {
324 self.hold_alt = true;
325 self
326 }
327
328 pub const fn keep_on_cancel(mut self) -> Self {
333 self.quit_on_cancel = false;
334 self
335 }
336
337 pub async fn start(self, build: impl FnOnce(AppEnv) -> Ui + Send) -> io::Result<App> {
344 let Self { probe, graphics, cursor_style, quit, hotkeys, quit_on_cancel, mouse, hold_alt } =
345 self;
346 let (base, probe) = match probe {
347 Some(timeout) => negotiate_async(timeout).await,
348 None => (detect(), ProbeResults::default()),
349 };
350 let forced = graphics.and_then(|forced| forced(&base));
351 let caps = TerminalCaps::resolve(base, None, forced);
352 let mut terminal_options = TerminalOptions::new(caps).mouse(mouse).probe_results(probe);
353 if let Some(style) = cursor_style {
354 terminal_options = terminal_options.cursor_style(style);
355 }
356 let mut terminal = Terminal::enter(terminal_options)?;
357 let viewport = terminal.size()?;
358
359 let loader = ImageLoader::new();
360 let msgs = loader.rx.clone();
361 let tx = loader.tx.clone();
362 let mut ctx = UiContext::default().with_terminal_caps(&caps);
363 ctx.loader = Some(loader);
364 let mut ui = build(AppEnv { viewport, caps, ctx });
365
366 let mut renderer = Renderer::new(TtyOut::new()?);
367 renderer.apply_caps(&caps)?;
368
369 let initial_hold = hold_alt || ui.has_overlay();
373 let last_stats = if initial_hold {
374 let alt_enter = terminal.stage_alt_enter(crate::AltScreenUse::Interactive);
375 ui.preview(&mut renderer, viewport.height, alt_enter.as_deref().unwrap_or(""))?
376 } else {
377 renderer.rebuild(ui.frame().clone(), viewport.height, 0, "")?
378 };
379 ui.clear_damage();
380 let now = tokio::time::Instant::now();
381 Ok(App {
382 ui,
383 renderer,
384 msgs,
385 tx,
386 cancel: CancellationToken::new(),
387 epoch: now,
388 caps,
389 viewport,
390 quit,
391 hotkeys,
392 quit_on_cancel,
393 #[cfg(unix)]
394 resize_wait: None,
395 #[cfg(windows)]
396 resize_wait: Some(now + WINDOWS_RESIZE_POLL),
397 resize_settle: None,
398 resize_alt: false,
399 alt_hold: initial_hold,
400 hold_request: hold_alt,
401 main_stale: false,
402 held_damage: false,
403 tail_drag: false,
404 needs_rebuild: false,
405 clipboard: ClipboardGate::default(),
406 stable_rows: 0,
407 last_stats,
408 terminal,
409 })
410 }
411}
412
413impl Default for AppOptions {
414 fn default() -> Self {
415 Self::new()
416 }
417}
418
419pub struct AppEnv {
421 pub viewport: Size,
423 pub caps: TerminalCaps,
425 pub ctx: UiContext,
427}
428
429#[derive(Clone, Debug, Eq, PartialEq)]
433pub enum AppEvent {
434 Updated,
436 Key(Key),
441 Submitted,
443 Pressed(Str),
445 Resized(Size),
447 Appearance(Appearance),
451 OverlayClosed(OverlayId),
453 Highlighted {
455 id: Str,
457 value: Str,
459 },
460 Changed {
462 id: Str,
464 value: Str,
466 },
467 Filtered {
469 id: Str,
471 query: Str,
473 value: Option<Str>,
475 },
476}
477
478pub struct App {
480 ui: Ui,
481 renderer: Renderer<TtyOut>,
482 msgs: flume::Receiver<Msg>,
483 tx: flume::Sender<Msg>,
484 cancel: CancellationToken,
485 epoch: tokio::time::Instant,
486 caps: TerminalCaps,
487 viewport: Size,
488 quit: SmallVec<Key, 4>,
489 hotkeys: SmallVec<Key, 4>,
490 quit_on_cancel: bool,
491 resize_wait: Option<tokio::time::Instant>,
492 resize_settle: Option<tokio::time::Instant>,
493 resize_alt: bool,
494 alt_hold: bool,
495 hold_request: bool,
496 main_stale: bool,
497 held_damage: bool,
498 tail_drag: bool,
499 needs_rebuild: bool,
500 clipboard: ClipboardGate,
501 stable_rows: u16,
502 last_stats: PaintStats,
503 terminal: Terminal,
504}
505
506impl App {
507 pub const fn ui(&self) -> &Ui {
509 &self.ui
510 }
511
512 pub const fn ui_mut(&mut self) -> &mut Ui {
514 &mut self.ui
515 }
516
517 pub fn set_hotkeys(&mut self, chords: impl IntoIterator<Item = Key>) {
520 self.hotkeys = chords.into_iter().collect();
521 }
522
523 pub fn handle(&self) -> UiHandle {
525 UiHandle { tx: self.tx.clone(), cancel: self.cancel.clone() }
526 }
527
528 pub const fn renderer_mut(&mut self) -> &mut Renderer<TtyOut> {
530 &mut self.renderer
531 }
532
533 pub const fn terminal_mut(&mut self) -> &mut Terminal {
535 &mut self.terminal
536 }
537
538 pub const fn caps(&self) -> TerminalCaps {
540 self.caps
541 }
542
543 pub const fn viewport(&self) -> Size {
545 self.viewport
546 }
547
548 pub const fn last_stats(&self) -> PaintStats {
550 self.last_stats
551 }
552
553 pub const fn set_stable_rows(&mut self, rows: u16) {
555 self.stable_rows = rows;
556 }
557
558 pub const fn hold_alt(&mut self, hold: bool) {
570 self.hold_request = hold;
571 }
572
573 #[expect(
581 clippy::future_not_send,
582 reason = "terminal UI components are intentionally confined to their owning thread"
583 )]
584 pub async fn next(&mut self) -> io::Result<Option<AppEvent>> {
585 loop {
586 if self.cancel.is_cancelled() {
587 return Ok(None);
588 }
589
590 let want_hold = self.hold_request || self.ui.has_overlay();
595 if want_hold {
596 if !self.alt_hold {
597 self.alt_hold = true;
598 if self.resize_alt || self.needs_rebuild {
602 self.resize_alt = false;
603 self.needs_rebuild = false;
604 self.main_stale = true;
605 }
606 if self.tail_drag {
609 self.tail_drag = false;
610 self.ui.resize(self.viewport.width);
611 }
612 self.held_damage |= self.ui.has_damage();
613 let alt_enter = self
614 .terminal
615 .stage_alt_enter(crate::AltScreenUse::Interactive);
616 self.last_stats = self.ui.preview(
617 &mut self.renderer,
618 self.viewport.height,
619 alt_enter.as_deref().unwrap_or(""),
620 )?;
621 } else if self.ui.has_damage() {
622 self.held_damage = true;
625 self.last_stats = self
626 .ui
627 .preview(&mut self.renderer, self.viewport.height, "")?;
628 }
629 } else if self.alt_hold {
630 self.alt_hold = false;
631 if self.main_stale {
632 self.main_stale = false;
637 self.held_damage = false;
638 self.ui.refit();
639 let alt_exit = self.terminal.stage_alt_leave().unwrap_or("");
640 self.last_stats = self.renderer.rebuild(
641 self.ui.frame().clone(),
642 self.viewport.height,
643 self.stable_rows,
644 alt_exit,
645 )?;
646 self.ui.clear_damage();
647 } else {
648 self.terminal.leave_alt()?;
653 if self.held_damage || self.ui.has_damage() {
654 self.held_damage = false;
655 self.ui.damage_all();
656 self.last_stats =
657 self
658 .ui
659 .present(&mut self.renderer, self.viewport.height, self.stable_rows)?;
660 }
661 }
662 } else if self.resize_settle.is_some() {
663 if self.ui.has_damage() {
668 let tail = if self.tail_drag {
669 self.ui.compose_resize_tail(self.viewport)
670 } else {
671 None
672 };
673 match tail {
674 Some(frame) => {
675 self.renderer.preview(&frame, self.viewport.height, "")?;
676 self.ui.clear_damage();
677 },
678 None => {
679 self.last_stats =
680 self
681 .ui
682 .preview(&mut self.renderer, self.viewport.height, "")?;
683 },
684 }
685 }
686 } else if self.needs_rebuild {
687 self.ui.refit();
692 let alt_exit = if self.resize_alt {
696 self.resize_alt = false;
697 self.terminal.stage_alt_leave().unwrap_or("")
698 } else {
699 ""
700 };
701 self.last_stats = self.renderer.rebuild(
702 self.ui.frame().clone(),
703 self.viewport.height,
704 self.stable_rows,
705 alt_exit,
706 )?;
707 self.ui.clear_damage();
708 self.needs_rebuild = false;
709 } else if self.ui.has_damage() {
710 self.last_stats =
711 self
712 .ui
713 .present(&mut self.renderer, self.viewport.height, self.stable_rows)?;
714 }
715
716 if let Some(event) = self.clipboard.drain() {
719 match self.dispatch_input(event) {
720 Routed::Continue | Routed::Unclaimed => continue,
722 Routed::Event(event) => return Ok(Some(event)),
723 Routed::Stop => return Ok(None),
724 }
725 }
726
727 let wake = self.ui.next_wake().map(|at| self.epoch + at);
728 let wakeup = tokio::select! {
729 () = self.cancel.cancelled() => Wakeup::Cancelled,
730 message = self.msgs.recv_async() => Wakeup::Message(message),
731 event = self.terminal.next() => Wakeup::Event(event),
732 () = deadline(wake) => Wakeup::Animation,
733 () = deadline(self.clipboard.deadline()) => Wakeup::ClipboardExpired,
734 () = deadline(self.resize_wait) => Wakeup::ResizeCheck,
735 () = deadline(self.resize_settle) => Wakeup::ResizeSettle,
736 };
737
738 match wakeup {
739 Wakeup::Cancelled => return Ok(None),
740 Wakeup::Message(Ok(Msg::Update(update))) => update(&mut self.ui),
741 Wakeup::Message(Ok(Msg::ImageDecoded { slot, state })) => {
742 self.ui.deliver_image(slot, state);
743 },
744 Wakeup::Message(Ok(Msg::Pasted { generation, raw, clipboard })) => {
745 if self.clipboard.settle(generation)
748 && let Some(clipboard) = clipboard
749 && let Some(event) = self.deliver_clipboard(clipboard, raw)
750 {
751 return Ok(Some(event));
752 }
753 },
754 Wakeup::Message(Err(_)) => {},
755 Wakeup::Event(event) => match event? {
756 TerminalEvent::Resize => {
757 self.resize_wait = Some(tokio::time::Instant::now());
758 },
759 TerminalEvent::Debug(query) => self.answer_debug(query),
760 TerminalEvent::Closed => return Ok(None),
762 TerminalEvent::Input(event) => {
763 let in_band_resize =
764 matches!(&event, InputEvent::Response(TerminalResponse::InBandResize { .. }));
765 if self
766 .terminal
767 .handle_input_event(&event, &mut self.renderer)?
768 {
769 if in_band_resize {
770 self.resize_wait = Some(tokio::time::Instant::now());
771 }
772 if let Some(event) = self.sync_appearance() {
773 return Ok(Some(event));
774 }
775 if let Some(pasted) = self.terminal.take_paste()
776 && let Some(event) = self.deliver_pasted(pasted)
777 {
778 return Ok(Some(event));
779 }
780 continue;
781 }
782 if let Some(event) = self.clipboard.admit(event, &self.quit) {
783 match self.dispatch_input(event) {
784 Routed::Continue | Routed::Unclaimed => {},
786 Routed::Event(event) => return Ok(Some(event)),
787 Routed::Stop => return Ok(None),
788 }
789 }
790 },
791 },
792 Wakeup::Animation => {
793 self.ui.tick(self.epoch.elapsed());
794 },
795 Wakeup::ClipboardExpired => self.clipboard.expire(),
796 Wakeup::ResizeCheck => {
797 self.resize_wait = None;
798 let now = tokio::time::Instant::now();
799 let consumed = match self.terminal.take_resize()? {
800 Some(viewport) if viewport != self.viewport || self.resize_settle.is_some() => {
805 self.begin_resize(viewport, now)?;
806 true
807 },
808 Some(_) => true,
810 None => false,
811 };
812 #[cfg(unix)]
813 if !consumed {
814 self.resize_wait = Some(now + RESIZE_RECHECK);
817 }
818 #[cfg(windows)]
819 {
820 let _ = consumed;
821 self.resize_wait = Some(now + WINDOWS_RESIZE_POLL);
822 }
823 },
824 Wakeup::ResizeSettle => {
825 self.resize_settle = None;
826 if self.tail_drag {
827 self.tail_drag = false;
828 self.ui.resize(self.viewport.width);
832 }
833 self.needs_rebuild = !self.alt_hold;
837 return Ok(Some(AppEvent::Resized(self.viewport)));
838 },
839 }
840 }
841 }
842
843 fn sync_appearance(&mut self) -> Option<AppEvent> {
848 let appearance = self.terminal.appearance()?;
849 let current = self.ui.context();
850 if current.appearance == appearance {
851 return None;
852 }
853 let mut ctx = current.clone();
854 if ctx.theme == Theme::for_appearance(ctx.appearance) {
855 ctx.theme = Theme::for_appearance(appearance);
856 }
857 ctx.appearance = appearance;
858 self.ui.set_context(ctx);
859 Some(AppEvent::Appearance(appearance))
860 }
861
862 fn route_paste(&mut self, text: &str, raw: bool) -> Option<AppEvent> {
866 let event = if raw {
867 self.ui.handle_paste_raw(text)
868 } else {
869 self.ui.handle_paste(text)
870 };
871 match select_event(event) {
872 Ok(event) => Some(event),
873 Err(_) if self.ui.has_damage() => Some(AppEvent::Updated),
874 Err(_) => None,
875 }
876 }
877
878 fn route_image(&mut self, image: &PastedImage) -> Option<AppEvent> {
882 let path = image.persist().ok()?;
883 self.route_paste(path.to_str()?, false)
884 }
885
886 fn deliver_pasted(&mut self, pasted: Pasted) -> Option<AppEvent> {
888 match pasted {
889 Pasted::Text(text) => self.route_paste(&text, false),
890 Pasted::Image(image) => self.route_image(&image),
891 }
892 }
893
894 fn deliver_clipboard(&mut self, clipboard: Clipboard, raw: bool) -> Option<AppEvent> {
898 match clipboard {
899 Clipboard::Text(text) => self.route_paste(&text, raw),
900 Clipboard::Image(image) => self.route_image(&image),
901 Clipboard::Paths(paths) => {
902 let mut joined = String::new();
905 for path in &paths {
906 if !joined.is_empty() {
907 joined.push(' ');
908 }
909 joined.push('"');
910 joined.push_str(path);
911 joined.push('"');
912 }
913 self.route_paste(&joined, false)
914 },
915 }
916 }
917
918 fn route_key(&mut self, key: Key) -> Routed {
919 let routed =
920 route_key_event(&mut self.ui, key, &self.quit, &self.hotkeys, self.quit_on_cancel);
921 if matches!(routed, Routed::Stop) {
922 self.cancel.cancel();
923 }
924 routed
925 }
926
927 fn begin_clipboard_read(&mut self, scope: ClipboardRead) {
938 let Some(generation) = self.clipboard.begin(tokio::time::Instant::now()) else {
939 return;
940 };
941 let rx = crate::paste::spawn_clipboard_read(scope);
942 let raw = scope == ClipboardRead::Text;
943 let tx = self.tx.clone();
944 tokio::spawn(async move {
945 let clipboard = rx.await.unwrap_or(None);
946 let _ = tx.send(Msg::Pasted { generation, raw, clipboard });
947 });
948 }
949
950 fn dispatch_input(&mut self, event: InputEvent) -> Routed {
953 self.ui.tick(self.epoch.elapsed());
956 match event {
957 InputEvent::Key(key) => match self.route_key(key) {
958 Routed::Unclaimed => {
962 if let Some(scope) = ClipboardRead::for_key(key) {
963 self.begin_clipboard_read(scope);
964 Routed::Continue
965 } else {
966 Routed::Event(AppEvent::Key(key))
967 }
968 },
969 routed => routed,
970 },
971 InputEvent::Mouse(report) => {
972 let offset = self.ui.height().saturating_sub(self.viewport.height);
973 let event =
974 self
975 .ui
976 .handle_mouse(report.col, report.row.saturating_add(offset), report.kind);
977 match select_event(event) {
978 Ok(event) => Routed::Event(event),
979 Err(UiEvent::Submit) => Routed::Event(AppEvent::Submitted),
980 Err(UiEvent::Pressed(id)) => Routed::Event(AppEvent::Pressed(id)),
981 Err(_) if self.ui.has_damage() => Routed::Event(AppEvent::Updated),
982 Err(_) => Routed::Continue,
983 }
984 },
985 InputEvent::Paste(text) => {
986 if text.is_empty() {
990 self.begin_clipboard_read(ClipboardRead::Smart);
991 Routed::Continue
992 } else {
993 self
994 .route_paste(&text, false)
995 .map_or(Routed::Continue, Routed::Event)
996 }
997 },
998 InputEvent::Focus(_) | InputEvent::Response(_) => Routed::Continue,
999 }
1000 }
1001
1002 fn begin_resize(&mut self, viewport: Size, now: tokio::time::Instant) -> io::Result<()> {
1008 let width_changed = viewport.width != self.viewport.width;
1009 self.viewport = viewport;
1010 if self.alt_hold {
1011 self.main_stale = true;
1014 self.ui.resize(viewport.width);
1015 self.ui.preview(&mut self.renderer, viewport.height, "")?;
1016 } else {
1017 let alt_enter = if self.caps.inside_multiplexer {
1025 None
1026 } else if width_changed && !self.resize_alt {
1027 let staged = self.terminal.stage_alt_enter(crate::AltScreenUse::Resize);
1028 self.resize_alt |= staged.is_some();
1029 staged
1030 } else {
1031 None
1032 };
1033 let leading = alt_enter.as_deref().unwrap_or("");
1034 if let Some(frame) = self.ui.compose_resize_tail(viewport) {
1037 self.tail_drag = true;
1038 self.renderer.preview(&frame, viewport.height, leading)?;
1039 } else {
1040 self.ui.resize(viewport.width);
1041 self
1042 .ui
1043 .preview(&mut self.renderer, viewport.height, leading)?;
1044 }
1045 }
1046 self.resize_settle = Some(now + RESIZE_SETTLE);
1047 Ok(())
1048 }
1049
1050 fn answer_debug(&self, query: DebugQuery) {
1053 use serde_json::json;
1054 let response = match query.op {
1055 DebugOp::Frame => {
1056 let frame = self.ui.frame();
1057 let lines: Vec<String> = (0..frame.size().height)
1058 .map(|row| crate::test_support::frame_row_text(frame, row))
1059 .collect();
1060 json!({ "ok": true, "lines": lines })
1061 },
1062 DebugOp::Tree => json!({ "ok": true, "tree": self.ui.debug_tree() }),
1063 DebugOp::Values => json!({ "ok": true, "values": self.ui.values() }),
1064 DebugOp::Info | DebugOp::Text | DebugOp::Resize | DebugOp::Quit => return,
1067 };
1068 crate::debug::respond_debug_query(query.id, response);
1069 }
1070}
1071
1072impl Drop for App {
1073 fn drop(&mut self) {
1074 let _ = self.terminal.leave_alt();
1079 let _ = self.renderer.clear_layers();
1080 }
1081}
1082
1083fn route_key_event(
1089 ui: &mut Ui,
1090 key: Key,
1091 quit: &[Key],
1092 hotkeys: &[Key],
1093 quit_on_cancel: bool,
1094) -> Routed {
1095 if quit.contains(&key) {
1096 return Routed::Stop;
1097 }
1098 if hotkeys.contains(&key) {
1101 return Routed::Event(AppEvent::Key(key));
1102 }
1103 let (event, claimed) = ui.handle_key_claimed(key);
1104 match event {
1105 UiEvent::Cancel if ui.has_overlay() => {
1106 let id = ui
1107 .close_active_overlay()
1108 .expect("a visible modal overlay routed this key");
1109 Routed::Event(AppEvent::OverlayClosed(id))
1110 },
1111 UiEvent::Cancel if quit_on_cancel => Routed::Stop,
1112 UiEvent::Submit => Routed::Event(AppEvent::Submitted),
1113 UiEvent::Pressed(id) => Routed::Event(AppEvent::Pressed(id)),
1114 event
1115 @ (UiEvent::Highlighted { .. } | UiEvent::Changed { .. } | UiEvent::Filtered { .. }) => {
1116 Routed::Event(select_event(event).expect("select events map to app events"))
1117 },
1118 UiEvent::None | UiEvent::Cancel if !claimed => Routed::Unclaimed,
1122 UiEvent::None | UiEvent::Cancel if ui.has_damage() => Routed::Event(AppEvent::Updated),
1123 UiEvent::None | UiEvent::Cancel => Routed::Continue,
1124 }
1125}
1126
1127fn select_event(event: UiEvent) -> Result<AppEvent, UiEvent> {
1130 match event {
1131 UiEvent::Highlighted { id, value } => Ok(AppEvent::Highlighted { id, value }),
1132 UiEvent::Changed { id, value } => Ok(AppEvent::Changed { id, value }),
1133 UiEvent::Filtered { id, query, value } => Ok(AppEvent::Filtered { id, query, value }),
1134 other => Err(other),
1135 }
1136}
1137
1138#[derive(Debug, Eq, PartialEq)]
1139enum Routed {
1140 Continue,
1142 Unclaimed,
1145 Event(AppEvent),
1147 Stop,
1149}
1150
1151enum Wakeup {
1152 Cancelled,
1153 Message(Result<Msg, flume::RecvError>),
1154 Event(io::Result<TerminalEvent>),
1155 Animation,
1156 ClipboardExpired,
1157 ResizeCheck,
1158 ResizeSettle,
1159}
1160
1161async fn deadline(at: Option<tokio::time::Instant>) {
1163 match at {
1164 Some(at) => tokio::time::sleep_until(at).await,
1165 None => std::future::pending().await,
1166 }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use std::time::Duration;
1172
1173 #[cfg(unix)]
1175 const HOLD_HELPER_FLAG: &str = "OMP_TUI_TEST_HOLD_HELPER";
1176
1177 #[test]
1181 fn clipboard_gate_orders_input_behind_the_read() {
1182 use super::{CLIPBOARD_READ_TIMEOUT, ClipboardGate};
1183 use crate::{InputEvent, Key};
1184
1185 let mut gate = ClipboardGate::default();
1186 let now = tokio::time::Instant::now();
1187 let generation = gate.begin(now).expect("gate idle");
1188 assert_eq!(gate.begin(now), None, "one read at a time");
1189 let quit = [Key::Ctrl('c')];
1190 assert_eq!(gate.admit(InputEvent::Key(Key::Enter), &quit), None, "Enter queues");
1191 assert_eq!(gate.admit(InputEvent::Key(Key::Char('x')), &quit), None);
1192 assert_eq!(
1194 gate.admit(InputEvent::Key(Key::Ctrl('c')), &quit),
1195 Some(InputEvent::Key(Key::Ctrl('c')))
1196 );
1197 assert_eq!(gate.drain(), None, "queue holds while the read runs");
1198 assert_eq!(gate.deadline(), Some(now + CLIPBOARD_READ_TIMEOUT));
1199 assert!(gate.settle(generation));
1200 assert_eq!(gate.drain(), Some(InputEvent::Key(Key::Enter)));
1201 assert_eq!(gate.drain(), Some(InputEvent::Key(Key::Char('x'))));
1202 assert_eq!(gate.drain(), None);
1203 assert_eq!(gate.deadline(), None);
1204 }
1205
1206 #[test]
1209 fn clipboard_gate_drops_expired_and_superseded_results() {
1210 use super::ClipboardGate;
1211 use crate::{InputEvent, Key};
1212
1213 let mut gate = ClipboardGate::default();
1214 let now = tokio::time::Instant::now();
1215 let first = gate.begin(now).expect("gate idle");
1216 assert_eq!(gate.admit(InputEvent::Key(Key::Enter), &[]), None);
1217 gate.expire();
1218 assert_eq!(gate.drain(), Some(InputEvent::Key(Key::Enter)), "expiry releases queued input");
1219 let second = gate.begin(now).expect("gate idle again");
1220 assert!(!gate.settle(first), "stale result is dropped");
1221 assert!(gate.settle(second));
1222 }
1223
1224 #[cfg(unix)]
1228 #[test]
1229 fn hold_alt_pty_helper() {
1230 use super::AppOptions;
1231 use crate::Ui;
1232
1233 if std::env::var_os(HOLD_HELPER_FLAG).is_none() {
1234 return;
1235 }
1236 tokio::runtime::Builder::new_multi_thread()
1237 .enable_all()
1238 .build()
1239 .expect("helper runtime builds")
1240 .block_on(async {
1241 let mut app = AppOptions::new()
1242 .hold_alt()
1243 .start(|env| {
1244 Ui::from_markup("<text>inline</text>", env.viewport.width, env.ctx).unwrap()
1245 })
1246 .await
1247 .expect("helper app starts on the override device");
1248 tokio::time::sleep(Duration::from_millis(250)).await;
1249 app.hold_alt(false);
1250 let _ = tokio::time::timeout(Duration::from_millis(200), app.next()).await;
1251 drop(app);
1252 });
1253 }
1254
1255 #[cfg(unix)]
1260 #[test]
1261 fn hold_alt_start_holds_without_overlay_and_releases() {
1262 use std::io::Read as _;
1263
1264 let winsize = nix::pty::Winsize { ws_row: 12, ws_col: 40, ws_xpixel: 0, ws_ypixel: 0 };
1265 let pty = nix::pty::openpty(Some(&winsize), None).expect("openpty succeeds");
1266 let device = nix::unistd::ttyname(&pty.slave).expect("the pty slave has a device path");
1267 let mut master = std::fs::File::from(pty.master);
1268 nix::fcntl::fcntl(&master, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_NONBLOCK))
1269 .expect("master goes nonblocking");
1270
1271 let exe = std::env::current_exe().expect("test binary path");
1272 let mut child = std::process::Command::new(exe)
1273 .args(["runtime::tests::hold_alt_pty_helper", "--exact", "--test-threads=1"])
1274 .env(HOLD_HELPER_FLAG, "1")
1275 .env(crate::tty::TTY_OVERRIDE, &device)
1276 .stdout(std::process::Stdio::null())
1277 .stderr(std::process::Stdio::null())
1278 .spawn()
1279 .expect("helper process spawns");
1280
1281 let mut stream = Vec::new();
1282 let mut buffer = [0_u8; 4096];
1283 let deadline = std::time::Instant::now() + Duration::from_secs(20);
1284 loop {
1285 match master.read(&mut buffer) {
1286 Ok(0) => break,
1287 Ok(read) => stream.extend_from_slice(&buffer[..read]),
1288 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1289 if let Some(status) = child.try_wait().expect("helper status readable") {
1290 assert!(status.success(), "helper scenario passes");
1291 while let Ok(read) = master.read(&mut buffer) {
1293 if read == 0 {
1294 break;
1295 }
1296 stream.extend_from_slice(&buffer[..read]);
1297 }
1298 break;
1299 }
1300 assert!(std::time::Instant::now() < deadline, "helper finishes in time");
1301 std::thread::sleep(Duration::from_millis(20));
1302 },
1303 Err(_) => break,
1304 }
1305 }
1306 drop(child.kill());
1307
1308 let position = |needle: &[u8]| {
1309 stream
1310 .windows(needle.len())
1311 .position(|window| window == needle)
1312 };
1313 let entry = position(b"\x1b[?1049h").expect("frame one stages the alternate screen");
1314 let content = position(b"inline").expect("frame one paints the tree");
1315 assert!(entry < content, "the buffer switch precedes the first paint");
1316 let exit = position(b"\x1b[?1049l").expect("hold_alt(false) restores the main screen");
1317 assert!(content < exit, "release follows the held frames");
1318 assert!(
1319 position(b"\x1b[3J").is_none(),
1320 "an alt-first start and clean release never touch main history"
1321 );
1322 }
1323
1324 #[test]
1325 fn overlay_cancel_dismisses_the_visible_layer_before_quit_policy() {
1326 use super::{AppEvent, Routed, route_key_event};
1327 use crate::{Key, OverlayOptions, dom};
1328
1329 let mut ui = Ui::from_markup("<input id=base/>", 40, UiContext::default()).unwrap();
1330 let lower = ui.show_overlay(dom! { <text>{"lower"}</text> }, OverlayOptions::default());
1331 let upper = ui.show_overlay(dom! { <text>{"upper"}</text> }, OverlayOptions::default());
1332 assert!(ui.set_overlay_hidden(upper, true));
1333
1334 let quit = [Key::Ctrl('c')];
1336 assert_eq!(route_key_event(&mut ui, Key::Ctrl('c'), &quit, &[], true), Routed::Stop);
1337
1338 assert_eq!(
1340 route_key_event(&mut ui, Key::Esc, &quit, &[], true),
1341 Routed::Event(AppEvent::OverlayClosed(lower)),
1342 );
1343 assert!(ui.overlay(lower).is_none(), "the dismissed layer is gone");
1344 assert!(ui.overlay(upper).is_some(), "the hidden layer is untouched");
1345
1346 assert_eq!(route_key_event(&mut ui, Key::Esc, &quit, &[], true), Routed::Stop);
1348 assert_eq!(
1349 route_key_event(&mut ui, Key::Esc, &quit, &[], false),
1350 Routed::Unclaimed,
1351 "a swallowed cancel falls back to the host as an unclaimed key",
1352 );
1353
1354 let dialog =
1357 ui.show_overlay(dom! { <button cancel>{"Cancel"}</button> }, OverlayOptions::default());
1358 assert_eq!(
1359 route_key_event(&mut ui, Key::Enter, &quit, &[], true),
1360 Routed::Event(AppEvent::OverlayClosed(dialog)),
1361 );
1362 assert!(ui.overlay(dialog).is_none());
1363 }
1364
1365 #[test]
1369 fn a_reserved_hotkey_outranks_the_focused_widgets_own_binding() {
1370 use super::{AppEvent, Routed, route_key_event};
1371 use crate::Key;
1372
1373 let mut ui =
1374 Ui::from_markup("<input id=composer value=hello/>", 40, UiContext::default()).unwrap();
1375 ui.handle_key(Key::Home);
1376 let quit = [Key::Ctrl('c')];
1377
1378 assert_eq!(
1379 route_key_event(&mut ui, Key::Ctrl('k'), &quit, &[Key::Ctrl('k')], true),
1380 Routed::Event(AppEvent::Key(Key::Ctrl('k'))),
1381 );
1382 assert_eq!(ui.values()["composer"], "hello", "the input never saw the reserved chord");
1383
1384 assert_eq!(
1386 route_key_event(&mut ui, Key::Ctrl('k'), &quit, &[], true),
1387 Routed::Event(AppEvent::Updated)
1388 );
1389 assert_eq!(ui.values()["composer"], "");
1390 }
1391
1392 #[test]
1396 fn an_unclaimed_key_surfaces_despite_pending_damage() {
1397 use super::{Routed, route_key_event};
1398 use crate::Key;
1399
1400 let mut ui =
1401 Ui::from_markup("<text id=status>idle</text>", 40, UiContext::default()).unwrap();
1402 ui.set_text("status", "running");
1403 assert!(ui.has_damage(), "the text write left damage pending");
1404
1405 let quit = [Key::Ctrl('c')];
1406 assert_eq!(route_key_event(&mut ui, Key::Char('m'), &quit, &[], true), Routed::Unclaimed);
1407 }
1408
1409 #[test]
1410 fn cancel_with_only_a_non_modal_layer_follows_the_quit_policy() {
1411 use super::{Routed, route_key_event};
1412 use crate::{Key, OverlayOptions, dom};
1413
1414 let mut ui = Ui::from_markup("<input id=base/>", 40, UiContext::default()).unwrap();
1415 let rail =
1416 ui.show_overlay(dom! { <text>{"rail"}</text> }, OverlayOptions::default().non_modal());
1417 let quit = [Key::Ctrl('c')];
1418 assert_eq!(
1419 route_key_event(&mut ui, Key::Esc, &quit, &[], true),
1420 Routed::Stop,
1421 "a non-modal layer never soaks up the cancel",
1422 );
1423 assert!(ui.overlay(rail).is_some(), "the rail is not dismissed");
1424 }
1425
1426 #[test]
1427 fn cancel_dismisses_the_modal_beneath_a_higher_z_non_modal_layer() {
1428 use super::{AppEvent, Routed, route_key_event};
1429 use crate::{Key, OverlayOptions, dom};
1430
1431 let mut ui = Ui::from_markup("<input id=base/>", 40, UiContext::default()).unwrap();
1432 let rail = ui
1433 .show_overlay(dom! { <text>{"rail"}</text> }, OverlayOptions::default().non_modal().z(10));
1434 let dialog = ui.show_overlay(dom! { <text>{"confirm"}</text> }, OverlayOptions::default());
1435 let quit = [Key::Ctrl('c')];
1436 assert_eq!(
1437 route_key_event(&mut ui, Key::Esc, &quit, &[], true),
1438 Routed::Event(AppEvent::OverlayClosed(dialog)),
1439 "the cancel dismisses the modal that routed it, not the stack top",
1440 );
1441 assert!(ui.overlay(rail).is_some(), "the higher-z pane survives");
1442 assert!(ui.overlay(dialog).is_none());
1443 }
1444
1445 use super::{ImageLoader, Msg, UiHandle};
1446 use crate::{
1447 Cached, Component, Elements, Prop, Props, Ui, UiContext, components::Img,
1448 test_support::frame_row_text,
1449 };
1450
1451 #[expect(
1452 clippy::future_not_send,
1453 reason = "this helper runs only in current-thread Tokio tests with thread-confined UI \
1454 components"
1455 )]
1456 async fn receive_image<'a>(loader: &'a ImageLoader, ui: &'a mut Ui) {
1457 let message = tokio::time::timeout(Duration::from_secs(5), loader.rx.recv_async())
1458 .await
1459 .expect("image decode completes")
1460 .expect("image bus remains connected");
1461 let Msg::ImageDecoded { slot, state } = message else {
1462 panic!("image loader only emits decode messages");
1463 };
1464 assert!(ui.deliver_image(slot, state));
1465 }
1466
1467 #[tokio::test(flavor = "current_thread")]
1468 async fn image_decode_delivers_without_blocking_initial_layout() {
1469 let dir = std::env::temp_dir().join(format!("omp-tui-runtime-image-{}", std::process::id()));
1470 std::fs::create_dir_all(&dir).unwrap();
1471 let path = dir.join("async.ppm");
1472 let mut ppm = b"P6\n4 4\n255\n".to_vec();
1473 for y in 0..4 {
1474 for _ in 0..4 {
1475 ppm.extend(if y < 2 { [255, 0, 0] } else { [0, 0, 255] });
1476 }
1477 }
1478 std::fs::write(&path, ppm).unwrap();
1479
1480 let loader = ImageLoader::new();
1481 let ctx = UiContext { loader: Some(loader.clone()), ..UiContext::default() };
1482 let mut ui = Ui::from_markup(format!("<img src={} w=4/>", path.display()), 10, ctx).unwrap();
1483 let initial_rows = (0..ui.height())
1484 .map(|row| frame_row_text(ui.frame(), row))
1485 .collect::<Vec<_>>();
1486 assert_eq!(ui.height(), 3, "loading uses the fixed box placeholder");
1487 assert!(initial_rows[0].contains('┌'));
1488 assert!(!initial_rows.iter().any(|row| row.contains('▀')));
1489
1490 receive_image(&loader, &mut ui).await;
1491 assert_eq!(ui.height(), 2, "4px source relayouts to two half-block rows");
1492 assert!((0..ui.height()).any(|row| frame_row_text(ui.frame(), row).contains('▀')));
1493
1494 let elements = Elements::builder()
1495 .with("logo", |_: &str, props: Props, _: Vec<Cached>| {
1496 let source = props.str_of(Prop::Src).map_or("", |value| value.as_str());
1497 Box::new(Img::new().with_str(Prop::Src, source).with(Prop::W, 4_u16))
1498 as Box<dyn Component>
1499 })
1500 .build();
1501 let custom_loader = ImageLoader::new();
1502 let mut custom_ctx = UiContext { elements, ..UiContext::default() };
1503 custom_ctx.loader = Some(custom_loader.clone());
1504 let mut custom_ui =
1505 Ui::from_markup(format!("<logo src={}/>", path.display()), 10, custom_ctx).unwrap();
1506 receive_image(&custom_loader, &mut custom_ui).await;
1507 assert!(
1508 (0..custom_ui.height()).any(|row| frame_row_text(custom_ui.frame(), row).contains('▀'))
1509 );
1510
1511 std::fs::remove_file(path).unwrap();
1512 std::fs::remove_dir(dir).unwrap();
1513 }
1514
1515 #[test]
1516 fn ui_handle_applies_sync_thread_update_between_frames() {
1517 let (tx, rx) = flume::unbounded();
1518 let handle = UiHandle { tx, cancel: tokio_util::sync::CancellationToken::new() };
1519 let mut ui =
1520 Ui::from_markup(r#"<text id="message">before</text>"#, 20, UiContext::default()).unwrap();
1521 let before = frame_row_text(ui.frame(), 0);
1522
1523 std::thread::spawn(move || handle.set_text("message", "after"))
1524 .join()
1525 .expect("update thread finishes");
1526 let Msg::Update(update) = rx.recv().expect("thread queues one mutation") else {
1527 panic!("UiHandle only emits update messages");
1528 };
1529 update(&mut ui);
1530
1531 let after = frame_row_text(ui.frame(), 0);
1532 assert_ne!(after, before);
1533 assert_eq!(after, "after");
1534 }
1535}