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