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