altui 0.2.0

A state-driven TUI runtime built on top of altui-core
Documentation
use altui_core::{backend::Backend, layout::Rect, Terminal};
use crossterm::event::Event;

use crate::{app_handler::AppHandler, ctxstore::CtxStore, ViewFactory};

/// The main runtime coordinator of `altui`.
///
/// `ViewLoop` owns the frame cycle and ties together:
///
/// - event acquisition
/// - optional built-in navigation
/// - dispatch to active or hovered views
/// - external event handling
/// - view logic
/// - area assignment
/// - rendering
///
/// A single `ViewLoop` usually drives the whole application. It runs:
///
/// - continuously while `state.running()` returns `true`, or
/// - one frame at a time when [`external_loop`](Self::external_loop) is enabled
///
/// # Runtime Model
///
/// Each frame follows the same structure:
///
/// 1. Read the next event, unless the runtime intentionally skips it.
/// 2. Optionally run built-in navigation.
/// 3. Dispatch the event with the priority `active -> hover -> event_handler`.
/// 4. Compute areas through the `areas` closure.
/// 5. Run `logic` for the remaining views and render visible ones.
///
/// The order in which views are inserted into [`ViewFactory`] defines:
///
/// - which [`ViewCtx`](crate::ViewCtx) belongs to which view
/// - which area is assigned to which view
/// - render order
///
/// Because of that, the `areas` closure must always provide rectangles in the
/// same order as the `views` closure inserted them.
///
/// See the complete examples:
///
/// - `flex_buttons`: <https://altlinux.space/writers/altui/src/branch/main/examples/flex_buttons>
/// - `simple_pages`: <https://altlinux.space/writers/altui/src/branch/main/examples/simple_pages>
/// - `popup`: <https://altlinux.space/writers/altui/src/branch/main/examples/popup>
pub struct ViewLoop<'a, State, B: Backend> {
    pub(crate) factory: ViewFactory<'a, State>,
    terminal: &'a mut Terminal<B>,
    external_loop: bool,
}

