Skip to main content

teksilo_widgets/code_editor/
log_view.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`LogView`] — a read-only, append-only, tail-following streaming view.
5//!
6//! The third face of the editor core, and the one that is *not* an editor. A
7//! program writes to it, forever, faster than a person types; a person only
8//! reads, scrolls, selects, and copies. That inversion is why it does not share
9//! the editor's frame step — the details are in [`log_stream`]
10//! — but it *is* the same [`CodeEditorState`], so
11//! selection, copy, scrolling, theming, and accessibility come for free and
12//! cannot drift from the editors'.
13//!
14//! What it adds over the read-only code viewer:
15//!
16//! - **Scale.** Only the visible rows are ever laid out, so a 100 000-line
17//!   buffer costs a viewport's worth of memory, not the document's. Feed it a
18//!   `scrollback_limit` to bound the raw text too.
19//! - **Following the tail.** New lines stick the view to the bottom *while it is
20//!   already at the bottom*; scroll up to read history and it pauses, scroll back
21//!   and it resumes — derived from position, never a fight.
22//! - **Severity colour.** An injected classifier paints a line by what it is (an
23//!   error line red). Language-agnostic: the view colours a line, the
24//!   application decides what an error looks like.
25
26use std::cell::Cell;
27use std::rc::Rc;
28
29use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
30use teksilo_core::accessibility::AccessNodeBuilder;
31use teksilo_core::binding::BindingLevel;
32use teksilo_core::build_context::BuildContext;
33use teksilo_core::widget::{
34    CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
35};
36use teksilo_core::widget_builder::HandlerSet;
37use teksilo_core::widget_id::WidgetId;
38use teksilo_text::text_document::TextDocument;
39use teksilo_tokens::Color;
40
41use super::log_stream::{self, LogStreamState};
42use super::policy::CODE_READ_ONLY_PRESET;
43use super::state::{CodeEditorState, SharedState};
44use super::{adopt_shared_typesetter, construct};
45use crate::common::scroll::OverscrollBehavior;
46use crate::rich_text::ScrollPolicy;
47use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVariant};
48
49/// Overlay scrollbar thickness, matching the code editor and `ScrollArea`.
50const SCROLLBAR_THICKNESS: f32 = 12.0;
51
52/// A read-only, append-only, tail-following log / console view.
53///
54/// Construct with [`LogView::new`], feed it with a [`LogViewHandle`] from
55/// [`handle`](LogView::handle), and add it to the tree. It owns an internal
56/// document; the application never touches one directly, it only appends lines.
57pub struct LogView {
58    state: SharedState,
59    v_scroll_policy: ScrollPolicy,
60    h_scroll_policy: ScrollPolicy,
61    overscroll_behavior: OverscrollBehavior,
62
63    body_id: Option<WidgetId>,
64    v_scrollbar_id: Option<WidgetId>,
65    h_scrollbar_id: Option<WidgetId>,
66    v_scrollbar_bounds: Rc<Cell<Rect>>,
67    h_scrollbar_bounds: Rc<Cell<Rect>>,
68}
69
70impl std::fmt::Debug for LogView {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("LogView").finish_non_exhaustive()
73    }
74}
75
76impl Default for LogView {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl LogView {
83    /// A fresh, empty log view: read-only, no caret, no wrapping, following the
84    /// tail, unbounded. Attach a [`handle`](LogView::handle) and append to it.
85    pub fn new() -> Self {
86        let state = construct(
87            TextDocument::new(),
88            CODE_READ_ONLY_PRESET,
89            super::config::CodeConfig::default(),
90            teksilo_text::WrapMode::None,
91        );
92        state.borrow_mut().log = Some(LogStreamState::new());
93        Self {
94            state,
95            v_scroll_policy: ScrollPolicy::Auto,
96            h_scroll_policy: ScrollPolicy::Auto,
97            overscroll_behavior: OverscrollBehavior::default(),
98            body_id: None,
99            v_scrollbar_id: None,
100            h_scrollbar_id: None,
101            v_scrollbar_bounds: Rc::new(Cell::new(Rect::ZERO)),
102            h_scrollbar_bounds: Rc::new(Cell::new(Rect::ZERO)),
103        }
104    }
105
106    /// Whether new lines stick the view to the bottom when it is already there
107    /// (default `true`). Off makes the view hold position while it grows.
108    pub fn follow_tail(self, follow: bool) -> Self {
109        if let Some(log) = self.state.borrow_mut().log.as_mut() {
110            log.follow_enabled = follow;
111        }
112        self
113    }
114
115    /// Cap the retained lines: older lines beyond `limit` are evicted from the
116    /// front. Unset (the default) keeps every line — *memory* stays flat in the
117    /// line count, since only the visible window is ever shaped, but the raw text
118    /// accumulates in the document and each append stays linear in the document's
119    /// size. A genuinely unbounded, sustained high-rate producer should therefore
120    /// set a limit; a bounded or bursty one need not. The cap is soft: eviction
121    /// is batched, so the count can briefly exceed `limit` (by a band that scales
122    /// down with the cap).
123    pub fn scrollback_limit(self, limit: usize) -> Self {
124        if let Some(log) = self.state.borrow_mut().log.as_mut() {
125            log.scrollback_limit = Some(limit);
126        }
127        self
128    }
129
130    /// Colour each line by what it is: the classifier maps a line's text to a
131    /// colour, or `None` to leave it in the default colour. The view knows how
132    /// to colour a line; the application knows what an error line looks like.
133    pub fn severity_highlighter(self, classify: impl Fn(&str) -> Option<Color> + 'static) -> Self {
134        if let Some(log) = self.state.borrow_mut().log.as_mut() {
135            log.severity = Some(Rc::new(classify));
136        }
137        self
138    }
139
140    /// Whether appended lines are announced to assistive technology (default
141    /// `false`). Off is the right default: a live region is correct for a
142    /// handful of meaningful events and hostile for a build log at fifty lines a
143    /// second. The application says which it is.
144    pub fn announce_appends(self, announce: bool) -> Self {
145        self.state.borrow_mut().announce_appends = announce;
146        self
147    }
148
149    /// Fallback font family. A log reads best monospaced, so columns align; pass
150    /// a monospace family here.
151    pub fn font_family(self, family: impl Into<String>) -> Self {
152        {
153            let mut st = self.state.borrow_mut();
154            let mut d = st.engine.typography_defaults().clone();
155            d.font_family = Some(family.into());
156            st.engine.set_typography_defaults(d);
157            st.needs_full_layout = true;
158        }
159        self
160    }
161
162    /// Whether the view grows text with the global accessibility text scale
163    /// (default `true`).
164    pub fn follow_text_scale(self, follow: bool) -> Self {
165        self.state.borrow_mut().follow_text_scale = follow;
166        self
167    }
168
169    /// Vertical scrollbar policy (default `Auto`).
170    pub fn v_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
171        self.v_scroll_policy = policy;
172        self
173    }
174
175    /// Horizontal scrollbar policy (default `Auto`).
176    pub fn h_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
177        self.h_scroll_policy = policy;
178        self
179    }
180
181    /// Override the background colour (accepts a `Color`, theme role, or
182    /// `Signal`). Default tracks the theme's `editor_bg`.
183    pub fn background(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
184        self.state.borrow_mut().background_prop = Some(color.into());
185        self
186    }
187
188    /// Override the default text colour. Per-line severity colours (from
189    /// [`severity_highlighter`](Self::severity_highlighter)) still win.
190    pub fn text_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
191        self.state.borrow_mut().text_color_prop = Some(color.into());
192        self
193    }
194
195    /// Override the selection colour.
196    pub fn selection_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
197        self.state.borrow_mut().selection_color_prop = Some(color.into());
198        self
199    }
200
201    /// A cloneable handle to append to the view and drive it from anywhere.
202    pub fn handle(&self) -> LogViewHandle {
203        LogViewHandle {
204            state: self.state.clone(),
205        }
206    }
207}
208
209impl Widget for LogView {
210    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
211        adopt_shared_typesetter(&self.state, ctx);
212
213        {
214            let mut st = self.state.borrow_mut();
215            st.frame_request = Some(ctx.frame_request_handle());
216            st.frame_wake_at = Some(ctx.wake_at_handle());
217            st.self_id = Some(ctx.self_id());
218        }
219        // Same dormancy discipline as `CodeEditor` / `RichTextEditor`: a log
220        // view parked in a non-selected Switcher branch must not keep the
221        // event loop awake via its streaming tick or window-active re-arm.
222        let activation = ctx.activation_signal(ctx.self_id());
223        if activation.get() {
224            ctx.request_frame();
225        }
226
227        {
228            let state = self.state.clone();
229            ctx.effect(&activation, move |&active| {
230                if active {
231                    // **Re-activated** — re-arm the frame loop. The dormant branch
232                    // below does not re-arm `frame_request` and the frame-tick
233                    // effect (the streaming step: drain, evict, window, follow) is
234                    // skipped entirely while dormant, so without this a log pane
235                    // that is hidden and shown again never resumes streaming. Same
236                    // defect and same fix as the editors.
237                    let st = state.borrow();
238                    if let Some(handle) = &st.frame_request {
239                        handle.set(true);
240                    }
241                    return;
242                }
243                let mut st = state.borrow_mut();
244                if st.has_focus {
245                    st.has_focus = false;
246                    st.focus_signal.set_if_changed(false);
247                }
248            });
249        }
250
251        // Frame-tick effect: the streaming step (drain, evict, window, follow).
252        // Skipped while dormant so a hidden log pane does not pump frames.
253        {
254            let state = self.state.clone();
255            let active = activation.clone();
256            let tick_signal = ctx.frame_tick();
257            ctx.effect(&tick_signal, move |delta| {
258                if !active.get() {
259                    return;
260                }
261                let mut st = state.borrow_mut();
262                let more = log_stream::tick(&mut st, *delta);
263                if more && let Some(handle) = &st.frame_request {
264                    handle.set(true);
265                }
266            });
267        }
268
269        // Window-active effect: mirror the flag so the selection desaturates in
270        // an inactive window (there is no caret to hide). Re-arm only while
271        // this view is itself active.
272        {
273            let state = self.state.clone();
274            let active = activation.clone();
275            let wa_signal = ctx.window_active_signal();
276            ctx.effect(&wa_signal, move |&window_active| {
277                let mut st = state.borrow_mut();
278                st.window_active = window_active;
279                if active.get()
280                    && let Some(handle) = &st.frame_request
281                {
282                    handle.set(true);
283                }
284            });
285        }
286
287        // Handlers on the wrapper — focus + event target. Reuses the editor's
288        // pointer / scroll / tap handlers (drag-select works: as the drag
289        // auto-scrolls, freshly-scrolled rows shape and the hit-test resolves
290        // them), and a scroll-based keyboard of its own.
291        let handlers = HandlerSet::new()
292            .focusable(true)
293            .cursor(CursorIcon::Text)
294            .on_focus({
295                let state = self.state.clone();
296                move |gained, ctx| {
297                    state.borrow_mut().focus_signal.set_if_changed(gained);
298                    state.borrow_mut().has_focus = gained;
299                    ctx.request_frame();
300                }
301            })
302            .on_pointer_event({
303                let state = self.state.clone();
304                let v_sb = self.v_scrollbar_bounds.clone();
305                let h_sb = self.h_scrollbar_bounds.clone();
306                move |event, ctx| {
307                    super::mouse::handle_pointer_event(&state, &v_sb, &h_sb, event, ctx)
308                }
309            })
310            .on_scroll({
311                let state = self.state.clone();
312                let overscroll = self.overscroll_behavior;
313                move |event, ctx| super::mouse::handle_scroll(&state, overscroll, event, ctx)
314            })
315            .on_key({
316                let state = self.state.clone();
317                move |event, ctx| log_stream::handle_log_key(&state, event, ctx)
318            })
319            .on_double_tap({
320                let state = self.state.clone();
321                move |event, ctx| super::mouse::handle_double_tap(&state, event.position, ctx)
322            })
323            .on_triple_tap({
324                let state = self.state.clone();
325                move |event, ctx| super::mouse::handle_triple_tap(&state, event.position, ctx)
326            })
327            .on_access_action_request({
328                let state = self.state.clone();
329                move |action, target, data, ctx| {
330                    super::a11y::handle_access_action(&state, action, target, data, ctx)
331                }
332            });
333        ctx.apply_self_handlers(handlers);
334
335        let body = log_body_for(&self.state);
336        let body_id = ctx.add(body);
337        self.body_id = Some(body_id);
338
339        // Reactive colour overrides repaint the body (the leaf that resolves
340        // them).
341        {
342            let props = {
343                let st = self.state.borrow();
344                [st.text_color_prop.clone(), st.selection_color_prop.clone()]
345            };
346            let registry = ctx.binding_registry();
347            for prop in props.iter().flatten() {
348                prop.register_if_bound(body_id, registry, BindingLevel::RepaintOnly);
349            }
350        }
351
352        let mut children = Vec::with_capacity(3);
353        children.push(body_id);
354
355        let (scroll_x, scroll_y, max_x, max_y, vr_x, vr_y) = {
356            let st = self.state.borrow();
357            (
358                st.scroll_x.clone(),
359                st.scroll_y.clone(),
360                st.max_scroll_x.clone(),
361                st.max_scroll_y.clone(),
362                st.viewport_ratio_x.clone(),
363                st.viewport_ratio_y.clone(),
364            )
365        };
366        if self.v_scroll_policy != ScrollPolicy::AlwaysOff {
367            let v = ScrollBar::new(
368                ScrollBarOrientation::Vertical,
369                scroll_y,
370                max_y.clone(),
371                vr_y,
372            )
373            .visual(ScrollBarVariant::Overlay);
374            let id = ctx.add(v);
375            self.v_scrollbar_id = Some(id);
376            children.push(id);
377        }
378        if self.h_scroll_policy != ScrollPolicy::AlwaysOff {
379            let h = ScrollBar::new(
380                ScrollBarOrientation::Horizontal,
381                scroll_x,
382                max_x.clone(),
383                vr_x,
384            )
385            .visual(ScrollBarVariant::Overlay);
386            let id = ctx.add(h);
387            self.h_scrollbar_id = Some(id);
388            children.push(id);
389        }
390
391        // Re-place when a maximum crosses zero (an `Auto` bar appears/vanishes).
392        let self_id = ctx.self_id();
393        let registry = ctx.binding_registry();
394        max_y.bind_to(self_id, registry, BindingLevel::Relayout);
395        max_x.bind_to(self_id, registry, BindingLevel::Relayout);
396
397        children
398    }
399
400    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
401        // Greedy: a log view is the scrollable region of a pane — take the space
402        // and scroll.
403        let w = proposal.width.unwrap_or(400.0).max(0.0);
404        let h = proposal.height.unwrap_or(300.0).max(0.0);
405        Size::new(w, h).into()
406    }
407
408    fn place_children(
409        &self,
410        bounds: Rect,
411        _proposal: SizeProposal,
412        children: &mut [WidgetPlacement],
413        _ctx: &LayoutContext,
414    ) {
415        self.state.borrow_mut().node_origin = Point::new(bounds.x, bounds.y);
416
417        let (max_y, max_x) = {
418            let st = self.state.borrow();
419            (st.max_scroll_y.get(), st.max_scroll_x.get())
420        };
421        let show_v = match self.v_scroll_policy {
422            ScrollPolicy::AlwaysOn => true,
423            ScrollPolicy::Auto => max_y > 0.0,
424            ScrollPolicy::AlwaysOff => false,
425        };
426        let show_h = match self.h_scroll_policy {
427            ScrollPolicy::AlwaysOn => true,
428            ScrollPolicy::Auto => max_x > 0.0,
429            ScrollPolicy::AlwaysOff => false,
430        };
431
432        let mut v_rect = Rect::ZERO;
433        let mut h_rect = Rect::ZERO;
434        for child in children.iter_mut() {
435            if Some(child.id) == self.body_id {
436                child.origin = Point::new(bounds.x, bounds.y);
437                child.size = Size::new(bounds.width, bounds.height);
438            } else if Some(child.id) == self.v_scrollbar_id {
439                if show_v {
440                    let h = if show_h {
441                        (bounds.height - SCROLLBAR_THICKNESS).max(0.0)
442                    } else {
443                        bounds.height
444                    };
445                    child.origin =
446                        Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
447                    child.size = Size::new(SCROLLBAR_THICKNESS, h);
448                    v_rect = Rect::new(
449                        bounds.width - SCROLLBAR_THICKNESS,
450                        0.0,
451                        SCROLLBAR_THICKNESS,
452                        h,
453                    );
454                } else {
455                    child.origin = Point::new(bounds.x, bounds.y);
456                    child.size = Size::ZERO;
457                }
458            } else if Some(child.id) == self.h_scrollbar_id {
459                if show_h {
460                    let w = if show_v {
461                        (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
462                    } else {
463                        bounds.width
464                    };
465                    child.origin =
466                        Point::new(bounds.x, bounds.y + bounds.height - SCROLLBAR_THICKNESS);
467                    child.size = Size::new(w, SCROLLBAR_THICKNESS);
468                    h_rect = Rect::new(
469                        0.0,
470                        bounds.height - SCROLLBAR_THICKNESS,
471                        w,
472                        SCROLLBAR_THICKNESS,
473                    );
474                } else {
475                    child.origin = Point::new(bounds.x, bounds.y);
476                    child.size = Size::ZERO;
477                }
478            }
479        }
480        self.v_scrollbar_bounds.set(v_rect);
481        self.h_scrollbar_bounds.set(h_rect);
482    }
483
484    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
485        // Background, then a 1 px border that brightens on focus — minimal chrome
486        // until a Tier-3 style lands, mirroring the code editor's wrapper.
487        let bg = {
488            let st = self.state.borrow();
489            match &st.background_prop {
490                Some(p) => p.resolve(ctx.theme, true),
491                None => ctx.theme.colors.editor_bg,
492            }
493        };
494        canvas.fill_rect(bounds, bg);
495
496        let focused = self.state.borrow().focus_signal.get();
497        let border = if focused {
498            ctx.theme.colors.border_focused
499        } else {
500            ctx.theme.colors.border
501        };
502        canvas.stroke_rect(bounds, border, 1.0);
503    }
504
505    fn children(&self) -> Vec<WidgetId> {
506        let mut ids = Vec::with_capacity(3);
507        ids.extend(self.body_id);
508        ids.extend(self.v_scrollbar_id);
509        ids.extend(self.h_scrollbar_id);
510        ids
511    }
512
513    fn clips_children(&self) -> bool {
514        true
515    }
516}
517
518/// The paint-only leaf that renders the windowed log, split from the wrapper for
519/// the same reason the code editor's body is.
520pub(crate) struct LogViewBody {
521    state: SharedState,
522}
523
524impl std::fmt::Debug for LogViewBody {
525    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        f.debug_struct("LogViewBody").finish_non_exhaustive()
527    }
528}
529
530/// Mount a log body over an existing state — the `body_for` analogue for the
531/// read-only streaming face. Used by `LogView::build` and by tests that drive
532/// the log body directly.
533pub(crate) fn log_body_for(state: &SharedState) -> LogViewBody {
534    LogViewBody {
535        state: state.clone(),
536    }
537}
538
539impl Widget for LogViewBody {
540    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
541        let self_id = ctx.self_id();
542        let registry = ctx.binding_registry();
543        let st = self.state.borrow();
544
545        // An append repaints. It does NOT drive the accessibility rebuild — the
546        // AT tree is whole-tree (no per-widget dirty tracking), so binding a
547        // 100k-line streaming log's per-append version to it would re-walk the
548        // entire app tree at frame rate. Instead the tree re-walks on the log's
549        // own `a11y_version`, bumped only when the *visible window* changes: a
550        // scroll crossing a row, a following-tail append, an eviction — never a
551        // pixel-scroll on the same rows or a tail append while scrolled away.
552        st.document_version
553            .bind_to(self_id, registry, BindingLevel::RepaintOnly);
554        if let Some(log) = st.log.as_ref() {
555            log.a11y_version
556                .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
557        }
558        // Scroll is repaint-only.
559        for sig in [&st.scroll_x, &st.scroll_y] {
560            sig.bind_to(self_id, registry, BindingLevel::RepaintOnly);
561        }
562        // The log is read-only, but it still supports selection — that is what
563        // makes its text copyable through AT. A selection change moves the caret
564        // and anchor without moving the window, so the `a11y_version` binding
565        // above does not fire; bind the caret signals at `AccessibilityOnly` too
566        // so a within-window selection re-walks and the reported selection
567        // tracks it. `has_selection` is derived from caret/anchor, so those two
568        // cover every selection change.
569        st.cursor_position
570            .bind_to(self_id, registry, BindingLevel::RepaintOnly);
571        st.cursor_position
572            .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
573        st.cursor_anchor
574            .bind_to(self_id, registry, BindingLevel::RepaintOnly);
575        st.cursor_anchor
576            .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
577        st.has_selection
578            .bind_to(self_id, registry, BindingLevel::RepaintOnly);
579
580        Vec::new()
581    }
582
583    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
584        let w = proposal.width.unwrap_or(200.0).max(0.0);
585        let h = proposal.height.unwrap_or(100.0).max(0.0);
586        Size::new(w, h).into()
587    }
588
589    fn place_children(
590        &self,
591        bounds: Rect,
592        _proposal: SizeProposal,
593        _children: &mut [WidgetPlacement],
594        _ctx: &LayoutContext,
595    ) {
596        self.state.borrow_mut().sync_viewport(bounds);
597    }
598
599    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
600        let mut st = self.state.borrow_mut();
601
602        // Resolve the app's colour overrides against the live theme each paint.
603        let new_text = match &st.text_color_prop {
604            Some(p) => p.resolve(ctx.theme, true).to_array(),
605            None => ctx.theme.colors.editor_fg.to_array(),
606        };
607        st.engine.set_text_color(new_text);
608
609        let new_sel = if let Some(p) = st.selection_color_prop.as_ref() {
610            p.resolve(ctx.theme, true).to_array()
611        } else if ctx.window_active {
612            ctx.theme.colors.editor_selection_bg.to_array()
613        } else {
614            ctx.theme.colors.selection_bg_inactive.to_array()
615        };
616        st.engine.set_selection_color(new_sel);
617
618        // Logical font scale (a11y × font_size_scale) changes glyph advances
619        // and the row height, so a change forces a re-window — and the scroll
620        // offset must be rescaled into the new row-height coordinate space, or
621        // a view scrolled away from the tail would jump to a different set of
622        // lines.
623        let target_scale = st.effective_font_scale(ctx.text_scale);
624        let old_scale = st.last_font_scale;
625        if old_scale.is_nan() || (old_scale - target_scale).abs() > f32::EPSILON {
626            st.last_font_scale = target_scale;
627            st.engine.set_font_scale(target_scale);
628            if old_scale.is_finite() && old_scale > 0.0 {
629                let ratio = target_scale / old_scale;
630                let scaled = st.scroll_y.get() * ratio;
631                st.scroll_y.set_if_changed(scaled);
632            }
633            if let Some(l) = st.log.as_mut() {
634                l.needs_rewindow = true;
635                l.row_height = 0.0;
636            }
637        }
638
639        st.sync_viewport(bounds);
640        // The authoritative window for the current (post-wheel) scroll offset.
641        log_stream::ensure_window(&mut st, false);
642
643        // Publish the selection to the engine — the caret stays hidden, but a
644        // selection band is drawn for the resident rows it covers.
645        let scroll_offset = st.scroll_y.get();
646        let affinity = st.cursor_affinity;
647        let cursors: Vec<teksilo_text::CursorDisplay> = st
648            .all_carets()
649            .map(|c| teksilo_text::CursorDisplay {
650                position: c.position(),
651                anchor: c.anchor(),
652                affinity,
653                visible: false,
654                selected_cells: Vec::new(),
655            })
656            .collect();
657        st.engine.set_cursors(&cursors);
658        st.engine.set_scroll_offset(scroll_offset);
659
660        canvas.set_clip(bounds);
661        let CodeEditorState {
662            ref mut engine,
663            ref document,
664            ref mut image_cache,
665            ..
666        } = *st;
667        engine.with_render_frame(|frame| {
668            crate::rich_text::paint::paint_frame(
669                canvas,
670                crate::rich_text::paint::PaintParams {
671                    frame,
672                    origin: Point::new(bounds.x, bounds.y),
673                    document,
674                    image_cache,
675                    // No inline images on this surface, so none can be missing.
676                    image_resolver: None,
677                    selection: None,
678                    selection_color: [0.0; 4],
679                    selected_image_out: None,
680                    resize_preview: None,
681                    draw_caret: false,
682                },
683            );
684        });
685        canvas.clear_clip();
686    }
687
688    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
689        use teksilo_core::accesskit::Live;
690
691        let st = self.state.borrow();
692        // Role::Document (a viewer — `Document` keeps caret + selection reportable
693        // where `Log`/`Code` would not), read-only, and the *windowed* paragraph/
694        // run tree: only the visible lines, so an append re-walks O(window), not
695        // O(document).
696        super::a11y::build_log_a11y(&st, builder);
697
698        // A log that asked for it announces its new lines. Off by default —
699        // `announce_appends` is the opt-in.
700        if st.announce_appends {
701            builder.inner_mut().set_live(Live::Polite);
702        }
703    }
704
705    fn clips_children(&self) -> bool {
706        true
707    }
708}
709
710/// A cloneable handle to append to a [`LogView`] and drive it.
711///
712/// Use it on the UI thread — from an event handler, a timer, or an async
713/// completion. It holds an `Rc`, so it is **not** `Send`; feeding a log from a
714/// background thread (a PTY reader, a tracing layer) means marshalling the lines
715/// to the UI thread first — through the app's async executor, or a channel whose
716/// receiver is drained in a handler. Each append wakes the view, which otherwise
717/// stops asking for frames when idle.
718#[derive(Clone)]
719pub struct LogViewHandle {
720    state: SharedState,
721}
722
723impl std::fmt::Debug for LogViewHandle {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        f.debug_struct("LogViewHandle").finish_non_exhaustive()
726    }
727}
728
729impl LogViewHandle {
730    /// Append text, split into lines on `\n`. A single trailing newline is a
731    /// terminator, not a blank line, so it is dropped; embedded blank lines are
732    /// kept. Enqueues for the next frame and wakes the view.
733    pub fn append(&self, text: &str) {
734        self.enqueue(text);
735    }
736
737    /// Append one line. `\n` is still split defensively — the document rejects a
738    /// block containing one — so a value that turns out to be multi-line becomes
739    /// several lines rather than an error.
740    pub fn append_line(&self, line: &str) {
741        self.enqueue(line);
742    }
743
744    /// Append many lines.
745    pub fn append_lines<I, S>(&self, lines: I)
746    where
747        I: IntoIterator<Item = S>,
748        S: AsRef<str>,
749    {
750        {
751            let st = self.state.borrow();
752            let Some(log) = st.log.as_ref() else { return };
753            let mut q = log.pending.lock().expect("log append queue poisoned");
754            for line in lines {
755                for piece in line.as_ref().split('\n') {
756                    q.push_back(piece.to_string());
757                }
758            }
759        }
760        self.wake();
761    }
762
763    fn enqueue(&self, text: &str) {
764        {
765            let st = self.state.borrow();
766            let Some(log) = st.log.as_ref() else { return };
767            let mut q = log.pending.lock().expect("log append queue poisoned");
768            // Drop exactly one trailing newline (a line terminator), then split.
769            let body = text.strip_suffix('\n').unwrap_or(text);
770            for piece in body.split('\n') {
771                q.push_back(piece.to_string());
772            }
773        }
774        self.wake();
775    }
776
777    /// Empty the view, resetting it to its pristine state. UI-thread only.
778    pub fn clear(&self) {
779        {
780            let mut st = self.state.borrow_mut();
781            if let Some(log) = st.log.as_ref() {
782                log.pending
783                    .lock()
784                    .expect("log append queue poisoned")
785                    .clear();
786            }
787            let _ = st.document.set_plain_text("");
788            if let Some(log) = st.log.as_mut() {
789                log.pristine = true;
790                log.total = 0;
791                log.anchor = None;
792                log.last_window = None;
793                log.needs_rewindow = true;
794            }
795            st.line_count.set_if_changed(0);
796            st.scroll_x.set_if_changed(0.0);
797            st.scroll_y.set_if_changed(0.0);
798        }
799        self.wake();
800    }
801
802    /// Scroll to the bottom, resuming tail-following. UI-thread only.
803    pub fn scroll_to_bottom(&self) {
804        {
805            let st = self.state.borrow();
806            let max_y = st.max_scroll_y.get();
807            st.scroll_y.set_if_changed(max_y);
808        }
809        self.wake();
810    }
811
812    /// The live line count — a status bar can bind it.
813    pub fn line_count(&self) -> teksilo_core::Signal<usize> {
814        self.state.borrow().line_count.clone()
815    }
816
817    /// Bumps on every content change.
818    pub fn document_version(&self) -> teksilo_core::Signal<u64> {
819        self.state.borrow().document_version.clone()
820    }
821
822    /// The vertical scroll offset — a follow-state indicator can read it against
823    /// [`max_scroll_y`](Self::max_scroll_y).
824    pub fn scroll_y(&self) -> teksilo_core::Signal<f32> {
825        self.state.borrow().scroll_y.clone()
826    }
827
828    /// The maximum vertical scroll offset.
829    pub fn max_scroll_y(&self) -> teksilo_core::Signal<f32> {
830        self.state.borrow().max_scroll_y.clone()
831    }
832
833    /// Wake the view so it drains and repaints on the next frame.
834    fn wake(&self) {
835        if let Some(handle) = &self.state.borrow().frame_request {
836            handle.set(true);
837        }
838    }
839
840    #[cfg(test)]
841    pub(crate) fn state_handle(&self) -> SharedState {
842        self.state.clone()
843    }
844
845    #[cfg(test)]
846    pub(crate) fn from_state_for_test(state: SharedState) -> Self {
847        Self { state }
848    }
849}