mirror of
https://github.com/flectar/mail.git
synced 2026-11-12 10:15:19 +00:00
1599 lines
58 KiB
Rust
1599 lines
58 KiB
Rust
use crate::rich_compose::{
|
|
BOLD, CODE, CharacterStyle, ComposeSelection, ITALIC, RichComposeDocument, STRIKE, UNDERLINE,
|
|
};
|
|
use cosmic_text::{
|
|
Action, Attrs, Buffer, Color as CosmicColor, Cursor, Edit, Editor, Family, FontSystem, Metrics,
|
|
Motion, PhysicalGlyph, Renderer, Selection, Shaping, Style, SwashCache, SwashContent,
|
|
UnderlineStyle, Weight, Wrap, fontdb::Source,
|
|
};
|
|
use slint::{Color, Image, Rgba8Pixel, SharedPixelBuffer};
|
|
use std::{
|
|
cell::OnceCell,
|
|
collections::BTreeMap,
|
|
ops::{Deref, DerefMut},
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
const FONT_SIZE_LOGICAL: f32 = 13.0;
|
|
const LINE_HEIGHT_LOGICAL: f32 = 18.0;
|
|
pub(crate) const UI_FONT_FAMILY: &str = "Google Sans Flex";
|
|
pub(crate) const UI_FONT_DATA: &[u8] = include_bytes!(
|
|
"../resources/fonts/google-sans-flex/GoogleSansFlex-VariableFont_GRAD,ROND,opsz,slnt,wdth,wght.ttf"
|
|
);
|
|
const TILE_HEIGHT_LOGICAL: f32 = 384.0;
|
|
const TILE_OVERSCAN: u32 = 1;
|
|
const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450);
|
|
const MULTI_CLICK_DISTANCE: f32 = 5.0;
|
|
// cosmic-text 0.19's rich-text path omits the final empty paragraph when the
|
|
// supplied text ends in a newline. Keep that paragraph alive in the layout
|
|
// with a zero-width, transparent character. This is renderer-only and never
|
|
// enters the editable document or its byte-offset mapping.
|
|
const TRAILING_LINE_SENTINEL: &str = "\u{200b}";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct ComposeEditorStyle {
|
|
pub text: Color,
|
|
pub link: Color,
|
|
pub selection: Color,
|
|
pub selected_text: Color,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct RenderedComposeTile {
|
|
pub image: Image,
|
|
pub y: f32,
|
|
pub height: f32,
|
|
}
|
|
|
|
pub struct RenderedComposeEditor {
|
|
pub tiles: Vec<RenderedComposeTile>,
|
|
pub content_height: f32,
|
|
pub caret_x: f32,
|
|
pub caret_y: f32,
|
|
pub caret_height: f32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
struct LayoutKey {
|
|
revision: u64,
|
|
physical_width: u32,
|
|
scale_bits: u32,
|
|
text_color: u32,
|
|
link_color: u32,
|
|
}
|
|
|
|
/// Retained cosmic-text layout and raster cache for the composer body.
|
|
///
|
|
/// The editor owns the only visible text layout. Slint's transparent TextInput
|
|
/// remains focused for native IME and clipboard behavior, but all visible text,
|
|
/// selection highlighting, hit-testing, and caret geometry come from here.
|
|
pub struct CosmicComposeEditor {
|
|
font_system: FontSystem,
|
|
swash_cache: SwashCache,
|
|
editor: Editor<'static>,
|
|
layout_key: Option<LayoutKey>,
|
|
paint_colors: Option<(u32, u32)>,
|
|
layout_preedit_text: String,
|
|
layout_preedit_offset: usize,
|
|
visual_text: String,
|
|
rendered_selection: Option<(i32, i32)>,
|
|
content_height_physical: f32,
|
|
tiles: BTreeMap<u32, RenderedComposeTile>,
|
|
pointer_down: bool,
|
|
last_pointer_down: Option<(Instant, f32, f32)>,
|
|
click_count: u8,
|
|
}
|
|
|
|
/// Construct the font database only when a composer action needs its surface.
|
|
/// Most sessions reach the mailbox without opening the composer.
|
|
#[derive(Default)]
|
|
pub struct LazyComposeEditor(OnceCell<CosmicComposeEditor>);
|
|
|
|
impl LazyComposeEditor {
|
|
/// The document owns text, selection and undo history independently.
|
|
pub fn release(&mut self) {
|
|
self.0.take();
|
|
}
|
|
}
|
|
|
|
impl Deref for LazyComposeEditor {
|
|
type Target = CosmicComposeEditor;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
self.0.get_or_init(CosmicComposeEditor::default)
|
|
}
|
|
}
|
|
|
|
impl DerefMut for LazyComposeEditor {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
self.0.get_or_init(CosmicComposeEditor::default);
|
|
self.0.get_mut().expect("compose editor was initialized")
|
|
}
|
|
}
|
|
|
|
impl Default for CosmicComposeEditor {
|
|
fn default() -> Self {
|
|
let mut font_system = FontSystem::new();
|
|
// The composer is rasterized by cosmic-text rather than Slint, so it
|
|
// must register the same bundled UI font independently. System fonts
|
|
// remain available as fallbacks for glyphs outside the font's coverage.
|
|
// The bytes already live in the executable for Slint. Share them with
|
|
// fontdb instead of copying the 4 MiB font into the heap.
|
|
font_system
|
|
.db_mut()
|
|
.load_font_source(Source::Binary(Arc::new(UI_FONT_DATA)));
|
|
// OpenHarmony ships no Fontconfig, so fontdb's system scan finds
|
|
// nothing; load the platform font directory explicitly. Without it
|
|
// the composer falls back to the bundled UI font alone, which has no
|
|
// CJK coverage (typed Chinese renders as missing glyphs).
|
|
if let Some(fonts) = crate::platform::email_fonts() {
|
|
font_system.db_mut().load_fonts_dir(fonts.dir);
|
|
}
|
|
let buffer = Buffer::new(
|
|
&mut font_system,
|
|
Metrics::new(FONT_SIZE_LOGICAL, LINE_HEIGHT_LOGICAL),
|
|
);
|
|
Self {
|
|
font_system,
|
|
swash_cache: SwashCache::new(),
|
|
editor: Editor::new(buffer),
|
|
layout_key: None,
|
|
paint_colors: None,
|
|
layout_preedit_text: String::new(),
|
|
layout_preedit_offset: 0,
|
|
visual_text: String::new(),
|
|
rendered_selection: None,
|
|
content_height_physical: LINE_HEIGHT_LOGICAL,
|
|
tiles: BTreeMap::new(),
|
|
pointer_down: false,
|
|
last_pointer_down: None,
|
|
click_count: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CosmicComposeEditor {
|
|
pub fn reset(&mut self) {
|
|
self.layout_key = None;
|
|
self.paint_colors = None;
|
|
self.layout_preedit_text.clear();
|
|
self.layout_preedit_offset = 0;
|
|
self.visual_text.clear();
|
|
self.rendered_selection = None;
|
|
self.tiles.clear();
|
|
self.pointer_down = false;
|
|
self.last_pointer_down = None;
|
|
self.click_count = 0;
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn render(
|
|
&mut self,
|
|
document: &RichComposeDocument,
|
|
selection: ComposeSelection,
|
|
logical_width: f32,
|
|
viewport_height: f32,
|
|
scroll_y: f32,
|
|
scale_factor: f32,
|
|
style: ComposeEditorStyle,
|
|
preedit_text: &str,
|
|
) -> RenderedComposeEditor {
|
|
let timing_start = crate::renderer::render_timings_enabled().then(Instant::now);
|
|
let scale_factor = scale_factor.max(1.0);
|
|
let relaid_out = self.ensure_layout(
|
|
document,
|
|
selection,
|
|
logical_width,
|
|
scale_factor,
|
|
style,
|
|
preedit_text,
|
|
);
|
|
let paint_colors = (
|
|
style.selection.as_argb_encoded(),
|
|
style.selected_text.as_argb_encoded(),
|
|
);
|
|
if self.paint_colors != Some(paint_colors) {
|
|
let selection_painted = self
|
|
.rendered_selection
|
|
.is_some_and(|(start, end)| start != end)
|
|
|| selection.start != selection.end;
|
|
if selection_painted {
|
|
self.tiles.clear();
|
|
}
|
|
self.paint_colors = Some(paint_colors);
|
|
}
|
|
self.set_selection(selection);
|
|
self.editor.shape_as_needed(&mut self.font_system, false);
|
|
|
|
let (caret_x, caret_y, caret_height) = self.caret_geometry(scale_factor);
|
|
let wanted = desired_tile_range(
|
|
scroll_y,
|
|
viewport_height,
|
|
self.content_height_physical / scale_factor,
|
|
);
|
|
self.tiles
|
|
.retain(|index, _| wanted.clone().any(|wanted| wanted == *index));
|
|
|
|
let physical_width = self
|
|
.layout_key
|
|
.map_or(1, |layout| layout.physical_width.max(1));
|
|
let tile_height_physical = (TILE_HEIGHT_LOGICAL * scale_factor).ceil().max(1.0) as u32;
|
|
let content_height_physical = self.content_height_physical.ceil().max(1.0) as u32;
|
|
let mut new_tiles = 0;
|
|
let mut paint_time = Duration::ZERO;
|
|
for index in wanted {
|
|
if self.tiles.contains_key(&index) {
|
|
continue;
|
|
}
|
|
let tile_start = index.saturating_mul(tile_height_physical);
|
|
if tile_start >= content_height_physical {
|
|
continue;
|
|
}
|
|
let tile_height = tile_height_physical.min(content_height_physical - tile_start);
|
|
let paint_start = timing_start.map(|_| Instant::now());
|
|
let image = self.render_tile(physical_width, tile_start, tile_height, style);
|
|
if let Some(paint_start) = paint_start {
|
|
paint_time += paint_start.elapsed();
|
|
}
|
|
new_tiles += 1;
|
|
self.tiles.insert(
|
|
index,
|
|
RenderedComposeTile {
|
|
image,
|
|
y: tile_start as f32 / scale_factor,
|
|
height: tile_height as f32 / scale_factor,
|
|
},
|
|
);
|
|
}
|
|
|
|
let rendered = RenderedComposeEditor {
|
|
tiles: self.tiles.values().cloned().collect(),
|
|
content_height: self.content_height_physical / scale_factor,
|
|
caret_x,
|
|
caret_y,
|
|
caret_height,
|
|
};
|
|
if let Some(timing_start) = timing_start {
|
|
let tile_bytes = self
|
|
.tiles
|
|
.values()
|
|
.map(|tile| {
|
|
let size = tile.image.size();
|
|
u64::from(size.width) * u64::from(size.height) * 4
|
|
})
|
|
.sum::<u64>();
|
|
eprintln!(
|
|
"compose cpu frame: total={:.2}ms layout={} new_tiles={new_tiles} paint={:.2}ms retained_tiles={} retained_tile_bytes={tile_bytes}",
|
|
timing_start.elapsed().as_secs_f64() * 1000.0,
|
|
u8::from(relaid_out),
|
|
paint_time.as_secs_f64() * 1000.0,
|
|
self.tiles.len(),
|
|
);
|
|
}
|
|
rendered
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn handle_pointer(
|
|
&mut self,
|
|
document: &RichComposeDocument,
|
|
selection: ComposeSelection,
|
|
logical_width: f32,
|
|
scale_factor: f32,
|
|
style: ComposeEditorStyle,
|
|
preedit_text: &str,
|
|
x: f32,
|
|
y: f32,
|
|
kind: &str,
|
|
shift: bool,
|
|
) -> Option<ComposeSelection> {
|
|
let scale_factor = scale_factor.max(1.0);
|
|
self.ensure_layout(
|
|
document,
|
|
selection,
|
|
logical_width,
|
|
scale_factor,
|
|
style,
|
|
preedit_text,
|
|
);
|
|
self.set_selection(selection);
|
|
self.editor.shape_as_needed(&mut self.font_system, false);
|
|
|
|
let x_physical = (x.max(0.0) * scale_factor).round() as i32;
|
|
let y_physical = (y.max(0.0) * scale_factor).round() as i32;
|
|
match kind {
|
|
"down" => {
|
|
self.pointer_down = true;
|
|
self.click_count = self.next_click_count(x, y);
|
|
let action = if shift {
|
|
Action::Drag {
|
|
x: x_physical,
|
|
y: y_physical,
|
|
}
|
|
} else if self.click_count >= 3 {
|
|
Action::TripleClick {
|
|
x: x_physical,
|
|
y: y_physical,
|
|
}
|
|
} else if self.click_count == 2 {
|
|
Action::DoubleClick {
|
|
x: x_physical,
|
|
y: y_physical,
|
|
}
|
|
} else {
|
|
Action::Click {
|
|
x: x_physical,
|
|
y: y_physical,
|
|
}
|
|
};
|
|
self.editor.action(&mut self.font_system, action);
|
|
}
|
|
"move" if self.pointer_down => {
|
|
self.editor.action(
|
|
&mut self.font_system,
|
|
Action::Drag {
|
|
x: x_physical,
|
|
y: y_physical,
|
|
},
|
|
);
|
|
}
|
|
"up" | "cancel" => {
|
|
self.pointer_down = false;
|
|
return None;
|
|
}
|
|
_ => return None,
|
|
}
|
|
|
|
Some(self.selection_from_editor())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn handle_navigation(
|
|
&mut self,
|
|
document: &RichComposeDocument,
|
|
selection: ComposeSelection,
|
|
logical_width: f32,
|
|
scale_factor: f32,
|
|
style: ComposeEditorStyle,
|
|
preedit_text: &str,
|
|
key: &str,
|
|
control: bool,
|
|
shift: bool,
|
|
alt: bool,
|
|
meta: bool,
|
|
) -> Option<ComposeSelection> {
|
|
let motion = navigation_motion(key, control, alt, meta)?;
|
|
let scale_factor = scale_factor.max(1.0);
|
|
self.ensure_layout(
|
|
document,
|
|
selection,
|
|
logical_width,
|
|
scale_factor,
|
|
style,
|
|
preedit_text,
|
|
);
|
|
self.set_selection(selection);
|
|
self.editor.shape_as_needed(&mut self.font_system, false);
|
|
|
|
if shift {
|
|
if self.editor.selection() == Selection::None {
|
|
self.editor
|
|
.set_selection(Selection::Normal(self.editor.cursor()));
|
|
}
|
|
} else if let Some((start, end)) = self.editor.selection_bounds() {
|
|
let collapse_to_start = matches!(
|
|
motion,
|
|
Motion::Left
|
|
| Motion::LeftWord
|
|
| Motion::Previous
|
|
| Motion::PreviousWord
|
|
| Motion::Up
|
|
| Motion::Home
|
|
| Motion::SoftHome
|
|
| Motion::ParagraphStart
|
|
| Motion::BufferStart
|
|
| Motion::PageUp
|
|
);
|
|
self.editor
|
|
.set_cursor(if collapse_to_start { start } else { end });
|
|
self.editor.set_selection(Selection::None);
|
|
return Some(self.selection_from_editor());
|
|
} else {
|
|
self.editor.set_selection(Selection::None);
|
|
}
|
|
|
|
self.editor
|
|
.action(&mut self.font_system, Action::Motion(motion));
|
|
if !shift {
|
|
self.editor.set_selection(Selection::None);
|
|
}
|
|
Some(self.selection_from_editor())
|
|
}
|
|
|
|
fn ensure_layout(
|
|
&mut self,
|
|
document: &RichComposeDocument,
|
|
selection: ComposeSelection,
|
|
logical_width: f32,
|
|
scale_factor: f32,
|
|
style: ComposeEditorStyle,
|
|
preedit_text: &str,
|
|
) -> bool {
|
|
let physical_width = (logical_width.max(1.0) * scale_factor).ceil().max(1.0) as u32;
|
|
let key = LayoutKey {
|
|
revision: document.revision(),
|
|
physical_width,
|
|
scale_bits: scale_factor.to_bits(),
|
|
text_color: style.text.as_argb_encoded(),
|
|
link_color: style.link.as_argb_encoded(),
|
|
};
|
|
let preedit_offset = valid_offset(document.text(), selection.end);
|
|
if self.layout_key == Some(key)
|
|
&& self.layout_preedit_text == preedit_text
|
|
&& (preedit_text.is_empty() || self.layout_preedit_offset == preedit_offset)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let metrics = Metrics::new(
|
|
FONT_SIZE_LOGICAL * scale_factor,
|
|
LINE_HEIGHT_LOGICAL * scale_factor,
|
|
);
|
|
let default_attrs = Attrs::new()
|
|
.family(Family::Name(UI_FONT_FAMILY))
|
|
.color(cosmic_color(style.text));
|
|
let mut spans = rich_spans(
|
|
document,
|
|
preedit_offset,
|
|
preedit_text,
|
|
&default_attrs,
|
|
cosmic_color(style.link),
|
|
);
|
|
self.visual_text.clear();
|
|
self.visual_text
|
|
.push_str(&document.text()[..preedit_offset]);
|
|
self.visual_text.push_str(preedit_text);
|
|
self.visual_text
|
|
.push_str(&document.text()[preedit_offset..]);
|
|
if self.visual_text.ends_with('\n') {
|
|
spans.push((
|
|
TRAILING_LINE_SENTINEL,
|
|
default_attrs.clone().color(CosmicColor::rgba(0, 0, 0, 0)),
|
|
));
|
|
}
|
|
|
|
self.editor.with_buffer_mut(|buffer| {
|
|
buffer.set_metrics_and_size(metrics, Some(physical_width as f32), None);
|
|
buffer.set_wrap(Wrap::WordOrGlyph);
|
|
buffer.set_rich_text(
|
|
spans.iter().map(|(text, attrs)| (*text, attrs.clone())),
|
|
&default_attrs,
|
|
Shaping::Advanced,
|
|
None,
|
|
);
|
|
});
|
|
self.editor.shape_as_needed(&mut self.font_system, false);
|
|
self.content_height_physical = self.editor.with_buffer(|buffer| {
|
|
buffer
|
|
.layout_runs()
|
|
.map(|run| run.line_top + run.line_height)
|
|
.fold(metrics.line_height, f32::max)
|
|
});
|
|
self.layout_key = Some(key);
|
|
self.layout_preedit_text.clear();
|
|
self.layout_preedit_text.push_str(preedit_text);
|
|
self.layout_preedit_offset = preedit_offset;
|
|
self.rendered_selection = None;
|
|
self.tiles.clear();
|
|
true
|
|
}
|
|
|
|
fn set_selection(&mut self, selection: ComposeSelection) {
|
|
if self.rendered_selection == Some((selection.start, selection.end)) {
|
|
return;
|
|
}
|
|
let previous = self
|
|
.rendered_selection
|
|
.and_then(|offsets| self.selection_cursor_bounds(offsets));
|
|
let current = self.selection_cursor_bounds((selection.start, selection.end));
|
|
let (anchor_offset, cursor_offset) = if self.layout_preedit_text.is_empty() {
|
|
(selection.start, selection.end)
|
|
} else {
|
|
let cursor = self
|
|
.layout_preedit_offset
|
|
.saturating_add(self.layout_preedit_text.len());
|
|
let cursor = i32::try_from(cursor).unwrap_or(i32::MAX);
|
|
(cursor, cursor)
|
|
};
|
|
let anchor = global_to_cursor(&self.visual_text, anchor_offset);
|
|
let cursor = global_to_cursor(&self.visual_text, cursor_offset);
|
|
self.editor.set_cursor(cursor);
|
|
self.editor.set_selection(if anchor == cursor {
|
|
Selection::None
|
|
} else {
|
|
Selection::Normal(anchor)
|
|
});
|
|
self.rendered_selection = Some((selection.start, selection.end));
|
|
if previous.is_some() || current.is_some() {
|
|
self.invalidate_selection_tiles([previous, current]);
|
|
}
|
|
}
|
|
|
|
fn selection_cursor_bounds(&self, (start, end): (i32, i32)) -> Option<(Cursor, Cursor)> {
|
|
if start == end || !self.layout_preedit_text.is_empty() {
|
|
return None;
|
|
}
|
|
let start = global_to_cursor(&self.visual_text, start);
|
|
let end = global_to_cursor(&self.visual_text, end);
|
|
(start != end).then_some((start.min(end), start.max(end)))
|
|
}
|
|
|
|
fn invalidate_selection_tiles(&mut self, bounds: [Option<(Cursor, Cursor)>; 2]) {
|
|
let Some(layout) = self.layout_key else {
|
|
self.tiles.clear();
|
|
return;
|
|
};
|
|
let tile_height = (TILE_HEIGHT_LOGICAL * f32::from_bits(layout.scale_bits)).ceil();
|
|
let mut extents: [Option<(f32, f32)>; 2] = [None, None];
|
|
let last_line = bounds
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|(_, end)| end.line)
|
|
.max()
|
|
.unwrap_or(0);
|
|
self.editor.with_buffer(|buffer| {
|
|
for run in buffer.layout_runs() {
|
|
if run.line_i > last_line {
|
|
break;
|
|
}
|
|
for (bound, extent) in bounds.into_iter().zip(extents.iter_mut()) {
|
|
let Some((start, end)) = bound else {
|
|
continue;
|
|
};
|
|
if run.line_i < start.line || run.line_i > end.line {
|
|
continue;
|
|
}
|
|
// A logical paragraph can wrap over many raster tiles.
|
|
// Follow Cosmic Text's selected-glyph predicate so runs
|
|
// outside the selected byte range retain their pixels.
|
|
let selected_glyph = run.glyphs.iter().any(|glyph| {
|
|
(start.line != run.line_i || glyph.end > start.index)
|
|
&& (end.line != run.line_i || glyph.start < end.index)
|
|
});
|
|
let selected_empty_line = run.glyphs.is_empty() && end.line > run.line_i;
|
|
if !selected_glyph && !selected_empty_line {
|
|
continue;
|
|
}
|
|
let top = run.line_top;
|
|
let bottom = run.line_top + run.line_height;
|
|
*extent = Some(match *extent {
|
|
Some((first, last)) => (first.min(top), last.max(bottom)),
|
|
None => (top, bottom),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
if tile_height <= 0.0
|
|
|| !tile_height.is_finite()
|
|
|| bounds
|
|
.into_iter()
|
|
.zip(extents.iter())
|
|
.any(|(bound, extent)| bound.is_some() && extent.is_none())
|
|
|| extents
|
|
.iter()
|
|
.flatten()
|
|
.any(|(top, bottom)| !top.is_finite() || !bottom.is_finite())
|
|
{
|
|
self.tiles.clear();
|
|
return;
|
|
}
|
|
let ranges = extents.map(|extent| {
|
|
extent.map(|(top, bottom)| {
|
|
let first = ((top.max(0.0) / tile_height).floor() as u32).saturating_sub(1);
|
|
let last = ((bottom.max(0.0) / tile_height).floor() as u32).saturating_add(1);
|
|
first..=last
|
|
})
|
|
});
|
|
self.tiles
|
|
.retain(|index, _| !ranges.iter().flatten().any(|range| range.contains(index)));
|
|
}
|
|
|
|
fn selection_from_editor(&self) -> ComposeSelection {
|
|
let cursor = self
|
|
.visual_to_document_offset(cursor_to_global(&self.visual_text, self.editor.cursor()));
|
|
match self.editor.selection() {
|
|
Selection::None => ComposeSelection {
|
|
start: cursor,
|
|
end: cursor,
|
|
},
|
|
Selection::Normal(anchor) => ComposeSelection {
|
|
start: self.visual_to_document_offset(cursor_to_global(&self.visual_text, anchor)),
|
|
end: cursor,
|
|
},
|
|
Selection::Line(_) | Selection::Word(_) => self.editor.selection_bounds().map_or(
|
|
ComposeSelection {
|
|
start: cursor,
|
|
end: cursor,
|
|
},
|
|
|(start, end)| ComposeSelection {
|
|
start: self
|
|
.visual_to_document_offset(cursor_to_global(&self.visual_text, start)),
|
|
end: self.visual_to_document_offset(cursor_to_global(&self.visual_text, end)),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
fn visual_to_document_offset(&self, offset: i32) -> i32 {
|
|
if self.layout_preedit_text.is_empty() {
|
|
return offset;
|
|
}
|
|
let offset = usize::try_from(offset).unwrap_or_default();
|
|
let preedit_end = self
|
|
.layout_preedit_offset
|
|
.saturating_add(self.layout_preedit_text.len());
|
|
let document_offset = if offset <= self.layout_preedit_offset {
|
|
offset
|
|
} else if offset <= preedit_end {
|
|
self.layout_preedit_offset
|
|
} else {
|
|
offset.saturating_sub(self.layout_preedit_text.len())
|
|
};
|
|
i32::try_from(document_offset).unwrap_or(i32::MAX)
|
|
}
|
|
|
|
fn caret_geometry(&self, scale_factor: f32) -> (f32, f32, f32) {
|
|
let Some((x, y)) = self.editor.cursor_position() else {
|
|
return (0.0, 0.0, LINE_HEIGHT_LOGICAL);
|
|
};
|
|
let cursor = self.editor.cursor();
|
|
let line_height = self.editor.with_buffer(|buffer| {
|
|
buffer
|
|
.layout_runs()
|
|
.find(|run| {
|
|
run.line_i == cursor.line
|
|
&& run.cursor_position(&cursor).is_some()
|
|
&& (run.line_top - y as f32).abs() < 0.5
|
|
})
|
|
.map_or(LINE_HEIGHT_LOGICAL * scale_factor, |run| run.line_height)
|
|
});
|
|
(
|
|
x as f32 / scale_factor,
|
|
y as f32 / scale_factor,
|
|
line_height / scale_factor,
|
|
)
|
|
}
|
|
|
|
fn render_tile(
|
|
&mut self,
|
|
width: u32,
|
|
tile_start: u32,
|
|
tile_height: u32,
|
|
style: ComposeEditorStyle,
|
|
) -> Image {
|
|
let mut pixels = SharedPixelBuffer::<Rgba8Pixel>::new(width, tile_height);
|
|
{
|
|
let mut canvas =
|
|
TileCanvas::new(width, tile_height, tile_start, pixels.make_mut_bytes());
|
|
let editor = &self.editor;
|
|
let mut renderer = TileRenderer {
|
|
font_system: &mut self.font_system,
|
|
swash_cache: &mut self.swash_cache,
|
|
canvas: &mut canvas,
|
|
};
|
|
editor.render(
|
|
&mut renderer,
|
|
cosmic_color(style.text),
|
|
CosmicColor::rgba(0, 0, 0, 0),
|
|
cosmic_color(style.selection),
|
|
cosmic_color(style.selected_text),
|
|
);
|
|
}
|
|
Image::from_rgba8_premultiplied(pixels)
|
|
}
|
|
|
|
fn next_click_count(&mut self, x: f32, y: f32) -> u8 {
|
|
let now = Instant::now();
|
|
let repeated = self.last_pointer_down.is_some_and(|(previous, px, py)| {
|
|
now.duration_since(previous) <= DOUBLE_CLICK_INTERVAL
|
|
&& (x - px).abs() <= MULTI_CLICK_DISTANCE
|
|
&& (y - py).abs() <= MULTI_CLICK_DISTANCE
|
|
});
|
|
self.last_pointer_down = Some((now, x, y));
|
|
if repeated {
|
|
self.click_count.saturating_add(1).min(3)
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rich_spans<'a>(
|
|
document: &'a RichComposeDocument,
|
|
preedit_offset: usize,
|
|
preedit_text: &'a str,
|
|
default_attrs: &Attrs<'static>,
|
|
link_color: CosmicColor,
|
|
) -> Vec<(&'a str, Attrs<'static>)> {
|
|
let runs = document.style_runs();
|
|
let mut spans = Vec::with_capacity(runs.len().saturating_add(2).max(1));
|
|
let mut inserted_preedit = preedit_text.is_empty();
|
|
let preedit_attrs = character_attrs(default_attrs, document.typing_style(), link_color)
|
|
.underline(UnderlineStyle::Single);
|
|
|
|
for run in runs {
|
|
let attrs = character_attrs(default_attrs, &run.style, link_color);
|
|
if !inserted_preedit && preedit_offset <= run.range.start {
|
|
push_span(&mut spans, preedit_text, preedit_attrs.clone());
|
|
inserted_preedit = true;
|
|
}
|
|
if !inserted_preedit && preedit_offset < run.range.end {
|
|
push_span(
|
|
&mut spans,
|
|
&document.text()[run.range.start..preedit_offset],
|
|
attrs.clone(),
|
|
);
|
|
push_span(&mut spans, preedit_text, preedit_attrs.clone());
|
|
push_span(
|
|
&mut spans,
|
|
&document.text()[preedit_offset..run.range.end],
|
|
attrs,
|
|
);
|
|
inserted_preedit = true;
|
|
} else {
|
|
push_span(&mut spans, &document.text()[run.range.clone()], attrs);
|
|
}
|
|
}
|
|
if !inserted_preedit {
|
|
push_span(&mut spans, preedit_text, preedit_attrs);
|
|
}
|
|
if spans.is_empty() {
|
|
spans.push(("", default_attrs.clone()));
|
|
}
|
|
spans
|
|
}
|
|
|
|
fn push_span<'a>(spans: &mut Vec<(&'a str, Attrs<'static>)>, text: &'a str, attrs: Attrs<'static>) {
|
|
if !text.is_empty() {
|
|
spans.push((text, attrs));
|
|
}
|
|
}
|
|
|
|
fn character_attrs(
|
|
default_attrs: &Attrs<'static>,
|
|
style: &CharacterStyle,
|
|
link_color: CosmicColor,
|
|
) -> Attrs<'static> {
|
|
let mut attrs = default_attrs.clone();
|
|
if style.marks & BOLD != 0 {
|
|
attrs = attrs.weight(Weight::BOLD);
|
|
}
|
|
if style.marks & ITALIC != 0 {
|
|
attrs = attrs.style(Style::Italic);
|
|
}
|
|
if style.marks & UNDERLINE != 0 {
|
|
attrs = attrs.underline(UnderlineStyle::Single);
|
|
}
|
|
if style.marks & STRIKE != 0 {
|
|
attrs = attrs.strikethrough();
|
|
}
|
|
if style.marks & CODE != 0 {
|
|
attrs = attrs.family(Family::Monospace);
|
|
}
|
|
if style.link.is_some() {
|
|
attrs = attrs.color(link_color);
|
|
}
|
|
attrs
|
|
}
|
|
|
|
fn navigation_motion(key: &str, control: bool, alt: bool, meta: bool) -> Option<Motion> {
|
|
let key = key.chars().next()?;
|
|
let word = control || alt;
|
|
match key {
|
|
'\u{f700}' if meta => Some(Motion::BufferStart),
|
|
'\u{f701}' if meta => Some(Motion::BufferEnd),
|
|
'\u{f700}' if control => Some(Motion::ParagraphStart),
|
|
'\u{f701}' if control => Some(Motion::ParagraphEnd),
|
|
'\u{f700}' => Some(Motion::Up),
|
|
'\u{f701}' => Some(Motion::Down),
|
|
'\u{f702}' if meta => Some(Motion::Home),
|
|
'\u{f703}' if meta => Some(Motion::End),
|
|
'\u{f702}' if word => Some(Motion::LeftWord),
|
|
'\u{f703}' if word => Some(Motion::RightWord),
|
|
'\u{f702}' => Some(Motion::Left),
|
|
'\u{f703}' => Some(Motion::Right),
|
|
'\u{f729}' if control || meta => Some(Motion::BufferStart),
|
|
'\u{f72b}' if control || meta => Some(Motion::BufferEnd),
|
|
'\u{f729}' => Some(Motion::Home),
|
|
'\u{f72b}' => Some(Motion::End),
|
|
'\u{f72c}' => Some(Motion::PageUp),
|
|
'\u{f72d}' => Some(Motion::PageDown),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn global_to_cursor(text: &str, offset: i32) -> Cursor {
|
|
let mut remaining = valid_offset(text, offset);
|
|
for (line, value) in text.split('\n').enumerate() {
|
|
if remaining <= value.len() {
|
|
return Cursor::new(line, remaining);
|
|
}
|
|
remaining = remaining.saturating_sub(value.len() + 1);
|
|
}
|
|
let line = text.matches('\n').count();
|
|
let index = text.rsplit('\n').next().map_or(0, str::len);
|
|
Cursor::new(line, index)
|
|
}
|
|
|
|
fn cursor_to_global(text: &str, cursor: Cursor) -> i32 {
|
|
let mut offset = 0usize;
|
|
for (line, value) in text.split('\n').enumerate() {
|
|
if line == cursor.line {
|
|
offset += cursor.index.min(value.len());
|
|
return i32::try_from(offset).unwrap_or(i32::MAX);
|
|
}
|
|
offset = offset.saturating_add(value.len() + 1);
|
|
}
|
|
i32::try_from(text.len()).unwrap_or(i32::MAX)
|
|
}
|
|
|
|
fn valid_offset(text: &str, offset: i32) -> usize {
|
|
let mut offset = usize::try_from(offset).unwrap_or_default().min(text.len());
|
|
while offset > 0 && !text.is_char_boundary(offset) {
|
|
offset -= 1;
|
|
}
|
|
offset
|
|
}
|
|
|
|
fn desired_tile_range(
|
|
scroll_y: f32,
|
|
viewport_height: f32,
|
|
content_height: f32,
|
|
) -> std::ops::Range<u32> {
|
|
let tile_count = (content_height.max(1.0) / TILE_HEIGHT_LOGICAL)
|
|
.ceil()
|
|
.max(1.0) as u32;
|
|
let first_visible = (scroll_y.max(0.0) / TILE_HEIGHT_LOGICAL).floor() as u32;
|
|
let last_visible =
|
|
((scroll_y.max(0.0) + viewport_height.max(1.0)) / TILE_HEIGHT_LOGICAL).floor() as u32;
|
|
let start = first_visible.saturating_sub(TILE_OVERSCAN);
|
|
let end = last_visible
|
|
.saturating_add(TILE_OVERSCAN)
|
|
.saturating_add(1)
|
|
.min(tile_count);
|
|
start..end.max(start + 1).min(tile_count)
|
|
}
|
|
|
|
fn cosmic_color(color: Color) -> CosmicColor {
|
|
CosmicColor::rgba(color.red(), color.green(), color.blue(), color.alpha())
|
|
}
|
|
|
|
struct TileCanvas<'a> {
|
|
width: u32,
|
|
height: u32,
|
|
y_offset: u32,
|
|
pixels: &'a mut [u8],
|
|
}
|
|
|
|
impl<'a> TileCanvas<'a> {
|
|
fn new(width: u32, height: u32, y_offset: u32, pixels: &'a mut [u8]) -> Self {
|
|
Self {
|
|
width,
|
|
height,
|
|
y_offset,
|
|
pixels,
|
|
}
|
|
}
|
|
|
|
fn blend(&mut self, x: i32, y: i32, color: CosmicColor) {
|
|
if x < 0 || y < self.y_offset as i32 {
|
|
return;
|
|
}
|
|
let x = x as u32;
|
|
let y = y as u32 - self.y_offset;
|
|
if x >= self.width || y >= self.height || color.a() == 0 {
|
|
return;
|
|
}
|
|
let index = ((y as usize * self.width as usize) + x as usize) * 4;
|
|
blend_premultiplied(&mut self.pixels[index..index + 4], color);
|
|
}
|
|
|
|
fn rectangle(&mut self, x: i32, y: i32, width: u32, height: u32, color: CosmicColor) {
|
|
if width == 0 || height == 0 || color.a() == 0 {
|
|
return;
|
|
}
|
|
let left = x.max(0) as u32;
|
|
let right = x.saturating_add(width as i32).max(0) as u32;
|
|
let top = y.max(self.y_offset as i32) as u32;
|
|
let bottom = y
|
|
.saturating_add(height as i32)
|
|
.min(self.y_offset.saturating_add(self.height) as i32)
|
|
.max(0) as u32;
|
|
for py in top..bottom {
|
|
for px in left..right.min(self.width) {
|
|
self.blend(px as i32, py as i32, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TileRenderer<'a, 'pixels> {
|
|
font_system: &'a mut FontSystem,
|
|
swash_cache: &'a mut SwashCache,
|
|
canvas: &'a mut TileCanvas<'pixels>,
|
|
}
|
|
|
|
impl Renderer for TileRenderer<'_, '_> {
|
|
fn rectangle(&mut self, x: i32, y: i32, width: u32, height: u32, color: CosmicColor) {
|
|
self.canvas.rectangle(x, y, width, height, color);
|
|
}
|
|
|
|
fn glyph(&mut self, glyph: PhysicalGlyph, color: CosmicColor) {
|
|
let tile_top = self.canvas.y_offset as i32;
|
|
let tile_bottom = self.canvas.y_offset.saturating_add(self.canvas.height) as i32;
|
|
if glyph.y < tile_top.saturating_sub(256) || glyph.y > tile_bottom.saturating_add(256) {
|
|
return;
|
|
}
|
|
let Some(image) = self
|
|
.swash_cache
|
|
.get_image(self.font_system, glyph.cache_key)
|
|
.as_ref()
|
|
else {
|
|
return;
|
|
};
|
|
let left = glyph.x + image.placement.left;
|
|
let top = glyph.y - image.placement.top;
|
|
let width = image.placement.width as usize;
|
|
let height = image.placement.height as usize;
|
|
match image.content {
|
|
SwashContent::Mask => {
|
|
for row in 0..height {
|
|
for column in 0..width {
|
|
let mask = image.data[row * width + column];
|
|
let alpha = multiply_alpha(mask, color.a());
|
|
self.canvas.blend(
|
|
left + column as i32,
|
|
top + row as i32,
|
|
CosmicColor::rgba(color.r(), color.g(), color.b(), alpha),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
SwashContent::Color => {
|
|
for row in 0..height {
|
|
for column in 0..width {
|
|
let index = (row * width + column) * 4;
|
|
self.canvas.blend(
|
|
left + column as i32,
|
|
top + row as i32,
|
|
CosmicColor::rgba(
|
|
image.data[index],
|
|
image.data[index + 1],
|
|
image.data[index + 2],
|
|
image.data[index + 3],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
SwashContent::SubpixelMask => {
|
|
for row in 0..height {
|
|
for column in 0..width {
|
|
// Swash stores subpixel masks as 32-bit RGBA. Use the
|
|
// strongest RGB coverage as grayscale antialiasing;
|
|
// the alpha byte is padding for this content type.
|
|
let index = (row * width + column) * 4;
|
|
let mask = image.data[index]
|
|
.max(image.data[index + 1])
|
|
.max(image.data[index + 2]);
|
|
let alpha = multiply_alpha(mask, color.a());
|
|
self.canvas.blend(
|
|
left + column as i32,
|
|
top + row as i32,
|
|
CosmicColor::rgba(color.r(), color.g(), color.b(), alpha),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn multiply_alpha(first: u8, second: u8) -> u8 {
|
|
((u16::from(first) * u16::from(second) + 127) / 255) as u8
|
|
}
|
|
|
|
fn blend_premultiplied(destination: &mut [u8], source: CosmicColor) {
|
|
let alpha = u32::from(source.a());
|
|
let inverse = 255 - alpha;
|
|
let premultiply = |channel: u8| (u32::from(channel) * alpha + 127) / 255;
|
|
destination[0] =
|
|
(premultiply(source.r()) + u32::from(destination[0]) * inverse / 255).min(255) as u8;
|
|
destination[1] =
|
|
(premultiply(source.g()) + u32::from(destination[1]) * inverse / 255).min(255) as u8;
|
|
destination[2] =
|
|
(premultiply(source.b()) + u32::from(destination[2]) * inverse / 255).min(255) as u8;
|
|
destination[3] = (alpha + u32::from(destination[3]) * inverse / 255).min(255) as u8;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn global_offsets_round_trip_across_unicode_lines() {
|
|
let text = "héllo\n世界";
|
|
for offset in [0, 1, 3, 6, 7, text.len() as i32] {
|
|
let cursor = global_to_cursor(text, offset);
|
|
assert_eq!(
|
|
cursor_to_global(text, cursor),
|
|
valid_offset(text, offset) as i32
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lazy_editor_releases_fonts_without_discarding_document_history() {
|
|
let mut editor = LazyComposeEditor::default();
|
|
assert!(editor.0.get().is_none());
|
|
editor.release();
|
|
assert!(editor.0.get().is_none());
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize("draft", 5, 5);
|
|
document.insert_text(" preserved");
|
|
editor.reset();
|
|
assert!(editor.0.get().is_some());
|
|
editor.release();
|
|
assert!(editor.0.get().is_none());
|
|
assert_eq!(document.text(), "draft preserved");
|
|
document.history("undo").unwrap();
|
|
assert_eq!(document.text(), "draft");
|
|
editor.reset();
|
|
assert!(editor.0.get().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn bundled_ui_font_is_registered_for_the_compose_renderer() {
|
|
let surface = CosmicComposeEditor::default();
|
|
let has_ui_font = surface.font_system.db().faces().any(|face| {
|
|
face.families
|
|
.iter()
|
|
.any(|(family, _)| family == UI_FONT_FAMILY)
|
|
});
|
|
|
|
assert!(has_ui_font, "{UI_FONT_FAMILY} is missing from cosmic-text");
|
|
}
|
|
|
|
#[test]
|
|
fn visible_tile_range_includes_overscan_and_stays_bounded() {
|
|
assert_eq!(desired_tile_range(0.0, 300.0, 2_000.0), 0..2);
|
|
assert_eq!(desired_tile_range(900.0, 300.0, 2_000.0), 1..5);
|
|
assert_eq!(desired_tile_range(1_900.0, 300.0, 2_000.0), 3..6);
|
|
}
|
|
|
|
#[test]
|
|
fn source_over_blending_stores_premultiplied_rgba() {
|
|
let mut pixel = [0, 0, 0, 0];
|
|
blend_premultiplied(&mut pixel, CosmicColor::rgba(200, 100, 50, 128));
|
|
assert_eq!(pixel, [100, 50, 25, 128]);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_color_changes_repaint_without_relayout() {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize("selected phrase and more", 0, 15);
|
|
let base = ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(30, 30, 30),
|
|
link: Color::from_rgb_u8(0, 90, 180),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
};
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
let mut surface = CosmicComposeEditor::default();
|
|
let draw =
|
|
|surface: &mut CosmicComposeEditor, document: &RichComposeDocument, style| {
|
|
surface.render(
|
|
document,
|
|
document.selection(),
|
|
320.0,
|
|
120.0,
|
|
0.0,
|
|
scale,
|
|
style,
|
|
"",
|
|
)
|
|
};
|
|
let initial = draw(&mut surface, &document, base);
|
|
let original_layout = surface.layout_key;
|
|
let highlight = ComposeEditorStyle {
|
|
selection: Color::from_argb_u8(140, 220, 70, 40),
|
|
..base
|
|
};
|
|
assert!(!surface.ensure_layout(
|
|
&document,
|
|
document.selection(),
|
|
320.0,
|
|
scale,
|
|
highlight,
|
|
"",
|
|
));
|
|
let highlighted = draw(&mut surface, &document, highlight);
|
|
let expected = draw(&mut CosmicComposeEditor::default(), &document, highlight);
|
|
let pixels = |frame: &RenderedComposeEditor| {
|
|
frame.tiles[0]
|
|
.image
|
|
.to_rgba8_premultiplied()
|
|
.unwrap()
|
|
.as_bytes()
|
|
.to_vec()
|
|
};
|
|
assert_eq!(surface.layout_key, original_layout);
|
|
assert_ne!(pixels(&initial), pixels(&highlighted));
|
|
assert_eq!(pixels(&highlighted), pixels(&expected));
|
|
|
|
let selected_text = ComposeEditorStyle {
|
|
selected_text: Color::from_rgb_u8(20, 220, 20),
|
|
..highlight
|
|
};
|
|
assert!(!surface.ensure_layout(
|
|
&document,
|
|
document.selection(),
|
|
320.0,
|
|
scale,
|
|
selected_text,
|
|
"",
|
|
));
|
|
let recolored = draw(&mut surface, &document, selected_text);
|
|
let expected = draw(
|
|
&mut CosmicComposeEditor::default(),
|
|
&document,
|
|
selected_text,
|
|
);
|
|
assert_eq!(surface.layout_key, original_layout);
|
|
assert_ne!(pixels(&highlighted), pixels(&recolored));
|
|
assert_eq!(pixels(&recolored), pixels(&expected));
|
|
|
|
document.update_selection(15, 15);
|
|
let collapsed = draw(&mut surface, &document, selected_text);
|
|
let backing = collapsed.tiles[0].image.to_rgba8_premultiplied().unwrap();
|
|
let no_selection = draw(&mut surface, &document, base);
|
|
let unchanged = no_selection.tiles[0]
|
|
.image
|
|
.to_rgba8_premultiplied()
|
|
.unwrap();
|
|
assert!(std::ptr::eq(backing.as_bytes(), unchanged.as_bytes()));
|
|
assert_eq!(surface.layout_key, original_layout);
|
|
document.update_selection(0, 15);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn selection_repaints_nearby_tiles_and_matches_full_render() {
|
|
let text = (0..160)
|
|
.map(|line| format!("Line {line} with some draft text\n"))
|
|
.collect::<String>();
|
|
let start = text.find("Line 95").unwrap() as i32;
|
|
let end = start + "Line 95 with".len() as i32;
|
|
let style = ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(30, 30, 30),
|
|
link: Color::from_rgb_u8(0, 90, 180),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
};
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize(&text, 0, 0);
|
|
let mut surface = CosmicComposeEditor::default();
|
|
let initial = surface.render(
|
|
&document,
|
|
document.selection(),
|
|
520.0,
|
|
900.0,
|
|
1600.0,
|
|
scale,
|
|
style,
|
|
"",
|
|
);
|
|
let selection = document.update_selection(start, end);
|
|
let updated =
|
|
surface.render(&document, selection, 520.0, 900.0, 1600.0, scale, style, "");
|
|
let mut fresh = CosmicComposeEditor::default();
|
|
let expected =
|
|
fresh.render(&document, selection, 520.0, 900.0, 1600.0, scale, style, "");
|
|
assert_eq!(initial.tiles.len(), updated.tiles.len());
|
|
assert_eq!(updated.tiles.len(), expected.tiles.len());
|
|
let mut preserved = 0;
|
|
let mut repainted = 0;
|
|
for ((before, after), full) in initial
|
|
.tiles
|
|
.iter()
|
|
.zip(&updated.tiles)
|
|
.zip(&expected.tiles)
|
|
{
|
|
assert_eq!(after.y, full.y);
|
|
let before_buffer = before.image.to_rgba8_premultiplied().unwrap();
|
|
let after_buffer = after.image.to_rgba8_premultiplied().unwrap();
|
|
let full_buffer = full.image.to_rgba8_premultiplied().unwrap();
|
|
assert_eq!(
|
|
after_buffer.as_bytes(),
|
|
full_buffer.as_bytes(),
|
|
"scale={scale}, tile_y={}",
|
|
after.y
|
|
);
|
|
if std::ptr::eq(before_buffer.as_bytes(), after_buffer.as_bytes()) {
|
|
preserved += 1;
|
|
} else {
|
|
repainted += 1;
|
|
}
|
|
}
|
|
assert!(
|
|
preserved > 0,
|
|
"scale={scale}: unrelated tiles were repainted"
|
|
);
|
|
assert!(
|
|
repainted > 0,
|
|
"scale={scale}: selection tiles were not repainted"
|
|
);
|
|
|
|
// The navigation handler changes Cosmic Text before the document
|
|
// selection is published back to the surface.
|
|
let next = surface
|
|
.handle_navigation(
|
|
&document, selection, 520.0, scale, style, "", "\u{f702}", false, false, false,
|
|
false,
|
|
)
|
|
.unwrap();
|
|
let collapsed = document.update_selection(next.start, next.end);
|
|
let after_collapse =
|
|
surface.render(&document, collapsed, 520.0, 900.0, 1600.0, scale, style, "");
|
|
let mut fresh = CosmicComposeEditor::default();
|
|
let full_collapse =
|
|
fresh.render(&document, collapsed, 520.0, 900.0, 1600.0, scale, style, "");
|
|
let mut preserved_after_collapse = 0;
|
|
for (after, full) in after_collapse.tiles.iter().zip(&full_collapse.tiles) {
|
|
assert_eq!(after.y, full.y);
|
|
assert_eq!(
|
|
after.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
full.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
"scale={scale}, collapsed tile_y={}",
|
|
after.y
|
|
);
|
|
}
|
|
for (before, after) in updated.tiles.iter().zip(&after_collapse.tiles) {
|
|
let before = before.image.to_rgba8_premultiplied().unwrap();
|
|
let after = after.image.to_rgba8_premultiplied().unwrap();
|
|
if std::ptr::eq(before.as_bytes(), after.as_bytes()) {
|
|
preserved_after_collapse += 1;
|
|
}
|
|
}
|
|
assert!(
|
|
preserved_after_collapse > 0,
|
|
"scale={scale}: collapse cleared all tiles"
|
|
);
|
|
|
|
let far_end = text.find("Line 120").unwrap() as i32 + "Line 120 with".len() as i32;
|
|
let reverse_multiline = document.update_selection(far_end, start);
|
|
let after_drag = surface.render(
|
|
&document,
|
|
reverse_multiline,
|
|
520.0,
|
|
900.0,
|
|
1600.0,
|
|
scale,
|
|
style,
|
|
"",
|
|
);
|
|
let mut fresh = CosmicComposeEditor::default();
|
|
let full_drag = fresh.render(
|
|
&document,
|
|
reverse_multiline,
|
|
520.0,
|
|
900.0,
|
|
1600.0,
|
|
scale,
|
|
style,
|
|
"",
|
|
);
|
|
for (after, full) in after_drag.tiles.iter().zip(&full_drag.tiles) {
|
|
assert_eq!(after.y, full.y);
|
|
assert_eq!(
|
|
after.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
full.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
"scale={scale}, drag tile_y={}",
|
|
after.y
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_selection_tiles_match_full_render() {
|
|
let text = "wideword ".repeat(450);
|
|
let style = ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(30, 30, 30),
|
|
link: Color::from_rgb_u8(0, 90, 180),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
};
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize(&text, 0, 0);
|
|
let mut surface = CosmicComposeEditor::default();
|
|
let initial = surface.render(
|
|
&document,
|
|
document.selection(),
|
|
280.0,
|
|
600.0,
|
|
350.0,
|
|
scale,
|
|
style,
|
|
"",
|
|
);
|
|
let selection = document.update_selection(1300, 1330);
|
|
let updated =
|
|
surface.render(&document, selection, 280.0, 600.0, 350.0, scale, style, "");
|
|
let mut fresh = CosmicComposeEditor::default();
|
|
let expected =
|
|
fresh.render(&document, selection, 280.0, 600.0, 350.0, scale, style, "");
|
|
assert_eq!(updated.tiles.len(), expected.tiles.len());
|
|
let mut preserved = 0;
|
|
let mut repainted = 0;
|
|
for ((before, after), full) in initial
|
|
.tiles
|
|
.iter()
|
|
.zip(&updated.tiles)
|
|
.zip(&expected.tiles)
|
|
{
|
|
if std::ptr::eq(
|
|
before.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
after.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
) {
|
|
preserved += 1;
|
|
} else {
|
|
repainted += 1;
|
|
}
|
|
assert_eq!(after.y, full.y);
|
|
assert_eq!(
|
|
after.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
full.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
"scale={scale}, wrapped tile_y={}",
|
|
after.y
|
|
);
|
|
}
|
|
assert!(
|
|
preserved > 0,
|
|
"scale={scale}: wrapping repainted every tile"
|
|
);
|
|
assert!(repainted > 0, "scale={scale}: selection failed to repaint");
|
|
println!("wrapped selection scale={scale} preserved={preserved} repainted={repainted}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_direction_selection_and_preedit_tiles_match_full_render() {
|
|
let text = (0..50)
|
|
.map(|line| format!("Row {line}: café e\u{301} العربية 👩💻 text that wraps\n"))
|
|
.collect::<String>();
|
|
let start = text.find("Row 6:").unwrap() as i32;
|
|
let end = text.find("Row 12:").unwrap() as i32;
|
|
let style = ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(30, 30, 30),
|
|
link: Color::from_rgb_u8(0, 90, 180),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
};
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize(&text, 0, 0);
|
|
let mut surface = CosmicComposeEditor::default();
|
|
surface.render(
|
|
&document,
|
|
document.selection(),
|
|
280.0,
|
|
700.0,
|
|
128.0,
|
|
scale,
|
|
style,
|
|
"",
|
|
);
|
|
for (anchor, cursor, preedit) in [
|
|
(start, end, ""),
|
|
(end, start, ""),
|
|
(start, start, ""),
|
|
(start, start, "世界"),
|
|
(start, end, ""),
|
|
(end, end, ""),
|
|
] {
|
|
let selection = document.update_selection(anchor, cursor);
|
|
let actual = surface.render(
|
|
&document, selection, 280.0, 700.0, 128.0, scale, style, preedit,
|
|
);
|
|
let expected = CosmicComposeEditor::default().render(
|
|
&document, selection, 280.0, 700.0, 128.0, scale, style, preedit,
|
|
);
|
|
assert_eq!(actual.tiles.len(), expected.tiles.len());
|
|
for (actual, expected) in actual.tiles.iter().zip(&expected.tiles) {
|
|
assert_eq!(actual.y, expected.y);
|
|
assert_eq!(
|
|
actual.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
expected.image.to_rgba8_premultiplied().unwrap().as_bytes(),
|
|
"scale={scale} anchor={anchor} cursor={cursor} preedit={preedit:?} tile_y={}",
|
|
actual.y,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn layout_spans_borrow_long_unicode_text_and_preedit() {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize(&"é".repeat(512 * 1024), 2, 2);
|
|
let preedit = String::from("世界");
|
|
let attrs = Attrs::new().family(Family::Name(UI_FONT_FAMILY));
|
|
let spans = rich_spans(&document, 2, &preedit, &attrs, CosmicColor::rgb(0, 90, 180));
|
|
|
|
assert_eq!(spans.len(), 3);
|
|
assert_eq!(spans[0].0, "é");
|
|
assert_eq!(spans[0].0.as_ptr(), document.text().as_ptr());
|
|
assert_eq!(spans[1].0, "世界");
|
|
assert_eq!(spans[1].0.as_ptr(), preedit.as_ptr());
|
|
assert_eq!(spans[1].1.text_decoration.underline, UnderlineStyle::Single);
|
|
assert_eq!(spans[2].0.as_ptr(), document.text()[2..].as_ptr());
|
|
assert_eq!(spans[0].0.len() + spans[2].0.len(), 1024 * 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn combined_styles_render_without_changing_caret_text_offsets() {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize("first second", 0, 12);
|
|
document.format("bold", "first second", 0, 12);
|
|
document.format("underline", "first second", 0, 12);
|
|
document.format("strike", "first second", 0, 12);
|
|
document.update_selection(12, 12);
|
|
|
|
let mut surface = CosmicComposeEditor::default();
|
|
let rendered = surface.render(
|
|
&document,
|
|
document.selection(),
|
|
320.0,
|
|
120.0,
|
|
0.0,
|
|
1.0,
|
|
ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(240, 240, 240),
|
|
link: Color::from_rgb_u8(90, 150, 255),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
},
|
|
"",
|
|
);
|
|
|
|
assert_eq!(document.text(), "first second");
|
|
assert!(!rendered.tiles.is_empty());
|
|
assert!(rendered.content_height >= LINE_HEIGHT_LOGICAL);
|
|
assert!(rendered.caret_x > 0.0);
|
|
assert!(rendered.caret_y < LINE_HEIGHT_LOGICAL);
|
|
assert!(rendered.caret_y + rendered.caret_height <= rendered.content_height + 1.0);
|
|
|
|
let default_attrs = Attrs::new().family(Family::Name(UI_FONT_FAMILY));
|
|
let spans = rich_spans(
|
|
&document,
|
|
document.text().len(),
|
|
"",
|
|
&default_attrs,
|
|
CosmicColor::rgb(90, 150, 255),
|
|
);
|
|
assert_eq!(spans[0].1.text_decoration.underline, UnderlineStyle::Single);
|
|
assert!(spans[0].1.text_decoration.strikethrough);
|
|
}
|
|
|
|
#[test]
|
|
fn ime_preedit_is_laid_out_at_the_document_cursor() {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize("alpha omega", 6, 6);
|
|
let mut surface = CosmicComposeEditor::default();
|
|
let rendered = surface.render(
|
|
&document,
|
|
document.selection(),
|
|
320.0,
|
|
120.0,
|
|
0.0,
|
|
1.0,
|
|
ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(240, 240, 240),
|
|
link: Color::from_rgb_u8(90, 150, 255),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
},
|
|
"世界",
|
|
);
|
|
|
|
assert_eq!(surface.visual_text, "alpha 世界omega");
|
|
assert_eq!(
|
|
surface.selection_from_editor(),
|
|
ComposeSelection { start: 6, end: 6 }
|
|
);
|
|
assert!(rendered.caret_x > 0.0);
|
|
assert!(!rendered.tiles.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn line_breaks_render_at_every_caret_position() {
|
|
let style = ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(240, 240, 240),
|
|
link: Color::from_rgb_u8(90, 150, 255),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
};
|
|
|
|
for text in ["\n", "first\n", "\nsecond", "first\nsecond"] {
|
|
for offset in 0..=text.len() {
|
|
if !text.is_char_boundary(offset) {
|
|
continue;
|
|
}
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize(text, offset as i32, offset as i32);
|
|
let mut surface = CosmicComposeEditor::default();
|
|
let rendered = surface.render(
|
|
&document,
|
|
document.selection(),
|
|
320.0,
|
|
120.0,
|
|
0.0,
|
|
1.0,
|
|
style,
|
|
"",
|
|
);
|
|
|
|
assert!(
|
|
rendered.content_height >= LINE_HEIGHT_LOGICAL,
|
|
"failed to lay out {text:?} at byte offset {offset}",
|
|
);
|
|
assert!(
|
|
!rendered.tiles.is_empty(),
|
|
"failed to render {text:?} at byte offset {offset}",
|
|
);
|
|
assert_eq!(
|
|
surface.selection_from_editor(),
|
|
ComposeSelection {
|
|
start: offset as i32,
|
|
end: offset as i32,
|
|
},
|
|
"caret mapping changed for {text:?} at byte offset {offset}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn trailing_line_sentinel_is_not_part_of_the_editable_document() {
|
|
let mut document = RichComposeDocument::default();
|
|
document.synchronize("message\n", 8, 8);
|
|
let mut surface = CosmicComposeEditor::default();
|
|
surface.render(
|
|
&document,
|
|
document.selection(),
|
|
320.0,
|
|
120.0,
|
|
0.0,
|
|
1.0,
|
|
ComposeEditorStyle {
|
|
text: Color::from_rgb_u8(240, 240, 240),
|
|
link: Color::from_rgb_u8(90, 150, 255),
|
|
selection: Color::from_argb_u8(140, 60, 120, 220),
|
|
selected_text: Color::from_rgb_u8(255, 255, 255),
|
|
},
|
|
"",
|
|
);
|
|
|
|
assert_eq!(document.text(), "message\n");
|
|
assert_eq!(surface.visual_text, document.text());
|
|
assert_eq!(
|
|
surface.selection_from_editor(),
|
|
ComposeSelection { start: 8, end: 8 },
|
|
);
|
|
}
|
|
}
|