use std::collections::VecDeque;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use teksilo_core::Signal;
use teksilo_core::event::{EventResponse, Key, WidgetEvent};
use teksilo_core::widget::EventContext;
use teksilo_text::text_document::{MoveMode, SelectionType};
use teksilo_tokens::Color;
use super::state::{CodeEditorState, DragState, SharedState};
use crate::common::editor_runtime::ScrollMetrics;
pub(crate) type SeverityFn = Rc<dyn Fn(&str) -> Option<Color>>;
const OVERSCAN_ROWS: usize = 8;
pub(crate) const A11Y_TOTAL_REFRESH_SECS: f32 = 1.0;
const FOLLOW_EPSILON: f32 = 1.5;
const EVICT_SLACK: usize = 256;
const WALK_LIMIT: usize = 4096;
pub(crate) struct LogStreamState {
pub pending: Arc<Mutex<VecDeque<String>>>,
pub follow_enabled: bool,
pub scrollback_limit: Option<usize>,
pub severity: Option<SeverityFn>,
pub row_height: f32,
pub anchor: Option<(usize, usize)>,
pub last_window: Option<(usize, usize)>,
pub needs_rewindow: bool,
pub pristine: bool,
pub total: usize,
pub a11y_version: Signal<u64>,
pub last_a11y_sig: Option<(usize, usize, usize)>,
pub a11y_total_lag: f32,
}
impl LogStreamState {
pub(crate) fn new() -> Self {
Self {
pending: Arc::new(Mutex::new(VecDeque::new())),
follow_enabled: true,
scrollback_limit: None,
severity: None,
row_height: 0.0,
anchor: None,
last_window: None,
needs_rewindow: true,
pristine: true,
total: 0,
a11y_version: Signal::new(0),
last_a11y_sig: None,
a11y_total_lag: 0.0,
}
}
}
pub(crate) fn tick(st: &mut CodeEditorState, delta: f32) -> bool {
let was_at_bottom = at_bottom(st);
let batch = drain_pending(st);
let mut content_changed = !batch.is_empty();
if content_changed {
apply_appends(st, batch);
}
if enforce_scrollback(st) {
content_changed = true;
}
let (had_events, _) = st.drain_events();
let _ = st.document.poll_events();
let total = st.log.as_ref().map_or(0, |l| l.total);
st.line_count.set_if_changed(total);
let viewport_ready = st.viewport_width > 0.0 && st.viewport_height > 0.0;
if viewport_ready {
st.engine
.set_viewport(st.viewport_width, st.viewport_height);
}
let mut windowed = viewport_ready && ensure_window(st, false);
let metrics = compute_metrics(st);
let follow = st.log.as_ref().is_some_and(|l| l.follow_enabled);
if follow && was_at_bottom && metrics.max_y > st.scroll_y.get() {
st.scroll_y.set_if_changed(metrics.max_y);
if viewport_ready {
windowed |= ensure_window(st, false);
}
}
let mut drag_active = false;
if let DragState::Selecting {
auto_scroll_v_per_s,
} = st.drag_state
&& auto_scroll_v_per_s.abs() > 0.0
{
drag_active = true;
let ny = (st.scroll_y.get() + auto_scroll_v_per_s * delta).clamp(0.0, metrics.max_y);
st.scroll_y.set_if_changed(ny);
if viewport_ready {
windowed |= ensure_window(st, false);
}
}
metrics.publish(
&st.scroll_x,
&st.scroll_y,
&st.max_scroll_x,
&st.max_scroll_y,
&st.viewport_ratio_x,
&st.viewport_ratio_y,
);
bump_a11y_if_window_changed(st, delta);
content_changed || had_events || drag_active || windowed
}
fn drain_pending(st: &CodeEditorState) -> Vec<String> {
let Some(log) = st.log.as_ref() else {
return Vec::new();
};
let mut q = log.pending.lock().expect("log append queue poisoned");
q.drain(..).collect()
}
fn apply_appends(st: &mut CodeEditorState, batch: Vec<String>) {
if batch.is_empty() {
return;
}
let pristine = st.log.as_ref().is_some_and(|l| l.pristine);
if pristine {
let cursor = st.document.cursor();
cursor.set_position(0, MoveMode::MoveAnchor);
let _ = cursor.insert_text(&batch[0]);
if batch.len() > 1 {
let _ = st.document.append_lines(batch[1..].iter());
}
} else {
let _ = st.document.append_lines(batch.iter());
}
if let Some(l) = st.log.as_mut() {
l.total += batch.len();
l.pristine = false;
l.needs_rewindow = true;
}
}
fn enforce_scrollback(st: &mut CodeEditorState) -> bool {
let (limit, total) = match st.log.as_ref() {
Some(l) => match l.scrollback_limit {
Some(limit) => (limit, l.total),
None => return false,
},
None => return false,
};
let slack = (limit / 4).clamp(1, EVICT_SLACK);
if total <= limit.saturating_add(slack) {
return false;
}
let excess = total - limit;
let removed = match st.document.truncate_front(excess) {
Ok(n) => n,
Err(_) => return false,
};
if removed == 0 {
return false;
}
let row_h = st.log.as_ref().map_or(0.0, |l| l.row_height);
if row_h > 0.0 {
let ny = (st.scroll_y.get() - removed as f32 * row_h).max(0.0);
st.scroll_y.set_if_changed(ny);
}
if let Some(l) = st.log.as_mut() {
l.total = l.total.saturating_sub(removed);
l.anchor = None;
l.needs_rewindow = true;
}
true
}
fn window_bounds(st: &CodeEditorState, row_h: f32) -> Option<(usize, usize, usize)> {
let total = st.log.as_ref().map_or(0, |l| l.total);
if total == 0 || row_h <= 0.0 {
return None;
}
let first = ((st.scroll_y.get() / row_h).floor() as usize).min(total - 1);
let visible = (st.viewport_height / row_h).ceil() as usize + 1;
let count = (visible + OVERSCAN_ROWS).min(total - first).max(1);
Some((first, count, total))
}
pub(crate) fn ensure_window(st: &mut CodeEditorState, force: bool) -> bool {
let row_h = current_row_height(st);
if row_h <= 0.0 {
return false;
}
let Some((first, count, total)) = window_bounds(st, row_h) else {
st.engine.set_uniform_extent(0, row_h);
if let Some(l) = st.log.as_mut() {
l.last_window = None;
l.row_height = row_h;
}
return false;
};
let (needs_flag, same_window, same_height) = {
let l = st.log.as_ref();
(
l.is_some_and(|l| l.needs_rewindow),
l.is_some_and(|l| l.last_window == Some((first, count))),
l.is_some_and(|l| (l.row_height - row_h).abs() <= 0.01),
)
};
if !force && !needs_flag && same_window && same_height {
st.engine.set_uniform_extent(total, row_h);
return false;
}
let rows = collect_window(st, first, count);
st.engine.layout_window_from_snapshots(&rows, total, row_h);
let learned = rows
.first()
.and_then(|(_, snap, _)| st.engine.block_visual_info(snap.block_id))
.map(|info| info.height)
.filter(|h| *h > 0.0)
.unwrap_or(row_h);
if let Some(l) = st.log.as_mut() {
l.last_window = Some((first, count));
l.row_height = learned;
l.needs_rewindow = false;
}
true
}
pub(crate) fn a11y_window(
st: &CodeEditorState,
) -> (
usize,
usize,
Vec<teksilo_text::text_document::BlockSnapshot>,
) {
let Some((first, count, total)) = window_bounds(st, current_row_height(st)) else {
let total = st.log.as_ref().map_or(0, |l| l.total);
return (0, total, Vec::new());
};
let anchor = st.log.as_ref().and_then(|l| l.anchor);
let Some(mut pos) = resolve_row_position(&st.document, first, anchor) else {
return (first, total, Vec::new());
};
let mut snaps = Vec::with_capacity(count);
for _ in 0..count {
let Some(snap) = st
.document
.snapshot_block_at_position_without_highlights(pos)
else {
break;
};
pos = snap.position + snap.length + 1;
snaps.push(snap);
}
(first, total, snaps)
}
fn bump_a11y_if_window_changed(st: &mut CodeEditorState, delta: f32) {
let Some((first, count, total)) = window_bounds(st, current_row_height(st)) else {
return;
};
let Some(l) = st.log.as_mut() else {
return;
};
let window_changed = l
.last_a11y_sig
.is_none_or(|(f, c, _)| (f, c) != (first, count));
let total_changed = l.last_a11y_sig.is_none_or(|(_, _, t)| t != total);
let refresh = if window_changed {
true
} else if total_changed {
l.a11y_total_lag += delta;
l.a11y_total_lag >= A11Y_TOTAL_REFRESH_SECS
} else {
false
};
if refresh || !total_changed {
l.a11y_total_lag = 0.0;
}
if !refresh {
return;
}
l.last_a11y_sig = Some((first, count, total));
let version = l.a11y_version.clone();
version.set(version.get() + 1);
}
fn collect_window(
st: &mut CodeEditorState,
first: usize,
count: usize,
) -> Vec<(
usize,
teksilo_text::text_document::BlockSnapshot,
Option<[f32; 4]>,
)> {
let severity = st.log.as_ref().and_then(|l| l.severity.clone());
let anchor = st.log.as_ref().and_then(|l| l.anchor);
let Some(mut pos) = resolve_row_position(&st.document, first, anchor) else {
return Vec::new();
};
if let Some(l) = st.log.as_mut() {
l.anchor = Some((first, pos));
}
let mut rows = Vec::with_capacity(count);
let mut row = first;
while row < first + count {
let Some(snap) = st
.document
.snapshot_block_at_position_without_highlights(pos)
else {
break;
};
let next_pos = snap.position + snap.length + 1;
let tint = severity
.as_ref()
.and_then(|f| f(&snap.text))
.map(|c| c.to_array());
rows.push((row, snap, tint));
pos = next_pos;
row += 1;
}
rows
}
fn resolve_row_position(
doc: &teksilo_text::text_document::TextDocument,
target: usize,
anchor: Option<(usize, usize)>,
) -> Option<usize> {
if let Some((arow, apos)) = anchor {
if target == arow {
return Some(apos);
}
if target > arow && target - arow <= WALK_LIMIT {
let mut pos = apos;
for _ in arow..target {
let snap = doc.snapshot_block_at_position_without_highlights(pos)?;
pos = snap.position + snap.length + 1;
}
return Some(pos);
}
}
doc.block_by_number(target).map(|b| b.position())
}
fn current_row_height(st: &CodeEditorState) -> f32 {
let learned = st.log.as_ref().map_or(0.0, |l| l.row_height);
if learned > 0.0 {
learned
} else {
st.engine.default_line_height() * st.engine.font_scale().max(0.01)
}
}
fn compute_metrics(st: &CodeEditorState) -> ScrollMetrics {
ScrollMetrics::compute(
st.engine.content_height(),
st.engine.max_content_width(),
st.viewport_width,
st.viewport_height,
)
}
fn at_bottom(st: &CodeEditorState) -> bool {
st.scroll_y.get() >= st.max_scroll_y.get() - FOLLOW_EPSILON
}
pub(crate) fn handle_log_key(
state: &SharedState,
event: &WidgetEvent,
ctx: &mut EventContext,
) -> EventResponse {
let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
return EventResponse::Ignored;
};
let ctrl = modifiers.command();
enum Act {
Scroll,
SelectAll,
Copy,
None,
}
let act = {
let mut st = state.borrow_mut();
let row_h = current_row_height(&st).max(1.0);
let page = (st.viewport_height - row_h).max(row_h);
let step_x = row_h * 4.0;
let (max_y, max_x) = (st.max_scroll_y.get(), st.max_scroll_x.get());
let (cy, cx) = (st.scroll_y.get(), st.scroll_x.get());
match key {
Key::ArrowDown if !ctrl => {
st.scroll_y.set_if_changed((cy + row_h).min(max_y));
Act::Scroll
}
Key::ArrowUp if !ctrl => {
st.scroll_y.set_if_changed((cy - row_h).max(0.0));
Act::Scroll
}
Key::PageDown => {
st.scroll_y.set_if_changed((cy + page).min(max_y));
Act::Scroll
}
Key::PageUp => {
st.scroll_y.set_if_changed((cy - page).max(0.0));
Act::Scroll
}
Key::Home if ctrl => {
st.scroll_y.set_if_changed(0.0);
Act::Scroll
}
Key::End if ctrl => {
st.scroll_y.set_if_changed(max_y);
Act::Scroll
}
Key::Home if !ctrl => {
st.scroll_x.set_if_changed(0.0);
Act::Scroll
}
Key::End if !ctrl => {
st.scroll_x.set_if_changed(max_x);
Act::Scroll
}
Key::ArrowLeft if !ctrl => {
st.scroll_x.set_if_changed((cx - step_x).max(0.0));
Act::Scroll
}
Key::ArrowRight if !ctrl => {
st.scroll_x.set_if_changed((cx + step_x).min(max_x));
Act::Scroll
}
Key::A if ctrl => {
st.clear_extra_carets();
st.cursor.select(SelectionType::Document);
Act::SelectAll
}
Key::C if ctrl => Act::Copy,
_ => Act::None,
}
};
match act {
Act::Scroll => {
ctx.request_frame();
EventResponse::Handled
}
Act::SelectAll => {
super::sync_cursor_signals(state);
ctx.request_frame();
EventResponse::Handled
}
Act::Copy => {
super::clipboard::copy(&state.borrow(), ctx);
EventResponse::Handled
}
Act::None => EventResponse::Ignored,
}
}