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:
817 files changed
+143101
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user