1use 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
66const SCROLLBAR_THICKNESS: f32 = 12.0;
68
69pub 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 pub(super) touch: Rc<crate::rich_text::touch_mount::EditorTouch>,
90 default_context_menu_enabled: bool,
92 custom_context_menu: Option<super::context_menu::CodeContextMenuFactory>,
94 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 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 pub fn label(mut self, label: impl Into<teksilo_i18n::LocalizedString>) -> Self {
151 self.label = Some(label.into());
152 self
153 }
154
155 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 pub fn default_context_menu(mut self, enabled: bool) -> Self {
177 self.default_context_menu_enabled = enabled;
178 self
179 }
180
181 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 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 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 pub fn announce_appends(self, announce: bool) -> Self {
220 self.state.borrow_mut().announce_appends = announce;
221 self
222 }
223
224 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 pub fn follow_text_scale(self, follow: bool) -> Self {
240 self.state.borrow_mut().follow_text_scale = follow;
241 self
242 }
243
244 pub fn v_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
246 self.v_scroll_policy = policy;
247 self
248 }
249
250 pub fn h_scroll_policy(mut self, policy: ScrollPolicy) -> Self {
252 self.h_scroll_policy = policy;
253 self
254 }
255
256 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 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 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 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 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 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 {
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 {
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 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 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 .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 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 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 {
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 {
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 let self_id = ctx.self_id();
498 self.touch.build(ctx, self_id);
499
500 let body = log_body_for(&self.state);
501 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 {
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 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 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 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 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
692 }
693
694 fn accessibility_proxy(&self) -> Option<WidgetId> {
695 self.body_id
699 }
700}
701
702pub(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
714pub(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 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 for sig in [&st.scroll_x, &st.scroll_y] {
746 sig.bind_to(self_id, registry, BindingLevel::RepaintOnly);
747 }
748 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 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 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 log_stream::ensure_window(&mut st, false);
828
829 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 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 super::a11y::build_log_a11y(&st, builder);
883
884 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#[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 pub fn append(&self, text: &str) {
920 self.enqueue(text);
921 }
922
923 pub fn append_line(&self, line: &str) {
927 self.enqueue(line);
928 }
929
930 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 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 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 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 pub fn line_count(&self) -> teksilo_core::Signal<usize> {
1000 self.state.borrow().line_count.clone()
1001 }
1002
1003 pub fn document_version(&self) -> teksilo_core::Signal<u64> {
1005 self.state.borrow().document_version.clone()
1006 }
1007
1008 pub fn scroll_y(&self) -> teksilo_core::Signal<f32> {
1011 self.state.borrow().scroll_y.clone()
1012 }
1013
1014 pub fn max_scroll_y(&self) -> teksilo_core::Signal<f32> {
1016 self.state.borrow().max_scroll_y.clone()
1017 }
1018
1019 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}