use std::sync::{Arc, Mutex};
use leaf_core::style::{Baseline, Role, Style as LStyle};
use leaf_core::wysiwyg::text_width;
use leaf_core::{
Alignment, BlockKind, Capabilities as CoreCapabilities, ColorScheme, Doc, Format, InlineKind,
LineFlow as CoreLineFlow, MarkupMode as CoreMarkupMode, MediaKind as CoreMediaKind, View,
VisualMap,
};
use unicode_segmentation::UnicodeSegmentation;
uniffi::setup_scaffolding!();
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum LeafError {
#[error("unknown format: {name}")]
UnknownFormat { name: String },
#[error("parse error: {message}")]
Parse { message: String },
}
#[derive(uniffi::Record)]
pub struct Run {
pub text: String,
pub role: String,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strike: bool,
pub sup: bool,
pub sub: bool,
pub src: u32,
pub sel: bool,
}
#[derive(uniffi::Record)]
pub struct LandingView {
pub start: u32,
pub end: u32,
}
impl From<leaf_core::Landing> for LandingView {
fn from(l: leaf_core::Landing) -> Self {
LandingView {
start: l.start as u32,
end: l.end as u32,
}
}
}
#[derive(uniffi::Record)]
pub struct FootnoteView {
pub label: String,
pub text: Option<String>,
pub offset: Option<u32>,
pub end: Option<u32>,
}
impl From<leaf_core::FootnoteRef> for FootnoteView {
fn from(f: leaf_core::FootnoteRef) -> Self {
FootnoteView {
label: f.label,
text: f.text,
offset: f.offset.map(|o| o as u32),
end: f.end.map(|o| o as u32),
}
}
}
#[derive(uniffi::Record)]
pub struct FootnoteDefView {
pub label: String,
pub offset: Option<u32>,
}
impl From<leaf_core::FootnoteDef> for FootnoteDefView {
fn from(f: leaf_core::FootnoteDef) -> Self {
FootnoteDefView {
label: f.label,
offset: f.offset.map(|o| o as u32),
}
}
}
#[derive(uniffi::Record)]
pub struct Row {
pub runs: Vec<Run>,
pub decoration: bool,
pub code: bool,
pub code_lang: Option<String>,
pub directive: bool,
pub directive_label: Option<String>,
pub heading: Option<u8>,
pub boundary: Option<Boundary>,
}
#[derive(uniffi::Record)]
pub struct Boundary {
pub above: BlockClass,
pub below: BlockClass,
}
#[derive(uniffi::Enum)]
pub enum BlockClass {
Paragraph,
Heading,
List,
ListItem,
Quote,
Code,
Table,
Media,
Directive,
Rule,
Footnote,
Other,
}
impl From<leaf_core::BlockClass> for BlockClass {
fn from(k: leaf_core::BlockClass) -> Self {
use leaf_core::BlockClass as K;
match k {
K::Paragraph => BlockClass::Paragraph,
K::Heading => BlockClass::Heading,
K::List => BlockClass::List,
K::ListItem => BlockClass::ListItem,
K::Quote => BlockClass::Quote,
K::Code => BlockClass::Code,
K::Table => BlockClass::Table,
K::Media => BlockClass::Media,
K::Directive => BlockClass::Directive,
K::Rule => BlockClass::Rule,
K::Footnote => BlockClass::Footnote,
K::Other => BlockClass::Other,
}
}
}
#[derive(uniffi::Record)]
pub struct TableCellLineView {
pub runs: Vec<Run>,
pub start: u32,
pub end: u32,
}
#[derive(uniffi::Record)]
pub struct TableCellView {
pub lines: Vec<TableCellLineView>,
pub align: String,
pub start: u32,
pub end: u32,
}
#[derive(uniffi::Record)]
pub struct TableRowView {
pub head: bool,
pub cells: Vec<TableCellView>,
}
#[derive(uniffi::Record)]
pub struct TableView {
pub start_row: u32,
pub end_row: u32,
pub grid: Vec<TableRowView>,
}
#[derive(uniffi::Record)]
pub struct DirectiveView {
pub start_row: u32,
pub end_row: u32,
pub name: String,
pub label: String,
pub attrs: Vec<DirectiveAttr>,
}
#[derive(uniffi::Record)]
pub struct DirectiveAttr {
pub key: String,
pub value: String,
}
#[derive(uniffi::Enum)]
pub enum MediaKind {
Image,
Video,
Audio,
}
#[derive(uniffi::Record)]
pub struct MediaSourceView {
pub media: String,
pub src: String,
pub mime: String,
}
#[derive(uniffi::Record)]
pub struct MediaView {
pub start_row: u32,
pub end_row: u32,
pub kind: MediaKind,
pub src: String,
pub poster: String,
pub alt: String,
pub sources: Vec<MediaSourceView>,
}
#[derive(uniffi::Record)]
pub struct MediaHeight {
pub destination: String,
pub rows: u32,
}
#[derive(uniffi::Record)]
pub struct DocView {
pub rows: Vec<Row>,
pub tables: Vec<TableView>,
pub directives: Vec<DirectiveView>,
pub media: Vec<MediaView>,
pub caret_row: u32,
pub caret_col: u32,
pub caret_ch: u32,
pub caret_src: u32,
pub has_selection: bool,
pub anchor_row: u32,
pub anchor_ch: u32,
pub dirty: bool,
pub view: String,
pub heading: Option<u32>,
pub active: Vec<String>,
pub link: Option<String>,
}
#[derive(uniffi::Record)]
pub struct RowCol {
pub row: u32,
pub ch: u32,
}
#[derive(uniffi::Record)]
pub struct RowRange {
pub first: u32,
pub last: u32,
}
#[derive(uniffi::Record)]
pub struct Capabilities {
pub bold: bool,
pub italic: bool,
pub code: bool,
pub mark: bool,
pub underline: bool,
pub strike: bool,
pub superscript: bool,
pub subscript: bool,
pub heading: bool,
pub blockquote: bool,
pub bullet_list: bool,
pub ordered_list: bool,
pub task: bool,
pub link: bool,
pub image: bool,
pub thematic_break: bool,
pub footnote: bool,
pub code_language: bool,
pub table: bool,
pub cell_line_break: bool,
}
impl From<CoreCapabilities> for Capabilities {
fn from(c: CoreCapabilities) -> Self {
Self {
bold: c.bold,
italic: c.italic,
code: c.code,
mark: c.mark,
underline: c.underline,
strike: c.strike,
superscript: c.superscript,
subscript: c.subscript,
heading: c.heading,
blockquote: c.blockquote,
bullet_list: c.bullet_list,
ordered_list: c.ordered_list,
task: c.task,
link: c.link,
image: c.image,
thematic_break: c.thematic_break,
footnote: c.footnote,
code_language: c.code_language,
table: c.table,
cell_line_break: c.cell_line_break,
}
}
}
#[derive(uniffi::Enum)]
pub enum TableAlignment {
Default,
Left,
Right,
Center,
}
impl TableAlignment {
fn into_core(self) -> Alignment {
match self {
TableAlignment::Default => Alignment::Default,
TableAlignment::Left => Alignment::Left,
TableAlignment::Right => Alignment::Right,
TableAlignment::Center => Alignment::Center,
}
}
}
#[derive(uniffi::Enum)]
pub enum MarkupMode {
None,
Shortcuts,
Full,
}
impl MarkupMode {
fn into_core(self) -> CoreMarkupMode {
match self {
MarkupMode::None => CoreMarkupMode::None,
MarkupMode::Shortcuts => CoreMarkupMode::Shortcuts,
MarkupMode::Full => CoreMarkupMode::Full,
}
}
fn from_core(mode: CoreMarkupMode) -> Self {
match mode {
CoreMarkupMode::None => MarkupMode::None,
CoreMarkupMode::Shortcuts => MarkupMode::Shortcuts,
CoreMarkupMode::Full => MarkupMode::Full,
}
}
}
#[derive(uniffi::Enum)]
pub enum LineFlow {
Fold,
Preserve,
}
impl LineFlow {
fn into_core(self) -> CoreLineFlow {
match self {
LineFlow::Fold => CoreLineFlow::Fold,
LineFlow::Preserve => CoreLineFlow::Preserve,
}
}
fn from_core(mode: CoreLineFlow) -> Self {
match mode {
CoreLineFlow::Fold => LineFlow::Fold,
CoreLineFlow::Preserve => LineFlow::Preserve,
}
}
}
#[derive(uniffi::Object)]
pub struct LeafDoc {
inner: Mutex<Inner>,
}
struct Inner {
doc: Doc,
width: Option<usize>,
scheme: ColorScheme,
}
unsafe impl Send for Inner {}
impl Inner {
fn sync(&mut self) {
match self.width {
Some(w) => self.doc.build_visual(w),
None => self.doc.build_visual_unwrapped(),
}
}
fn row_text(&self, row: usize) -> String {
match self.doc.view {
View::Wysiwyg => self
.doc
.vmap
.rows
.get(row)
.map(|r| r.glyphs.iter().map(|g| g.ch).collect())
.unwrap_or_default(),
View::Source => self
.doc
.source
.split('\n')
.nth(row)
.unwrap_or("")
.to_string(),
}
}
fn pos_of_offset(&self, off: usize) -> (usize, usize) {
match self.doc.view {
View::Wysiwyg => self.doc.vmap.pos_of_offset(off),
View::Source => {
let s = &self.doc.source;
let mut off = off.min(s.len());
while off > 0 && !s.is_char_boundary(off) {
off -= 1;
}
let row = s[..off].bytes().filter(|&b| b == b'\n').count();
let line_start = s[..off].rfind('\n').map_or(0, |i| i + 1);
(row, text_width(&s[line_start..off]))
}
}
}
fn row_range_for(&self, start: usize, end: usize) -> (usize, usize) {
match self.doc.view {
View::Wysiwyg => self.doc.vmap.row_range_for(start..end),
View::Source => {
let first = self.pos_of_offset(start).0;
let last = self.pos_of_offset(end.max(start.saturating_add(1)) - 1).0;
(first, last.max(first))
}
}
}
fn offset_at(&mut self, row: usize, ch: usize) -> usize {
self.sync();
let col = utf16_to_col(&self.row_text(row), ch);
self.doc.click(row, col, false);
self.doc.caret
}
fn offset_of_col(&self, row: usize, col: usize) -> usize {
match self.doc.view {
View::Wysiwyg => self.doc.vmap.offset_of_pos(row, col),
View::Source => {
let line = self.row_text(row);
let (mut c, mut b) = (0usize, 0usize);
for g in line.graphemes(true) {
if c >= col {
break;
}
c += text_width(g);
b += g.len();
}
self.source_line_start(row) + b
}
}
}
fn source_line_start(&self, row: usize) -> usize {
self.doc
.source
.split('\n')
.take(row)
.map(|l| l.len() + 1)
.sum()
}
fn stop_after(&self, off: usize) -> Option<usize> {
match self.doc.view {
View::Wysiwyg => self.doc.vmap.stop_after(off),
View::Source => {
let s = &self.doc.source;
if off >= s.len() {
None
} else {
Some(
s[off..]
.grapheme_indices(true)
.nth(1)
.map_or(s.len(), |(i, _)| off + i),
)
}
}
}
}
fn stop_before(&self, off: usize) -> Option<usize> {
match self.doc.view {
View::Wysiwyg => self.doc.vmap.stop_before(off),
View::Source => {
let s = &self.doc.source;
let off = off.min(s.len());
if off == 0 {
None
} else {
s[..off].grapheme_indices(true).next_back().map(|(i, _)| i)
}
}
}
}
fn snap_stop(&self, off: usize) -> usize {
let s = &self.doc.source;
let mut off = off.min(s.len());
match self.doc.view {
View::Wysiwyg => self.doc.vmap.snap_to_stop(off),
View::Source => {
while off > 0 && !s.is_char_boundary(off) {
off -= 1;
}
off
}
}
}
fn nav_above(&self, row: usize) -> Option<usize> {
match self.doc.view {
View::Wysiwyg => self.doc.vmap.navigable_above(row),
View::Source => (row > 0).then(|| row - 1),
}
}
fn nav_below(&self, row: usize) -> Option<usize> {
match self.doc.view {
View::Wysiwyg => self.doc.vmap.navigable_below(row),
View::Source => {
let n = self.doc.source.split('\n').count();
(row + 1 < n).then_some(row + 1)
}
}
}
fn view(&mut self) -> DocView {
self.sync();
let (ss, se) = self.doc.selection().unwrap_or((usize::MAX, usize::MAX));
let rows = match self.doc.view {
View::Wysiwyg => wysiwyg_rows(&self.doc.vmap, ss, se),
View::Source => source_rows(&self.doc.source, ss, se),
};
let tables = match self.doc.view {
View::Wysiwyg => wysiwyg_tables(&self.doc.vmap, ss, se),
View::Source => Vec::new(),
};
let directives = match self.doc.view {
View::Wysiwyg => wysiwyg_directives(&self.doc.vmap),
View::Source => Vec::new(),
};
let media = match self.doc.view {
View::Wysiwyg => wysiwyg_media(&self.doc.vmap, self.scheme),
View::Source => Vec::new(),
};
let (caret_row, caret_col) = self.doc.caret_pos();
let caret_ch = col_to_utf16(&self.row_text(caret_row), caret_col);
let (has_selection, anchor_row, anchor_ch) = match self.doc.selection() {
Some(_) => {
let a = self.doc.anchor.unwrap_or(self.doc.caret);
let (ar, ac) = self.pos_of_offset(a);
(true, ar, col_to_utf16(&self.row_text(ar), ac))
}
None => (false, caret_row, caret_ch),
};
let heading = self.doc.current_heading_level();
let active = self
.doc
.active_inline_marks()
.iter()
.map(|k| mark_id(k).to_string())
.collect();
let link = self.doc.link_destination_at_caret();
DocView {
rows,
tables,
directives,
media,
caret_row: caret_row as u32,
caret_col: caret_col as u32,
caret_ch: caret_ch as u32,
caret_src: self.doc.caret.min(self.doc.source.len()) as u32,
has_selection,
anchor_row: anchor_row as u32,
anchor_ch: anchor_ch as u32,
dirty: self.doc.dirty,
view: self.doc.view_name().to_string(),
heading,
active,
link,
}
}
}
#[uniffi::export]
impl LeafDoc {
#[uniffi::constructor]
pub fn new(source: String, format: String) -> Result<Arc<Self>, LeafError> {
let format = match format.to_ascii_lowercase().as_str() {
"markdown" | "md" => Format::Markdown,
"djot" | "dj" => Format::Djot,
"html" | "htm" => Format::Html,
"xml" => Format::Xml,
other => {
return Err(LeafError::UnknownFormat {
name: other.to_string(),
});
}
};
let doc = Doc::from_source(source, format).map_err(|e| LeafError::Parse {
message: e.to_string(),
})?;
Ok(Arc::new(LeafDoc {
inner: Mutex::new(Inner {
doc,
width: Some(80),
scheme: ColorScheme::Light,
}),
}))
}
pub fn view(&self) -> DocView {
self.lock().view()
}
pub fn set_width(&self, cols: u32) -> DocView {
let mut g = self.lock();
g.width = Some((cols as usize).max(1));
g.view()
}
pub fn set_unwrapped(&self) -> DocView {
let mut g = self.lock();
g.width = None;
g.view()
}
pub fn set_dark_appearance(&self, dark: bool) -> DocView {
let mut g = self.lock();
g.scheme = if dark {
ColorScheme::Dark
} else {
ColorScheme::Light
};
g.view()
}
pub fn set_media_rows(&self, heights: Vec<MediaHeight>) -> DocView {
let mut g = self.lock();
g.doc.set_media_rows(
heights
.into_iter()
.map(|h| (h.destination, h.rows.max(1) as usize))
.collect(),
);
g.view()
}
pub fn insert_media(&self, kind: MediaKind, destination: String, alt: String) -> DocView {
let mut g = self.lock();
let kind = match kind {
MediaKind::Image => CoreMediaKind::Image,
MediaKind::Video => CoreMediaKind::Video,
MediaKind::Audio => CoreMediaKind::Audio,
};
g.doc.insert_media(kind, &destination, &alt);
g.view()
}
pub fn insert_thematic_break(&self) -> DocView {
let mut g = self.lock();
g.doc.insert_thematic_break();
g.view()
}
pub fn source(&self) -> String {
self.lock().doc.source.clone()
}
pub fn selected_text(&self) -> Option<String> {
self.lock().doc.selected_text().map(str::to_string)
}
pub fn mark_saved(&self) -> DocView {
let mut g = self.lock();
g.doc.mark_saved();
g.view()
}
pub fn insert(&self, text: String) -> DocView {
let mut g = self.lock();
g.doc.insert(&text);
g.view()
}
pub fn paste(&self, text: String) -> DocView {
let mut g = self.lock();
g.doc.paste(&text);
g.view()
}
pub fn newline(&self) -> DocView {
let mut g = self.lock();
g.doc.newline();
g.view()
}
pub fn indent(&self) -> DocView {
let mut g = self.lock();
g.doc.indent();
g.view()
}
pub fn outdent(&self) -> DocView {
let mut g = self.lock();
g.doc.outdent();
g.view()
}
pub fn cell_tab(&self, forward: bool) -> Option<DocView> {
let mut g = self.lock();
g.sync();
g.doc.cell_tab(forward).then(|| g.view())
}
pub fn cell_return(&self) -> Option<DocView> {
let mut g = self.lock();
g.sync();
g.doc.cell_return().then(|| g.view())
}
pub fn cell_line_break(&self) -> Option<DocView> {
let mut g = self.lock();
g.sync();
g.doc.cell_line_break().then(|| g.view())
}
pub fn backspace(&self) -> DocView {
let mut g = self.lock();
g.doc.backspace();
g.view()
}
pub fn delete_forward(&self) -> DocView {
let mut g = self.lock();
g.doc.delete_forward();
g.view()
}
pub fn delete_word_back(&self) -> DocView {
let mut g = self.lock();
g.doc.delete_word_back();
g.view()
}
pub fn delete_word_forward(&self) -> DocView {
let mut g = self.lock();
g.doc.delete_word_forward();
g.view()
}
pub fn move_left(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_left(extend);
g.view()
}
pub fn move_right(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_right(extend);
g.view()
}
pub fn move_up(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_up(extend);
g.view()
}
pub fn move_down(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_down(extend);
g.view()
}
pub fn move_word_left(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_word_left(extend);
g.view()
}
pub fn move_word_right(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_word_right(extend);
g.view()
}
pub fn move_home(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_home(extend);
g.view()
}
pub fn move_end(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_end(extend);
g.view()
}
pub fn move_doc_start(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_doc_start(extend);
g.view()
}
pub fn move_doc_end(&self, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.move_doc_end(extend);
g.view()
}
pub fn select_all(&self) -> DocView {
let mut g = self.lock();
g.doc.select_all();
g.view()
}
pub fn click(&self, row: u32, col: u32, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
g.doc.click(row as usize, col as usize, extend);
g.view()
}
pub fn click_ch(&self, row: u32, ch: u32, extend: bool) -> DocView {
let mut g = self.lock();
g.sync();
let col = utf16_to_col(&g.row_text(row as usize), ch as usize);
g.doc.click(row as usize, col, extend);
g.view()
}
pub fn select_word_ch(&self, row: u32, ch: u32) -> DocView {
let mut g = self.lock();
let off = g.offset_at(row as usize, ch as usize);
g.doc.select_word_at(off);
g.view()
}
pub fn select_block_ch(&self, row: u32, ch: u32) -> DocView {
let mut g = self.lock();
let off = g.offset_at(row as usize, ch as usize);
g.doc.select_block_at(off);
g.view()
}
pub fn set_selection(
&self,
anchor_row: u32,
anchor_ch: u32,
focus_row: u32,
focus_ch: u32,
) -> DocView {
let mut g = self.lock();
let anchor = g.offset_at(anchor_row as usize, anchor_ch as usize);
let focus = g.offset_at(focus_row as usize, focus_ch as usize);
g.doc.place_caret(anchor, false);
if anchor != focus {
g.doc.place_caret(focus, true);
}
g.view()
}
pub fn selection_html(&self) -> Option<String> {
self.lock().doc.selection_html()
}
pub fn paste_rich(&self, html: Option<String>, text: String) -> DocView {
let mut g = self.lock();
let took = html.as_deref().is_some_and(|h| g.doc.paste_html(h));
if !took {
g.doc.paste(&text);
}
g.view()
}
pub fn toggle_bold(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle(InlineKind::Strong);
g.view()
}
pub fn toggle_italic(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle(InlineKind::Emph);
g.view()
}
pub fn toggle_code(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle(InlineKind::Verbatim);
g.view()
}
pub fn toggle_mark(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle(InlineKind::Mark);
g.view()
}
pub fn toggle_underline(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle(InlineKind::Insert);
g.view()
}
pub fn toggle_strike(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle(InlineKind::Delete);
g.view()
}
pub fn set_paragraph(&self) -> DocView {
let mut g = self.lock();
g.doc.set_block(BlockKind::Paragraph);
g.view()
}
pub fn set_heading(&self, level: u32) -> DocView {
let mut g = self.lock();
g.doc.toggle_heading(level);
g.view()
}
pub fn toggle_blockquote(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle_blockquote();
g.view()
}
pub fn toggle_list(&self, ordered: bool) -> DocView {
let mut g = self.lock();
g.doc.toggle_list(ordered);
g.view()
}
pub fn toggle_task_checked(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle_task_checked();
g.view()
}
pub fn toggle_task_at(&self, offset: u64) -> DocView {
let mut g = self.lock();
g.doc.toggle_task_at(offset as usize);
g.view()
}
pub fn toggle_task_item(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle_task_item();
g.view()
}
pub fn task_checked_at_caret(&self) -> Option<bool> {
let mut g = self.lock();
g.doc.task_checked_at_caret()
}
pub fn capabilities(&self) -> Capabilities {
self.lock().doc.capabilities().into()
}
pub fn authorable(&self) -> bool {
self.lock().doc.authorable()
}
pub fn caret_in_table(&self) -> bool {
self.lock().doc.caret_in_table()
}
pub fn table_insert_row(&self, below: bool) -> DocView {
let mut g = self.lock();
g.doc.table_insert_row(below);
g.view()
}
pub fn table_delete_row(&self) -> DocView {
let mut g = self.lock();
g.doc.table_delete_row();
g.view()
}
pub fn table_insert_column(&self, right: bool) -> DocView {
let mut g = self.lock();
g.doc.table_insert_column(right);
g.view()
}
pub fn table_delete_column(&self) -> DocView {
let mut g = self.lock();
g.doc.table_delete_column();
g.view()
}
pub fn table_set_alignment(&self, alignment: TableAlignment) -> DocView {
let mut g = self.lock();
g.doc.table_set_alignment(alignment.into_core());
g.view()
}
pub fn table_move_row(&self, down: bool) -> DocView {
let mut g = self.lock();
g.doc.table_move_row(down);
g.view()
}
pub fn table_move_column(&self, right: bool) -> DocView {
let mut g = self.lock();
g.doc.table_move_column(right);
g.view()
}
pub fn insert_link(&self, destination: String) -> DocView {
let mut g = self.lock();
g.doc.insert_link(&destination);
g.view()
}
pub fn link_destination_at_caret(&self) -> Option<String> {
self.lock().doc.link_destination_at_caret()
}
pub fn link_destination_at(&self, off: u32) -> Option<String> {
self.lock().doc.link_destination_at(off as usize)
}
pub fn locate(&self, id: String) -> Option<LandingView> {
self.lock().doc.locate(&id).map(LandingView::from)
}
pub fn insert_footnote(&self) -> DocView {
let mut g = self.lock();
g.doc.insert_footnote();
g.view()
}
pub fn footnote_at_caret(&self) -> Option<FootnoteView> {
self.lock().doc.footnote_at_caret().map(FootnoteView::from)
}
pub fn footnote_at(&self, off: u32) -> Option<FootnoteView> {
self.lock()
.doc
.footnote_at(off as usize)
.map(FootnoteView::from)
}
pub fn footnote_definition_at_caret(&self) -> Option<FootnoteDefView> {
self.lock()
.doc
.footnote_definition_at_caret()
.map(FootnoteDefView::from)
}
pub fn undo(&self) -> DocView {
let mut g = self.lock();
g.doc.undo();
g.view()
}
pub fn redo(&self) -> DocView {
let mut g = self.lock();
g.doc.redo();
g.view()
}
pub fn toggle_view(&self) -> DocView {
let mut g = self.lock();
g.doc.toggle_view();
g.view()
}
pub fn markup_mode(&self) -> MarkupMode {
MarkupMode::from_core(self.lock().doc.markup_mode())
}
pub fn set_markup_mode(&self, mode: MarkupMode) -> DocView {
let mut g = self.lock();
g.doc.set_markup_mode(mode.into_core());
g.view()
}
pub fn line_flow(&self) -> LineFlow {
LineFlow::from_core(self.lock().doc.line_flow())
}
pub fn set_line_flow(&self, mode: LineFlow) -> DocView {
let mut g = self.lock();
g.doc.set_line_flow(mode.into_core());
g.view()
}
}
#[uniffi::export]
impl LeafDoc {
pub fn caret_offset(&self) -> u32 {
self.lock().doc.caret as u32
}
pub fn anchor_offset(&self) -> u32 {
let g = self.lock();
g.doc.anchor.unwrap_or(g.doc.caret) as u32
}
pub fn doc_end_offset(&self) -> u32 {
let mut g = self.lock();
g.sync();
let end = g.doc.source.len();
g.snap_stop(end) as u32
}
pub fn snap_offset(&self, off: u32) -> u32 {
let mut g = self.lock();
g.sync();
g.snap_stop(off as usize) as u32
}
pub fn pos_for_offset(&self, off: u32) -> RowCol {
let mut g = self.lock();
g.sync();
let (row, col) = g.pos_of_offset(off as usize);
let ch = col_to_utf16(&g.row_text(row), col);
RowCol {
row: row as u32,
ch: ch as u32,
}
}
pub fn row_range_for(&self, start: u32, end: u32) -> RowRange {
let mut g = self.lock();
g.sync();
let (first, last) = g.row_range_for(start as usize, end as usize);
RowRange {
first: first as u32,
last: last as u32,
}
}
pub fn offset_for_pos(&self, row: u32, ch: u32) -> u32 {
let mut g = self.lock();
g.sync();
let col = utf16_to_col(&g.row_text(row as usize), ch as usize);
g.offset_of_col(row as usize, col) as u32
}
pub fn step_offset(&self, off: u32, delta: i32) -> u32 {
let mut g = self.lock();
g.sync();
let mut o = g.snap_stop(off as usize);
if delta >= 0 {
for _ in 0..delta {
match g.stop_after(o) {
Some(n) => o = n,
None => break,
}
}
} else {
for _ in 0..(-delta) {
match g.stop_before(o) {
Some(p) => o = p,
None => break,
}
}
}
o as u32
}
pub fn distance_offset(&self, from: u32, to: u32) -> i32 {
let mut g = self.lock();
g.sync();
let (from, to) = (from as usize, to as usize);
let (mut a, b, sign) = if from <= to {
(from, to, 1i32)
} else {
(to, from, -1i32)
};
a = g.snap_stop(a);
let mut n = 0i32;
while a < b {
match g.stop_after(a) {
Some(x) => {
a = x;
n += 1;
}
None => break,
}
}
n * sign
}
pub fn vertical_offset(&self, off: u32, down: bool) -> Option<u32> {
let mut g = self.lock();
g.sync();
let (row, col) = g.pos_of_offset(off as usize);
let target = if down {
g.nav_below(row)
} else {
g.nav_above(row)
};
target.map(|r| g.offset_of_col(r, col) as u32)
}
pub fn text_in_range(&self, from: u32, to: u32) -> String {
let mut g = self.lock();
g.sync();
let len = g.doc.source.len();
let (mut a, mut b) = ((from as usize).min(len), (to as usize).min(len));
if a > b {
std::mem::swap(&mut a, &mut b);
}
match g.doc.view {
View::Wysiwyg => g.doc.vmap.visible_text(a, b),
View::Source => {
let s = &g.doc.source;
while a > 0 && !s.is_char_boundary(a) {
a -= 1;
}
while b < s.len() && !s.is_char_boundary(b) {
b += 1;
}
s[a..b].to_string()
}
}
}
pub fn set_selection_offsets(&self, anchor: u32, focus: u32) -> DocView {
let mut g = self.lock();
g.doc.place_caret(anchor as usize, false);
if focus != anchor {
g.doc.place_caret(focus as usize, true);
}
g.view()
}
pub fn replace_range(&self, from: u32, to: u32, text: String) -> DocView {
let mut g = self.lock();
g.doc.place_caret(from as usize, false);
if to != from {
g.doc.place_caret(to as usize, true);
}
g.doc.insert(&text);
g.view()
}
}
impl LeafDoc {
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(|p| p.into_inner())
}
}
fn col_to_utf16(text: &str, col: usize) -> usize {
let mut c = 0usize;
let mut u = 0usize;
for g in text.graphemes(true) {
if c >= col {
break;
}
c += text_width(g);
u += g.chars().map(char::len_utf16).sum::<usize>();
}
u
}
fn utf16_to_col(text: &str, off: usize) -> usize {
let mut c = 0usize;
let mut u = 0usize;
for g in text.graphemes(true) {
if u >= off {
break;
}
u += g.chars().map(char::len_utf16).sum::<usize>();
c += text_width(g);
}
c
}
fn role_name(r: Role) -> String {
match r {
Role::Body => "body".into(),
Role::Heading(level) => format!("h{}", level.clamp(1, 6)),
Role::Code => "code".into(),
Role::Link => "link".into(),
Role::Mark => "mark".into(),
Role::ListMarker => "list".into(),
Role::QuoteGutter => "quote".into(),
Role::Rule => "rule".into(),
Role::Image => "image".into(),
Role::Delimiter => "delimiter".into(),
}
}
fn mark_id(kind: InlineKind) -> &'static str {
match kind {
InlineKind::Strong => "bold",
InlineKind::Emph => "italic",
InlineKind::Verbatim => "code",
InlineKind::Mark => "mark",
InlineKind::Insert => "underline",
InlineKind::Delete => "strike",
InlineKind::Superscript => "superscript",
InlineKind::Subscript => "subscript",
}
}
fn wysiwyg_rows(vmap: &VisualMap, ss: usize, se: usize) -> Vec<Row> {
vmap.rows
.iter()
.map(|vrow| {
Row {
runs: runs_of(&vrow.glyphs, ss, se),
decoration: vrow.decoration,
code: vrow.code,
code_lang: vrow.code_lang.clone(),
directive: vrow.directive,
directive_label: vrow.directive_label.clone(),
heading: vrow.heading,
boundary: vrow.boundary.map(|b| Boundary {
above: b.above.into(),
below: b.below.into(),
}),
}
})
.collect()
}
fn cell_lines(
glyphs: &[leaf_core::Glyph],
cell_start: usize,
cell_end: usize,
ss: usize,
se: usize,
) -> Vec<TableCellLineView> {
let mut lines = Vec::new();
let mut seg: Vec<leaf_core::Glyph> = Vec::new();
let mut line_start: Option<usize> = Some(cell_start);
for g in glyphs {
if g.ch == '\n' {
let start = line_start.unwrap_or(g.src);
lines.push(TableCellLineView {
runs: runs_of(&seg, ss, se),
start: start as u32,
end: g.src as u32,
});
seg.clear();
line_start = None;
} else {
if line_start.is_none() {
line_start = Some(g.src);
}
seg.push(g.clone());
}
}
lines.push(TableCellLineView {
runs: runs_of(&seg, ss, se),
start: line_start.unwrap_or(cell_end) as u32,
end: cell_end as u32,
});
lines
}
fn runs_of(glyphs: &[leaf_core::Glyph], ss: usize, se: usize) -> Vec<Run> {
let mut runs: Vec<Run> = Vec::new();
let mut buf = String::new();
let mut cur: Option<(LStyle, bool, usize)> = None;
for g in glyphs {
let key = (g.style, g.src >= ss && g.src < se);
match cur {
Some((style, sel, _)) if (style, sel) == key => buf.push(g.ch),
_ => {
if let Some((style, was_sel, src)) = cur.take() {
runs.push(make_run(std::mem::take(&mut buf), style, was_sel, src));
}
cur = Some((key.0, key.1, g.src));
buf.push(g.ch);
}
}
}
if let Some((style, was_sel, src)) = cur {
runs.push(make_run(buf, style, was_sel, src));
}
runs
}
fn wysiwyg_directives(vmap: &VisualMap) -> Vec<DirectiveView> {
vmap.directives
.iter()
.map(|d| DirectiveView {
start_row: d.rows_span.start as u32,
end_row: d.rows_span.end as u32,
name: d.name.clone(),
label: d.label.clone(),
attrs: d
.attrs
.iter()
.map(|(k, v)| DirectiveAttr {
key: k.clone(),
value: v.clone().unwrap_or_default(),
})
.collect(),
})
.collect()
}
fn wysiwyg_media(vmap: &VisualMap, scheme: ColorScheme) -> Vec<MediaView> {
vmap.media
.iter()
.map(|m| MediaView {
start_row: m.rows_span.start as u32,
end_row: m.rows_span.end as u32,
kind: match m.kind {
CoreMediaKind::Image => MediaKind::Image,
CoreMediaKind::Video => MediaKind::Video,
CoreMediaKind::Audio => MediaKind::Audio,
},
src: m.resolve(scheme).to_string(),
poster: m.poster.clone(),
alt: m.alt.clone(),
sources: m
.sources
.iter()
.map(|s| MediaSourceView {
media: s.media.clone(),
src: s.srcset.clone(),
mime: s.mime.clone(),
})
.collect(),
})
.collect()
}
fn wysiwyg_tables(vmap: &VisualMap, ss: usize, se: usize) -> Vec<TableView> {
vmap.tables
.iter()
.map(|t| TableView {
start_row: t.rows_span.start as u32,
end_row: t.rows_span.end as u32,
grid: t
.grid
.iter()
.map(|row| TableRowView {
head: row.head,
cells: row
.cells
.iter()
.map(|cell| TableCellView {
lines: cell_lines(&cell.glyphs, cell.start, cell.end, ss, se),
align: align_name(cell.align),
start: cell.start as u32,
end: cell.end as u32,
})
.collect(),
})
.collect(),
})
.collect()
}
fn align_name(a: Alignment) -> String {
match a {
Alignment::Left => "left",
Alignment::Right => "right",
Alignment::Center => "center",
Alignment::Default => "default",
}
.to_string()
}
fn source_rows(source: &str, ss: usize, se: usize) -> Vec<Row> {
let body = LStyle::default();
let mut rows = Vec::new();
let mut byte = 0usize;
for raw in source.split('\n') {
let start = byte;
let end = start + raw.len();
let a = ss.clamp(start, end) - start;
let b = se.clamp(start, end) - start;
let mut runs = Vec::new();
if a < b {
if a > 0 {
runs.push(make_run(raw[..a].to_string(), body, false, start));
}
runs.push(make_run(raw[a..b].to_string(), body, true, start + a));
if b < raw.len() {
runs.push(make_run(raw[b..].to_string(), body, false, start + b));
}
} else if !raw.is_empty() {
runs.push(make_run(raw.to_string(), body, false, start));
}
rows.push(Row {
runs,
decoration: false,
code: false,
code_lang: None,
directive: false,
directive_label: None,
heading: None, boundary: None, });
byte = end + 1; }
rows
}
fn make_run(text: String, style: LStyle, sel: bool, src: usize) -> Run {
Run {
text,
role: role_name(style.role),
bold: style.bold,
italic: style.italic,
underline: style.underline,
strike: style.strikethrough,
sup: style.baseline == Baseline::Super,
sub: style.baseline == Baseline::Sub,
src: src as u32,
sel,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(src: &str) -> Arc<LeafDoc> {
LeafDoc::new(src.to_string(), "markdown".to_string()).unwrap()
}
#[test]
fn a_footnote_definition_ending_the_file_is_itself_not_a_copy() {
let src = "A claim[^1] worth checking.\n\n# A heading with a reference[^1] in it\n\n[^1]: The first note.\n[^note]: A note with a word for a label.";
let d = LeafDoc::new(src.to_string(), "djot".to_string()).unwrap();
let text: Vec<String> = d
.view()
.rows
.iter()
.map(|r| r.runs.iter().map(|x| x.text.as_str()).collect())
.collect();
assert_eq!(
text.last().map(String::as_str),
Some("[note] A note with a word for a label."),
"the last definition should render itself: {text:?}"
);
assert_eq!(
text.iter()
.filter(|t| t.contains("A heading with a reference"))
.count(),
1,
"the heading should render exactly once: {text:?}"
);
}
#[test]
fn an_empty_heading_crosses_the_boundary_carrying_its_level() {
let d = doc("body\n\n# \n");
let v = d.view();
let head = v.rows.last().expect("the heading's row");
assert!(
head.runs.iter().all(|r| r.text.is_empty()),
"the `# ` marker is hidden"
);
assert_eq!(head.heading, Some(1));
assert_eq!(
v.rows[0].heading, None,
"the paragraph above is not a heading"
);
}
#[test]
fn typing_into_a_heading_made_on_a_blank_line_keeps_the_caret_on_its_row() {
let d = doc("one\n\ntwo\n\n\n\n");
let _ = d.click(4, 0, false); let _ = d.set_heading(1);
let mut v = d.view();
for c in "title".chars() {
v = d.insert(c.to_string());
}
assert_eq!(d.source(), "one\n\ntwo\n\n# title\n\n");
assert_eq!(
(v.caret_row, v.caret_ch),
(4, 5),
"the caret is on the heading's row"
);
assert_eq!(v.rows[4].heading, Some(1));
}
#[test]
fn a_video_crosses_the_boundary_as_media_with_the_rows_to_lay_it_over() {
let d = doc("<video src=\"clip.mp4\" poster=\"still.png\" controls></video>\n");
let v = d.view();
assert_eq!(v.media.len(), 1);
let m = &v.media[0];
assert!(matches!(m.kind, MediaKind::Video));
assert_eq!(m.src, "clip.mp4");
assert_eq!(m.poster, "still.png");
assert!(
m.end_row > m.start_row,
"the span must cover at least its label row"
);
}
#[test]
fn a_pictures_dark_source_resolves_by_appearance() {
let d = doc(
"<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
<img src=\"l.svg\" alt=\"banner\"></picture>\n",
);
assert_eq!(d.view().media[0].src, "l.svg", "light by default");
assert_eq!(d.set_dark_appearance(true).media[0].src, "d.svg");
assert_eq!(d.set_dark_appearance(false).media[0].src, "l.svg");
}
#[test]
fn tapping_below_a_trailing_picture_and_typing_keeps_it_a_picture() {
let d = doc("hi\n\n\n");
let v = d.set_unwrapped();
let row = v.media[0].start_row;
let label: u32 = v.rows[row as usize]
.runs
.iter()
.map(|r| r.text.encode_utf16().count() as u32)
.sum();
let off = d.offset_for_pos(row, label);
assert_eq!(
off,
"hi\n\n".len() as u32,
"the stop past the picture"
);
d.set_selection_offsets(off, off);
let after = d.insert("x".to_string());
assert_eq!(d.source(), "hi\n\n\n\nx\n");
assert_eq!(after.media.len(), 1, "still a picture, one paragraph up");
}
#[test]
fn backspace_from_that_same_tap_takes_the_picture_whole() {
let d = doc("hi\n\n\n");
d.set_unwrapped();
let off = "hi\n\n".len() as u32;
d.set_selection_offsets(off, off);
let after = d.backspace();
assert_eq!(d.source(), "hi\n");
assert_eq!(after.media.len(), 0, "gone as a picture, not as bytes");
let undone = d.undo();
assert_eq!(d.source(), "hi\n\n\n");
assert_eq!(
undone.media.len(),
1,
"and one undo brings the picture back"
);
}
#[test]
fn measured_heights_grow_the_reserved_span() {
let d = doc("\n");
let before = &d.view().media[0];
assert_eq!(
before.end_row - before.start_row,
1,
"one row until measured"
);
let after = d.set_media_rows(vec![MediaHeight {
destination: "cat.png".to_string(),
rows: 6,
}]);
let m = &after.media[0];
assert_eq!(
m.end_row - m.start_row,
6,
"the span grew to what was measured"
);
}
#[test]
fn inserted_media_comes_straight_back_out_as_media() {
let d = doc("\n");
let v = d.insert_media(
MediaKind::Audio,
"take.mp3".to_string(),
"a take".to_string(),
);
assert_eq!(v.media.len(), 1);
assert!(matches!(v.media[0].kind, MediaKind::Audio));
assert_eq!(v.media[0].src, "take.mp3");
assert_eq!(v.media[0].alt, "a take");
}
#[test]
fn the_source_view_publishes_no_media() {
let d = doc("<video src=\"clip.mp4\" controls></video>\n");
assert_eq!(d.view().media.len(), 1);
assert!(
d.toggle_view().media.is_empty(),
"no placeholders in the source view"
);
}
#[test]
fn an_offset_inside_a_multibyte_char_does_not_panic() {
let d = doc(
"# April 02, 2026\n\nAn interesting thing AI said to me:\n\n> a person… who journals\n",
);
d.toggle_view(); let src = d.source();
let mid = src.find('…').expect("the ellipsis is in the fixture") + 1;
assert!(
!src.is_char_boundary(mid),
"the fixture must be mid-character"
);
let _ = d.pos_for_offset(mid as u32);
let _ = d.vertical_offset(mid as u32, true);
let _ = d.vertical_offset(mid as u32, false);
let _ = d.snap_offset(mid as u32);
let _ = d.step_offset(mid as u32, 1);
let _ = d.step_offset(mid as u32, -1);
let _ = d.distance_offset(0, mid as u32);
let _ = d.text_in_range(0, mid as u32);
let _ = d.set_selection_offsets(mid as u32, mid as u32);
let _ = d.replace_range(mid as u32, mid as u32, "x".to_string());
assert!(
d.source().is_char_boundary(d.caret_offset() as usize),
"the caret must sit on a character boundary"
);
}
#[test]
fn cell_lines_split_on_the_break_glyph_carrying_each_lines_source_range() {
use leaf_core::Glyph;
let g = |ch, src| Glyph {
ch,
style: LStyle::default(),
src,
stop: true,
};
let glyphs = [g('a', 10), g('\n', 11), g('b', 15)];
let lines = cell_lines(&glyphs, 10, 16, 0, 0);
assert_eq!(lines.len(), 2, "one break makes two lines");
assert_eq!(
(lines[0].start, lines[0].end),
(10, 11),
"line 1 ends at the break"
);
assert_eq!(
(lines[1].start, lines[1].end),
(15, 16),
"line 2 begins past it"
);
let text =
|l: &TableCellLineView| l.runs.iter().map(|r| r.text.clone()).collect::<String>();
assert_eq!(text(&lines[0]), "a");
assert_eq!(text(&lines[1]), "b");
let trailing = [g('a', 10), g('\n', 11)];
let lines = cell_lines(&trailing, 10, 15, 0, 0);
assert_eq!(lines.len(), 2);
assert!(lines[1].runs.is_empty());
assert_eq!((lines[1].start, lines[1].end), (15, 15));
let plain = [g('P', 10), g('e', 11)];
let lines = cell_lines(&plain, 10, 12, 0, 0);
assert_eq!(lines.len(), 1);
assert_eq!((lines[0].start, lines[0].end), (10, 12));
}
fn row_text(v: &DocView, row: usize) -> String {
v.rows[row].runs.iter().map(|r| r.text.clone()).collect()
}
#[test]
fn unwrapped_collapses_a_paragraph_to_one_row() {
let d = doc("one two three four five six seven eight\n");
let wrapped = d.set_width(10);
let unwrapped = d.set_unwrapped();
assert!(
unwrapped.rows.len() < wrapped.rows.len(),
"a narrow column wrap splits the paragraph; unwrapped keeps it whole"
);
assert!(
(0..unwrapped.rows.len()).any(|i| row_text(&unwrapped, i).contains("eight")),
"the whole paragraph, including its last word, sits on a single unwrapped row"
);
}
#[test]
fn offsets_round_trip_when_unwrapped() {
let d = doc("hello world\n");
d.set_unwrapped();
let rc = d.pos_for_offset(6); assert_eq!(d.offset_for_pos(rc.row, rc.ch), 6);
}
#[test]
fn set_unwrapped_is_idempotent() {
let d = doc("a paragraph of some length here\n");
let first = d.set_unwrapped();
let second = d.set_unwrapped();
assert_eq!(first.rows.len(), second.rows.len());
}
#[test]
fn newline_on_last_list_item_before_a_blockquote_starts_a_new_item() {
let src = "- one\n- two\n- three\n\n> quote\n";
let d = doc(src);
let off = (src.find("three").unwrap() + "three".len()) as u32; d.set_selection_offsets(off, off);
d.newline();
let after = d.source();
assert!(
after.contains("- three\n- ") && after.contains("> quote"),
"expected a new empty list item with the blockquote intact, got: {after:?}"
);
}
#[test]
fn enter_on_an_empty_line_adds_one_newline_and_one_backspace_undoes_it() {
let d = doc("hello\n");
d.set_selection_offsets(5, 5);
d.newline(); let after_para = d.source();
let caret_para = d.caret_offset();
d.newline(); assert_eq!(
d.source().len(),
after_para.len() + 1,
"an empty-line Enter adds a single newline, not another paragraph break"
);
d.backspace(); assert_eq!(d.source(), after_para);
assert_eq!(d.caret_offset(), caret_para);
}
#[test]
fn enter_in_a_nonempty_paragraph_still_opens_a_new_paragraph() {
let d = doc("hello\n");
d.set_selection_offsets(5, 5);
let before = d.source().len();
d.newline();
assert_eq!(
d.source().len(),
before + 2,
"a paragraph break is still \\n\\n"
);
}
#[test]
fn link_destination_at_caret_reads_the_caret_link() {
let d = doc("see [t](https://x.dev) ok\n");
d.set_selection_offsets(5, 5); assert_eq!(
d.link_destination_at_caret().as_deref(),
Some("https://x.dev")
);
d.set_selection_offsets(0, 0); assert_eq!(d.link_destination_at_caret(), None);
}
#[test]
fn the_frame_carries_the_caret_link_so_a_toolbar_can_light_and_seed_from_it() {
let d = doc("see [t](https://x.dev) ok\n");
d.set_selection_offsets(5, 5);
let inside = d.view();
assert_eq!(inside.link.as_deref(), Some("https://x.dev"));
assert_eq!(inside.heading, None);
assert!(inside.active.is_empty());
d.set_selection_offsets(0, 0);
let outside = d.view();
assert_eq!(outside.link, None);
assert_eq!(outside.heading, inside.heading);
assert_eq!(outside.active, inside.active);
}
#[test]
fn insert_footnote_crosses_and_leaves_the_caret_in_the_new_note() {
let d = doc("A claim and more.\n");
d.set_selection_offsets(7, 7); d.insert_footnote();
assert!(
d.source().starts_with("A claim[^1] and more."),
"{:?}",
d.source()
);
assert!(d.source().contains("[^1]:"), "{:?}", d.source());
let note = d.footnote_at(9).expect("the reference just written");
assert_eq!(note.label, "1");
assert_eq!(
d.caret_offset(),
note.offset.expect("an empty note is still a place")
);
assert_eq!(
d.footnote_definition_at_caret().expect("in the note").label,
"1"
);
}
#[test]
fn capabilities_answer_for_footnotes_the_way_the_format_does() {
assert!(
doc("x\n").capabilities().footnote,
"markdown spells the pair"
);
let html = LeafDoc::new("<p>x</p>\n".to_string(), "html".to_string()).unwrap();
assert!(
!html.capabilities().footnote,
"html has no footnote of its own"
);
}
#[test]
fn footnote_at_caret_crosses_with_its_note_and_its_offset() {
let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
d.set_selection_offsets(9, 9); let f = d
.footnote_at_caret()
.expect("the caret stands in a reference");
assert_eq!(f.label, "1");
assert_eq!(f.text.as_deref(), Some("the note"));
assert_eq!(f.offset, Some(29));
assert_eq!(f.end, Some(37));
d.set_selection_offsets(0, 0); assert!(d.footnote_at_caret().is_none());
}
#[test]
fn footnote_at_crosses_for_an_offset_without_moving_the_caret() {
let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
d.set_selection_offsets(0, 0);
let f = d.footnote_at(9).expect("offset 9 stands in the reference");
assert_eq!(f.label, "1");
assert_eq!(f.text.as_deref(), Some("the note"));
assert_eq!(d.caret_offset(), 0, "asking must not move the caret");
assert!(d.footnote_at(2).is_none(), "offset 2 is prose");
}
#[test]
fn footnote_definition_at_caret_crosses_with_the_way_back() {
let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
d.set_selection_offsets(30, 30); let f = d
.footnote_definition_at_caret()
.expect("the caret stands in a definition");
assert_eq!(f.label, "1");
assert_eq!(f.offset, Some(9), "the reference's label");
d.set_selection_offsets(9, 9);
assert!(d.footnote_definition_at_caret().is_none());
assert!(d.footnote_at_caret().is_some());
}
#[test]
fn a_notes_offsets_map_to_its_rendered_rows() {
let src = "Claim[^a].\n\n[^a]: see *emphasis* and `code` and [a link](https://x.dev).\n";
let d = doc(src);
let view = d.set_unwrapped();
d.set_selection_offsets(6, 6);
let f = d.footnote_at_caret().expect("a reference");
let start = d.pos_for_offset(f.offset.expect("a note"));
let end = d.pos_for_offset(f.end.expect("a note") - 1);
assert_eq!(
start.row, end.row,
"a one-paragraph note is one unwrapped row"
);
let row = &view.rows[start.row as usize];
let runs: Vec<(&str, &str, bool)> = row
.runs
.iter()
.map(|r| (r.role.as_str(), r.text.as_str(), r.italic))
.collect();
assert!(runs.contains(&("body", "emphasis", true)), "got {runs:?}");
assert!(
runs.iter()
.any(|(role, text, _)| *role == "code" && *text == "code"),
"got {runs:?}"
);
assert!(
runs.iter()
.any(|(role, text, _)| *role == "link" && *text == "a link"),
"got {runs:?}"
);
let rendered: String = row.runs.iter().map(|r| r.text.as_str()).collect();
assert!(
!rendered.contains('*') && !rendered.contains('`'),
"got {rendered:?}"
);
assert!(
f.text.as_deref().unwrap().contains('*'),
"the source answer keeps them"
);
assert_eq!(row.runs[0].role, "list");
assert_eq!(start.ch as usize, row.runs[0].text.chars().count());
let link = row
.runs
.iter()
.find(|r| r.role == "link")
.expect("a link run");
assert_eq!(
d.link_destination_at(link.src).as_deref(),
Some("https://x.dev"),
"the run at {} is the link",
link.src
);
}
#[test]
fn a_note_ending_in_a_link_covers_its_own_row_and_no_other() {
let src = "A[^1] B[^2] C[^3].\n\n\
[^1]: https://en.wikipedia.org/wiki/Moravec%27s_paradox\n\n\
[^2]: [\"How to Get Startup Ideas,\" Nov 2012](https://www.paulgraham.com/startupideas.html)\n\n\
[^3]: [Alma 37:46](https://www.churchofjesuschrist.org/study/scriptures/bofm/alma/37?lang=eng&id=p46#p46)\n";
let d = doc(src);
let view = d.set_unwrapped();
let off2 = src.find("[^2] C").unwrap() as u32 + 2;
d.set_selection_offsets(off2, off2);
let f = d.footnote_at_caret().expect("a reference");
let (start, end) = (f.offset.expect("a note"), f.end.expect("a note"));
let span = d.row_range_for(start, end);
assert_eq!(span.first, span.last, "one note is one unwrapped row");
let drawn: String = view.rows[span.first as usize]
.runs
.iter()
.map(|r| r.text.as_str())
.collect();
assert!(drawn.contains("How to Get Startup Ideas"), "got {drawn:?}");
assert!(
!drawn.contains("Alma"),
"note 3 leaked into the peek: {drawn:?}"
);
assert_ne!(
d.pos_for_offset(end - 1).row,
span.last,
"the forward snap still leaves the note's row — that is the point",
);
let off1 = src.find("[^1] B").unwrap() as u32 + 2;
d.set_selection_offsets(off1, off1);
let f1 = d.footnote_at_caret().expect("a reference");
let one = d.row_range_for(f1.offset.unwrap(), f1.end.unwrap());
assert_eq!(one.first, one.last);
assert_ne!(one.first, span.first, "and it is a different note");
}
#[test]
fn a_runs_source_offset_survives_multibyte_prose_ahead_of_it() {
let src = "Claim[^a].\n\n[^a]: 日記 café [a link](https://x.dev).\n";
let d = doc(src);
let view = d.set_unwrapped();
d.set_selection_offsets(6, 6);
let f = d.footnote_at_caret().expect("a reference");
let start = d.pos_for_offset(f.offset.expect("a note"));
let row = &view.rows[start.row as usize];
let link = row
.runs
.iter()
.find(|r| r.role == "link")
.expect("a link run");
assert_eq!(
d.link_destination_at(link.src).as_deref(),
Some("https://x.dev")
);
assert_eq!(
&src[link.src as usize..][.."a link".len()],
"a link",
"and it is a byte offset, not a character or column index"
);
let counted: usize = row
.runs
.iter()
.take_while(|r| r.role != "link")
.map(|r| r.text.chars().count())
.sum();
assert_ne!(counted, link.src as usize);
}
#[test]
fn following_a_footnote_and_coming_back_lands_on_real_caret_stops() {
let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
d.set_selection_offsets(9, 9);
let down = d
.footnote_at_caret()
.expect("a reference")
.offset
.expect("a note");
d.set_selection_offsets(down, down);
assert_eq!(
d.caret_offset(),
down,
"the note is somewhere the caret fits"
);
let up = d
.footnote_definition_at_caret()
.expect("arrived inside the definition")
.offset
.expect("a reference to return to");
d.set_selection_offsets(up, up);
assert_eq!(d.caret_offset(), up, "and so is the reference");
assert_eq!(
d.footnote_at_caret().expect("back on the reference").label,
"1"
);
}
#[test]
fn a_footnote_reference_crosses_the_ffi_raised() {
let d = doc("A claim[^1] and more.\n");
let view = d.view();
let runs: Vec<&Run> = view.rows.iter().flat_map(|r| &r.runs).collect();
let chip = runs
.iter()
.find(|r| r.text.contains('1'))
.expect("the reference's chip");
assert!(chip.sup, "the reference should cross raised");
assert!(!chip.sub);
assert_eq!(
chip.role, "link",
"and still carrying the role every frontend paints"
);
let prose = runs
.iter()
.find(|r| r.text.contains("claim"))
.expect("the prose");
assert!(!prose.sup && !prose.sub);
}
#[test]
fn text_in_range_hides_delimiters_like_the_screen_does() {
let d = doc("a **bold** c\n");
assert_eq!(d.text_in_range(7, 10), "d");
assert_eq!(
d.text_in_range(7, 10).chars().count() as i32,
d.distance_offset(7, 10),
"text(in:).count() must equal offset(from:to:) — the UITextInput invariant this bug broke"
);
assert_eq!(d.text_in_range(0, 1), "a");
assert_eq!(d.text_in_range(11, 12), "c");
assert_eq!(
d.text_in_range(0, 1).chars().count() as i32,
d.distance_offset(0, 1)
);
}
#[test]
fn text_in_range_matches_distance_offset_across_marked_up_and_plain_spans() {
let d = doc("a **bold** _em_ and `code` here\n");
let len = d.source().len() as u32;
let mut pairs = Vec::new();
let mut a = 0u32;
while a < len {
let mut b = a + 1;
while b <= len {
pairs.push((a, b));
b += 3; }
a += 1;
}
for (a, b) in pairs {
let text = d.text_in_range(a, b);
let dist = d.distance_offset(a, b).abs();
assert_eq!(
text.chars().count() as i32,
dist,
"text_in_range({a}, {b}) = {text:?} has {} chars, but distance_offset says {dist}",
text.chars().count()
);
}
}
#[test]
fn text_in_range_separates_paragraphs_so_words_dont_merge_across_the_gap() {
let d = doc("hello\n\nhello\n\nhello\n");
let src = d.source();
assert_eq!(
src.find("hello").unwrap(),
0,
"paragraph 1 at the very start"
);
let p2 = src[5..].find("hello").unwrap() + 5;
let text = d.text_in_range(3, p2 as u32 + 2);
assert_ne!(
text, "lohe",
"the two paragraphs' words must not read as merged"
);
assert!(
text.chars().any(|c| !c.is_alphanumeric()),
"a non-letter must separate the two paragraphs' words: got {text:?}"
);
assert_eq!(
text, "lo\nhe",
"exactly one separator opens the second paragraph's head"
);
let gap_only = d.text_in_range(5, p2 as u32);
assert!(
gap_only.chars().count() as i32 >= d.distance_offset(5, p2 as u32),
"text_in_range must never be shorter than distance_offset: {gap_only:?}"
);
for (a, b) in [(0u32, src.len() as u32), (3, p2 as u32 + 2), (5, p2 as u32)] {
let text = d.text_in_range(a, b);
let dist = d.distance_offset(a, b);
assert!(
text.chars().count() as i32 >= dist,
"text_in_range({a}, {b}) = {text:?} ({} chars) is shorter than distance_offset {dist}",
text.chars().count()
);
}
assert_eq!(
d.distance_offset(5, p2 as u32),
1,
"one Right crosses the whole gap"
);
}
}