Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
//! The clipboard for LokView (LOK-06, TABLE-08).
use std::{
io,
sync::atomic::{AtomicU64, Ordering},
};
use ely_platform::clipboard::{self, Clip};
use crate::{Doc, Worker};
/// The clipboard payload for copied LOK flavors.
pub(super) fn clip_of(flavors: Vec<(String, Vec<u8>)>) -> Option<Clip> {
let take = |mime: &str| {
flavors
.iter()
.find(|(name, _)| name == mime)
.map(|(_, data)| data.clone())
};
let text =
take("text/plain;charset=utf-8").map(|data| String::from_utf8_lossy(&data).into_owned());
if let (Some(png), None) = (take("image/png"), &text) {
return Some(Clip::Png(png));
}
match (take("text/html"), text) {
(Some(html), text) => Some(Clip::Html {
html: String::from_utf8_lossy(&html).into_owned(),
text: text.unwrap_or_default(),
}),
(None, Some(text)) => Some(Clip::Text(text)),
(None, None) => None,
}
}
/// The LOK flavor for a clipboard payload.
pub(super) fn flavor_of(clip: Clip) -> (&'static str, Vec<u8>) {
match clip {
Clip::Text(text) => ("text/plain;charset=utf-8", text.into_bytes()),
Clip::Html { html, .. } => ("text/html", html.into_bytes()),
Clip::Png(png) => ("image/png", png),
}
}
/// Copies out, marked; a cut deletes after the write.
pub(super) fn copy_out(
worker: &mut Worker,
doc: &Doc,
cut: bool,
write: impl FnOnce(&Clip) -> io::Result<()>,
) -> io::Result<Option<String>> {
let Some(mut clip) = clip_of(worker.copy(doc)?) else {
return Ok(None);
};
let mark = if let Clip::Html { html, .. } = &mut clip {
static COPIES: AtomicU64 = AtomicU64::new(0);
let mark = format!(
"{}-{}",
std::process::id(),
COPIES.fetch_add(1, Ordering::SeqCst)
);
html.push_str(&format!("<!--{MARK}{mark}-->"));
Some(mark)
} else {
None
};
write(&clip)?;
if cut {
worker.cut(doc)?;
}
Ok(mark)
}
/// Ely's copies end their HTML with this and a mark.
const MARK: &str = "ely-copy:";
/// The mark of Ely's copy in `html`, if any.
pub(super) fn mark_of(html: &str) -> Option<&str> {
let start = html.rfind(&format!("<!--{MARK}"))? + 4 + MARK.len();
let end = html[start..].find("-->")?;
Some(&html[start..start + end])
}
/// Pastes the system clipboard.
pub(super) fn paste(worker: &mut Worker, doc: &Doc) -> io::Result<()> {
let Some(clip) = clipboard::read().map_err(io::Error::other)? else {
return Ok(());
};
let (mime, data) = flavor_of(clip);
worker.paste(doc, mime, &data)
}
+97
View File
@@ -0,0 +1,97 @@
//! Times an edit to the tile showing it (PERF-02).
use std::time::Instant;
/// One edit's clock: sent when, its revision, its generation.
type Edit = (Instant, Option<u64>, Option<u64>);
#[derive(Default)]
pub(super) struct EditClock {
edit: Option<Edit>,
/// The last generation bump: its revision and generation.
bumped: (u64, u64),
}
impl EditClock {
/// An edit queued; an unseen earlier start stays.
pub(super) fn sent(&mut self) {
self.edit.get_or_insert((Instant::now(), None, None));
}
/// The edit landed at `revision`; its batch may precede it.
pub(super) fn landed(&mut self, revision: Option<u64>) {
let Some((_, rev @ None, generation)) = &mut self.edit else {
return;
};
let Some(revision) = revision else {
self.edit = None;
return;
};
*rev = Some(revision);
if self.bumped.0 >= revision {
*generation = Some(self.bumped.1);
}
}
/// A batch at `revision` dirtied tiles into `generation`.
pub(super) fn bumped(&mut self, revision: u64, generation: u64) {
self.bumped = (revision, generation);
if let Some((_, Some(rev), shown @ None)) = &mut self.edit
&& revision >= *rev
{
*shown = Some(generation);
}
}
/// A tile of `generation` came; logs the edit it shows.
pub(super) fn painted(&mut self, generation: u64) {
if let Some((at, _, Some(shown))) = self.edit
&& generation >= shown
{
log::debug!("lok: edit to tile {} ms", at.elapsed().as_millis());
self.edit = None;
}
}
#[cfg(test)]
fn running(&self) -> bool {
self.edit.is_some()
}
}
#[cfg(test)]
mod tests {
use super::EditClock;
#[test]
fn a_batch_before_the_landing_still_ends_the_edit() {
let mut clock = EditClock::default();
clock.sent();
clock.bumped(7, 3);
clock.landed(Some(7));
clock.painted(2);
assert!(clock.running(), "an older picture leaves it running");
clock.painted(3);
assert!(!clock.running(), "the edit's own picture ends it");
}
#[test]
fn only_a_batch_at_the_edit_revision_counts() {
let mut clock = EditClock::default();
clock.sent();
clock.bumped(6, 3);
clock.landed(Some(7));
clock.painted(3);
assert!(clock.running(), "a batch before the edit shows the old text");
clock.bumped(7, 4);
clock.painted(4);
assert!(!clock.running());
}
#[test]
fn an_unread_revision_drops_the_edit() {
let mut clock = EditClock::default();
clock.sent();
clock.landed(None);
assert!(!clock.running());
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Formula entry: reference marks and function hints (TABLE-03).
use ely_gpui_component::{
editor::{CompletionItem, CompletionMenu, Signature, SignatureHelp, SymbolKind, completions},
primitives::raise,
};
use gpui::{
AnyElement, Bounds, Context, Hsla, IntoElement, Keystroke, Modifiers, ParentElement, Pixels,
anchored, rgb,
};
use super::{LokView, input::Input};
impl LokView {
/// The sheet shown, which reference marks follow.
pub fn set_part(&mut self, part: usize) {
self.part = part;
}
/// The shown sheet's reference marks, in window pixels.
pub(super) fn marks(&self) -> Vec<(Bounds<Pixels>, Hsla)> {
self.cell
.marks
.iter()
.filter(|mark| mark.part == self.part)
.map(|mark| (self.screen_rect(mark.rect), rgb(mark.color).into()))
.collect()
}
/// The offered functions, in the order the menu shows.
fn offered(&self) -> Vec<CompletionItem> {
let items: Vec<CompletionItem> = self
.cell
.functions
.iter()
.map(|function| {
CompletionItem::new(function.name.clone(), SymbolKind::Function)
.detail(function.signature.clone())
.docs(function.description.clone())
})
.collect();
completions(&items, "")
.into_iter()
.map(|(item, _)| item)
.collect()
}
/// Up and Down walk the offered functions.
pub(super) fn walk(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
let count = self.cell.functions.len();
if count == 0 || keystroke.modifiers != Modifiers::none() {
return false;
}
self.picked = match keystroke.key.as_str() {
"down" => (self.picked + 1) % count,
"up" => (self.picked + count - 1) % count,
_ => return false,
};
cx.notify();
true
}
/// Takes the chosen function, in order with typing.
pub(super) fn complete(&mut self) -> bool {
let Some(item) = self.offered().into_iter().nth(self.picked) else {
return false;
};
log::info!("lok: completes {}", item.label);
self.send(Input::Complete(item.label.to_string()));
true
}
/// The offered functions, else the call's usage, under the cell.
pub(super) fn hints(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let cell = self.screen_rect(self.cell.cursor?);
let hint = if self.cell.functions.is_empty() {
SignatureHelp::new("lok-view-usage", [usage(&self.cell.usage)?]).into_any_element()
} else {
let this = cx.entity();
let offered = self.offered();
let labels: Vec<_> = offered.iter().map(|item| item.label.clone()).collect();
CompletionMenu::new("lok-view-functions", offered)
.selected(self.picked)
.on_pick(move |item, _, cx| {
let at = labels.iter().position(|label| *label == item.label);
this.update(cx, |view, cx| {
view.picked = at.expect("a picked item is offered");
view.complete();
cx.notify();
})
})
.into_any_element()
};
let place = anchored()
.position(cell.bottom_left())
.snap_to_window()
.child(hint);
Some(raise("lok-view-hints", place).into_any_element())
}
}
/// The kernel's usage text as a signature.
fn usage(text: &str) -> Option<Signature> {
let (call, docs) = text.split_once(" : ").unwrap_or((text, ""));
let at = call.find('►')?;
let label = call.replacen('►', "", 1);
let rest = &label[at..];
let end = at + rest.find([',', ';', ')']).unwrap_or(rest.len());
Some(Signature {
label: label.clone().into(),
params: std::iter::once(at..end).collect(),
docs: (!docs.is_empty()).then(|| docs.to_string().into()),
})
}
#[cfg(test)]
mod tests {
use super::usage;
#[test]
fn the_kernel_usage_reads_as_a_signature() {
let signature = usage("SUM(► Number 1, Number 2) : The numbers.").expect("a usage");
assert_eq!(signature.label.as_ref(), "SUM( Number 1, Number 2)");
assert_eq!(&signature.label[signature.params[0].clone()], " Number 1");
assert_eq!(signature.docs.as_deref(), Some("The numbers."));
assert!(usage("").is_none());
}
}
+108
View File
@@ -0,0 +1,108 @@
//! The selected object's frame and its eight handles (DOC-08).
use gpui::{Pixels, Point};
use super::LokView;
use crate::Rect;
/// A held handle: which, from where, to where.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Grip {
pub(super) at: usize,
pub(super) from: (i32, i32),
pub(super) to: (i32, i32),
}
/// Corners and edge middles, left to right, top to bottom.
pub(super) fn points((x, y, w, h): Rect) -> [(i32, i32); 8] {
let (cx, cy, r, b) = (x + w / 2, y + h / 2, x + w, y + h);
[
(x, y),
(cx, y),
(r, y),
(x, cy),
(r, cy),
(x, b),
(cx, b),
(r, b),
]
}
/// The frame with handle `at` moved to `to`.
pub(super) fn dragged((x, y, w, h): Rect, at: usize, (tx, ty): (i32, i32)) -> Rect {
let (mut left, mut top, mut right, mut bottom) = (x, y, x + w, y + h);
if matches!(at, 0 | 3 | 5) {
left = tx;
}
if matches!(at, 2 | 4 | 7) {
right = tx;
}
if matches!(at, 0..=2) {
top = ty;
}
if matches!(at, 5..=7) {
bottom = ty;
}
(
left.min(right),
top.min(bottom),
(right - left).abs(),
(bottom - top).abs(),
)
}
impl LokView {
/// Grabs the handle under `position`, if a frame is selected.
pub(super) fn grab(&mut self, position: Point<Pixels>) -> bool {
let Some(frame) = self.graphic else {
return false;
};
let reach = self.handle_size;
let hit = points(frame).iter().position(|&point| {
let at = self.window_point(point);
(at.x - position.x).abs() <= reach && (at.y - position.y).abs() <= reach
});
let Some(at) = hit else {
return false;
};
let from = points(frame)[at];
log::info!("lok: handle {at} grabbed");
self.grip = Some(Grip { at, from, to: from });
true
}
/// The frame to paint, previewing a held handle.
pub(super) fn frame(&self) -> Option<Rect> {
let frame = self.graphic?;
Some(match self.grip {
Some(grip) => dragged(frame, grip.at, grip.to),
None => frame,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handles_move_their_own_sides() {
let frame = (100, 200, 300, 400);
assert_eq!(points(frame)[7], (400, 600));
assert_eq!(
dragged(frame, 7, (300, 500)),
(100, 200, 200, 300),
"a corner moves two sides"
);
assert_eq!(
dragged(frame, 4, (500, 0)),
(100, 200, 400, 400),
"an edge moves one"
);
assert_eq!(
dragged(frame, 0, (500, 700)),
(400, 600, 100, 100),
"past the far side flips"
);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! The IME composition bridge for LokView (LOK-07).
use std::ops::Range;
use gpui::{Bounds, Context, EntityInputHandler, Pixels, UTF16Selection, Window};
use super::{LokView, input::Input};
/// The live composition and its UTF-16 selection.
pub(super) struct Marked {
pub text: String,
pub selected: Range<usize>,
}
fn len16(text: &str) -> usize {
text.encode_utf16().count()
}
/// The byte offset of a UTF-16 index, clamped.
fn byte_at(text: &str, at16: usize) -> usize {
let mut seen = 0;
for (byte, c) in text.char_indices() {
if seen >= at16 {
return byte;
}
seen += c.len_utf16();
}
text.len()
}
/// `base` with its UTF-16 `range` replaced by `text`.
fn splice(base: &str, range: &Range<usize>, text: &str) -> String {
let (start, end) = (
byte_at(base, range.start),
byte_at(base, range.end.max(range.start)),
);
format!("{}{text}{}", &base[..start], &base[end..])
}
impl LokView {
/// Replaces part of the composition; empty text cancels it.
pub(super) fn compose(
&mut self,
range: Option<Range<usize>>,
text: &str,
selected: Option<Range<usize>>,
) {
let (composed, start) = match (&self.marked, range) {
(Some(marked), Some(range)) => (splice(&marked.text, &range, text), range.start),
_ => (text.to_string(), 0),
};
self.send(Input::Text {
commit: false,
text: composed.clone(),
});
if composed.is_empty() {
self.send(Input::Text {
commit: true,
text: String::new(),
});
self.marked = None;
return;
}
let end = len16(&composed);
let tail = start + len16(text);
let selected = selected.map_or(tail..tail, |s| s.start + start..s.end + start);
self.marked = Some(Marked {
selected: selected.start.min(end)..selected.end.min(end),
text: composed,
});
}
/// Types `text` at the cursor.
pub fn insert(&mut self, text: &str) {
self.commit(None, text);
}
/// Commits `text` into the composition's `range`, or at the cursor.
pub(super) fn commit(&mut self, range: Option<Range<usize>>, text: &str) {
let committed = match (&self.marked, range) {
(Some(marked), Some(range)) => splice(&marked.text, &range, text),
_ => text.to_string(),
};
self.send(Input::Text {
commit: false,
text: committed,
});
self.send(Input::Text {
commit: true,
text: String::new(),
});
self.marked = None;
}
}
impl EntityInputHandler for LokView {
fn text_for_range(
&mut self,
range: Range<usize>,
adjusted: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
let marked = self.marked.as_ref()?;
let end = len16(&marked.text);
let range = range.start.min(end)..range.end.min(end);
let text =
&marked.text[byte_at(&marked.text, range.start)..byte_at(&marked.text, range.end)];
*adjusted = Some(range);
Some(text.to_string())
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
let range = self
.marked
.as_ref()
.map_or(0..0, |marked| marked.selected.clone());
Some(UTF16Selection {
range,
reversed: false,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.marked.as_ref().map(|marked| 0..len16(&marked.text))
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
if let Some(marked) = self.marked.take() {
self.commit(None, &marked.text);
}
}
fn replace_text_in_range(
&mut self,
range: Option<Range<usize>>,
text: &str,
_: &mut Window,
_: &mut Context<Self>,
) {
self.commit(range, text);
}
fn replace_and_mark_text_in_range(
&mut self,
range: Option<Range<usize>>,
text: &str,
selected: Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) {
self.compose(range, text, selected);
}
fn bounds_for_range(
&mut self,
_: Range<usize>,
_: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
self.cursor_bounds()
}
fn character_index_for_point(
&mut self,
_: gpui::Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splices_count_utf16() {
assert_eq!(splice("nihao", &(0..2), "你"), "你hao");
assert_eq!(splice("你好", &(1..2), "们"), "你们");
assert_eq!(splice("ab", &(5..9), "c"), "abc", "ranges clamp");
assert_eq!(byte_at("😀a", 2), 4, "a surrogate pair is two units");
}
}
+423
View File
@@ -0,0 +1,423 @@
//! Keys, mouse, IME and clipboard for LokView (LOK-06, LOK-07).
use std::{io, sync::Mutex, time::Instant};
use ely_platform::clipboard;
use gpui::{
App, Context, KeyBinding, KeyDownEvent, Keystroke, Modifiers, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Window, actions,
};
use super::{LokView, clip, handles};
use crate::{Doc, Mouse, Rect, Worker};
actions!(lok_view, [Copy, Cut, Paste, NextField, PreviousField]);
/// The key context of every LokView.
pub const CONTEXT: &str = "LokView";
/// Binds the clipboard keys; call once at startup.
pub fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("secondary-c", Copy, Some(CONTEXT)),
KeyBinding::new("secondary-x", Cut, Some(CONTEXT)),
KeyBinding::new("secondary-v", Paste, Some(CONTEXT)),
// The document owns Tab, as office apps do.
KeyBinding::new("tab", NextField, Some(CONTEXT)),
KeyBinding::new("shift-tab", PreviousField, Some(CONTEXT)),
]);
}
/// One queued request; the queue keeps input order.
pub(super) enum Input {
Key {
char_code: u32,
key_code: i32,
},
Mouse(Mouse),
Text {
commit: bool,
text: String,
},
Copy {
cut: bool,
},
Paste,
/// Values or formulas alone: `paste.values` or `paste.formulas`.
PasteOnly(&'static str),
/// A function the kernel offered, taken.
Complete(String),
/// The kernel's color scheme; not an edit.
Theme(&'static str),
/// Commands in order, from one read.
Run {
commands: Vec<(&'static str, Vec<String>)>,
/// The document as read, for commands by number.
at: Option<crate::Doc>,
},
/// A handle of the selected object, moved.
Drag {
from: (i32, i32),
to: (i32, i32),
},
/// The area the view shows, in twips.
Visible(Rect),
/// Shows sheet or slide `part`, from 0.
Part(usize),
}
impl Input {
/// Whether the input may change the document.
pub(super) fn edits(&self) -> bool {
match self {
// Arrows, Home, End, Page keys, Escape.
Input::Key { key_code, .. } => !matches!(key_code & 0x0FFF, 1024..=1031 | 1281),
Input::Mouse(_)
| Input::Copy { cut: false }
| Input::Visible(_)
| Input::Part(_)
| Input::Theme(_) => false,
Input::Run { commands, .. } => commands.iter().any(|(id, _)| *id != "edit.select_all"),
Input::Text { .. }
| Input::Copy { cut: true }
| Input::Paste
| Input::PasteOnly(_)
| Input::Drag { .. }
| Input::Complete(_) => true,
}
}
}
/// vcl modifier bits.
const SHIFT: i32 = 0x1000;
const MOD1: i32 = 0x2000;
const MOD2: i32 = 0x4000;
const MOD3: i32 = 0x8000;
/// vcl bits for GPUI modifiers; macOS Cmd is MOD1.
fn vcl_modifiers(modifiers: &Modifiers) -> i32 {
let mut bits = 0;
if modifiers.shift {
bits |= SHIFT;
}
if modifiers.alt {
bits |= MOD2;
}
if cfg!(target_os = "macos") {
if modifiers.platform {
bits |= MOD1;
}
if modifiers.control {
bits |= MOD3;
}
} else if modifiers.control {
bits |= MOD1;
}
bits
}
/// A non-text keystroke as (char, vcl code).
pub(super) fn vcl_key(keystroke: &Keystroke) -> Option<(u32, i32)> {
let modifiers = &keystroke.modifiers;
let command =
modifiers.control || modifiers.platform || (modifiers.alt && !cfg!(target_os = "macos"));
let named = match keystroke.key.as_str() {
"down" => Some(1024),
"up" => Some(1025),
"left" => Some(1026),
"right" => Some(1027),
"home" => Some(1028),
"end" => Some(1029),
"pageup" => Some(1030),
"pagedown" => Some(1031),
"enter" => Some(1280),
"escape" => Some(1281),
"tab" => Some(1282),
"backspace" => Some(1283),
"space" if command => Some(1284),
"insert" => Some(1285),
"delete" => Some(1286),
key => key
.strip_prefix('f')
.and_then(|n| n.parse::<i32>().ok())
.filter(|n| (1..=26).contains(n))
.map(|n| 767 + n),
};
let code = match named {
Some(code) => code,
None if !command => return None,
None => {
let mut chars = keystroke.key.chars();
match (chars.next(), chars.next()) {
(Some(c @ 'a'..='z'), None) => 512 + (c as i32 - 'a' as i32),
(Some(c @ '0'..='9'), None) => 256 + (c as i32 - '0' as i32),
_ => return None,
}
}
};
Some((0, code | vcl_modifiers(modifiers)))
}
/// LOK mouse buttons: 1 left, 2 middle, 4 right.
fn lok_button(button: MouseButton) -> Option<i32> {
match button {
MouseButton::Left => Some(1),
MouseButton::Middle => Some(2),
MouseButton::Right => Some(4),
_ => None,
}
}
/// Runs one input; gives a copy's mark and the revision.
pub(super) fn run(worker: &Mutex<Worker>, doc: &Doc, input: Input) -> (io::Result<Option<String>>, Option<u64>) {
let mut worker = worker.lock().expect("the worker lock");
let counts = !matches!(input, Input::Visible(_));
let copied = apply(&mut worker, doc, input);
// A failed input may have moved the revision too.
let now = match counts {
true => worker.now(doc).inspect_err(|error| log::warn!("lok: revision unread: {error}")).ok().map(|now| now.rev),
false => None,
};
(copied, now)
}
fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<String>> {
let done = match input {
Input::Key {
char_code,
key_code,
} => worker.post_key(doc, char_code, key_code),
Input::Mouse(event) => worker.post_mouse(doc, event),
Input::Drag { from, to } => worker.drag_graphic(doc, from, to),
Input::Visible(area) => doc.visible_area(worker, area),
Input::Part(part) => doc.show_sheet(worker, part),
Input::Complete(name) => doc.complete_function(worker, &name),
Input::Theme(scheme) => worker.run(doc, "view.theme", &[scheme]),
Input::Text { commit, text } => worker.post_text(doc, commit, &text),
Input::Copy { cut } => {
return clip::copy_out(worker, doc, cut, |clip| {
clipboard::write(clip).map_err(io::Error::other)
});
}
Input::Run { commands, at } => {
let values: Vec<Vec<&str>> =
commands.iter().map(|(_, values)| values.iter().map(String::as_str).collect()).collect();
let commands: Vec<(&str, &[&str])> =
commands.iter().zip(&values).map(|((id, _), values)| (*id, values.as_slice())).collect();
worker.run_all(at.as_ref().unwrap_or(doc), &commands)
}
Input::Paste => clip::paste(worker, doc),
// The kernel's own cells: the app checked the clipboard.
Input::PasteOnly(id) => worker.run(doc, id, &[]),
};
done.map(|()| None)
}
impl LokView {
/// Queues `input` in order; edits mark the view pending.
pub(super) fn send(&mut self, input: Input) {
self.sent += 1;
if input.edits() {
self.last_edit = self.sent;
self.clock.sent();
}
self.lane
.inputs
.unbounded_send((self.sent, input, Instant::now()))
.expect("the input queue lives with the view");
}
/// Shows part `part` after the input queued before it.
pub fn show_part(&mut self, part: usize) {
log::info!("lok: shows part {part}");
self.part = part;
self.send(Input::Part(part));
}
/// Whether a possible edit is still queued.
pub fn edit_pending(&self) -> bool {
self.applied < self.last_edit
}
/// How many edits reached the kernel.
pub fn edited(&self) -> u64 {
self.edited
}
/// The sequence number of the last queued input.
pub fn sent(&self) -> u64 {
self.sent
}
/// Whether input `seq` reached the kernel.
pub fn reached(&self, seq: u64) -> bool {
self.applied >= seq
}
/// Whether every queued input reached the kernel.
pub fn settled(&self) -> bool {
self.applied >= self.sent
}
/// Posts a non-text key; text waits for the IME.
pub(super) fn key_down(&mut self, keystroke: &Keystroke) -> bool {
let Some((char_code, key_code)) = vcl_key(keystroke) else {
return false;
};
self.send(Input::Key {
char_code,
key_code,
});
true
}
/// Posts a mouse event at a view position (LOK-04).
pub(super) fn mouse(
&mut self,
kind: i32,
position: gpui::Point<Pixels>,
button: i32,
count: usize,
modifiers: &Modifiers,
) {
let (x, y) = self.doc_point(position);
self.send(Input::Mouse(Mouse {
kind,
x,
y,
count: count.max(1) as i32,
buttons: button,
modifiers: vcl_modifiers(modifiers),
}));
}
pub(super) fn on_key_down(
&mut self,
event: &KeyDownEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.walk(&event.keystroke, cx) || self.key_down(&event.keystroke) {
cx.stop_propagation();
}
}
pub(super) fn on_mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(button) = lok_button(event.button) else {
return;
};
window.focus(&self.focus, cx);
if event.button == MouseButton::Left && self.grab(event.position) {
cx.notify();
return;
}
self.dragging = event.button == MouseButton::Left;
self.mouse(
0,
event.position,
button,
event.click_count,
&event.modifiers,
);
}
pub(super) fn on_mouse_up(
&mut self,
event: &MouseUpEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(grip) = self.grip.take() {
let to = self.doc_point(event.position);
log::info!("lok: handle released");
self.send(Input::Drag {
from: grip.from,
to,
});
cx.notify();
return;
}
let Some(button) = lok_button(event.button) else {
return;
};
if event.button == MouseButton::Left {
self.dragging = false;
}
self.mouse(
1,
event.position,
button,
event.click_count,
&event.modifiers,
);
}
/// Ends a drag released outside the view.
pub(super) fn on_mouse_up_out(
&mut self,
event: &MouseUpEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.dragging || self.grip.is_some() {
self.on_mouse_up(event, window, cx);
}
}
pub(super) fn on_mouse_move(
&mut self,
event: &MouseMoveEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.grip.is_some() {
let to = self.doc_point(event.position);
self.grip = self.grip.map(|grip| handles::Grip { to, ..grip });
cx.notify();
return;
}
if self.dragging && event.pressed_button == Some(MouseButton::Left) {
self.mouse(2, event.position, 1, 1, &event.modifiers);
}
}
pub(super) fn on_copy(&mut self, _: &Copy, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Copy { cut: false });
}
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
if !self.complete() {
self.key_down(&Keystroke::parse("tab").expect("a keystroke"));
}
}
pub(super) fn on_previous_field(
&mut self,
_: &PreviousField,
_: &mut Window,
_: &mut Context<Self>,
) {
self.key_down(&Keystroke::parse("shift-tab").expect("a keystroke"));
}
pub(super) fn on_cut(&mut self, _: &Cut, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Copy { cut: true });
}
pub(super) fn on_paste(&mut self, _: &Paste, _: &mut Window, _: &mut Context<Self>) {
self.send(Input::Paste);
}
/// Pastes values or formulas alone (TABLE-08).
pub fn paste_only(&mut self, id: &'static str) {
self.send(Input::PasteOnly(id));
}
}
#[cfg(test)]
mod tests;
+93
View File
@@ -0,0 +1,93 @@
use super::*;
use crate::view::clip::{clip_of, flavor_of};
use ely_platform::clipboard::Clip;
fn stroke(text: &str) -> Keystroke {
Keystroke::parse(text).expect("a keystroke")
}
#[test]
fn named_keys_map_to_vcl_codes() {
assert_eq!(vcl_key(&stroke("enter")), Some((0, 1280)));
assert_eq!(vcl_key(&stroke("shift-left")), Some((0, 1026 | SHIFT)));
assert_eq!(vcl_key(&stroke("backspace")), Some((0, 1283)));
assert_eq!(vcl_key(&stroke("f5")), Some((0, 772)));
let primary = if cfg!(target_os = "macos") {
"cmd"
} else {
"ctrl"
};
assert_eq!(
vcl_key(&stroke(&format!("{primary}-b"))),
Some((0, 513 | MOD1))
);
assert_eq!(
vcl_key(&stroke(&format!("{primary}-shift-z"))),
Some((0, 537 | MOD1 | SHIFT))
);
}
#[test]
fn printable_text_waits_for_the_ime() {
let mut typed = stroke("a");
typed.key_char = Some("a".into());
assert_eq!(vcl_key(&typed), None);
let mut space = stroke("space");
space.key_char = Some(" ".into());
assert_eq!(vcl_key(&space), None);
}
#[test]
fn copied_flavors_pick_the_richest_clip() {
let flavor = |mime: &str, data: &str| (mime.to_string(), data.as_bytes().to_vec());
let html = clip_of(vec![
flavor("text/html", "<b>x</b>"),
flavor("text/plain;charset=utf-8", "x"),
]);
assert_eq!(
html,
Some(Clip::Html {
html: "<b>x</b>".into(),
text: "x".into()
})
);
assert_eq!(
clip_of(vec![flavor("image/png", "png")]),
Some(Clip::Png(b"png".to_vec()))
);
assert_eq!(clip_of(Vec::new()), None);
assert_eq!(
flavor_of(Clip::Text("t".into())).0,
"text/plain;charset=utf-8"
);
}
#[test]
fn copy_marks_read_back() {
use crate::view::clip::mark_of;
assert_eq!(mark_of("<b>x</b><!--ely-copy:7-3-->"), Some("7-3"));
assert_eq!(mark_of("<b>x</b>"), None);
let wrapped = "<html><body><b>x</b><!--ely-copy:7-4--></body></html>";
assert_eq!(mark_of(wrapped), Some("7-4"), "the clipboard may wrap it");
}
#[test]
fn a_failed_input_still_reports_the_revision_it_reached() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("book.fods");
std::fs::write(&source, crate::client::tests::input::FODS).expect("the fixture writes");
let doc = worker.load(&format!("file://{}", source.display())).expect("the book loads");
let worker = Mutex::new(worker);
// Nothing merged to split: the kernel refuses.
let input = Input::Run { commands: vec![("cells.split", Vec::new())], at: None };
let (outcome, now) = run(&worker, &doc, input);
assert!(outcome.is_err(), "the split fails");
let reached = worker.lock().expect("the worker lock").now(&doc).expect("the state");
assert_eq!(now, Some(reached.rev), "the view learns the revision anyway");
assert!(reached.rev > doc.rev, "the request counted");
}
+125
View File
@@ -0,0 +1,125 @@
//! One background lane per view: inputs first, then tiles (LOK-03).
use std::{
io,
sync::{Arc, Mutex},
time::Instant,
};
use futures::{StreamExt, channel::mpsc, select_biased};
use gpui::Context;
use super::{Geom, LokError, LokView, Reported, TILE_TWIPS, input::Input};
use crate::{Doc, Tile, Worker};
/// An input slower than this is logged as a warning.
const SLOW_MS: u128 = 100;
/// A tile to paint at `key` for `geom`.
pub(super) struct Job {
pub key: (i32, i32),
pub geom: Geom,
}
/// What came back from the lane.
enum Landed {
Input(u64, bool, (io::Result<Option<String>>, Option<u64>)),
Tile(Job, io::Result<Tile>),
}
/// The lane's two queues.
pub(super) struct Lane {
pub inputs: mpsc::UnboundedSender<(u64, Input, Instant)>,
pub tiles: mpsc::UnboundedSender<Job>,
}
impl LokView {
/// Starts the lane; it ends with the view.
pub(super) fn lane(worker: Arc<Mutex<Worker>>, doc: Doc, cx: &mut Context<Self>) -> Lane {
let (inputs, mut input_queue) = mpsc::unbounded::<(u64, Input, Instant)>();
let (tiles, mut tile_queue) = mpsc::unbounded::<Job>();
let (landing, mut landed) = mpsc::unbounded::<Landed>();
cx.background_executor()
.spawn(async move {
loop {
let done = select_biased! {
input = input_queue.next() => {
let Some((seq, input, sent)) = input else { break };
let (waited, edits) = (sent.elapsed().as_millis(), input.edits());
let outcome = super::input::run(&worker, &doc, input);
let took = sent.elapsed().as_millis();
if took > SLOW_MS {
log::warn!("lok: input {seq} waited {waited}, took {took} ms");
} else {
log::debug!("lok: input {seq} waited {waited}, took {took} ms");
}
Landed::Input(seq, edits, outcome)
}
job = tile_queue.next() => {
let Some(job) = job else { break };
let started = Instant::now();
let side = job.geom.tile_pixels();
let (x, y) = (job.key.0 * TILE_TWIPS, job.key.1 * TILE_TWIPS);
let tile = doc.paint_tile(
&mut worker.lock().expect("the worker lock"),
(x, y, TILE_TWIPS, TILE_TWIPS),
(side, side),
);
let key = job.key;
log::debug!("lok: tile {key:?} in {} ms", started.elapsed().as_millis());
Landed::Tile(job, tile)
}
};
if landing.unbounded_send(done).is_err() {
break;
}
}
})
.detach();
cx.spawn(async move |this, cx| {
while let Some(first) = landed.next().await {
let mut batch = vec![first];
while let Ok(more) = landed.try_recv() {
batch.push(more);
}
let taken = this.update(cx, |view, cx| view.take_landed(batch, cx));
if taken.is_err() {
break;
}
}
})
.detach();
Lane { inputs, tiles }
}
fn take_landed(&mut self, batch: Vec<Landed>, cx: &mut Context<Self>) {
let mut reported = false;
for landed in batch {
match landed {
Landed::Input(seq, edits, outcome) => {
self.applied = seq;
self.edited += u64::from(edits);
let (outcome, now) = outcome;
if edits {
self.clock.landed(now);
}
self.seen_revision(now.unwrap_or(0));
match outcome {
Ok(Some(copied)) => self.copied = Some(copied),
Ok(None) => {}
Err(error) => {
log::error!("lok: input failed ({:?})", error.kind());
cx.emit(LokError(error.to_string()));
}
}
reported = true;
}
Landed::Tile(job, tile) => self.finish_tile(job.key, job.geom, tile, cx),
}
}
if reported {
cx.emit(Reported);
}
cx.notify();
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Kernel reads off the UI thread.
use std::{
io,
sync::{Arc, Mutex},
};
use gpui::Context;
use crate::{Doc, Worker};
/// Runs `read` on a background thread; `then` takes its result.
pub fn read_off<E: 'static, T: Send + 'static>(
worker: &Arc<Mutex<Worker>>,
doc: &Doc,
read: impl FnOnce(&mut Worker, &Doc) -> io::Result<T> + Send + 'static,
then: impl FnOnce(&mut E, io::Result<T>, &mut Context<E>) + 'static,
cx: &mut Context<E>,
) {
let (worker, doc) = (Arc::clone(worker), doc.clone());
cx.spawn(async move |this, cx| {
let read = cx
.background_executor()
.spawn(async move { read(&mut worker.lock().expect("the worker lock"), &doc) })
.await;
if this
.update(cx, |entity, cx| then(entity, read, cx))
.is_err()
{
log::info!("lok: a read outlived its reader");
}
})
.detach();
}
+69
View File
@@ -0,0 +1,69 @@
//! Frozen rows and columns stay; the rest scrolls (TABLE-02).
/// One axis: frozen extent and scroll past it, in twips.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Axis {
pub frozen: i32,
pub scroll: i32,
}
impl Axis {
/// Where document `doc` shows, in twips from the view's start.
pub fn show(self, doc: i32) -> i32 {
if doc < self.frozen {
doc
} else {
(doc - self.scroll).max(self.frozen)
}
}
/// The document point under view twips `at`.
pub fn doc(self, at: i32) -> i32 {
if at < self.frozen {
at
} else {
at + self.scroll
}
}
/// Each pane: document start and end, and its view start.
pub fn panes(self, length: i32) -> Vec<(i32, i32, i32)> {
let mut panes = Vec::with_capacity(2);
if self.frozen > 0 {
panes.push((0, self.frozen, 0));
}
let start = self.frozen + self.scroll;
panes.push((start, start + (length - self.frozen).max(0), self.frozen));
panes
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_frozen_part_stays_and_the_rest_scrolls() {
let axis = Axis {
frozen: 300,
scroll: 1000,
};
assert_eq!(axis.show(100), 100, "frozen rows stay");
assert_eq!(axis.show(1500), 500, "the rest moves by the scroll");
assert_eq!(axis.show(800), 300, "the hidden band folds to the line");
assert_eq!(axis.doc(100), 100);
assert_eq!(axis.doc(500), 1500);
assert_eq!(axis.panes(2000), [(0, 300, 0), (1300, 3000, 300)]);
}
#[test]
fn without_a_freeze_one_pane_scrolls() {
let axis = Axis {
frozen: 0,
scroll: 400,
};
assert_eq!(axis.show(1000), 600);
assert_eq!(axis.doc(600), 1000);
assert_eq!(axis.panes(2000), [(400, 2400, 0)]);
}
}
+113
View File
@@ -0,0 +1,113 @@
//! What the view knows, for apps to read.
use gpui::FocusHandle;
use super::LokView;
use crate::Rect;
impl LokView {
/// Whether the kernel paints dark; None before the first frame.
pub fn dark(&self) -> Option<bool> {
self.dark
}
/// The kernel's state for `uno`, as LOK reports it (LOK-05).
pub fn state(&self, uno: &str) -> Option<&str> {
self.states.get(uno).map(String::as_str)
}
/// The selected object's frame in twips, if any.
pub fn graphic(&self) -> Option<Rect> {
self.graphic
}
/// Bumps when comments or tracked changes change.
pub fn review(&self) -> u64 {
self.review
}
/// The cursor in twips, if the kernel shows one.
pub fn cursor(&self) -> Option<Rect> {
self.cursor
}
/// Whether the kernel shows a caret.
pub fn caret(&self) -> bool {
self.cursor_visible && self.cursor.is_some()
}
/// Counts cursor moves the kernel reported.
pub fn moves(&self) -> u64 {
self.moves
}
/// What a spreadsheet reports about its current cell.
pub fn cell(&self) -> &crate::Cell {
&self.cell
}
/// Document twips as a point inside the view.
pub fn place(&self, at: (i32, i32)) -> gpui::Point<gpui::Pixels> {
self.window_point(at) - self.origin
}
/// The view's size in pixels at the last paint.
pub fn viewport(&self) -> (f32, f32) {
self.viewport
}
/// Frozen columns and rows, in twips.
pub fn frozen(&self) -> (i32, i32) {
self.frozen
}
/// The view's top left in window coordinates.
pub fn origin(&self) -> gpui::Point<gpui::Pixels> {
self.origin
}
/// A view-local point as document twips.
pub fn twips_at(&self, at: gpui::Point<gpui::Pixels>) -> (i32, i32) {
self.doc_point(at + self.origin)
}
/// Each page's frame in twips.
pub fn pages(&self) -> &[Rect] {
&self.pages
}
/// The selection's rectangles in twips.
pub fn selection(&self) -> &[Rect] {
&self.selection
}
/// The last search's count and matches.
pub fn search(&self) -> (u64, &[Rect]) {
(self.search.0, &self.search.1)
}
/// Fonts the document names and the system lacks.
pub fn missing_fonts(&self) -> &[String] {
&self.fonts
}
/// Takes the context menu the kernel last offered.
pub fn take_menu(&mut self) -> Option<String> {
self.menu.take()
}
/// The paragraph's own tab stops, in 1/100 mm.
pub fn tabs(&self) -> &[i32] {
&self.tabs
}
/// The handle apps focus to type into the document.
pub fn focus_handle(&self) -> &FocusHandle {
&self.focus
}
/// Whether the clipboard `html` is this view's last copy.
pub fn holds_copy(&self, html: &str) -> bool {
super::clip::mark_of(html).is_some_and(|mark| self.copied.as_deref() == Some(mark))
}
}
+346
View File
@@ -0,0 +1,346 @@
//! LokView's element tree and paint (LOK-03, §7 tail).
use ely_gpui_component::theme::ActiveTheme;
use gpui::{
BorderStyle, Bounds, ContentMask, Context, Corners, CursorStyle, Edges, ElementInputHandler,
InteractiveElement, IntoElement, MouseButton, ParentElement, Render, Role,
StatefulInteractiveElement, Styled, Window, canvas, div, fill, outline, px, quad, size,
transparent_black,
};
use std::sync::Arc;
use gpui::{Pixels, RenderImage, point};
use super::{CONTEXT, LokView, TILE_TWIPS, TWIPS_PER_PIXEL, handles, input};
use crate::Rect;
/// Ely's kernel color schemes, from `ely.xcd`.
const DARK: &str = "COLOR_SCHEME_ELY_DARK";
const LIGHT: &str = "COLOR_SCHEME_ELY_LIGHT";
/// The kernel scheme for a light or dark app.
pub fn scheme(dark: bool) -> &'static str {
if dark { DARK } else { LIGHT }
}
impl LokView {
/// Paints with the app's light or dark scheme.
pub(super) fn follow_theme(&mut self, dark: bool) {
if self.dark != Some(dark) {
self.dark = Some(dark);
let scheme = scheme(dark);
log::info!("lok: paints with {scheme}");
self.send(input::Input::Theme(scheme));
}
}
}
impl Render for LokView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let dpi = window.scale_factor();
if dpi != self.geom.dpi {
self.geom.dpi = dpi;
self.retire_tiles();
}
self.follow_theme(cx.theme().is_dark());
let this = cx.entity().clone();
let focus = self.focus.clone();
let colors = &cx.theme().colors;
let paper = super::paper(cx);
let (selection_color, caret_color) = (colors.selection, colors.focus);
let canvas_color = colors.sunken;
let caret_width = cx.theme().caret_width().to_pixels(window.rem_size());
self.handle_size = cx.theme().handle_hit().to_pixels(window.rem_size());
let (frame_color, handle_fill) = (colors.focus, colors.bg);
div()
.id("lok-view")
.role(Role::MultilineTextInput)
.aria_value(self.a11y.text.clone())
.key_context(CONTEXT)
.track_focus(&self.focus)
.cursor(CursorStyle::IBeam)
.size_full()
.overflow_hidden()
.bg(canvas_color)
.on_action(cx.listener(Self::on_copy))
.on_action(cx.listener(Self::on_cut))
.on_action(cx.listener(Self::on_next_field))
.on_action(cx.listener(Self::on_previous_field))
.on_action(cx.listener(Self::on_paste))
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_down(MouseButton::Right, cx.listener(Self::on_mouse_down))
.on_mouse_down(MouseButton::Middle, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_up(MouseButton::Right, cx.listener(Self::on_mouse_up))
.on_mouse_up(MouseButton::Middle, cx.listener(Self::on_mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up_out))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.on_scroll_wheel(cx.listener(Self::on_wheel))
.on_pinch(cx.listener(Self::on_pinch))
.child(
canvas(
{
let this = this.clone();
move |bounds, window, app| {
this.update(app, |view, _| {
view.retired.release(window);
view.prepare(bounds)
})
}
},
move |bounds, painted, window, app| {
for page in &painted.pages {
window.paint_quad(fill(*page, paper));
window.with_content_mask(
Some(ContentMask { bounds: *page }),
|window| {
for (rect, image, clip) in &painted.tiles {
let mask = Some(ContentMask { bounds: *clip });
let painted = window.with_content_mask(mask, |window| {
window.paint_image(
*rect,
*rect,
Corners::default(),
image.clone(),
0,
false,
)
});
if let Err(error) = painted {
log::error!("lok: tile paint failed: {error}");
}
}
},
);
}
window.with_content_mask(Some(ContentMask { bounds }), |window| {
for rect in painted.selection {
window.paint_quad(fill(rect, selection_color));
}
for (mark, color) in painted.marks {
window.paint_quad(quad(
mark,
Corners::default(),
transparent_black(),
Edges::all(px(2.0)),
color,
BorderStyle::Solid,
));
}
if let Some(cell) = painted.cell {
window.paint_quad(quad(
cell,
Corners::default(),
transparent_black(),
Edges::all(px(2.0)),
frame_color,
BorderStyle::Solid,
));
}
if let Some((frame, handles)) = painted.frame {
window.paint_quad(outline(frame, frame_color, BorderStyle::Solid));
for handle in handles {
window.paint_quad(quad(
handle,
Corners::default(),
handle_fill,
Edges::all(px(1.0)),
frame_color,
BorderStyle::Solid,
));
}
}
if let Some(caret) = painted.caret.filter(|_| focus.is_focused(window))
{
let caret = Bounds {
size: size(caret_width, caret.size.height),
..caret
};
window.paint_quad(fill(caret, caret_color));
}
});
window.handle_input(&focus, ElementInputHandler::new(bounds, this), app);
},
)
.size_full(),
)
.children(self.hints(cx))
}
}
/// The page mask, tiles, selection and caret to paint.
pub(super) struct Painted {
/// Page frames on screen; tiles show only inside them.
pub(super) pages: Vec<Bounds<Pixels>>,
/// Each tile, its place and the pane that clips it.
pub(super) tiles: Vec<(Bounds<Pixels>, Arc<RenderImage>, Bounds<Pixels>)>,
pub(super) selection: Vec<Bounds<Pixels>>,
pub(super) caret: Option<Bounds<Pixels>>,
/// A spreadsheet's cell cursor.
pub(super) cell: Option<Bounds<Pixels>>,
/// The selected object's frame and handle squares.
pub(super) frame: Option<(Bounds<Pixels>, Vec<Bounds<Pixels>>)>,
/// The references of a formula being typed.
pub(super) marks: Vec<(Bounds<Pixels>, gpui::Hsla)>,
}
impl LokView {
/// The zoom that fits the page, with a margin.
fn fit_zoom(&mut self) {
const MARGIN: f32 = 0.94;
let (w, h) = self.doc_size;
if w <= 0 || h <= 0 || self.viewport.0 <= 0.0 || self.viewport.1 <= 0.0 {
return;
}
let across = self.viewport.0 * TWIPS_PER_PIXEL / w as f32;
let down = self.viewport.1 * TWIPS_PER_PIXEL / h as f32;
let zoom = (across.min(down) * MARGIN).min(4.0);
if (zoom - self.geom.zoom).abs() > 0.001 {
self.geom.zoom = zoom;
self.retire_tiles();
self.failed.clear();
}
}
/// All its images, for a closing tab to free.
pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired {
self.retire_tiles();
std::mem::take(&mut self.retired)
}
/// Every tile goes; their images are freed next paint.
pub(super) fn retire_tiles(&mut self) {
for (_, tile) in self.tiles.drain() {
self.retired.push(tile.image);
}
}
/// Drops tiles over a screen away from any shown one.
fn keep_near(&mut self, (vw, vh): (i32, i32)) {
let reach = (vw / TILE_TWIPS + 1, vh / TILE_TWIPS + 1);
let shown = &self.on_screen;
let before = self.tiles.len();
let retired = &mut self.retired;
self.tiles.retain(|key, tile| {
let near = shown
.iter()
.any(|at| (key.0 - at.0).abs() <= reach.0 && (key.1 - at.1).abs() <= reach.1);
if !near {
retired.push(tile.image.clone());
}
near
});
if self.tiles.len() < before {
log::debug!("lok: dropped {} far tiles", before - self.tiles.len());
}
}
/// The page mask and visible tiles; requests the missing ones.
pub(super) fn prepare(&mut self, bounds: Bounds<Pixels>) -> Painted {
self.viewport = (bounds.size.width.into(), bounds.size.height.into());
self.origin = bounds.origin;
if self.fit {
self.fit_zoom();
}
self.clamp_scroll();
let geom = self.geom;
let (sx, sy) = self.scroll;
// A narrow document sits centered.
let extent = self.extent();
let slack = |view: f32, doc: i32| (view - geom.px_of(doc)).max(0.0) / 2.0;
// A fitted page sits centred both ways.
self.spare = (
slack(self.viewport.0, extent.0),
if self.fit {
slack(self.viewport.1, extent.1)
} else {
0.0
},
);
let pages = if self.pages.is_empty() {
vec![self.screen_rect((0, 0, extent.0, extent.1))]
} else {
self.pages
.iter()
.map(|&page| self.screen_rect(page))
.collect()
};
let (vw, vh) = (
geom.twips_of(bounds.size.width.into()),
geom.twips_of(bounds.size.height.into()),
);
if self.shown != Some((sx, sy, vw, vh)) {
self.shown = Some((sx, sy, vw, vh));
self.failed.clear();
self.send(input::Input::Visible((sx, sy, vw, vh)));
}
let (ax, ay) = self.axes();
let side = px(geom.px_of(TILE_TWIPS));
let at = |vx: i32, vy: i32| {
bounds.origin
+ point(
px(self.spare.0 + geom.px_of(vx)),
px(self.spare.1 + geom.px_of(vy)),
)
};
let mut ready = Vec::new();
let mut missing = Vec::new();
self.on_screen.clear();
for (dx0, dx1, vx) in ax.panes(vw) {
for (dy0, dy1, vy) in ay.panes(vh) {
let clip = Bounds::from_corners(at(vx, vy), at(vx + dx1 - dx0, vy + dy1 - dy0));
let (kx0, ky0) = (dx0.div_euclid(TILE_TWIPS), dy0.div_euclid(TILE_TWIPS));
let (kx1, ky1) = (dx1.div_euclid(TILE_TWIPS), dy1.div_euclid(TILE_TWIPS));
for key in (kx0..=kx1).flat_map(|kx| (ky0..=ky1).map(move |ky| (kx, ky))) {
if key.0 * TILE_TWIPS >= extent.0 || key.1 * TILE_TWIPS >= extent.1 {
continue;
}
self.on_screen.push(key);
match self.tiles.get(&key) {
Some(tile) => {
let origin =
at(vx + key.0 * TILE_TWIPS - dx0, vy + key.1 * TILE_TWIPS - dy0);
let place = Bounds {
origin,
size: size(side, side),
};
ready.push((place, Arc::clone(&tile.image), clip));
if tile.stale {
missing.push(key);
}
}
None => missing.push(key),
}
}
}
}
self.keep_near((vw, vh));
for key in missing {
self.request_tile(key);
}
let rect = |rect: Rect| self.screen_rect(rect);
let caret = self.cursor.filter(|_| self.cursor_visible).map(rect);
let half = self.handle_size / 2.0;
let frame = self.frame().map(|frame| {
let squares = handles::points(frame)
.iter()
.map(|&(x, y)| Bounds {
origin: self.window_point((x, y)) - point(half, half),
size: size(self.handle_size, self.handle_size),
})
.collect();
(rect(frame), squares)
});
Painted {
pages,
tiles: ready,
selection: self.selection.iter().copied().map(rect).collect(),
caret,
cell: self.cell.cursor.map(rect),
frame,
marks: self.marks(),
}
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Scrolling the page view (LOK-04).
use gpui::{
Bounds, Context, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, point, px, size,
};
use super::{LokView, input::Input};
use crate::{Mouse, Rect};
impl LokView {
/// Scrolls by view pixels, clamped to the document (LOK-04).
pub(super) fn scroll_by(&mut self, dx: f32, dy: f32, cx: &mut Context<Self>) {
self.scroll.0 -= self.geom.twips_of(dx);
self.scroll.1 -= self.geom.twips_of(dy);
self.clamp_scroll();
cx.notify();
}
/// Keeps the viewport inside the document (LOK-04).
pub(super) fn clamp_scroll(&mut self) {
let (vw, vh) = (
self.geom.twips_of(self.viewport.0),
self.geom.twips_of(self.viewport.1),
);
let extent = self.extent();
self.scroll.0 = self.scroll.0.clamp(0, (extent.0 - vw).max(0));
self.scroll.1 = self.scroll.1.clamp(0, (extent.1 - vh).max(0));
}
/// Scrolls the wheel in twips, clamped to the document (LOK-04).
pub(super) fn on_scroll(
&mut self,
event: &ScrollWheelEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let (dx, dy) = match event.delta {
ScrollDelta::Pixels(point) => (f32::from(point.x), f32::from(point.y)),
ScrollDelta::Lines(point) => (point.x * 40.0, point.y * 40.0),
};
self.scroll_by(dx, dy, cx);
}
/// Scrolls `rect` into view; frozen parts always show.
pub fn show(&mut self, (x, y, w, h): Rect, cx: &mut Context<Self>) {
let (fx, fy) = self.frozen;
let (vw, vh) = (
self.geom.twips_of(self.viewport.0) - fx,
self.geom.twips_of(self.viewport.1) - fy,
);
// Room past the target, so the clamp lets it show.
self.reach = (
self.reach.0.max(x + w + vw + fx),
self.reach.1.max(y + h + vh + fy),
);
if x >= fx && (x < fx + self.scroll.0 || x + w > fx + self.scroll.0 + vw) {
self.scroll.0 = x - fx - vw / 2;
}
if y >= fy && (y < fy + self.scroll.1 || y + h > fy + self.scroll.1 + vh) {
self.scroll.1 = y - fy - vh / 3;
}
self.clamp_scroll();
cx.notify();
}
/// Freezes what lies before `frozen`, in twips.
pub fn set_frozen(&mut self, frozen: (i32, i32), cx: &mut Context<Self>) {
if self.frozen != frozen {
log::info!("lok: frozen at {frozen:?}");
self.frozen = frozen;
cx.notify();
}
}
/// Scrolls `rect` into view and puts the cursor there.
pub fn reveal(&mut self, rect: Rect, cx: &mut Context<Self>) {
self.show(rect, cx);
let (x, y, _, h) = rect;
log::info!("lok: reveal at {x},{y}");
for kind in [0, 1] {
let (count, buttons, modifiers) = (1, 1, 0);
let y = y + h / 2;
self.send(Input::Mouse(Mouse {
kind,
x,
y,
count,
buttons,
modifiers,
}));
}
}
/// The scroll offset in twips.
pub fn scroll(&self) -> (i32, i32) {
self.scroll
}
/// The document extent the view may scroll over.
pub(super) fn extent(&self) -> (i32, i32) {
// A fitted page is the page, whatever the cursor reached.
if self.fit {
return self.doc_size;
}
(
self.doc_size.0.max(self.reach.0),
self.doc_size.1.max(self.reach.1),
)
}
/// The document point under a view position (LOK-04).
pub fn doc_point(&self, position: Point<Pixels>) -> (i32, i32) {
let local = position - self.origin;
let (ax, ay) = self.axes();
(
ax.doc(self.geom.twips_of(f32::from(local.x) - self.spare.0)),
ay.doc(self.geom.twips_of(f32::from(local.y) - self.spare.1)),
)
}
/// The panes of each axis.
pub(super) fn axes(&self) -> (super::panes::Axis, super::panes::Axis) {
let axis = |frozen, scroll| super::panes::Axis { frozen, scroll };
(
axis(self.frozen.0, self.scroll.0),
axis(self.frozen.1, self.scroll.1),
)
}
/// The window point of a document point in twips (LOK-04).
pub fn window_point(&self, (x, y): (i32, i32)) -> gpui::Point<Pixels> {
let (ax, ay) = self.axes();
let (x, y) = (self.geom.px_of(ax.show(x)), self.geom.px_of(ay.show(y)));
self.origin + point(px(self.spare.0 + x), px(self.spare.1 + y))
}
/// A document rect in window pixels, panes applied.
pub(super) fn screen_rect(&self, (x, y, w, h): crate::Rect) -> Bounds<Pixels> {
Bounds::from_corners(self.window_point((x, y)), self.window_point((x + w, y + h)))
}
/// The cursor rect in window pixels.
pub fn cursor_bounds(&self) -> Option<Bounds<Pixels>> {
let (x, y, w, h) = self.cursor?;
Some(Bounds {
origin: self.window_point((x, y)),
size: size(px(self.geom.px_of(w.max(1))), px(self.geom.px_of(h))),
})
}
}
+466
View File
@@ -0,0 +1,466 @@
use super::*;
mod clip;
mod commands;
mod fit;
mod input;
mod large;
mod theme;
use gpui::{App, Bounds, Entity, point, size};
#[test]
fn geom_converts_both_ways() {
let geom = Geom {
zoom: 2.0,
dpi: 2.0,
};
assert_eq!(geom.px_of(15), 2.0, "15 twips at 200% zoom is 2 logical px");
assert_eq!(geom.twips_of(2.0), 15, "2 logical px back to 15 twips");
assert_eq!(geom.tile_pixels(), 1024, "the texture is 1024 device px");
let base = Geom {
zoom: 1.0,
dpi: 2.0,
};
assert_eq!(base.px_of(TILE_TWIPS), 256.0, "one tile is 256 logical px");
assert_eq!(
base.twips_of(256.0),
TILE_TWIPS,
"256 logical px is one tile"
);
assert_eq!(base.tile_pixels(), 512, "the texture is 512 device px");
}
pub(super) fn live_view(
app: &mut App,
install: &std::path::Path,
) -> (Entity<LokView>, Arc<Mutex<crate::Worker>>) {
live_view_of(app, install, "note.fodt", crate::client::tests::FODT)
}
/// A view on `body` saved as `name`.
pub(super) fn live_view_of(
app: &mut App,
install: &std::path::Path,
name: &str,
body: &str,
) -> (Entity<LokView>, Arc<Mutex<crate::Worker>>) {
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker =
crate::Worker::spawn(install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join(name);
std::fs::write(&source, body).expect("the fixture writes");
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
std::mem::forget(dir);
let shared = Arc::new(Mutex::new(worker));
let laid = super::layout(&mut shared.lock().expect("the worker lock"), &doc)
.expect("the layout reads");
let view = LokView::open(Arc::clone(&shared), doc, laid, app);
(view, shared)
}
#[test]
fn edits_grow_the_view_size() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
let before = view.doc_size;
{
let mut worker = view.worker.lock().expect("the worker lock");
for _ in 0..80 {
worker.post_key(&view.doc, 0, 1280).expect("Return posts");
}
}
let measured = super::measure(&view.worker, &view.doc);
view.apply_batch(
crate::Events {
generation: 1,
all: true,
rects: Vec::new(),
..crate::Events::default()
},
Some(measured),
cx,
);
assert!(
view.doc_size.1 > before.1,
"the view size follows the document: {before:?} to {:?}",
view.doc_size
);
});
});
}
#[test]
fn releasing_the_view_stops_the_watch() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
let (view, shared) = cx.update(|app| live_view(app, &install));
for _ in 0..5 {
cx.executor().tick();
}
let weak = Arc::downgrade(&shared);
drop(shared);
cx.update(|_| drop(view));
let mut released = false;
for _ in 0..20 {
cx.executor().tick();
released = weak.upgrade().is_none();
if released {
break;
}
}
assert!(released, "the idle watch releases the worker");
}
#[test]
fn tiles_paint_in_logical_pixels_at_2x() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
view.geom.dpi = 2.0;
let side = view.geom.tile_pixels() as usize;
assert_eq!(side, 512, "a 2x tile texture is 512 px");
for key in [(0, 0), (1, 0)] {
let tile = crate::Tile {
generation: 0,
pixels: vec![255u8; side * side * 4],
};
view.finish_tile(key, view.geom, Ok(tile), cx);
}
let bounds = Bounds {
origin: point(px(10.0), px(20.0)),
size: size(px(800.0), px(600.0)),
};
let ready = view.prepare(bounds).tiles;
let rects: Vec<_> = ready.iter().map(|(rect, ..)| *rect).collect();
assert!(rects.len() >= 2, "both cached tiles paint: {rects:?}");
assert_eq!(rects[0].size.width, px(256.0), "one tile is 256 logical px");
assert_eq!(rects[0].origin, point(px(10.0), px(20.0)));
assert_eq!(rects[1].origin.x, px(266.0), "the next tile sits 256 px on");
assert_eq!(view.doc_point(point(px(266.0), px(20.0))), (TILE_TWIPS, 0));
});
});
}
#[test]
fn a_red_tile_stays_red_in_the_render_image() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
let side = view.geom.tile_pixels() as usize;
let red_bgra = [0u8, 0, 255, 255];
let tile = crate::Tile {
generation: 0,
pixels: red_bgra.repeat(side * side),
};
view.finish_tile((0, 0), view.geom, Ok(tile), cx);
let image = &view.tiles[&(0, 0)].image;
let bytes = image.as_bytes(0).expect("one frame");
assert_eq!(&bytes[..4], &red_bgra, "GPUI gets the BGRA bytes as is");
});
});
}
#[test]
fn the_view_clamps_scroll_and_guards_generations() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
view.viewport = (800.0, 600.0);
view.scroll_by(0.0, -100_000.0, cx);
let max_y = (view.doc_size.1 - view.geom.twips_of(600.0)).max(0);
assert_eq!(view.scroll.1, max_y, "scroll clamps at the bottom");
view.scroll_by(0.0, 1_000_000.0, cx);
assert_eq!(view.scroll.1, 0, "scroll clamps at the top");
assert!(view.doc_size.0 > 0 && view.doc_size.1 > 0, "the size loads");
let geom = view.geom;
let side = geom.tile_pixels() as usize;
let painted = |generation: u64| {
Ok(crate::Tile {
generation,
pixels: vec![0u8; side * side * 4],
})
};
view.finish_tile((0, 0), geom, painted(0), cx);
assert_eq!(view.tile_count(), 1, "a current tile lands");
view.apply_batch(
crate::Events {
generation: 5,
all: true,
rects: Vec::new(),
..crate::Events::default()
},
None,
cx,
);
assert_eq!(view.tile_count(), 0, "an all batch outdates the grid");
assert_eq!(view.tiles.len(), 1, "the old image shows until its repaint");
view.finish_tile((0, 0), geom, painted(4), cx);
assert_eq!(view.tile_count(), 0, "a stale tile never lands");
view.finish_tile((0, 0), geom, painted(5), cx);
assert_eq!(view.tile_count(), 1, "a fresh tile lands again");
view.apply_batch(
crate::Events {
generation: 6,
all: false,
rects: vec![(100, 100, 50, 50)],
..crate::Events::default()
},
None,
cx,
);
assert_eq!(
view.tile_count(),
0,
"a rect batch outdates intersecting tiles"
);
view.finish_tile((0, 0), geom, painted(6), cx);
view.finish_tile((3, 3), geom, painted(6), cx);
view.apply_batch(
crate::Events {
generation: 7,
all: false,
rects: vec![(284, 1418, 3715, 551)],
..crate::Events::default()
},
None,
cx,
);
assert!(!view.tiles[&(3, 3)].stale, "a distant tile stays current");
assert!(
view.tiles[&(0, 0)].stale,
"the dirty tile waits for its repaint"
);
let bounds = Bounds {
origin: point(px(0.0), px(0.0)),
size: size(px(800.0), px(600.0)),
};
let painted = view.prepare(bounds);
assert!(!painted.tiles.is_empty(), "stale tiles still paint");
assert!(
view.fetching.contains(&(0, 0)),
"and their repaint is asked"
);
});
});
}
#[test]
fn scroll_stays_inside_after_shrink_zoom_and_resize() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
let bottom =
|view: &LokView| (view.doc_size.1 - view.geom.twips_of(view.viewport.1)).max(0);
view.viewport = (800.0, 600.0);
{
let mut worker = view.worker.lock().expect("the worker lock");
for _ in 0..80 {
worker.post_key(&view.doc, 0, 1280).expect("Return posts");
}
}
let batch = |generation| crate::Events {
generation,
all: true,
..crate::Events::default()
};
view.apply_batch(batch(1), None, cx);
view.scroll_by(0.0, -1_000_000.0, cx);
assert_eq!(view.scroll.1, bottom(view), "scrolled to the grown bottom");
{
let mut worker = view.worker.lock().expect("the worker lock");
worker
.run(&view.doc, "edit.select_all", &[])
.expect("select all");
worker.cut(&view.doc).expect("the cut runs");
}
view.apply_batch(batch(2), None, cx);
assert_eq!(
view.scroll.1,
bottom(view),
"a shrink pulls the scroll back"
);
view.set_zoom(2.0, cx);
view.scroll_by(0.0, -1_000_000.0, cx);
view.set_zoom(0.25, cx);
assert_eq!(
view.scroll.1,
bottom(view),
"a smaller zoom pulls the scroll back"
);
view.set_zoom(2.0, cx);
view.viewport = (800.0, 300.0);
view.scroll_by(0.0, -1_000_000.0, cx);
let bounds = Bounds {
origin: point(px(0.0), px(0.0)),
size: size(px(800.0), px(1200.0)),
};
view.prepare(bounds);
assert_eq!(
view.scroll.1,
bottom(view),
"a taller viewport pulls the scroll back"
);
});
});
}
#[test]
fn a_moved_cursor_scrolls_into_view_and_a_still_one_does_not() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
view.viewport = (800.0, 200.0);
let far = Some((1418, view.doc_size.1 - 600, 1, 276));
let batch = || crate::Events {
cursor: far,
..crate::Events::default()
};
view.apply_batch(batch(), None, cx);
assert!(view.scroll.1 > 0, "the view follows the cursor");
view.scroll_by(0.0, 1_000_000.0, cx);
view.apply_batch(batch(), None, cx);
assert_eq!(view.scroll.1, 0, "a still cursor leaves the scroll alone");
let far = view.doc_size.1 + 20_000;
let cell = crate::Cell {
cursor: Some((1418, far, 1280, 277)),
..crate::Cell::default()
};
view.apply_batch(
crate::Events {
cell,
..crate::Events::default()
},
None,
cx,
);
let height = view.geom.twips_of(view.viewport.1);
assert!(
view.scroll.1 <= far && far + 277 <= view.scroll.1 + height,
"a cell past the kernel's size still comes into view"
);
view.scroll = (0, 0);
let width = view.geom.twips_of(view.viewport.0);
view.show((width - 200, 0, 1280, 256), cx);
assert!(
view.scroll.0 > 0 && width - 200 + 1280 <= view.scroll.0 + width,
"a cell cut at the right edge comes into view"
);
view.scroll = (0, 0);
view.frozen = (0, 600);
view.show((1418, 300, 1280, 277), cx);
assert_eq!(view.scroll.1, 0, "a frozen row needs no scroll");
view.show((1418, 9000, 1280, 277), cx);
let scrolled = view.scroll.1;
assert!(scrolled > 0, "a far row scrolls the rest");
let row = view.place((0, 300)).y;
assert_eq!(f32::from(row), view.geom.px_of(300), "the frozen row stays");
let far = view.twips_at(view.place((0, 9000)));
assert!(
(far.1 - 9000).abs() <= 15,
"a scrolled row maps back: {far:?}"
);
});
});
}
#[test]
fn a_closing_view_hands_over_every_image() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
let side = view.geom.tile_pixels() as usize;
let tile = |generation| Ok(crate::Tile { generation, pixels: vec![0; side * side * 4] });
let (geom, generation) = (view.geom, view.generation);
view.finish_tile((0, 0), geom, tile(generation), cx);
view.finish_tile((0, 0), geom, tile(generation), cx);
assert_eq!(view.tiles.len(), 1);
let images = view.retire_all();
assert!(!images.is_empty(), "the shown and the replaced tile go");
assert!(view.tiles.is_empty() && view.retired.is_empty(), "the view keeps none");
});
});
}
#[test]
fn zooming_keeps_the_pointed_spot_and_walks_the_steps() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view(app, &install);
view.update(app, |view, cx| {
view.viewport = (800.0, 600.0);
view.origin = gpui::point(gpui::px(0.0), gpui::px(0.0));
view.doc_size = (60_000, 80_000);
view.scroll = (3_000, 9_000);
view.fit = true;
let at = gpui::point(gpui::px(300.0), gpui::px(200.0));
let spot = |view: &LokView| {
(view.scroll.0 + view.geom.twips_of(300.0), view.scroll.1 + view.geom.twips_of(200.0))
};
let before = spot(view);
view.zoom_at(2.0, Some(at), cx);
let after = spot(view);
assert!((before.0 - after.0).abs() <= 1 && (before.1 - after.1).abs() <= 1, "{before:?} {after:?}");
assert!(!view.fitting(), "a chosen zoom ends fitting");
view.set_zoom(1.0, cx);
view.zoom_step(true, cx);
assert_eq!(view.zoom(), 1.25);
view.zoom_step(false, cx);
view.zoom_step(false, cx);
assert_eq!(view.zoom(), 0.8);
view.set_zoom(9.0, cx);
assert_eq!(view.zoom(), 4.0, "the most is 400 %");
view.zoom_step(true, cx);
assert_eq!(view.zoom(), 4.0);
view.fit_page(cx);
assert!(view.fitting());
});
});
}
+91
View File
@@ -0,0 +1,91 @@
use super::*;
use ely_platform::clipboard::{self, Clip};
use gpui::TestAppContext;
const BOOK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S"><table:table-row>
<table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell>
<table:table-cell table:formula="of:=[.A1]*3" office:value-type="float" office:value="6"><text:p>6</text:p></table:table-cell>
</table:table-row></table:table></office:spreadsheet></office:body>
</office:document>"#;
fn flush(cx: &TestAppContext) {
for _ in 0..80 {
cx.executor().tick();
}
}
fn goto(view: &Entity<LokView>, cx: &mut TestAppContext, cell: &str) {
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
worker
.run(&view.doc, "cell.goto", &[cell])
.expect("the cell");
});
}
/// The workbook as flat XML.
fn saved(view: &Entity<LokView>, cx: &mut TestAppContext) -> String {
let out = tempfile::tempdir()
.expect("a temp dir")
.keep()
.join("out.fods");
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
view.doc
.save_as(&mut worker, &format!("file://{}", out.display()), "fods")
.expect("the save works");
});
std::fs::read_to_string(&out).expect("the save reads")
}
#[test]
fn values_and_formulas_paste_alone_from_the_views_copy() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
return;
}
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "book.fods", BOOK));
goto(&view, &mut cx, "B1");
cx.update(|app| {
view.update(app, |view, _| {
view.send(crate::view::input::Input::Copy { cut: false })
})
});
flush(&cx);
for (cell, id) in [("C1", "paste.values"), ("D1", "paste.formulas")] {
goto(&view, &mut cx, cell);
cx.update(|app| view.update(app, |view, _| view.paste_only(id)));
flush(&cx);
}
let xml = saved(&view, &mut cx);
assert_eq!(xml.matches("table:formula=").count(), 2, "C1 holds 6 alone");
assert!(xml.contains("of:=[.C1]*3"), "D1 keeps the formula");
let copy = cx.update(|app| {
let read = clipboard::read().expect("the clipboard reads");
let Some(Clip::Html { html, .. }) = read else {
panic!("the copy lands as html");
};
view.read(app).holds_copy(&html)
});
assert!(copy, "the view knows its own copy");
// Copies made elsewhere carry no mark of this view.
let other = "<table><tr><td>6</td></tr></table>";
assert!(cx.update(|app| !view.read(app).holds_copy(other)));
let foreign = "<td>6</td><!--ely-copy:1-999-->";
assert!(
cx.update(|app| !view.read(app).holds_copy(foreign)),
"another copy's mark"
);
}
+167
View File
@@ -0,0 +1,167 @@
use super::*;
use gpui::TestAppContext;
/// Steps the scheduler until `done` or the budget ends.
fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool {
for _ in 0..60 {
for _ in 0..20 {
cx.executor().tick();
}
if cx.update(|app| done(app)) {
return true;
}
}
false
}
#[test]
fn run_reaches_the_kernel_and_state_comes_back() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
cx.update(|app| {
view.update(app, |view, _| {
assert!(
view.run("format.dialog", &[]).is_err(),
"unknown ids fail fast"
);
assert!(
view.run("format.size", &[]).is_err(),
"missing values fail fast"
);
view.commit(None, "a");
view.run("edit.select_all", &[]).expect("select all queues");
view.run("format.bold", &[]).expect("bold queues");
view.run("format.size", &["20"]).expect("size queues");
})
});
let landed = settle(&cx, |app| {
let view = view.read(app);
view.state(".uno:Bold") == Some("true")
&& view.state(".uno:FontHeight") == Some("20")
&& view.a11y.text == "aHello 世界"
});
assert!(landed, "the kernel state reaches the view");
cx.update(|app| {
let view = view.read(app);
assert_eq!(
view.a11y.text, "aHello 世界",
"the a11y text follows the paragraph"
);
});
}
#[test]
fn a_kernel_dialog_reaches_apps_as_an_error() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let body = crate::client::tests::input::FODS;
let (view, _) = cx.update(|app| live_view_of(app, &install, "in.fods", body));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| {
errors.borrow_mut().push(error.0.clone())
})
.detach();
view.update(app, |view, _| {
view.key_down(&gpui::Keystroke::parse("backspace").expect("backspace"));
});
});
let reported = settle(&cx, |_| !errors.borrow().is_empty());
assert!(reported, "the blocked dialog reaches subscribers");
assert!(
errors.borrow()[0].contains("Delete Contents"),
"{:?}",
errors.borrow()
);
}
#[test]
fn a_dead_worker_reaches_apps_while_idle() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, shared) = cx.update(|app| live_view(app, &install));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| {
errors.borrow_mut().push(error.0.clone())
})
.detach();
});
let pid = shared.lock().expect("the worker lock").child.id();
std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.status()
.expect("kill runs");
let reported = settle(&cx, |_| !errors.borrow().is_empty());
assert!(reported, "the idle watch reports the dead engine");
assert!(
errors.borrow()[0].contains("engine stopped"),
"{:?}",
errors.borrow()
);
}
#[test]
fn a_handle_drag_resizes_the_selected_image() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
let dir = tempfile::tempdir().expect("a temp dir");
let mut png = Vec::new();
image::RgbaImage::from_pixel(300, 200, image::Rgba([0, 0, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("the png encodes");
let path = dir.path().join("blue.png");
std::fs::write(&path, png).expect("the png writes");
let url = crate::file_url(&path);
cx.update(|app| view.update(app, |view, _| view.run("image.insert", &[&url])))
.expect("the insert queues");
assert!(
settle(&cx, |app| view.read(app).graphic.is_some()),
"the image is selected"
);
let frame = cx.update(|app| view.read(app).graphic).expect("selected");
let corner = super::super::handles::points(frame)[7];
let to = (corner.0 - frame.2 / 3, corner.1 - frame.3 / 3);
cx.update(|app| {
view.update(app, |view, _| {
assert!(
view.grab(view.window_point(corner)),
"the corner handle grabs"
);
view.grip = view
.grip
.map(|grip| super::super::handles::Grip { to, ..grip });
assert_eq!(
view.frame(),
Some(super::super::handles::dragged(frame, 7, to))
);
let grip = view.grip.take().expect("held");
view.send(super::super::input::Input::Drag {
from: grip.from,
to: grip.to,
});
})
});
let resized = settle(&cx, |app| {
view.read(app)
.graphic
.is_some_and(|now| now.2 < frame.2 && now.3 < frame.3)
});
assert!(resized, "the kernel resized the image");
}
+90
View File
@@ -0,0 +1,90 @@
use super::*;
#[test]
fn a_fitted_slide_fills_the_viewport_centred() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let deck = crate::client::tests::show::DECK;
let (view, _) = live_view_of(app, &install, "deck.fodp", deck);
view.update(app, |view, cx| {
view.fit_page(cx);
let bounds = Bounds {
origin: point(px(10.0), px(20.0)),
size: size(px(800.0), px(600.0)),
};
view.prepare(bounds);
let (w, h) = view.doc_size;
let want = (800.0 * 15.0 / w as f32).min(600.0 * 15.0 / h as f32) * 0.94;
assert!(
(view.geom.zoom - want).abs() < 0.001,
"{} vs {want}",
view.geom.zoom
);
assert!(
view.spare.0 > 0.0 && view.spare.1 > 0.0,
"centred: {:?}",
view.spare
);
let corner = view.window_point((0, 0));
assert_eq!(view.doc_point(corner), (0, 0), "points map both ways");
// A cursor far out must not move a fitted page.
view.reach = (w * 3, h * 3);
view.prepare(bounds);
assert_eq!(view.window_point((0, 0)), corner, "the page stays put");
let far = view.window_point((w, h));
assert!(
f32::from(far.y) < 620.0 && f32::from(far.x) < 810.0,
"the page fits"
);
});
});
}
#[test]
fn a_resized_slide_reaches_the_view_and_still_fits() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
let view = cx.update(|app| {
let deck = crate::client::tests::show::DECK;
live_view_of(app, &install, "deck.fodp", deck).0
});
cx.update(|app| {
view.update(app, |view, cx| {
view.fit_page(cx);
view.run("slide.size", &["300000", "300000", "true"])
.expect("sent");
})
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while cx.update(|app| view.read(app).doc_size.0 < 100_000) {
assert!(
std::time::Instant::now() < deadline,
"the view never read the new size"
);
if !cx.executor().tick() {
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
cx.update(|app| {
view.update(app, |view, _| {
let bounds = Bounds {
origin: point(px(0.0), px(0.0)),
size: size(px(800.0), px(600.0)),
};
view.prepare(bounds);
let (w, h) = view.doc_size;
let far = view.window_point((w, h));
assert!(
f32::from(far.x) <= 800.0 && f32::from(far.y) <= 600.0,
"a 3 m page fits: {far:?}"
);
})
});
}
+271
View File
@@ -0,0 +1,271 @@
use super::*;
use crate::client::tests::input::paragraphs;
use ely_platform::clipboard::{self, Clip};
use gpui::{InputHandler, Keystroke, TestAppContext, Window};
/// Steps the scheduler so queued input reaches LOK.
fn flush(cx: &TestAppContext) {
for _ in 0..80 {
cx.executor().tick();
}
}
/// The document's paragraphs, saved through the shared worker.
fn saved(view: &Entity<LokView>, cx: &mut TestAppContext) -> Vec<String> {
let out = tempfile::tempdir()
.expect("a temp dir")
.keep()
.join("out.fodt");
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
view.doc
.save_as(&mut worker, &format!("file://{}", out.display()), "fodt")
.expect("the save works");
});
paragraphs(&std::fs::read_to_string(&out).expect("the save reads"))
}
fn typed(key: &str) -> Keystroke {
let mut stroke = Keystroke::parse(key).expect("a keystroke");
stroke.key_char = Some(key.to_string());
stroke
}
#[test]
fn typing_and_composing_reach_the_document() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
cx.update(|app| {
view.update(app, |view, _| {
assert!(!view.key_down(&typed("a")), "text waits for the IME");
view.commit(None, "ab");
view.compose(None, "n", None);
view.compose(None, "ni", None);
view.compose(None, "你", None);
assert_eq!(view.marked.as_ref().map(|m| m.text.as_str()), Some("你"));
view.commit(None, "你");
assert!(view.marked.is_none());
view.compose(None, "x", None);
view.compose(None, "", None);
assert!(view.marked.is_none(), "an empty composition cancels");
assert!(view.key_down(&Keystroke::parse("enter").expect("enter")));
})
});
flush(&cx);
assert_eq!(saved(&view, &mut cx), ["ab你", "Hello 世界"]);
}
struct Blank;
impl gpui::Render for Blank {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
gpui::Empty
}
}
#[test]
fn the_platform_ime_callbacks_compose_in_utf16() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
let window = cx.add_window(|_, _| Blank);
cx.update_window(window.into(), |_, window, app| {
let bounds = Bounds::default();
let mut ime = gpui::ElementInputHandler::new(bounds, view.clone());
ime.replace_and_mark_text_in_range(None, "nihao", Some(2..2), window, app);
let selected = ime
.selected_text_range(false, window, app)
.expect("a selection");
assert_eq!(selected.range, 2..2, "the IME caret sits inside");
let mut adjusted = None;
let head = ime.text_for_range(0..2, &mut adjusted, window, app);
assert_eq!(head.as_deref(), Some("ni"));
assert_eq!(adjusted, Some(0..2));
assert_eq!(ime.marked_text_range(window, app), Some(0..5));
ime.replace_and_mark_text_in_range(Some(0..2), "你", Some(1..1), window, app);
let marked = view.read(app).marked.as_ref().map(|m| m.text.clone());
assert_eq!(marked.as_deref(), Some("你hao"));
let selected = ime.selected_text_range(false, window, app).map(|s| s.range);
assert_eq!(selected, Some(1..1));
ime.replace_text_in_range(Some(1..4), "好", window, app);
assert_eq!(
ime.marked_text_range(window, app),
None,
"the commit ends it"
);
ime.replace_and_mark_text_in_range(None, "x", None, window, app);
ime.unmark_text(window, app);
})
.expect("the window lives");
flush(&cx);
assert_eq!(saved(&view, &mut cx), ["你好xHello 世界"]);
}
#[test]
fn a_cut_keeps_the_text_when_the_clipboard_fails() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, shared) = cx.update(|app| live_view(app, &install));
let doc = cx.update(|app| view.read(app).doc.clone());
{
let mut worker = shared.lock().expect("the worker lock");
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let failed = crate::view::clip::copy_out(&mut worker, &doc, true, |_| {
Err(std::io::Error::other("the clipboard refused"))
});
assert!(failed.is_err(), "the write failure surfaces");
assert!(
!worker.copy(&doc).expect("copy runs").is_empty(),
"the selection stays"
);
}
assert_eq!(saved(&view, &mut cx), ["Hello 世界"], "the text stays");
}
#[test]
fn a_drag_selects_and_events_carry_the_cursor() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
cx.update(|app| {
view.update(app, |view, _| {
let none = gpui::Modifiers::default();
let y = px(view.geom.px_of(1500));
view.mouse(0, point(px(view.geom.px_of(1500)), y), 1, 1, &none);
view.mouse(2, point(px(view.geom.px_of(4000)), y), 1, 1, &none);
view.mouse(1, point(px(view.geom.px_of(4000)), y), 1, 1, &none);
})
});
let mut selected = false;
for _ in 0..40 {
flush(&cx);
selected = cx.update(|app| !view.read(app).selection.is_empty());
if selected {
break;
}
}
assert!(selected, "the drag selection reaches the view");
cx.update(|app| {
let view = view.read(app);
let bounds = view.cursor_bounds().expect("the cursor arrived");
assert!(bounds.size.height > px(0.0), "the caret has height");
});
}
#[test]
fn a_dead_document_surfaces_an_error() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, shared) = cx.update(|app| live_view(app, &install));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| {
errors.borrow_mut().push(error.0.clone())
})
.detach();
let doc = view.read(app).doc.clone();
shared
.lock()
.expect("the worker lock")
.close_doc(doc)
.expect("the doc closes");
view.update(app, |view, _| {
view.key_down(&Keystroke::parse("enter").expect("enter"));
});
});
flush(&cx);
let errors = errors.borrow();
assert!(
errors
.iter()
.any(|error| error.contains("no such document")),
"the failure reaches subscribers: {errors:?}"
);
}
#[test]
fn the_clipboard_moves_html_tables_and_images() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
return;
}
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
let primary = if cfg!(target_os = "macos") {
"cmd-a"
} else {
"ctrl-a"
};
cx.update(|app| {
view.update(app, |view, _| {
view.key_down(&Keystroke::parse(primary).expect("select all"));
view.send(crate::view::input::Input::Copy { cut: false });
})
});
flush(&cx);
let Some(Clip::Html { html, text }) = clipboard::read().expect("the clipboard reads") else {
panic!("the copy lands as html");
};
assert!(
html.contains("Hello") && text == "Hello 世界",
"{html} / {text}"
);
clipboard::write(&Clip::Html {
html: "<table><tr><td>left</td><td>right</td></tr></table>".into(),
text: "left\tright".into(),
})
.expect("the table writes");
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
flush(&cx);
let text = saved(&view, &mut cx);
assert!(
text.contains(&"left".to_string()) && text.contains(&"right".to_string()),
"{text:?}"
);
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("the png encodes");
clipboard::write(&Clip::Png(png)).expect("the image writes");
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
flush(&cx);
let out = tempfile::tempdir()
.expect("a temp dir")
.keep()
.join("img.fodt");
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
view.doc
.save_as(&mut worker, &format!("file://{}", out.display()), "fodt")
.expect("the save works");
});
let xml = std::fs::read_to_string(&out).expect("the save reads");
assert!(xml.contains("<draw:image"), "the image lands");
}
+62
View File
@@ -0,0 +1,62 @@
use super::*;
/// A sheet whose last value sits 100,000 rows down.
const TALL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>top</text:p></table:table-cell></table:table-row>
<table:table-row table:number-rows-repeated="99998"><table:table-cell/></table:table-row>
<table:table-row><table:table-cell><text:p>bottom</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn a_tall_sheet_paints_and_keeps_only_tiles_near_the_screen() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = gpui::TestAppContext::single();
cx.update(|app| {
let (view, _) = live_view_of(app, &install, "tall.fods", TALL);
view.update(app, |view, cx| {
let bounds = Bounds {
origin: point(px(0.0), px(0.0)),
size: size(px(800.0), px(600.0)),
};
let side = view.geom.tile_pixels() as usize;
let painted = |generation| {
Ok(crate::Tile {
generation,
pixels: vec![0u8; side * side * 4],
})
};
// 800 by 600 needs 5 by 4 tiles.
view.prepare(bounds);
let top = view.on_screen.clone();
assert!(!top.is_empty() && top.len() <= 20, "{} tiles", top.len());
for key in &top {
view.finish_tile(*key, view.geom, painted(view.generation), cx);
}
assert_eq!(view.tile_count(), top.len(), "the top paints");
view.scroll_by(0.0, -3.0e6, cx);
view.prepare(bounds);
let bottom = view.on_screen.clone();
assert!(bottom.len() <= 20, "{} tiles at the bottom", bottom.len());
assert!(
bottom.iter().all(|key| key.1 > 1000),
"far down: {bottom:?}"
);
assert!(
top.iter().all(|key| !view.tiles.contains_key(key)),
"the top's tiles are gone"
);
assert!(
view.fetching.len() <= 20 + top.len(),
"only shown tiles fetch"
);
});
});
}
+97
View File
@@ -0,0 +1,97 @@
use super::*;
use gpui::TestAppContext;
/// Steps the scheduler until `done` or ten seconds pass.
fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if !cx.executor().tick() {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
}
if cx.update(|app| done(app)) {
return true;
}
}
false
}
/// The view's 800 by 600 pixel area.
fn area() -> Bounds<Pixels> {
Bounds {
origin: point(px(0.0), px(0.0)),
size: size(px(800.0), px(600.0)),
}
}
#[test]
fn the_view_paints_the_app_theme_and_back() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, worker) = cx.update(|app| live_view(app, &install));
let doc = cx.update(|app| view.read(app).doc.clone());
let mut shades = Vec::new();
for dark in [true, false] {
cx.update(|app| view.update(app, |view, _| view.follow_theme(dark)));
assert!(
settle(&cx, |app| view.read(app).settled()),
"the theme goes out"
);
let mut worker = worker.lock().expect("the worker");
let tile = doc
.paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64))
.expect("a tile");
let sum: u64 = tile
.pixels
.chunks_exact(4)
.map(|px| u64::from(px[0]) + u64::from(px[1]) + u64::from(px[2]))
.sum();
shades.push(sum / (64 * 64 * 3));
}
assert!(
shades[0] < 100 && shades[1] > 200,
"dark then light: {shades:?}"
);
}
#[test]
fn a_failed_tile_reports_and_retries_on_a_move() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view(app, &install));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| {
errors.borrow_mut().push(error.0.clone())
})
.detach();
view.update(app, |view, cx| {
let geom = view.geom;
view.prepare(area());
view.fetching.remove(&(0, 0));
view.finish_tile((0, 0), geom, Err(io::Error::other("no paint")), cx);
assert!(view.failed.contains(&((0, 0), view.generation)));
view.prepare(area());
assert!(!view.fetching.contains(&(0, 0)), "no retry storm in place");
view.scroll_by(0.0, -40.0, cx);
view.prepare(area());
assert!(view.fetching.contains(&(0, 0)), "a move retries the tile");
});
});
assert!(
settle(&cx, |_| !errors.borrow().is_empty()),
"the failure reaches the app"
);
assert!(
errors.borrow()[0].contains("no paint"),
"{:?}",
errors.borrow()
);
}
+82
View File
@@ -0,0 +1,82 @@
//! Zooming the page view about the pointer (LOK-04).
use gpui::{Context, EventEmitter, PinchEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window};
use super::LokView;
/// The zoom changed, by key, wheel or pinch.
pub struct Zoomed;
impl EventEmitter<Zoomed> for LokView {}
/// The steps of zooming in and out.
pub const ZOOM_STEPS: [f32; 10] = [0.25, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const LEAST: f32 = 0.25;
const MOST: f32 = 4.0;
impl LokView {
/// The zoom now; 1.0 is the page's actual size.
pub fn zoom(&self) -> f32 {
self.geom.zoom
}
/// Whether the zoom follows the viewport (a whole page).
pub fn fitting(&self) -> bool {
self.fit
}
/// Sets the zoom; the grid is repainted (LOK-04).
pub fn set_zoom(&mut self, zoom: f32, cx: &mut Context<Self>) {
self.zoom_at(zoom, None, cx);
}
/// The next step in or out from the zoom now.
pub fn zoom_step(&mut self, larger: bool, cx: &mut Context<Self>) {
let now = self.geom.zoom;
let next = match larger {
true => ZOOM_STEPS.into_iter().find(|step| *step > now + 0.001).unwrap_or(MOST),
false => ZOOM_STEPS.into_iter().rev().find(|step| *step < now - 0.001).unwrap_or(LEAST),
};
self.zoom_at(next, None, cx);
}
/// Zooms keeping `at` (window pixels) still; none keeps the middle.
pub fn zoom_at(&mut self, zoom: f32, at: Option<Point<Pixels>>, cx: &mut Context<Self>) {
let zoom = zoom.clamp(LEAST, MOST);
let (ax, ay) = match at {
Some(at) => (f32::from(at.x - self.origin.x), f32::from(at.y - self.origin.y)),
None => (self.viewport.0 / 2.0, self.viewport.1 / 2.0),
};
let under = (self.scroll.0 + self.geom.twips_of(ax), self.scroll.1 + self.geom.twips_of(ay));
self.fit = false;
if (zoom - self.geom.zoom).abs() > 0.0001 {
self.geom.zoom = zoom;
self.retire_tiles();
self.failed.clear();
}
self.scroll = (under.0 - self.geom.twips_of(ax), under.1 - self.geom.twips_of(ay));
self.clamp_scroll();
log::debug!("lok: zoom {:.0}%", zoom * 100.0);
cx.emit(Zoomed);
cx.notify();
}
/// Command with the wheel zooms; otherwise it scrolls.
pub(super) fn on_wheel(&mut self, event: &ScrollWheelEvent, window: &mut Window, cx: &mut Context<Self>) {
if !event.modifiers.platform {
return self.on_scroll(event, window, cx);
}
let dy = match event.delta {
ScrollDelta::Pixels(point) => f32::from(point.y),
ScrollDelta::Lines(point) => point.y * 40.0,
};
let zoom = self.geom.zoom * (dy / 200.0).exp();
self.zoom_at(zoom, Some(event.position), cx);
}
/// A trackpad pinch zooms about its centre.
pub(super) fn on_pinch(&mut self, event: &PinchEvent, _: &mut Window, cx: &mut Context<Self>) {
let zoom = self.geom.zoom * (1.0 + event.delta);
self.zoom_at(zoom, Some(event.position), cx);
}
}