use std::borrow::Cow;
use std::sync::{Arc, Mutex};
use leaf_core::style::{Baseline, Role, Style as LStyle};
use leaf_core::wysiwyg::text_width;
use leaf_core::{
Align as CoreAlign, Alignment, BlockKind, Capabilities as CoreCapabilities, ColorScheme, Doc,
FaceTable as CoreFaceTable, FontFace as CoreFontFace, FontFamily as CoreFontFamily,
FontSize as CoreFontSize, Format, Hundredths as CoreHundredths, InlineKind,
LineFlow as CoreLineFlow, LineHeight as CoreLineHeight, LineSpacing as CoreLineSpacing,
MarkColor as CoreMarkColor, MarkupMode as CoreMarkupMode, MediaKind as CoreMediaKind,
SizeStep as CoreSizeStep, TextColor as CoreTextColor, View, VisualMap,
};
use unicode_segmentation::UnicodeSegmentation;
use resvg_uniffi as _;
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 SelectionQuote {
pub exact: String,
pub prefix: String,
pub suffix: String,
pub start: u64,
pub end: u64,
}
#[derive(uniffi::Record)]
pub struct Highlight {
pub start: u64,
pub end: u64,
pub id: String,
pub color: Option<String>,
pub marker: Option<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,
pub hl: Option<String>,
pub hl_color: Option<String>,
pub mark_color: Option<String>,
pub token: Option<String>,
pub size: Option<String>,
pub font: Option<String>,
pub text_color: Option<String>,
}
#[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 align: Option<String>,
pub line_height: Option<String>,
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 can_undo: bool,
pub can_redo: bool,
pub view: String,
pub heading: Option<u32>,
pub active: Vec<String>,
pub link: Option<String>,
pub mark_color: Option<MarkColor>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum MarkColor {
Red,
Orange,
Yellow,
Green,
Blue,
Purple,
Brown,
}
impl From<CoreMarkColor> for MarkColor {
fn from(c: CoreMarkColor) -> Self {
match c {
CoreMarkColor::Red => MarkColor::Red,
CoreMarkColor::Orange => MarkColor::Orange,
CoreMarkColor::Yellow => MarkColor::Yellow,
CoreMarkColor::Green => MarkColor::Green,
CoreMarkColor::Blue => MarkColor::Blue,
CoreMarkColor::Purple => MarkColor::Purple,
CoreMarkColor::Brown => MarkColor::Brown,
}
}
}
impl From<MarkColor> for CoreMarkColor {
fn from(c: MarkColor) -> Self {
match c {
MarkColor::Red => CoreMarkColor::Red,
MarkColor::Orange => CoreMarkColor::Orange,
MarkColor::Yellow => CoreMarkColor::Yellow,
MarkColor::Green => CoreMarkColor::Green,
MarkColor::Blue => CoreMarkColor::Blue,
MarkColor::Purple => CoreMarkColor::Purple,
MarkColor::Brown => CoreMarkColor::Brown,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum Align {
Center,
Right,
Justify,
}
impl From<CoreAlign> for Align {
fn from(a: CoreAlign) -> Self {
match a {
CoreAlign::Center => Align::Center,
CoreAlign::Right => Align::Right,
CoreAlign::Justify => Align::Justify,
}
}
}
impl From<Align> for CoreAlign {
fn from(a: Align) -> Self {
match a {
Align::Center => CoreAlign::Center,
Align::Right => CoreAlign::Right,
Align::Justify => CoreAlign::Justify,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum LineSpacing {
OneFifteen,
OneHalf,
Double,
}
impl From<CoreLineSpacing> for LineSpacing {
fn from(l: CoreLineSpacing) -> Self {
match l {
CoreLineSpacing::OneFifteen => LineSpacing::OneFifteen,
CoreLineSpacing::OneHalf => LineSpacing::OneHalf,
CoreLineSpacing::Double => LineSpacing::Double,
}
}
}
impl From<LineSpacing> for CoreLineSpacing {
fn from(l: LineSpacing) -> Self {
match l {
LineSpacing::OneFifteen => CoreLineSpacing::OneFifteen,
LineSpacing::OneHalf => CoreLineSpacing::OneHalf,
LineSpacing::Double => CoreLineSpacing::Double,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum SizeStep {
XxSmall,
XSmall,
Small,
Large,
XLarge,
XxLarge,
XxxLarge,
}
impl From<CoreSizeStep> for SizeStep {
fn from(s: CoreSizeStep) -> Self {
match s {
CoreSizeStep::XxSmall => SizeStep::XxSmall,
CoreSizeStep::XSmall => SizeStep::XSmall,
CoreSizeStep::Small => SizeStep::Small,
CoreSizeStep::Large => SizeStep::Large,
CoreSizeStep::XLarge => SizeStep::XLarge,
CoreSizeStep::XxLarge => SizeStep::XxLarge,
CoreSizeStep::XxxLarge => SizeStep::XxxLarge,
}
}
}
impl From<SizeStep> for CoreSizeStep {
fn from(s: SizeStep) -> Self {
match s {
SizeStep::XxSmall => CoreSizeStep::XxSmall,
SizeStep::XSmall => CoreSizeStep::XSmall,
SizeStep::Small => CoreSizeStep::Small,
SizeStep::Large => CoreSizeStep::Large,
SizeStep::XLarge => CoreSizeStep::XLarge,
SizeStep::XxLarge => CoreSizeStep::XxLarge,
SizeStep::XxxLarge => CoreSizeStep::XxxLarge,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum FontFamily {
Serif,
SansSerif,
Monospace,
Cursive,
}
impl From<CoreFontFamily> for FontFamily {
fn from(f: CoreFontFamily) -> Self {
match f {
CoreFontFamily::Serif => FontFamily::Serif,
CoreFontFamily::SansSerif => FontFamily::SansSerif,
CoreFontFamily::Monospace => FontFamily::Monospace,
CoreFontFamily::Cursive => FontFamily::Cursive,
}
}
}
impl From<FontFamily> for CoreFontFamily {
fn from(f: FontFamily) -> Self {
match f {
FontFamily::Serif => CoreFontFamily::Serif,
FontFamily::SansSerif => CoreFontFamily::SansSerif,
FontFamily::Monospace => CoreFontFamily::Monospace,
FontFamily::Cursive => CoreFontFamily::Cursive,
}
}
}
enum Meant<T> {
Value(T),
Absence,
Refused,
}
impl<T> Meant<T> {
fn of(value: Option<T>) -> Self {
match value {
Some(value) => Self::Value(value),
None => Self::Refused,
}
}
}
fn written<T, C>(value: Option<T>, into_core: impl FnOnce(T) -> Meant<C>) -> Option<Option<C>> {
match value.map(into_core) {
None | Some(Meant::Absence) => Some(None),
Some(Meant::Value(value)) => Some(Some(value)),
Some(Meant::Refused) => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, uniffi::Enum)]
pub enum FontSize {
Step(SizeStep),
Points(f64),
}
impl FontSize {
fn into_core(self) -> Meant<CoreFontSize> {
match self {
FontSize::Step(step) => Meant::Value(CoreFontSize::Step(step.into())),
FontSize::Points(points) => Meant::of(CoreFontSize::points(points as f32)),
}
}
}
impl From<CoreFontSize> for FontSize {
fn from(s: CoreFontSize) -> Self {
match s {
CoreFontSize::Step(step) => FontSize::Step(step.into()),
CoreFontSize::Points(pt) => FontSize::Points(f64::from(pt.hundredths()) / 100.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, uniffi::Enum)]
pub enum LineHeight {
Step(LineSpacing),
Ratio(f64),
}
impl LineHeight {
fn into_core(self) -> Meant<CoreLineHeight> {
match self {
LineHeight::Step(step) => Meant::Value(CoreLineHeight::Step(step.into())),
LineHeight::Ratio(ratio) => match CoreHundredths::from_f32(ratio as f32) {
None => Meant::Refused,
Some(_) => match CoreLineHeight::ratio(ratio as f32) {
Some(height) => Meant::Value(height),
None => Meant::Absence,
},
},
}
}
}
impl From<CoreLineHeight> for LineHeight {
fn from(l: CoreLineHeight) -> Self {
match l {
CoreLineHeight::Step(step) => LineHeight::Step(step.into()),
CoreLineHeight::Ratio(r) => LineHeight::Ratio(f64::from(r.hundredths()) / 100.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, uniffi::Enum)]
pub enum TextColor {
Named(MarkColor),
Rgb { r: u8, g: u8, b: u8 },
}
impl TextColor {
fn into_core(self) -> CoreTextColor {
match self {
TextColor::Named(color) => CoreTextColor::Named(color.into()),
TextColor::Rgb { r, g, b } => CoreTextColor::Rgb { r, g, b },
}
}
}
impl From<CoreTextColor> for TextColor {
fn from(c: CoreTextColor) -> Self {
match c {
CoreTextColor::Named(color) => TextColor::Named(color.into()),
CoreTextColor::Rgb { r, g, b } => TextColor::Rgb { r, g, b },
}
}
}
#[derive(Clone, Debug, PartialEq, uniffi::Enum)]
pub enum FontFace {
Generic(FontFamily),
Named(String),
}
impl FontFace {
fn into_core(self) -> Meant<CoreFontFace> {
match self {
FontFace::Generic(family) => Meant::Value(CoreFontFace::Generic(family.into())),
FontFace::Named(name) => Meant::of(CoreFontFace::from_attr(&name)),
}
}
}
impl From<CoreFontFace> for FontFace {
fn from(f: CoreFontFace) -> Self {
match f {
CoreFontFace::Generic(family) => FontFace::Generic(family.into()),
CoreFontFace::Named(name) => FontFace::Named(name),
}
}
}
#[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 mark_color: 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,
pub alignment: bool,
pub line_spacing: bool,
pub font_size: bool,
pub font_family: bool,
pub text_color: bool,
pub page_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,
mark_color: c.mark_color,
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,
alignment: c.alignment,
line_spacing: c.line_spacing,
font_size: c.font_size,
font_family: c.font_family,
text_color: c.text_color,
page_break: c.page_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 snap_glyph_stop(&self, off: usize) -> usize {
match self.doc.view {
View::Wysiwyg => self
.doc
.vmap
.snap_to_glyph_stop(off.min(self.doc.source.len())),
View::Source => self.snap_stop(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, self.doc.highlights()),
View::Source => source_rows(&self.doc.source, ss, se),
};
let tables = match self.doc.view {
View::Wysiwyg => wysiwyg_tables(&self.doc.vmap, ss, se, self.doc.highlights()),
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();
let mark_color = self.doc.mark_color_at_caret().map(MarkColor::from);
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,
can_undo: self.doc.can_undo(),
can_redo: self.doc.can_redo(),
view: self.doc.view_name().to_string(),
heading,
active,
link,
mark_color,
}
}
}
#[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 selection_quote(&self, context: u32) -> Option<SelectionQuote> {
let g = self.lock();
g.doc
.selection_quote(context as usize)
.map(|q| SelectionQuote {
exact: q.exact,
prefix: q.prefix,
suffix: q.suffix,
start: q.start as u64,
end: q.end as u64,
})
}
pub fn read_only(&self) -> bool {
self.lock().doc.read_only()
}
pub fn set_read_only(&self, on: bool) -> DocView {
let mut g = self.lock();
g.doc.set_read_only(on);
g.view()
}
pub fn set_highlights(&self, highlights: Vec<Highlight>) -> DocView {
let mut g = self.lock();
let hls = highlights
.into_iter()
.map(|h| leaf_core::Highlight {
start: h.start as usize,
end: h.end as usize,
id: h.id,
color: h.color,
marker: h.marker,
})
.collect();
g.doc.set_highlights(hls);
g.view()
}
pub fn highlight_at(&self, offset: u32) -> Option<String> {
self.lock()
.doc
.highlight_at(offset as usize)
.map(|h| h.id.clone())
}
pub fn highlights(&self) -> Vec<Highlight> {
self.lock()
.doc
.highlights()
.iter()
.map(|h| Highlight {
start: h.start as u64,
end: h.end as u64,
id: h.id.clone(),
color: h.color.clone(),
marker: h.marker.clone(),
})
.collect()
}
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 caret_in_mark(&self) -> bool {
self.lock().doc.caret_in_mark()
}
pub fn set_mark_color(&self, color: Option<MarkColor>) -> DocView {
let mut g = self.lock();
g.doc.set_mark_color(color.map(CoreMarkColor::from));
g.view()
}
pub fn highlight(&self, color: Option<MarkColor>) -> DocView {
let mut g = self.lock();
g.doc.highlight(color.map(CoreMarkColor::from));
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 set_alignment(&self, align: Option<Align>) -> DocView {
let mut g = self.lock();
g.doc.set_alignment(align.map(CoreAlign::from));
g.view()
}
pub fn set_line_spacing(&self, spacing: Option<LineHeight>) -> DocView {
let mut g = self.lock();
if let Some(spacing) = written(spacing, LineHeight::into_core) {
g.doc.set_line_spacing(spacing);
}
g.view()
}
pub fn set_font_size(&self, size: Option<FontSize>) -> DocView {
let mut g = self.lock();
if let Some(size) = written(size, FontSize::into_core) {
g.doc.set_font_size(size);
}
g.view()
}
pub fn set_font_family(&self, font: Option<FontFace>) -> DocView {
let mut g = self.lock();
if let Some(font) = written(font, FontFace::into_core) {
g.doc.set_font_family(font);
}
g.view()
}
pub fn set_text_color(&self, color: Option<TextColor>) -> DocView {
let mut g = self.lock();
g.doc.set_text_color(color.map(TextColor::into_core));
g.view()
}
pub fn insert_page_break(&self) -> DocView {
let mut g = self.lock();
g.doc.insert_page_break();
g.view()
}
pub fn alignment_at_caret(&self) -> Option<Align> {
let mut g = self.lock();
g.doc.alignment_at_caret().map(Align::from)
}
pub fn line_spacing_at_caret(&self) -> Option<LineHeight> {
let mut g = self.lock();
g.doc.line_spacing_at_caret().map(LineHeight::from)
}
pub fn font_size_at_caret(&self) -> Option<FontSize> {
let mut g = self.lock();
g.doc.font_size_at_caret().map(FontSize::from)
}
pub fn font_family_at_caret(&self) -> Option<FontFace> {
let mut g = self.lock();
g.doc.font_family_at_caret().map(FontFace::from)
}
pub fn text_color_at_caret(&self) -> Option<TextColor> {
let mut g = self.lock();
g.doc.text_color_at_caret().map(TextColor::from)
}
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_table(&self, rows: u32, cols: u32) -> DocView {
let mut g = self.lock();
g.doc.insert_table(rows as usize, cols as usize);
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 image_destination_at_caret(&self) -> Option<String> {
self.lock().doc.image_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_glyph_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_glyph_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 utf16_index_for_offset(&self, off: u32) -> u32 {
let mut g = self.lock();
g.sync();
let off = (off as usize).min(g.doc.source.len());
match g.doc.view {
View::Wysiwyg => g.doc.vmap.visible_utf16_len(0, off) as u32,
View::Source => {
let off = g.snap_stop(off);
g.doc.source[..off].encode_utf16().count() as u32
}
}
}
pub fn offset_for_utf16_index(&self, index: u32) -> u32 {
let mut g = self.lock();
g.sync();
let len = g.doc.source.len();
let end = g.snap_stop(len);
let index = index as usize;
match g.doc.view {
View::Wysiwyg => g
.doc
.vmap
.offset_at_visible_utf16(end, index)
.map_or(end, |o| g.snap_stop(o)) as u32,
View::Source => {
let mut seen = 0usize;
for (i, ch) in g.doc.source.char_indices() {
let n = ch.len_utf16();
if index < seen + n {
return i as u32;
}
seen += n;
}
end as u32
}
}
}
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 select_range(&self, start: u32, end: u32) -> DocView {
let mut g = self.lock();
g.doc.select_range(start as usize, end as usize);
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, hls: &[leaf_core::Highlight]) -> Vec<Row> {
let faces = vmap.faces();
vmap.rows
.iter()
.map(|vrow| {
Row {
runs: runs_of(&vrow.glyphs, ss, se, hls, faces),
decoration: vrow.decoration,
code: vrow.code,
code_lang: vrow.code_lang.clone(),
directive: vrow.directive,
directive_label: vrow.directive_label.clone(),
heading: vrow.heading,
align: vrow.align.map(|a| a.name().to_string()),
line_height: vrow.line_height.map(|l| l.name().to_string()),
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,
hls: &[leaf_core::Highlight],
faces: &CoreFaceTable,
) -> 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, hls, faces),
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, hls, faces),
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,
hls: &[leaf_core::Highlight],
faces: &CoreFaceTable,
) -> Vec<Run> {
let hl_of = |src: usize| hls.iter().position(|h| h.start <= src && src < h.end);
let mut runs: Vec<Run> = Vec::new();
let mut buf = String::new();
let mut cur: Option<(LStyle, bool, Option<usize>, usize)> = None;
for g in glyphs {
let key = (g.style, g.src >= ss && g.src < se, hl_of(g.src));
match cur {
Some((style, sel, hl, _)) if (style, sel, hl) == key => buf.push(g.ch),
_ => {
if let Some((style, was_sel, hl, src)) = cur.take() {
runs.push(make_run(
std::mem::take(&mut buf),
style,
was_sel,
hl.map(|i| &hls[i]),
src,
faces,
));
}
cur = Some((key.0, key.1, key.2, g.src));
buf.push(g.ch);
}
}
}
if let Some((style, was_sel, hl, src)) = cur {
runs.push(make_run(
buf,
style,
was_sel,
hl.map(|i| &hls[i]),
src,
faces,
));
}
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,
hls: &[leaf_core::Highlight],
) -> Vec<TableView> {
let faces = vmap.faces();
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,
hls,
faces,
),
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 faces = CoreFaceTable::default();
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,
None,
start,
&faces,
));
}
runs.push(make_run(
raw[a..b].to_string(),
body,
true,
None,
start + a,
&faces,
));
if b < raw.len() {
runs.push(make_run(
raw[b..].to_string(),
body,
false,
None,
start + b,
&faces,
));
}
} else if !raw.is_empty() {
runs.push(make_run(raw.to_string(), body, false, None, start, &faces));
}
rows.push(Row {
runs,
decoration: false,
code: false,
code_lang: None,
directive: false,
directive_label: None,
heading: None, align: None, line_height: None,
boundary: None, });
byte = end + 1; }
rows
}
fn make_run(
text: String,
style: LStyle,
sel: bool,
hl: Option<&leaf_core::Highlight>,
src: usize,
faces: &CoreFaceTable,
) -> 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,
hl: hl.map(|h| h.id.clone()),
hl_color: hl.and_then(|h| h.color.clone()),
mark_color: mark_color_name(style.role),
token: style.token.map(|t| t.name().to_string()),
size: style.size.map(|s| s.name().to_string()),
font: style.font.and_then(|f| faces.spell(f).map(Cow::into_owned)),
text_color: style.color.map(|c| c.name().to_string()),
}
}
fn mark_color_name(role: Role) -> Option<String> {
match role {
Role::Mark(c) => c.map(|c| c.name().to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(src: &str) -> Arc<LeafDoc> {
LeafDoc::new(src.to_string(), "markdown".to_string()).unwrap()
}
#[cfg(feature = "syntax")]
#[test]
fn a_highlighted_block_splits_its_runs_by_token() {
let v = doc("```rust\nlet x = 1;\n```\n").set_unwrapped();
let row = v.rows.iter().find(|r| r.code).expect("a code row");
let classed: Vec<(&str, Option<&str>)> = row
.runs
.iter()
.map(|r| (r.text.as_str(), r.token.as_deref()))
.collect();
assert_eq!(classed[0], ("let", Some("keyword")));
assert!(row.runs.iter().all(|r| r.role == "code"), "{classed:?}");
assert!(
classed
.iter()
.any(|(t, k)| *t == "1" && *k == Some("constant")),
"{classed:?}"
);
let v = doc("```text\nlet x = 1;\n```\n").set_unwrapped();
let row = v.rows.iter().find(|r| r.code).unwrap();
assert_eq!(row.runs.len(), 1);
assert_eq!(row.runs[0].token, None);
}
#[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_block_vocabulary_crosses_on_the_row_and_comes_back_at_the_caret() {
let d = doc("a centred paragraph\n");
let v = d.set_alignment(Some(Align::Center));
let aligned: Vec<&str> = v
.rows
.iter()
.filter_map(|r| r.align.as_deref())
.collect::<Vec<_>>();
assert_eq!(aligned, ["center"], "the token, on the paragraph's row");
assert_eq!(d.alignment_at_caret(), Some(Align::Center));
let at = d.source().find("centred").unwrap() as u32;
d.set_selection_offsets(at, at);
let v = d.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneHalf)));
let row = v
.rows
.iter()
.find(|r| r.align.is_some())
.expect("the block");
assert_eq!(row.align.as_deref(), Some("center"));
assert_eq!(row.line_height.as_deref(), Some("1.5"));
assert_eq!(
d.line_spacing_at_caret(),
Some(LineHeight::Step(LineSpacing::OneHalf))
);
let at = d.source().find("centred").unwrap() as u32;
d.set_selection_offsets(at, at);
let v = d.set_alignment(None);
assert!(v.rows.iter().all(|r| r.align.is_none()));
assert_eq!(d.alignment_at_caret(), None);
assert_eq!(
d.line_spacing_at_caret(),
Some(LineHeight::Step(LineSpacing::OneHalf)),
"clearing one key leaves the other standing"
);
}
#[test]
fn the_run_vocabulary_crosses_on_the_run_and_comes_back_at_the_caret() {
let d = doc("big serif blue\n");
let in_text = |d: &Arc<LeafDoc>| {
let at = d.source().find("serif").unwrap() as u32;
d.set_selection_offsets(at, at);
};
d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
in_text(&d);
d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)));
in_text(&d);
let v = d.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
let styled: Vec<(Option<&str>, Option<&str>, Option<&str>)> = v
.rows
.iter()
.flat_map(|r| r.runs.iter())
.filter(|r| !r.text.trim().is_empty())
.map(|r| {
(
r.size.as_deref(),
r.font.as_deref(),
r.text_color.as_deref(),
)
})
.collect();
assert_eq!(styled, [(Some("large"), Some("serif"), Some("blue"))]);
assert_eq!(
d.font_size_at_caret(),
Some(FontSize::Step(SizeStep::Large))
);
assert_eq!(
d.font_family_at_caret(),
Some(FontFace::Generic(FontFamily::Serif))
);
assert_eq!(
d.text_color_at_caret(),
Some(TextColor::Named(MarkColor::Blue))
);
assert!(!d.caret_in_mark());
assert!(
v.rows
.iter()
.flat_map(|r| r.runs.iter())
.all(|r| r.mark_color.is_none())
);
}
#[test]
fn an_exact_value_crosses_as_its_own_token_and_comes_back_whole() {
let d = doc("exact\n");
let in_text = |d: &Arc<LeafDoc>| {
let at = d.source().find("exact").unwrap() as u32;
d.set_selection_offsets(at, at);
};
d.set_font_size(Some(FontSize::Points(14.0)));
in_text(&d);
d.set_font_family(Some(FontFace::Named("Garamond".to_string())));
in_text(&d);
d.set_text_color(Some(TextColor::Rgb {
r: 0xc0,
g: 0x30,
b: 0x30,
}));
in_text(&d);
let v = d.set_line_spacing(Some(LineHeight::Ratio(1.3)));
let styled: Vec<(Option<&str>, Option<&str>, Option<&str>)> = v
.rows
.iter()
.flat_map(|r| r.runs.iter())
.filter(|r| !r.text.trim().is_empty())
.map(|r| {
(
r.size.as_deref(),
r.font.as_deref(),
r.text_color.as_deref(),
)
})
.collect();
assert_eq!(
styled,
[(Some("14pt"), Some("Garamond"), Some("#c03030"))],
"the value's own spelling, where a name stood before"
);
let spaced: Vec<&str> = v
.rows
.iter()
.filter_map(|r| r.line_height.as_deref())
.collect();
assert_eq!(spaced, ["1.3"]);
assert_eq!(d.font_size_at_caret(), Some(FontSize::Points(14.0)));
assert_eq!(
d.font_family_at_caret(),
Some(FontFace::Named("Garamond".to_string()))
);
assert_eq!(
d.text_color_at_caret(),
Some(TextColor::Rgb {
r: 0xc0,
g: 0x30,
b: 0x30
})
);
assert_eq!(d.line_spacing_at_caret(), Some(LineHeight::Ratio(1.3)));
}
#[test]
fn a_named_face_reaches_a_run_inside_a_table_cell() {
let d = doc("| a | b |\n|---|---|\n| one | two |\n");
let at = d.source().find("one").unwrap() as u32;
d.set_selection_offsets(at, at + 3);
let v = d.set_font_family(Some(FontFace::Named("Garamond".to_string())));
let faced: Vec<(&str, &str)> = v
.tables
.iter()
.flat_map(|t| t.grid.iter())
.flat_map(|r| r.cells.iter())
.flat_map(|c| c.lines.iter())
.flat_map(|l| l.runs.iter())
.filter_map(|r| Some((r.text.trim(), r.font.as_deref()?)))
.collect();
assert_eq!(faced, [("one", "Garamond")]);
}
#[test]
fn a_value_outside_the_vocabulary_leaves_the_key_alone() {
let d = doc("plain\n");
let in_text = |d: &Arc<LeafDoc>| {
let at = d.source().find("plain").unwrap() as u32;
d.set_selection_offsets(at, at);
};
d.set_font_size(Some(FontSize::Points(14.0)));
in_text(&d);
assert_eq!(d.font_size_at_caret(), Some(FontSize::Points(14.0)));
for refused in [700.0, 0.0, -3.0, f64::NAN] {
d.set_font_size(Some(FontSize::Points(refused)));
in_text(&d);
assert_eq!(
d.font_size_at_caret(),
Some(FontSize::Points(14.0)),
"{refused} is not a size, and the author's 14pt is not its casualty"
);
}
d.set_line_spacing(Some(LineHeight::Ratio(1.5)));
in_text(&d);
assert_eq!(
d.line_spacing_at_caret(),
Some(LineHeight::Step(LineSpacing::OneHalf)),
"a ratio that spells a name is that name"
);
d.set_line_spacing(Some(LineHeight::Ratio(0.0)));
in_text(&d);
assert_eq!(
d.line_spacing_at_caret(),
Some(LineHeight::Step(LineSpacing::OneHalf)),
"nought is not a spacing, and refusing it keeps the block's own"
);
d.set_line_spacing(Some(LineHeight::Ratio(1.0)));
in_text(&d);
assert_eq!(d.line_spacing_at_caret(), None, "single is the theme's own");
d.set_font_family(Some(FontFace::Named(" Serif ".to_string())));
in_text(&d);
assert_eq!(
d.font_family_at_caret(),
Some(FontFace::Generic(FontFamily::Serif))
);
d.set_font_family(Some(FontFace::Named(" ".to_string())));
in_text(&d);
assert_eq!(
d.font_family_at_caret(),
Some(FontFace::Generic(FontFamily::Serif))
);
d.set_font_size(None);
d.set_font_family(None);
in_text(&d);
assert_eq!(d.font_size_at_caret(), None);
assert_eq!(d.font_family_at_caret(), None);
}
#[test]
fn a_page_break_crosses_as_a_directive_of_its_own() {
let d = doc("before\n\nafter\n");
let v = d.insert_page_break();
let names: Vec<&str> = v.directives.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names, ["page-break"]);
assert!(d.source().contains("page-break"));
}
#[test]
fn capabilities_answer_for_the_presentation_controls_too() {
let md = doc("x\n").capabilities();
assert!(md.alignment && md.line_spacing);
assert!(md.font_size && md.font_family && md.text_color);
assert!(md.page_break);
let xml = LeafDoc::new("<a>x</a>".to_string(), "xml".to_string())
.unwrap()
.capabilities();
assert!(!xml.alignment && !xml.line_spacing);
assert!(!xml.font_size && !xml.font_family && !xml.text_color);
assert!(!xml.page_break);
}
#[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, &[], &CoreFaceTable::default());
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, &[], &CoreFaceTable::default());
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, &[], &CoreFaceTable::default());
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 image_destination_at_caret_reads_the_image_under_the_caret() {
let d = doc(" after\n");
d.set_selection_offsets(3, 3); assert_eq!(d.image_destination_at_caret().as_deref(), Some("cat.png"));
d.set_selection_offsets(15, 15); assert_eq!(d.image_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 a_highlight_is_coloured_at_the_caret_and_the_frame_says_which() {
let d = doc("a word b\n");
d.set_selection_offsets(2, 6);
d.toggle_mark();
assert_eq!(d.source(), "a ==word== b\n");
d.set_selection_offsets(5, 5); assert!(d.caret_in_mark());
let v = d.set_mark_color(Some(MarkColor::Red));
assert_eq!(d.source(), "a ==🔴 word== b\n");
assert_eq!(v.mark_color, Some(MarkColor::Red));
let v = d.set_mark_color(None);
assert_eq!(d.source(), "a ==word== b\n");
assert_eq!(v.mark_color, None);
assert!(d.caret_in_mark());
}
#[test]
fn one_press_highlights_and_colours_and_one_undo_takes_it_back() {
let d = doc("a word b\n");
d.set_selection_offsets(2, 6);
let v = d.highlight(Some(MarkColor::Purple));
assert_eq!(d.source(), "a ==\u{1F7E3} word== b\n");
assert_eq!(v.mark_color, Some(MarkColor::Purple));
d.undo();
assert_eq!(d.source(), "a word b\n");
}
#[test]
fn the_palette_has_two_gates_and_they_ask_different_questions() {
assert!(doc("x\n").capabilities().mark_color);
let dj = LeafDoc::new("a {=word=} b\n".to_string(), "djot".to_string()).unwrap();
assert!(dj.capabilities().mark, "djot writes the highlight");
assert!(!dj.capabilities().mark_color, "and no colour on it");
dj.set_selection_offsets(5, 5);
assert!(dj.caret_in_mark(), "the caret is in one all the same");
let d = doc("a word b\n");
d.set_selection_offsets(3, 3);
assert!(!d.caret_in_mark(), "no highlight to colour here");
assert_eq!(d.set_mark_color(Some(MarkColor::Blue)).mark_color, None);
assert_eq!(d.source(), "a word b\n", "and nothing written");
}
#[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 utf16_indices_round_trip_through_the_visible_text_in_both_views() {
let d = doc("a **b\u{1F600}** c\n\nd\n");
let end = d.doc_end_offset();
let text = d.text_in_range(0, end);
assert_eq!(text, "a b\u{1F600} c\nd");
let total = text.encode_utf16().count() as u32;
assert_eq!(d.utf16_index_for_offset(end), total);
assert_eq!(d.offset_for_utf16_index(total), end);
let mut off = 0u32;
loop {
let idx = d.utf16_index_for_offset(off);
assert_eq!(
d.offset_for_utf16_index(idx),
off,
"stop {off} via index {idx}"
);
let next = d.step_offset(off, 1);
if next == off {
break;
}
off = next;
}
let c = "a **b\u{1F600}** c".find(" c").unwrap() as u32 + 1;
assert_eq!(
d.utf16_index_for_offset(c),
"a b\u{1F600} ".encode_utf16().count() as u32
);
d.toggle_view();
assert_eq!(
d.text_in_range(0, d.doc_end_offset()),
"a **b\u{1F600}** c\n\nd\n"
);
assert_eq!(
d.utf16_index_for_offset(c),
"a **b\u{1F600}** ".encode_utf16().count() as u32
);
assert_eq!(d.offset_for_utf16_index(d.utf16_index_for_offset(c)), c);
let emoji = "a **b".len() as u32;
assert_eq!(
d.offset_for_utf16_index(d.utf16_index_for_offset(emoji) + 1),
emoji
);
}
#[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_eq!(gap_only, "\n");
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_eq!(
text.chars().count() as i32,
dist,
"text_in_range({a}, {b}) = {text:?} ({} chars) vs distance_offset {dist}",
text.chars().count()
);
}
assert_eq!(
d.distance_offset(5, p2 as u32),
1,
"one Right crosses the whole gap"
);
}
#[test]
fn text_in_range_ends_a_line_at_each_table_cell_and_splits_none_inside() {
let d = doc("| Feature | Status |\n| --- | --- |\n| Tables | editable |\n");
assert_eq!(
d.text_in_range(0, d.doc_end_offset()),
"Feature\nStatus\nTables\neditable\n"
);
}
#[test]
fn a_coloured_highlight_rides_out_as_a_name_beside_the_mark_role() {
let d = doc("a ==\u{1F534} red== and ==plain== b\n");
let runs = &d.view().rows[0].runs;
let marks: Vec<(&str, Option<&str>)> = runs
.iter()
.filter(|r| r.role == "mark")
.map(|r| (r.text.as_str(), r.mark_color.as_deref()))
.collect();
assert_eq!(marks, [("red", Some("red")), ("plain", None)]);
assert!(
runs.iter()
.all(|r| r.role == "mark" || r.mark_color.is_none()),
"nothing but a mark names a colour"
);
}
#[test]
fn a_highlight_splits_runs_on_its_own_bytes_and_carries_its_id() {
let d = doc("one two three\n");
let view = d.set_highlights(vec![Highlight {
start: 4,
end: 7,
id: "remark-1".into(),
color: Some("#ffe066".into()),
marker: Some("text.bubble".into()),
}]);
let row = &view.rows[0];
let texts: Vec<(&str, Option<&str>)> = row
.runs
.iter()
.map(|r| (r.text.as_str(), r.hl.as_deref()))
.collect();
assert_eq!(
texts,
[("one ", None), ("two", Some("remark-1")), (" three", None)],
"the wash begins and ends exactly on the highlight's bytes"
);
assert_eq!(row.runs[1].hl_color.as_deref(), Some("#ffe066"));
assert_eq!(d.highlight_at(5).as_deref(), Some("remark-1"));
assert_eq!(
d.highlights()
.first()
.and_then(|h| h.marker.clone())
.as_deref(),
Some("text.bubble"),
"the marker rides back out for the frontend's margin pass"
);
assert_eq!(d.highlight_at(7), None, "end is exclusive");
let view = d.set_highlights(Vec::new());
assert!(view.rows[0].runs.iter().all(|r| r.hl.is_none()));
}
}