Skip to main content

urushi_tui_app/
entry.rs

1//! Public construction and terminal ownership for the runtime.
2
3use std::fmt;
4use std::io;
5use std::sync::{Arc, Mutex, MutexGuard};
6
7#[cfg(feature = "graphics")]
8use urushi_graphics::{GraphicsPreference, GraphicsSelection, select_graphics};
9use urushi_terminal::{
10    Command, CommandWriter, Event, EventSource, KeyboardEnhancementFlags, KeyboardEnhancementQuery,
11    Position, RawModeControl, SessionOptions, TerminalBackend, TerminalBackground,
12    TerminalCapabilities, TerminalOutput, TerminalQuery, TerminalSession, TerminalSize, WindowSize,
13};
14use urushi_tui::Screen;
15
16use super::application::Application;
17use super::core::{RuntimeCore, RuntimeError};
18use super::executor::{Clock, Executor, TokioClock, TokioExecutor};
19#[cfg(feature = "graphics")]
20use super::presentation::spawn_graphics_terminal;
21use super::presentation::{BlockingPresentation, BlockingPresentationError};
22use super::source::Surface;
23use super::sources::RuntimeSourceSpawner;
24use super::terminal_source::TerminalSourceSpawner;
25
26/// A failure to construct or drive a terminal application.
27#[derive(Debug)]
28#[non_exhaustive]
29pub enum Error {
30    /// A physical terminal operation failed.
31    Terminal(io::Error),
32    /// Runtime-owned execution or presentation machinery failed.
33    Runtime(io::Error),
34}
35
36impl fmt::Display for Error {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Terminal(error) => write!(formatter, "terminal runtime failed: {error}"),
40            Self::Runtime(error) => write!(formatter, "application runtime failed: {error}"),
41        }
42    }
43}
44
45impl std::error::Error for Error {
46    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
47        match self {
48            Self::Terminal(error) | Self::Runtime(error) => Some(error),
49        }
50    }
51}
52
53impl From<io::Error> for Error {
54    fn from(error: io::Error) -> Self {
55        Self::Terminal(error)
56    }
57}
58
59/// The marker used until a caller supplies a terminal backend.
60#[doc(hidden)]
61pub struct DefaultTerminal;
62
63/// A blocking full-screen application runtime.
64///
65/// [`Runtime::new`] uses the production executor, clock, and TUI session
66/// profile. With the `crossterm` feature it also supplies a production
67/// terminal backend: graphics-enabled Unix builds use the native bidirectional
68/// connection for capability queries, and other builds use Crossterm. A
69/// runtime-only build must provide one with
70/// [`backend`](Runtime::backend). Builders replace those boundaries or
71/// individual session choices before [`run`](Runtime::run) takes ownership and
72/// blocks the calling thread.
73pub struct Runtime<A, B = DefaultTerminal> {
74    application: A,
75    backend: B,
76    executor: Option<Arc<dyn Executor>>,
77    clock: Option<Arc<dyn Clock>>,
78    session: SessionOptions,
79    presentation: PresentationOptions,
80}
81
82#[derive(Clone, Copy, Debug, Default)]
83struct PresentationOptions {
84    #[cfg(feature = "graphics")]
85    graphics: GraphicsPreference,
86}
87
88impl<A> Runtime<A, DefaultTerminal> {
89    /// Builds a runtime with production defaults.
90    pub fn new(application: A) -> Self {
91        Self {
92            application,
93            backend: DefaultTerminal,
94            executor: None,
95            clock: None,
96            session: tui_session_options(),
97            presentation: PresentationOptions::default(),
98        }
99    }
100
101    /// Runs with the default production terminal backend.
102    ///
103    /// A graphics-enabled Unix build uses the native bidirectional connection
104    /// so protocol capabilities can be positively queried. Other builds use
105    /// the portable Crossterm backend.
106    #[cfg(feature = "crossterm")]
107    pub fn run(self) -> Result<A::Model, Error>
108    where
109        A: Application,
110    {
111        let Self {
112            application,
113            executor,
114            clock,
115            session,
116            presentation,
117            ..
118        } = self;
119        Runtime {
120            application,
121            backend: open_default_terminal()?,
122            executor,
123            clock,
124            session,
125            presentation,
126        }
127        .run()
128    }
129}
130
131#[cfg(all(feature = "crossterm", feature = "graphics", unix))]
132type ProductionTerminal = urushi_terminal::backend::native::NativeTerminal;
133
134#[cfg(all(feature = "crossterm", not(all(feature = "graphics", unix))))]
135type ProductionTerminal = urushi_terminal::backend::crossterm::CrosstermBackend<std::io::Stdout>;
136
137/// Opens the default physical connection for the enabled presentation.
138///
139/// Runtime-owned graphics require positive capability replies. On Unix the
140/// native connection owns both sides of `/dev/tty`, so it can issue those
141/// queries before its event reader starts. The portable Crossterm adapter
142/// remains the default where the native connection is unavailable and for
143/// cell-only builds, whose presentation does not require capability probing.
144#[cfg(feature = "crossterm")]
145fn open_default_terminal() -> io::Result<ProductionTerminal> {
146    #[cfg(all(feature = "graphics", unix))]
147    {
148        ProductionTerminal::open()
149    }
150    #[cfg(not(all(feature = "graphics", unix)))]
151    {
152        Ok(ProductionTerminal::new(io::stdout()))
153    }
154}
155
156impl<A, B> Runtime<A, B> {
157    /// Replaces the physical connection used for the session, input, and queries.
158    pub fn backend<U>(self, backend: U) -> Runtime<A, U>
159    where
160        U: TerminalBackend + Send + 'static,
161    {
162        Runtime {
163            application: self.application,
164            backend,
165            executor: self.executor,
166            clock: self.clock,
167            session: self.session,
168            presentation: self.presentation,
169        }
170    }
171
172    /// Replaces the executor used by effects and subscriptions.
173    pub fn executor(mut self, executor: impl Executor) -> Self {
174        self.executor = Some(Arc::new(executor));
175        self
176    }
177
178    /// Replaces the clock used by effects and frame scheduling.
179    pub fn clock(mut self, clock: impl Clock) -> Self {
180        self.clock = Some(Arc::new(clock));
181        self
182    }
183
184    /// Selects terminal image output for this runtime.
185    #[cfg(feature = "graphics")]
186    pub const fn graphics(mut self, preference: GraphicsPreference) -> Self {
187        self.presentation.graphics = preference;
188        self
189    }
190
191    /// Enables or disables raw input mode for the session.
192    pub const fn raw_mode(mut self, enabled: bool) -> Self {
193        self.session.raw_mode = enabled;
194        self
195    }
196
197    /// Enables or disables the alternate screen for the session.
198    pub const fn alternate_screen(mut self, enabled: bool) -> Self {
199        self.session.alternate_screen = enabled;
200        self
201    }
202
203    /// Enables or disables bracketed-paste reporting.
204    pub const fn bracketed_paste(mut self, enabled: bool) -> Self {
205        self.session.bracketed_paste = enabled;
206        self
207    }
208
209    /// Enables or disables focus-change reporting.
210    pub const fn focus_change(mut self, enabled: bool) -> Self {
211        self.session.focus_change = enabled;
212        self
213    }
214
215    /// Selects the enhanced-keyboard information requested when supported.
216    pub const fn keyboard_enhancement(mut self, flags: Option<KeyboardEnhancementFlags>) -> Self {
217        self.session.keyboard_enhancement = flags;
218        self
219    }
220
221    /// Enables or disables mouse capture.
222    pub const fn mouse(mut self, enabled: bool) -> Self {
223        self.session.mouse_capture = enabled;
224        self
225    }
226
227    /// Hides the cursor until a frame places it or the session restores it.
228    pub const fn hide_cursor(mut self, enabled: bool) -> Self {
229        self.session.hide_cursor = enabled;
230        self
231    }
232}
233
234impl<A, B> Runtime<A, B>
235where
236    A: Application,
237    B: TerminalBackend + Send + 'static,
238{
239    /// Drives the application on the calling thread and returns its final model.
240    pub fn run(self) -> Result<A::Model, Error> {
241        run_runtime(
242            self.application,
243            self.backend,
244            self.executor,
245            self.clock,
246            self.session,
247            self.presentation,
248            |shared, context, sources| {
249                let screen = Screen::new(shared, context.surface.size).map_err(Error::Terminal)?;
250                Ok(sized_presentation(screen, context, sources))
251            },
252        )
253    }
254}
255
256fn run_runtime<A, B>(
257    application: A,
258    backend: B,
259    executor: Option<Arc<dyn Executor>>,
260    clock: Option<Arc<dyn Clock>>,
261    session_options: SessionOptions,
262    presentation_options: PresentationOptions,
263    build_presentation: impl FnOnce(
264        SharedTerminal<B>,
265        PresentationContext,
266        Arc<TerminalSourceSpawner<SharedTerminal<B>, A::Message>>,
267    ) -> Result<BlockingPresentation, Error>,
268) -> Result<A::Model, Error>
269where
270    A: Application,
271    B: TerminalBackend + Send + 'static,
272{
273    #[cfg(not(feature = "graphics"))]
274    let _ = presentation_options;
275    let tokio = BackgroundRuntime::new()?;
276    let executor = executor.unwrap_or_else(|| {
277        Arc::new(TokioExecutor::new(tokio.handle().clone())) as Arc<dyn Executor>
278    });
279    let clock = clock.unwrap_or_else(|| Arc::new(TokioClock) as Arc<dyn Clock>);
280    let shared = SharedTerminal::new(backend);
281    let mut session_control = shared.clone();
282    let mut session = TerminalSession::enter(&mut session_control, session_options)
283        .map_err(|error| Error::Terminal(io::Error::other(error)))?;
284
285    let initial_window = match session.control_mut().window_size() {
286        Ok(size) => size,
287        Err(error) => {
288            return finish_with_restore(Err(Error::Terminal(error)), session.restore());
289        }
290    };
291    let initial_surface = Surface::from_window_size(initial_window);
292    #[cfg(feature = "graphics")]
293    let graphics = {
294        let capabilities = if presentation_options.graphics == GraphicsPreference::Text {
295            TerminalCapabilities::none()
296        } else {
297            match session.control_mut().terminal_capabilities() {
298                Ok(capabilities) => capabilities,
299                Err(error) => {
300                    return finish_with_restore(Err(Error::Terminal(error)), session.restore());
301                }
302            }
303        };
304        match select_graphics(
305            presentation_options.graphics,
306            capabilities,
307            initial_surface.cell_pixels,
308        ) {
309            Ok(selection) => selection,
310            Err(error) => {
311                return finish_with_restore(
312                    Err(Error::Terminal(io::Error::other(error))),
313                    session.restore(),
314                );
315            }
316        }
317    };
318    let context = PresentationContext {
319        surface: initial_surface,
320        #[cfg(feature = "graphics")]
321        graphics,
322    };
323    let runtime_result = tokio.block_on(async move {
324        let fallback = Arc::new(RuntimeSourceSpawner::new(
325            Arc::clone(&executor),
326            Arc::clone(&clock),
327        ));
328        let sources = Arc::new(
329            TerminalSourceSpawner::new(shared.clone(), Arc::clone(&executor), fallback)
330                .map_err(Error::Terminal)?,
331        );
332        let presentation = build_presentation(shared, context, Arc::clone(&sources))?;
333        let core = RuntimeCore::new(application, executor, clock, sources, presentation)
334            .map_err(runtime_error)?;
335        core.run().await.map_err(runtime_error)
336    });
337
338    finish_with_restore(runtime_result, session.restore())
339}
340
341struct BackgroundRuntime(Option<tokio::runtime::Runtime>);
342
343impl BackgroundRuntime {
344    fn new() -> Result<Self, Error> {
345        tokio::runtime::Builder::new_current_thread()
346            .enable_all()
347            .build()
348            .map(|runtime| Self(Some(runtime)))
349            .map_err(Error::Runtime)
350    }
351
352    fn handle(&self) -> &tokio::runtime::Handle {
353        self.0.as_ref().expect("runtime is live").handle()
354    }
355
356    fn block_on<F: std::future::Future>(&self, future: F) -> F::Output {
357        self.0.as_ref().expect("runtime is live").block_on(future)
358    }
359}
360
361impl Drop for BackgroundRuntime {
362    fn drop(&mut self) {
363        if let Some(runtime) = self.0.take() {
364            runtime.shutdown_background();
365        }
366    }
367}
368
369#[derive(Clone, Copy, Debug)]
370struct PresentationContext {
371    surface: Surface,
372    #[cfg(feature = "graphics")]
373    graphics: GraphicsSelection,
374}
375
376fn sized_presentation<W, B, Message>(
377    screen: Screen<W>,
378    context: PresentationContext,
379    sources: Arc<TerminalSourceSpawner<SharedTerminal<B>, Message>>,
380) -> BlockingPresentation
381where
382    W: CommandWriter + Send + 'static,
383    B: TerminalBackend + Send + 'static,
384    Message: Send + 'static,
385{
386    #[cfg(feature = "graphics")]
387    {
388        spawn_graphics_terminal(screen, context.graphics, move || {
389            sources.presentation_surface(context.surface)
390        })
391    }
392    #[cfg(not(feature = "graphics"))]
393    {
394        BlockingPresentation::spawn_sized_terminal(screen, move || {
395            sources.presentation_surface(context.surface)
396        })
397    }
398}
399
400/// Runs an application with the production defaults.
401#[cfg(feature = "crossterm")]
402pub fn run<A>(application: A) -> Result<A::Model, Error>
403where
404    A: Application,
405{
406    Runtime::new(application).run()
407}
408
409fn runtime_error(error: RuntimeError<BlockingPresentationError>) -> Error {
410    match error {
411        RuntimeError::Terminal(error) => Error::Terminal(error),
412        RuntimeError::Presentation(error) => Error::Runtime(io::Error::other(error)),
413    }
414}
415
416fn finish_with_restore<Model>(
417    runtime: Result<Model, Error>,
418    restore: io::Result<()>,
419) -> Result<Model, Error> {
420    match (runtime, restore) {
421        (Ok(model), Ok(())) => Ok(model),
422        (Err(error), Ok(())) => Err(error),
423        (Ok(_), Err(error)) => Err(Error::Terminal(error)),
424        (Err(runtime), Err(restore)) => Err(Error::Terminal(io::Error::other(format!(
425            "{runtime}; terminal restoration also failed: {restore}"
426        )))),
427    }
428}
429
430const fn tui_session_options() -> SessionOptions {
431    SessionOptions {
432        raw_mode: true,
433        alternate_screen: true,
434        bracketed_paste: true,
435        focus_change: true,
436        keyboard_enhancement: Some(
437            KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
438                .union(KeyboardEnhancementFlags::REPORT_EVENT_TYPES),
439        ),
440        mouse_capture: false,
441        hide_cursor: true,
442    }
443}
444
445struct SharedTerminal<T> {
446    inner: Arc<Mutex<T>>,
447}
448
449impl<T> Clone for SharedTerminal<T> {
450    fn clone(&self) -> Self {
451        Self {
452            inner: Arc::clone(&self.inner),
453        }
454    }
455}
456
457impl<T> SharedTerminal<T> {
458    fn new(terminal: T) -> Self {
459        Self {
460            inner: Arc::new(Mutex::new(terminal)),
461        }
462    }
463
464    fn lock(&self) -> MutexGuard<'_, T> {
465        self.inner
466            .lock()
467            .unwrap_or_else(std::sync::PoisonError::into_inner)
468    }
469}
470
471impl<T: TerminalOutput> TerminalOutput for SharedTerminal<T> {
472    fn flush(&mut self) -> io::Result<()> {
473        self.lock().flush()
474    }
475}
476
477impl<T: CommandWriter> CommandWriter for SharedTerminal<T> {
478    fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
479        self.lock().write_command(command)
480    }
481}
482
483impl<T: EventSource> EventSource for SharedTerminal<T> {
484    fn read_event(&mut self) -> io::Result<Event> {
485        self.lock().read_event()
486    }
487
488    fn poll_event(&mut self) -> io::Result<Option<Event>> {
489        self.lock().poll_event()
490    }
491
492    fn poll_event_timeout(&mut self, timeout: std::time::Duration) -> io::Result<Option<Event>> {
493        let event = self.lock().poll_event()?;
494        if event.is_none() {
495            // Presentation shares this physical connection. Wait outside the
496            // mutex so an idle input source cannot starve frame output.
497            std::thread::sleep(timeout);
498        }
499        Ok(event)
500    }
501}
502
503impl<T: RawModeControl> RawModeControl for SharedTerminal<T> {
504    fn is_interactive(&self) -> bool {
505        self.lock().is_interactive()
506    }
507
508    fn enable_raw_mode(&mut self) -> io::Result<()> {
509        self.lock().enable_raw_mode()
510    }
511
512    fn disable_raw_mode(&mut self) -> io::Result<()> {
513        self.lock().disable_raw_mode()
514    }
515}
516
517impl<T: TerminalQuery> TerminalQuery for SharedTerminal<T> {
518    fn terminal_size(&mut self) -> io::Result<TerminalSize> {
519        self.lock().terminal_size()
520    }
521
522    fn cursor_position(&mut self) -> io::Result<Position> {
523        self.lock().cursor_position()
524    }
525
526    fn window_size(&mut self) -> io::Result<WindowSize> {
527        self.lock().window_size()
528    }
529
530    fn raw_mode_enabled(&mut self) -> io::Result<bool> {
531        self.lock().raw_mode_enabled()
532    }
533
534    fn terminal_capabilities(&mut self) -> io::Result<TerminalCapabilities> {
535        self.lock().terminal_capabilities()
536    }
537
538    fn terminal_background(&mut self) -> io::Result<Option<TerminalBackground>> {
539        self.lock().terminal_background()
540    }
541}
542
543impl<T: KeyboardEnhancementQuery> KeyboardEnhancementQuery for SharedTerminal<T> {
544    fn supports_keyboard_enhancement(&mut self) -> io::Result<bool> {
545        self.lock().supports_keyboard_enhancement()
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use std::collections::VecDeque;
552    use std::sync::Condvar;
553    use std::time::Duration;
554
555    use urushi::{TextStyle, View};
556    #[cfg(feature = "graphics")]
557    use urushi_terminal::TerminalGraphicsProtocols;
558    use urushi_terminal::{KeyCode, KeyEvent, PixelSize};
559
560    use super::*;
561    use crate::{Effect, Input, Subscription, Surface};
562
563    #[cfg(all(feature = "crossterm", feature = "graphics", unix))]
564    #[test]
565    fn graphics_default_uses_the_query_capable_native_connection() {
566        fn returns_native(
567            _open: fn() -> io::Result<urushi_terminal::backend::native::NativeTerminal>,
568        ) {
569        }
570
571        returns_native(open_default_terminal);
572    }
573
574    fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
575        mutex
576            .lock()
577            .unwrap_or_else(std::sync::PoisonError::into_inner)
578    }
579
580    #[derive(Default)]
581    struct ObservedTerminal {
582        events: VecDeque<Event>,
583        event_delay_polls: usize,
584        raw: bool,
585        alternate_screen: bool,
586        bracketed_paste: bool,
587        focus_change: bool,
588        keyboard_enhancement: bool,
589        cursor_visible: bool,
590        flushes: usize,
591        fail_window_query: bool,
592        window_pixels: Option<PixelSize>,
593        capabilities: Option<TerminalCapabilities>,
594    }
595
596    struct FakeTerminal {
597        observed: Arc<Mutex<ObservedTerminal>>,
598        size: TerminalSize,
599    }
600
601    impl TerminalOutput for FakeTerminal {
602        fn flush(&mut self) -> io::Result<()> {
603            lock(&self.observed).flushes += 1;
604            Ok(())
605        }
606    }
607
608    impl CommandWriter for FakeTerminal {
609        fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
610            let mut observed = lock(&self.observed);
611            match command {
612                Command::SetAlternateScreen(enabled) => observed.alternate_screen = enabled,
613                Command::SetBracketedPaste(enabled) => observed.bracketed_paste = enabled,
614                Command::SetFocusReporting(enabled) => observed.focus_change = enabled,
615                Command::PushKeyboardEnhancement(_) => observed.keyboard_enhancement = true,
616                Command::PopKeyboardEnhancement => observed.keyboard_enhancement = false,
617                Command::SetCursorVisible(visible) => observed.cursor_visible = visible,
618                _ => {}
619            }
620            Ok(())
621        }
622    }
623
624    impl EventSource for FakeTerminal {
625        fn read_event(&mut self) -> io::Result<Event> {
626            loop {
627                if let Some(event) = self.poll_event()? {
628                    return Ok(event);
629                }
630                std::thread::yield_now();
631            }
632        }
633
634        fn poll_event(&mut self) -> io::Result<Option<Event>> {
635            let mut observed = lock(&self.observed);
636            if observed.event_delay_polls != 0 {
637                observed.event_delay_polls -= 1;
638                Ok(None)
639            } else {
640                Ok(observed.events.pop_front())
641            }
642        }
643
644        fn poll_event_timeout(&mut self, _timeout: Duration) -> io::Result<Option<Event>> {
645            let event = self.poll_event()?;
646            if event.is_none() {
647                std::thread::sleep(Duration::from_millis(1));
648            }
649            Ok(event)
650        }
651    }
652
653    impl RawModeControl for FakeTerminal {
654        fn is_interactive(&self) -> bool {
655            true
656        }
657
658        fn enable_raw_mode(&mut self) -> io::Result<()> {
659            lock(&self.observed).raw = true;
660            Ok(())
661        }
662
663        fn disable_raw_mode(&mut self) -> io::Result<()> {
664            lock(&self.observed).raw = false;
665            Ok(())
666        }
667    }
668
669    impl TerminalQuery for FakeTerminal {
670        fn terminal_size(&mut self) -> io::Result<TerminalSize> {
671            Ok(self.size)
672        }
673
674        fn cursor_position(&mut self) -> io::Result<Position> {
675            Ok(Position::new(0, 0))
676        }
677
678        fn window_size(&mut self) -> io::Result<WindowSize> {
679            let observed = lock(&self.observed);
680            if observed.fail_window_query {
681                return Err(io::Error::other("window query failed"));
682            }
683            Ok(WindowSize::new(self.size, observed.window_pixels))
684        }
685
686        fn raw_mode_enabled(&mut self) -> io::Result<bool> {
687            Ok(lock(&self.observed).raw)
688        }
689
690        fn terminal_capabilities(&mut self) -> io::Result<TerminalCapabilities> {
691            Ok(lock(&self.observed)
692                .capabilities
693                .unwrap_or_else(TerminalCapabilities::none))
694        }
695    }
696
697    impl KeyboardEnhancementQuery for FakeTerminal {
698        fn supports_keyboard_enhancement(&mut self) -> io::Result<bool> {
699            Ok(true)
700        }
701    }
702
703    struct ExampleApplication;
704
705    #[derive(Debug, Default, PartialEq, Eq)]
706    struct Model {
707        surface: Option<TerminalSize>,
708        effect_completed: bool,
709    }
710
711    enum Message {
712        Input(Input),
713        Surface(Surface),
714        EffectCompleted,
715    }
716
717    impl Application for ExampleApplication {
718        type Model = Model;
719        type Message = Message;
720
721        fn init(&self) -> (Self::Model, Effect<Self::Message>) {
722            (Model::default(), Effect::none())
723        }
724
725        fn update(&self, model: &mut Self::Model, message: Self::Message) -> Effect<Self::Message> {
726            match message {
727                Message::Surface(surface) => {
728                    model.surface = Some(surface.size);
729                    Effect::none()
730                }
731                Message::Input(Input::Key(_)) => Effect::perform(|| Message::EffectCompleted),
732                Message::Input(_) => Effect::none(),
733                Message::EffectCompleted => {
734                    model.effect_completed = true;
735                    Effect::shutdown()
736                }
737            }
738        }
739
740        fn view(&self, model: &Self::Model) -> View {
741            View::text(format!("done={}", model.effect_completed), TextStyle::new())
742        }
743
744        fn subscriptions(&self, _model: &Self::Model) -> Subscription<Self::Message> {
745            Subscription::batch([
746                Subscription::surface(Message::Surface),
747                Subscription::input(Message::Input),
748            ])
749        }
750    }
751
752    struct BlockingApplication {
753        started: std::sync::mpsc::Sender<()>,
754        exited: std::sync::mpsc::Sender<()>,
755        release: Arc<(Mutex<bool>, Condvar)>,
756    }
757
758    enum BlockingMessage {
759        Input,
760        Finished,
761    }
762
763    impl Application for BlockingApplication {
764        type Model = ();
765        type Message = BlockingMessage;
766
767        fn init(&self) -> (Self::Model, Effect<Self::Message>) {
768            let started = self.started.clone();
769            let exited = self.exited.clone();
770            let release = Arc::clone(&self.release);
771            let effect = Effect::perform(move || {
772                started.send(()).expect("test waits for effect start");
773                let (released, wake) = &*release;
774                let mut released = lock(released);
775                while !*released {
776                    released = wake
777                        .wait(released)
778                        .unwrap_or_else(std::sync::PoisonError::into_inner);
779                }
780                exited.send(()).expect("test waits for effect exit");
781                BlockingMessage::Finished
782            });
783            ((), effect)
784        }
785
786        fn update(
787            &self,
788            _model: &mut Self::Model,
789            message: Self::Message,
790        ) -> Effect<Self::Message> {
791            match message {
792                BlockingMessage::Input => Effect::shutdown(),
793                BlockingMessage::Finished => Effect::none(),
794            }
795        }
796
797        fn view(&self, _model: &Self::Model) -> View {
798            View::text("blocking", TextStyle::new())
799        }
800
801        fn subscriptions(&self, _model: &Self::Model) -> Subscription<Self::Message> {
802            Subscription::input(|_| BlockingMessage::Input)
803        }
804    }
805
806    #[test]
807    fn public_runtime_wires_surface_input_effect_and_session_restoration() {
808        let observed = Arc::new(Mutex::new(ObservedTerminal {
809            events: VecDeque::from([Event::Key(KeyEvent::new(KeyCode::Enter))]),
810            event_delay_polls: 0,
811            cursor_visible: true,
812            ..ObservedTerminal::default()
813        }));
814        let size = TerminalSize::new(8, 2);
815        let terminal = FakeTerminal {
816            observed: Arc::clone(&observed),
817            size,
818        };
819
820        let model = Runtime::new(ExampleApplication)
821            .backend(terminal)
822            .run()
823            .expect("runtime completes");
824
825        assert_eq!(
826            model,
827            Model {
828                surface: Some(size),
829                effect_completed: true,
830            }
831        );
832        let observed = lock(&observed);
833        assert!(!observed.raw);
834        assert!(!observed.alternate_screen);
835        assert!(!observed.bracketed_paste);
836        assert!(!observed.focus_change);
837        assert!(!observed.keyboard_enhancement);
838        assert!(observed.cursor_visible);
839        assert!(observed.flushes >= 2);
840    }
841
842    #[cfg(feature = "graphics")]
843    #[test]
844    fn unavailable_explicit_graphics_preference_is_diagnostic_and_restores_the_session() {
845        let observed = Arc::new(Mutex::new(ObservedTerminal {
846            cursor_visible: true,
847            ..ObservedTerminal::default()
848        }));
849        let terminal = FakeTerminal {
850            observed: Arc::clone(&observed),
851            size: TerminalSize::new(8, 2),
852        };
853
854        let error = Runtime::new(ExampleApplication)
855            .backend(terminal)
856            .graphics(GraphicsPreference::Kitty)
857            .run()
858            .expect_err("an unsupported explicit protocol is rejected");
859
860        assert!(
861            error.to_string().contains(
862                "Kitty graphics were requested, but the terminal did not confirm support"
863            )
864        );
865        let observed = lock(&observed);
866        assert!(!observed.raw);
867        assert!(!observed.alternate_screen);
868        assert!(!observed.bracketed_paste);
869        assert!(!observed.focus_change);
870        assert!(!observed.keyboard_enhancement);
871        assert!(observed.cursor_visible);
872    }
873
874    #[cfg(feature = "graphics")]
875    #[test]
876    fn explicit_sixel_rejects_non_uniform_window_pixel_geometry() {
877        let observed = Arc::new(Mutex::new(ObservedTerminal {
878            cursor_visible: true,
879            window_pixels: Some(PixelSize::new(801, 480)),
880            capabilities: Some(
881                TerminalCapabilities::none()
882                    .with_graphics_protocols(TerminalGraphicsProtocols::SIXEL),
883            ),
884            ..ObservedTerminal::default()
885        }));
886        let terminal = FakeTerminal {
887            observed: Arc::clone(&observed),
888            size: TerminalSize::new(80, 24),
889        };
890
891        let error = Runtime::new(ExampleApplication)
892            .backend(terminal)
893            .graphics(GraphicsPreference::Sixel)
894            .run()
895            .expect_err("non-uniform cell pixels cannot support Sixel");
896
897        assert!(error.to_string().contains(
898            "Sixel graphics were requested, but the terminal did not report uniform character-cell pixel geometry"
899        ));
900        let observed = lock(&observed);
901        assert!(!observed.raw);
902        assert!(!observed.alternate_screen);
903        assert!(observed.cursor_visible);
904    }
905
906    #[test]
907    fn shutdown_returns_while_started_blocking_work_finishes_in_the_background() {
908        let observed = Arc::new(Mutex::new(ObservedTerminal {
909            events: VecDeque::from([Event::Key(KeyEvent::new(KeyCode::Enter))]),
910            event_delay_polls: 2,
911            cursor_visible: true,
912            ..ObservedTerminal::default()
913        }));
914        let backend = FakeTerminal {
915            observed: Arc::clone(&observed),
916            size: TerminalSize::new(8, 2),
917        };
918        let (started_tx, started_rx) = std::sync::mpsc::channel();
919        let (exited_tx, exited_rx) = std::sync::mpsc::channel();
920        let release = Arc::new((Mutex::new(false), Condvar::new()));
921        let application = BlockingApplication {
922            started: started_tx,
923            exited: exited_tx,
924            release: Arc::clone(&release),
925        };
926        let (result_tx, result_rx) = std::sync::mpsc::channel();
927
928        std::thread::spawn(move || {
929            let result = Runtime::new(application).backend(backend).run();
930            result_tx.send(result).expect("test waits for run result");
931        });
932
933        started_rx
934            .recv_timeout(Duration::from_secs(1))
935            .expect("blocking effect starts");
936        result_rx
937            .recv_timeout(Duration::from_secs(1))
938            .expect("runtime returns without waiting for blocking effect")
939            .expect("shutdown succeeds");
940        assert!(!lock(&observed).raw);
941
942        let (released, wake) = &*release;
943        *lock(released) = true;
944        wake.notify_all();
945        exited_rx
946            .recv_timeout(Duration::from_secs(1))
947            .expect("background effect can finish after runtime return");
948    }
949
950    #[test]
951    fn setup_query_failure_restores_the_entered_session() {
952        let observed = Arc::new(Mutex::new(ObservedTerminal {
953            cursor_visible: true,
954            fail_window_query: true,
955            ..ObservedTerminal::default()
956        }));
957        let terminal = FakeTerminal {
958            observed: Arc::clone(&observed),
959            size: TerminalSize::new(8, 2),
960        };
961
962        let error = Runtime::new(ExampleApplication)
963            .backend(terminal)
964            .run()
965            .expect_err("window query failure ends setup");
966
967        assert!(error.to_string().contains("window query failed"));
968        let observed = lock(&observed);
969        assert!(!observed.raw);
970        assert!(!observed.alternate_screen);
971        assert!(!observed.bracketed_paste);
972        assert!(!observed.focus_change);
973        assert!(!observed.keyboard_enhancement);
974        assert!(observed.cursor_visible);
975    }
976}