use std::collections::HashMap;
use teksilo_core::accessibility::{AccessNodeBuilder, TextRunAttributes};
use teksilo_core::accesskit::{Action, ActionData, NodeId, Role, TextPosition};
use teksilo_core::event::EventResponse;
use teksilo_core::widget::EventContext;
use teksilo_text::RichTextEngine;
use teksilo_text::text_document::{
BlockSnapshot, FlowElementSnapshot, FragmentContent, MoveMode, TextFormat,
};
use super::state::{CodeEditorState, SharedState, SyntheticElementRef};
use crate::common::editor_runtime::AccessibilityRole;
const MAX_RUN_CHARS: usize = 255;
pub(crate) fn set_role(st: &CodeEditorState, builder: &mut AccessNodeBuilder) {
let role = match st.policy.access_role {
AccessibilityRole::Editor => Role::MultilineTextInput,
AccessibilityRole::Document => Role::Document,
};
builder.set_role(role);
if st.policy.is_read_only() {
builder.set_read_only();
}
}
fn user_selection(st: &CodeEditorState) -> (usize, usize) {
match st.ime_preedit_range.clone() {
Some(range) => (range.start, range.end),
None => (st.cursor.anchor(), st.cursor.position()),
}
}
struct WalkAcc {
user_pos: usize,
user_anchor: usize,
caret_pair: Option<(NodeId, usize)>,
anchor_pair: Option<(NodeId, usize)>,
syn_map: HashMap<NodeId, SyntheticElementRef>,
}
impl WalkAcc {
fn new(st: &CodeEditorState) -> Self {
let (user_anchor, user_pos) = user_selection(st);
Self {
user_pos,
user_anchor,
caret_pair: None,
anchor_pair: None,
syn_map: HashMap::new(),
}
}
}
pub(crate) fn build_editor_a11y(st: &CodeEditorState, builder: &mut AccessNodeBuilder) {
set_role(st, builder);
let snap = {
let mut cache = st.accessibility_flow_snapshot.borrow_mut();
if cache.is_none() {
*cache = Some(st.flow_snapshot_for_a11y());
}
cache.as_ref().cloned()
};
let mut acc = WalkAcc::new(st);
if let Some(snap) = snap {
let total = snap
.elements
.iter()
.filter(|e| matches!(e, FlowElementSnapshot::Block(_)))
.count();
let mut line = 0usize;
for elem in &snap.elements {
if let FlowElementSnapshot::Block(block) = elem {
emit_block(builder, &st.engine, block, line, total, &mut acc);
line += 1;
}
}
}
finish(st, builder, acc);
}
pub(crate) fn build_log_a11y(st: &CodeEditorState, builder: &mut AccessNodeBuilder) {
set_role(st, builder);
let (first, total, snaps) = super::log_stream::a11y_window(st);
let mut acc = WalkAcc::new(st);
for (i, block) in snaps.iter().enumerate() {
emit_block(builder, &st.engine, block, first + i, total, &mut acc);
}
finish(st, builder, acc);
}
fn finish(st: &CodeEditorState, builder: &mut AccessNodeBuilder, acc: WalkAcc) {
if let (Some(anchor), Some(caret)) = (acc.anchor_pair, acc.caret_pair) {
builder.set_text_selection_to(anchor, caret);
}
*st.synthetic_to_element.borrow_mut() = acc.syn_map;
builder.add_action(Action::Focus);
builder.add_action(Action::ScrollIntoView);
builder.add_action(Action::SetTextSelection);
if matches!(st.policy.access_role, AccessibilityRole::Editor) {
builder.add_action(Action::SetValue);
builder.add_action(Action::ReplaceSelectedText);
}
}
fn emit_block(
builder: &mut AccessNodeBuilder,
engine: &RichTextEngine,
block: &BlockSnapshot,
line_index: usize,
total_lines: usize,
acc: &mut WalkAcc,
) {
let para_id = builder.push_paragraph_child(block.block_id as u64);
if let Some(level) = block.block_format.heading_level {
builder.set_paragraph_as_heading(para_id, level);
}
builder.set_child_position_in_set(para_id, line_index + 1, total_lines.max(1));
struct Chunk<'a> {
text: &'a str,
char_start: usize,
char_count: usize,
element_id: u64,
format: &'a TextFormat,
own_word_starts: Option<&'a Vec<u8>>,
prev_ws: bool,
}
let mut chunks: Vec<Chunk> = Vec::new();
for frag in &block.fragments {
if let FragmentContent::Text {
text,
offset,
length,
element_id,
word_starts,
format,
} = frag
{
if *length == 0 {
continue;
}
let char_byte: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
let nchars = char_byte.len();
let whole_fits = nchars <= MAX_RUN_CHARS;
let mut cs = 0usize;
let mut prev_ws = true; while cs < nchars {
let ce = (cs + MAX_RUN_CHARS).min(nchars);
let bs = char_byte[cs];
let be = char_byte.get(ce).copied().unwrap_or(text.len());
let chunk_text = &text[bs..be];
chunks.push(Chunk {
text: chunk_text,
char_start: *offset + cs,
char_count: ce - cs,
element_id: *element_id,
format,
own_word_starts: whole_fits.then_some(word_starts),
prev_ws,
});
prev_ws = chunk_text.chars().last().is_some_and(|c| c.is_whitespace());
cs = ce;
}
}
}
let mut run_ids: Vec<NodeId> = Vec::with_capacity(chunks.len().max(1));
let default_format = TextFormat::default();
if chunks.is_empty() {
run_ids.push(emit_run(
builder,
engine,
para_id,
block,
0,
"",
0,
block.block_id as u64,
&default_format,
None,
true,
true,
acc,
));
} else {
let n = chunks.len();
for (i, ch) in chunks.iter().enumerate() {
run_ids.push(emit_run(
builder,
engine,
para_id,
block,
ch.char_start,
ch.text,
ch.char_count,
ch.element_id,
ch.format,
ch.own_word_starts,
ch.prev_ws,
i + 1 == n,
acc,
));
}
}
builder.link_runs_on_line(&run_ids);
}
#[allow(clippy::too_many_arguments)]
fn emit_run(
builder: &mut AccessNodeBuilder,
engine: &RichTextEngine,
para_id: NodeId,
block: &BlockSnapshot,
char_start: usize,
text: &str,
char_count: usize,
element_id: u64,
format: &TextFormat,
own_word_starts: Option<&Vec<u8>>,
prev_ws: bool,
is_last: bool,
acc: &mut WalkAcc,
) -> NodeId {
let mut value = text.to_string();
let mut char_lengths: Vec<u8> = text.chars().map(|c| c.len_utf8() as u8).collect();
let word_starts: Vec<u8> = match own_word_starts {
Some(ws) => ws.clone(),
None => word_starts_for(text, prev_ws),
};
let geom = engine.character_geometry(block.block_id, char_start, char_start + char_count);
let mut char_positions: Vec<f32> = geom.iter().map(|g| g.position).collect();
let mut char_widths: Vec<f32> = geom.iter().map(|g| g.width).collect();
if is_last {
value.push('\n');
char_lengths.push(1);
if !char_positions.is_empty() {
let end = char_positions.last().copied().unwrap_or(0.0)
+ char_widths.last().copied().unwrap_or(0.0);
char_positions.push(end);
char_widths.push(0.0);
}
}
let attrs = TextRunAttributes {
font_weight: format.font_weight.map(|w| w as u16),
bold: format.font_bold.unwrap_or(false),
italic: format.font_italic.unwrap_or(false),
underline: format.font_underline.unwrap_or(false),
strikethrough: format.font_strikeout.unwrap_or(false),
};
let n = char_lengths.len();
let positions = (char_positions.len() == n).then_some(char_positions);
let widths = (char_widths.len() == n).then_some(char_widths);
let node_id = builder.push_text_run_child(
para_id,
element_id,
char_start,
value,
char_lengths,
Some(word_starts),
positions,
widths,
attrs,
);
let absolute_start = block.position + char_start;
acc.syn_map.insert(
node_id,
SyntheticElementRef {
element_id,
absolute_start,
text: text.to_string(),
},
);
let run_end = absolute_start + char_count;
if acc.user_pos >= absolute_start && acc.user_pos <= run_end {
acc.caret_pair = Some((node_id, acc.user_pos - absolute_start));
}
if acc.user_anchor >= absolute_start && acc.user_anchor <= run_end {
acc.anchor_pair = Some((node_id, acc.user_anchor - absolute_start));
}
node_id
}
fn word_starts_for(text: &str, prev_ws: bool) -> Vec<u8> {
let mut out = Vec::new();
let mut prev_ws = prev_ws;
for (ci, ch) in text.chars().enumerate() {
let ws = ch.is_whitespace();
if !ws && prev_ws {
match u8::try_from(ci) {
Ok(idx) => out.push(idx),
Err(_) => break,
}
}
prev_ws = ws;
}
out
}
pub(crate) fn handle_access_action(
state: &SharedState,
action: Action,
_target: NodeId,
data: Option<ActionData>,
ctx: &mut EventContext,
) -> EventResponse {
match (action, data) {
(Action::SetTextSelection, Some(ActionData::SetTextSelection(sel))) => {
let resolve = |pos: TextPosition| -> Option<usize> {
let st = state.borrow();
let map = st.synthetic_to_element.borrow();
let er = map.get(&pos.node)?;
let char_count = er.text.chars().count();
Some(er.absolute_start + pos.character_index.min(char_count))
};
match (resolve(sel.anchor), resolve(sel.focus)) {
(Some(anchor), Some(focus)) => {
{
let mut st = state.borrow_mut();
st.clear_extra_carets();
st.cursor.set_position(anchor, MoveMode::MoveAnchor);
st.cursor.set_position(focus, MoveMode::KeepAnchor);
}
super::sync_cursor_signals(state);
ctx.request_frame();
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
}
(Action::SetValue, Some(ActionData::Value(value))) => {
if state.borrow().policy.is_read_only() {
return EventResponse::Ignored;
}
let _ = state.borrow().document.set_plain_text(&value);
super::sync_cursor_signals(state);
ctx.request_frame();
EventResponse::Handled
}
(Action::ReplaceSelectedText, Some(ActionData::Value(value))) => {
if state.borrow().policy.is_read_only() {
return EventResponse::Ignored;
}
{
let st = state.borrow();
let _ = st.cursor.insert_text(&value);
}
super::sync_cursor_signals(state);
ctx.request_frame();
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
}