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 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 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}