Skip to main content

ansiq_surface/
session.rs

1use std::io::{self, Stdout, Write};
2
3use crate::{TerminalCapabilities, detect_terminal_capabilities};
4use ansiq_core::HistoryEntry;
5use ansiq_render::render_history_entries;
6use crossterm::{
7    cursor, execute,
8    terminal::{self, ClearType, ScrollUp},
9};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct InlineReservePlan {
13    pub origin_y: u16,
14    pub scroll_up: u16,
15}
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct Viewport {
19    pub width: u16,
20    pub height: u16,
21    pub origin_y: u16,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum ViewportPolicy {
26    PreserveVisible,
27    ReservePreferred(u16),
28    ReserveFitContent { min: u16, max: u16 },
29}
30
31impl ViewportPolicy {
32    pub fn requested_height(self, current_height: u16, content_height: u16) -> Option<u16> {
33        match self {
34            Self::PreserveVisible => None,
35            Self::ReservePreferred(_) if content_height > current_height => Some(content_height),
36            Self::ReservePreferred(_) => None,
37            Self::ReserveFitContent { min, max } => {
38                let target = content_height.clamp(min.max(1), max.max(min.max(1)));
39                (target != current_height).then_some(target)
40            }
41        }
42    }
43
44    pub fn resolve(
45        self,
46        size: (u16, u16),
47        cursor_y: u16,
48        capabilities: TerminalCapabilities,
49    ) -> Viewport {
50        let (width, height) = normalize_terminal_size(size);
51        let cursor_y = cursor_y.min(height.saturating_sub(1));
52
53        match self {
54            Self::PreserveVisible => Viewport {
55                width,
56                height: height.saturating_sub(cursor_y).max(1),
57                origin_y: cursor_y,
58            },
59            Self::ReservePreferred(preferred_height) if capabilities.supports_inline_reserve => {
60                let plan = inline_reserve_plan(height, cursor_y, preferred_height);
61                Viewport {
62                    width,
63                    height: preferred_height.clamp(1, height),
64                    origin_y: plan.origin_y,
65                }
66            }
67            Self::ReservePreferred(_) => Viewport {
68                width,
69                height: height.saturating_sub(cursor_y).max(1),
70                origin_y: cursor_y,
71            },
72            Self::ReserveFitContent { min, .. } if capabilities.supports_inline_reserve => {
73                let plan = inline_reserve_plan(height, cursor_y, min.max(1));
74                Viewport {
75                    width,
76                    height: min.clamp(1, height),
77                    origin_y: plan.origin_y,
78                }
79            }
80            Self::ReserveFitContent { .. } => Viewport {
81                width,
82                height: height.saturating_sub(cursor_y).max(1),
83                origin_y: cursor_y,
84            },
85        }
86    }
87}
88
89pub fn initial_viewport_plan(
90    policy: ViewportPolicy,
91    size: (u16, u16),
92    cursor_y: u16,
93    capabilities: TerminalCapabilities,
94) -> (Viewport, Option<InlineReservePlan>) {
95    let viewport = policy.resolve(size, cursor_y, capabilities);
96    let reserve_plan = match policy {
97        ViewportPolicy::ReservePreferred(preferred_height)
98            if capabilities.supports_inline_reserve =>
99        {
100            let (_, terminal_height) = normalize_terminal_size(size);
101            Some(inline_reserve_plan(
102                terminal_height,
103                cursor_y,
104                preferred_height,
105            ))
106        }
107        ViewportPolicy::ReserveFitContent { min, .. } if capabilities.supports_inline_reserve => {
108            let (_, terminal_height) = normalize_terminal_size(size);
109            Some(inline_reserve_plan(terminal_height, cursor_y, min.max(1)))
110        }
111        _ => None,
112    };
113
114    (viewport, reserve_plan)
115}
116
117pub fn reanchor_viewport_plan(
118    policy: ViewportPolicy,
119    size: (u16, u16),
120    cursor_y: u16,
121    current: Viewport,
122    capabilities: TerminalCapabilities,
123) -> (Viewport, Option<InlineReservePlan>) {
124    let (width, height) = normalize_terminal_size(size);
125    let cursor_y = cursor_y.min(height.saturating_sub(1));
126
127    match policy {
128        ViewportPolicy::PreserveVisible => (
129            Viewport {
130                width,
131                height: height.saturating_sub(cursor_y).max(1),
132                origin_y: cursor_y,
133            },
134            None,
135        ),
136        ViewportPolicy::ReservePreferred(preferred_height)
137            if capabilities.supports_inline_reserve =>
138        {
139            let target_height = preferred_height.clamp(1, height);
140            let plan = inline_reserve_plan(height, cursor_y, target_height);
141            (
142                Viewport {
143                    width,
144                    height: target_height,
145                    origin_y: plan.origin_y,
146                },
147                Some(plan),
148            )
149        }
150        ViewportPolicy::ReserveFitContent { min, max } if capabilities.supports_inline_reserve => {
151            let target_height = current
152                .height
153                .clamp(min.max(1), max.max(min.max(1)))
154                .clamp(1, height);
155            let plan = inline_reserve_plan(height, cursor_y, target_height);
156            (
157                Viewport {
158                    width,
159                    height: target_height,
160                    origin_y: plan.origin_y,
161                },
162                Some(plan),
163            )
164        }
165        ViewportPolicy::ReservePreferred(_) | ViewportPolicy::ReserveFitContent { .. } => (
166            Viewport {
167                width,
168                height: height.saturating_sub(cursor_y).max(1),
169                origin_y: cursor_y,
170            },
171            None,
172        ),
173    }
174}
175
176pub fn resize_viewport_plan(
177    policy: ViewportPolicy,
178    size: (u16, u16),
179    current: Viewport,
180    capabilities: TerminalCapabilities,
181) -> Viewport {
182    let (width, height) = normalize_terminal_size(size);
183
184    match policy {
185        ViewportPolicy::PreserveVisible => Viewport {
186            width,
187            height: height.saturating_sub(current.origin_y).max(1),
188            origin_y: current.origin_y.min(height.saturating_sub(1)),
189        },
190        ViewportPolicy::ReservePreferred(preferred_height)
191            if capabilities.supports_inline_reserve =>
192        {
193            fit_viewport_height(
194                Viewport { width, ..current },
195                height,
196                current.height.max(preferred_height),
197            )
198        }
199        ViewportPolicy::ReserveFitContent { min, max } if capabilities.supports_inline_reserve => {
200            let target_height = current.height.clamp(min.max(1), max.max(min.max(1)));
201            fit_viewport_height(Viewport { width, ..current }, height, target_height)
202        }
203        ViewportPolicy::ReservePreferred(_) | ViewportPolicy::ReserveFitContent { .. } => {
204            Viewport {
205                width,
206                height: height.saturating_sub(current.origin_y).max(1),
207                origin_y: current.origin_y.min(height.saturating_sub(1)),
208            }
209        }
210    }
211}
212
213pub fn fit_viewport_height(
214    current: Viewport,
215    terminal_height: u16,
216    preferred_height: u16,
217) -> Viewport {
218    let terminal_height = terminal_height.max(1);
219    let target_height = preferred_height.clamp(1, terminal_height);
220
221    if target_height <= current.height {
222        Viewport {
223            width: current.width,
224            height: target_height,
225            origin_y: current
226                .origin_y
227                .min(terminal_height.saturating_sub(target_height)),
228        }
229    } else {
230        let plan = inline_reserve_plan(terminal_height, current.origin_y, target_height);
231        Viewport {
232            width: current.width,
233            height: target_height,
234            origin_y: plan.origin_y,
235        }
236    }
237}
238
239pub fn cursor_y_after_history_entries(origin_y: u16, rendered_rows: u16) -> u16 {
240    origin_y.saturating_add(rendered_rows)
241}
242
243pub fn safe_exit_row(exit_row: u16, size: (u16, u16)) -> u16 {
244    let (_, height) = normalize_terminal_size(size);
245    exit_row.min(height.saturating_sub(1))
246}
247
248pub struct TerminalSession {
249    stdout: Stdout,
250    capabilities: TerminalCapabilities,
251    viewport: Viewport,
252    exit_row: u16,
253}
254
255impl TerminalSession {
256    pub fn enter(policy: ViewportPolicy) -> io::Result<Self> {
257        let (_, cursor_y) = cursor::position()?;
258        terminal::enable_raw_mode()?;
259
260        let mut stdout = io::stdout();
261        execute!(stdout, cursor::Hide)?;
262
263        let capabilities = detect_terminal_capabilities();
264        let size = terminal::size()?;
265        let (viewport, reserve_plan) = initial_viewport_plan(policy, size, cursor_y, capabilities);
266        if let Some(plan) = reserve_plan {
267            // ReservePreferred is the app-like mode: we may scroll to make room,
268            // but that policy now lives entirely in the surface layer.
269            if plan.scroll_up > 0 {
270                execute!(stdout, ScrollUp(plan.scroll_up))?;
271            }
272        }
273
274        Ok(Self {
275            stdout,
276            capabilities,
277            viewport,
278            exit_row: viewport
279                .origin_y
280                .saturating_add(viewport.height.saturating_sub(1)),
281        })
282    }
283
284    pub fn size(&self) -> io::Result<(u16, u16)> {
285        terminal::size()
286    }
287
288    pub fn capabilities(&self) -> TerminalCapabilities {
289        self.capabilities
290    }
291
292    pub fn origin_y(&self) -> u16 {
293        self.viewport.origin_y
294    }
295
296    pub fn viewport(&self) -> Viewport {
297        self.viewport
298    }
299
300    pub fn resize(&mut self, policy: ViewportPolicy, size: (u16, u16)) -> Viewport {
301        self.viewport = resize_viewport_plan(policy, size, self.viewport, self.capabilities);
302        self.exit_row = self
303            .viewport
304            .origin_y
305            .saturating_add(self.viewport.height.saturating_sub(1));
306        self.viewport
307    }
308
309    pub fn reserve_inline_space(&mut self, preferred_height: u16) -> io::Result<()> {
310        let (_, terminal_height) = self.size()?;
311        let old_bottom = self
312            .viewport
313            .origin_y
314            .saturating_add(self.viewport.height.saturating_sub(1));
315        let target_viewport = fit_viewport_height(self.viewport, terminal_height, preferred_height);
316        let plan = inline_reserve_plan(
317            terminal_height,
318            self.viewport.origin_y,
319            target_viewport.height,
320        );
321        if plan.scroll_up > 0 {
322            execute!(self.stdout, ScrollUp(plan.scroll_up))?;
323        }
324        let clear_from = self.viewport.origin_y.min(target_viewport.origin_y);
325        self.viewport = target_viewport;
326        let new_bottom = self
327            .viewport
328            .origin_y
329            .saturating_add(self.viewport.height.saturating_sub(1));
330        if self.viewport.origin_y != clear_from || new_bottom < old_bottom {
331            execute!(
332                self.stdout,
333                cursor::MoveTo(0, clear_from),
334                terminal::Clear(ClearType::FromCursorDown)
335            )?;
336        }
337        self.exit_row = self
338            .viewport
339            .origin_y
340            .saturating_add(self.viewport.height.saturating_sub(1));
341        Ok(())
342    }
343
344    pub fn commit_history_blocks(
345        &mut self,
346        blocks: Vec<HistoryEntry>,
347        policy: ViewportPolicy,
348    ) -> io::Result<Viewport> {
349        if blocks.is_empty() {
350            return Ok(self.viewport);
351        }
352
353        execute!(
354            self.stdout,
355            cursor::MoveTo(0, self.viewport.origin_y),
356            terminal::Clear(ClearType::FromCursorDown)
357        )?;
358
359        let rendered_rows = render_history_entries(&mut self.stdout, &blocks, self.viewport.width)?;
360        let cursor_y = cursor_y_after_history_entries(self.viewport.origin_y, rendered_rows);
361        self.reanchor(policy, cursor_y)
362    }
363
364    pub fn reanchor(&mut self, policy: ViewportPolicy, cursor_y: u16) -> io::Result<Viewport> {
365        let size = self.size()?;
366        let (viewport, reserve_plan) =
367            reanchor_viewport_plan(policy, size, cursor_y, self.viewport, self.capabilities);
368        if let Some(plan) = reserve_plan {
369            if self.capabilities.supports_inline_reserve && plan.scroll_up > 0 {
370                execute!(self.stdout, ScrollUp(plan.scroll_up))?;
371            }
372        }
373        self.viewport = viewport;
374        self.exit_row = self
375            .viewport
376            .origin_y
377            .saturating_add(self.viewport.height.saturating_sub(1));
378        Ok(self.viewport)
379    }
380
381    pub fn set_exit_row(&mut self, row: u16) {
382        self.exit_row = row;
383    }
384
385    pub fn write_ansi(&mut self, output: &str) -> io::Result<()> {
386        self.stdout.write_all(output.as_bytes())?;
387        self.stdout.flush()
388    }
389}
390
391impl Drop for TerminalSession {
392    fn drop(&mut self) {
393        let exit_row = terminal::size()
394            .map(|size| safe_exit_row(self.exit_row, size))
395            .unwrap_or(self.exit_row);
396        let _ = execute!(self.stdout, cursor::MoveTo(0, exit_row), cursor::Show);
397        let _ = writeln!(self.stdout);
398        let _ = self.stdout.flush();
399        let _ = terminal::disable_raw_mode();
400    }
401}
402
403pub type TerminalGuard = TerminalSession;
404
405pub fn inline_reserve_plan(
406    terminal_height: u16,
407    cursor_y: u16,
408    preferred_height: u16,
409) -> InlineReservePlan {
410    // Keep the viewport anchored to the launch cursor when possible. Only ask the
411    // terminal to scroll when a preferred inline working height cannot fit below it.
412    let terminal_height = terminal_height.max(1);
413    let cursor_y = cursor_y.min(terminal_height.saturating_sub(1));
414    let target_height = preferred_height.clamp(1, terminal_height);
415    let remaining = terminal_height.saturating_sub(cursor_y);
416
417    if remaining >= target_height {
418        InlineReservePlan {
419            origin_y: cursor_y,
420            scroll_up: 0,
421        }
422    } else {
423        let scroll_up = target_height - remaining;
424        InlineReservePlan {
425            origin_y: cursor_y.saturating_sub(scroll_up),
426            scroll_up,
427        }
428    }
429}
430
431fn normalize_terminal_size((width, height): (u16, u16)) -> (u16, u16) {
432    let width = if width == 0 { 80 } else { width };
433    let height = if height == 0 { 24 } else { height };
434    (width, height)
435}