impl<'a, State: AppHandler, B> ViewLoop<'a, State, B>
where
    B: Backend,
{
    /// Creates a new `ViewLoop`.
    ///
    /// Automatically enables terminal autoresize handling.
    ///
    /// # Errors
    ///
    /// Returns an error if terminal autoresize initialization fails.
    pub fn new(terminal: &'a mut Terminal<B>) -> std::io::Result<Self> {
        terminal.autoresize()?;
        Ok(Self {
            factory: ViewFactory::new(),
            terminal,
            external_loop: false,
        })
    }

    /// Sets the click delay (in milliseconds).
    ///
    /// This value is used internally when handling mouse click timing
    /// and event skipping logic.
    ///
    /// Useful when tuning double-click or interaction responsiveness.
    pub fn set_click_time(&mut self, millis: u64) -> &mut Self {
        self.factory.click_time = millis;
        self
    }

    /// Enables external loop mode.
    ///
    /// In this mode [`run`](Self::run) processes exactly one frame and returns,
    /// instead of looping until `state.running()` becomes `false`.
    ///
    /// This is useful when integrating `altui` into a custom outer loop. The
    /// caller keeps ownership of the application schedule and repeatedly calls
    /// `run` whenever another frame should be processed.
    pub fn external_loop(&mut self) -> &mut Self {
        self.external_loop = true;
        self
    }

    /// Enables or disables vertical navigation in built-in navigation mode.
    ///
    /// When enabled, [`basic_navigation`](Self::basic_navigation) also reacts to:
    ///
    /// - `Up` / `k`
    /// - `Down` / `j`
    ///
    /// This does not calculate geometry-based movement. Instead, it moves
    /// through the interactive view list using the vertical step configured by
    /// [`ViewFactory::set_vscroll`](crate::ViewFactory::set_vscroll).
    ///
    /// This mode is demonstrated in:
    /// <https://altlinux.space/writers/altui/src/branch/main/examples/simple_buttons_with_vscroll>
    pub fn vscroll(&mut self, on: bool) -> &mut Self {
        self.factory.vscroll = on;
        self
    }

    /// Enables or disables the built-in keyboard and mouse navigation layer.
    ///
    /// When enabled, `ViewLoop` performs the default navigation before any
    /// event reaches your views or `event_handler`. The built-in logic handles:
    ///
    /// - hover movement with `Tab`, `BackTab`, `Left`, `Right`, `h`, `l`
    /// - optional vertical movement with `Up`, `Down`, `j`, `k`
    /// - activation with `Enter`, `i`, `Space`
    /// - deactivation with `Esc`
    /// - mouse hover updates
    /// - mouse click activation
    ///
    /// If disabled, the runtime still delivers events, but navigation becomes
    /// fully manual. Many [`ViewFactory`] methods are public for that reason:
    /// you can reproduce or replace the built-in policy inside the
    /// `event_handler` closure by calling methods such as
    /// [`first`](crate::ViewFactory::first), [`next`](crate::ViewFactory::next),
    /// [`prev`](crate::ViewFactory::prev),
    /// [`mouse_set_hover`](crate::ViewFactory::mouse_set_hover), and
    /// [`set_last_active_action_for`](crate::ViewFactory::set_last_active_action_for).
    ///
    /// The built-in implementation is the default policy, not the only one.
    pub fn basic_navigation(&mut self, navigation: bool) -> &mut Self {
        self.factory.basic_navigation = navigation;
        self
    }

    /// Starts the event loop.
    ///
    /// # Parameters
    ///
    /// - `state`: global application state implementing [`AppHandler`]
    /// - `views`: closure used to insert views into [`ViewFactory`]
    /// - `areas`: closure used to compute layout areas
    /// - `event`: event reader closure
    /// - `event_handler`: external event handler executed before view dispatch
    ///
    /// # Looping Behavior
    ///
    /// Without [`external_loop`](Self::external_loop), `run` keeps processing
    /// frames while `state.running()` is `true`. With `external_loop`, it
    /// processes exactly one frame.
    ///
    /// # Dispatch Order
    ///
    /// Each frame uses the following event priority:
    ///
    /// 1. `active`
    /// 2. `hover`
    /// 3. `event_handler`
    ///
    /// More precisely:
    ///
    /// - If an active view exists, the runtime sends the event to that view
    ///   first through [`View::on_event`](crate::View::on_event), then runs its
    ///   `logic`.
    /// - If the active view is `selectable`, its active state is cleared before
    ///   `logic`, so `logic` can immediately observe the widget as hovered
    ///   again. This is what makes one-shot actions such as page buttons in
    ///   `simple_pages` or layout toggles in `flex_buttons` convenient.
    /// - If there is no active view but there is a hovered view, the runtime
    ///   calls `event_handler` first, then sends the event to the hovered view,
    ///   then runs its `logic`.
    /// - If neither active nor hover is present, only `event_handler` runs.
    ///
    /// After that, the runtime:
    ///
    /// 1. computes areas with the `areas` closure
    /// 2. checks that the number of views, contexts, and assigned rectangles
    ///    still match
    /// 3. runs `logic` for the remaining views
    /// 4. renders visible views in insertion order
    ///
    /// # Why `event_handler` Still Matters
    ///
    /// `event_handler` is the place for app-level behavior that should remain
    /// outside individual views: quitting the app, opening a popup layer,
    /// installing custom navigation, or coordinating several views at once.
    ///
    /// The `popup` example uses it for layer switching, while still letting the
    /// popup view react to `Enter` inside [`View::on_event`](crate::View::on_event).
    ///
    /// # Panics
    ///
    /// Panics if:
    ///
    /// - The number of views,
    /// - The number of contexts,
    /// - The number of assigned areas
    ///
    /// are not synchronized.
    ///
    /// This indicates that the `areas` closure does not match
    /// the order or number of inserted views.
    pub fn run(
        &mut self,
        state: &mut State,
        views: impl Fn(&mut ViewFactory<'a, State>, &mut State),
        areas: impl Fn(Rect, &mut CtxStore, &mut State),
        mut event: impl FnMut() -> std::io::Result<Event>,
        event_handler: impl Fn(&mut State, &mut ViewFactory<'a, State>),
    ) {
        if self.factory.first_iteration {
            views(&mut self.factory, state);
        }
        match self.external_loop {
            true => self.run_once(state, &areas, &mut event, &event_handler),
            false => {
                while state.running() {
                    self.run_once(state, &areas, &mut event, &event_handler)
                }
            }
        }
    }

    fn run_once(
        &mut self,
        state: &mut State,
        areas: &impl Fn(Rect, &mut CtxStore, &mut State),
        event: &mut impl FnMut() -> std::io::Result<Event>,
        event_handler: &impl Fn(&mut State, &mut ViewFactory<'a, State>),
    ) {
        self.factory.event = if !self.factory.skip_event {
            match event() {
                Ok(event) => {
                    tracing::trace!(
                        "Altui: hover is {:?}, active is: {:?}\nEvent: {:?}",
                        state.hover(),
                        state.active(),
                        event
                    );
                    Some(event)
                }
                Err(e) => {
                    tracing::trace!("Altui: {e}");
                    None
                }
            }
        } else {
            if !self.factory.first_iteration {
                std::thread::sleep(std::time::Duration::from_millis(self.factory.click_time));
            } else {
                self.factory.first_iteration = false;
            }
            self.factory.skip_event = false;
            None
        };

        self.terminal
            .draw(|frame| {
                self.factory.set_current_area(frame.size());

                if !self.factory.skip_event {
                    if self.factory.basic_navigation {
                        self.factory.navigation(state);
                    }
                }

                if let Some(id) = state.active().or(self.factory.take_last_active()) {
                    let (i, _, active_view) = self.factory.views.get_full_mut(&id).unwrap();
                    let active_ctx = &mut self.factory.contexts.contexts[i];
                    if active_ctx.is_visible() && active_ctx.is_interactive() {
                        // В первую очередь реагируем на event виджетом
                        if let Some(event) = self.factory.event.take() {
                            active_view.on_event(event, active_ctx, state);
                        }
                        // Сбрасываем active, чтобы внутри logic запустилась логика hover
                        if active_ctx.is_selectable() {
                            active_ctx.reset_active();
                        }
                        active_view.logic(active_ctx, state);
                    } else {
                        active_ctx.reset_hover();
                    }
                // Во вторую очередь выполняем действие активного виджета
                // Если виджет просто selectable, сбрасываем активное состояние
                } else if let Some(id) = state.hover() {
                    event_handler(state, &mut self.factory);
                    let (i, _, hover_view) = self.factory.views.get_full_mut(&id).unwrap();
                    let hover_ctx = &mut self.factory.contexts.contexts[i];
                    if hover_ctx.is_visible() && hover_ctx.is_interactive() {
                        // В первую очередь реагируем на event виджетом
                        if let Some(event) = self.factory.event.take() {
                            hover_view.on_event(event, hover_ctx, state);
                        }
                        hover_view.logic(hover_ctx, state);
                    } else {
                        hover_ctx.reset_hover();
                    }
                } else {
                    event_handler(state, &mut self.factory);
                }

                // Calculate areas (and scroll by offset after event call)
                areas(frame.size(), &mut self.factory.contexts, state);
                if self.factory.contexts.current_area != self.factory.views.len()
                    || self.factory.contexts.current_area != self.factory.contexts.contexts.len()
                {
                    panic!(
                        "View, states and areas vectors are  not sync: views {}, states {}, areas {}",
                        self.factory.views.len(),
                        self.factory.contexts.contexts.len(),
                        self.factory.contexts.current_area
                    );
                } else {
                    // Reset areas number in ViewsContexts
                    self.factory.contexts.current_area = 0;
                }

                // Reset last active action if it was skipped on the previous step
                let _ = self.factory.take_last_active();
                self.factory.iteration(frame, state);

                // Reset index for AreaCache counter
                self.factory.contexts.layout_cache.current_split = 0;
            })
            .unwrap();
    }
}