181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
192 lines
7.7 KiB
Rust
192 lines
7.7 KiB
Rust
//! The compact (phone) layout, driven the way a user does at 390 x 844.
|
|
use egui::{Context, Event, PointerButton, Pos2, RawInput};
|
|
use notescraft_engine::Session;
|
|
use notescraft_ui_egui::app::NotesApp;
|
|
use notescraft_ui_egui::layout::LayoutRects;
|
|
use notescraft_ui_egui::shell::compact::{CompactPage, TAB_BAR_HEIGHT};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
const PHONE: egui::Vec2 = egui::vec2(390.0, 844.0);
|
|
const TAB_STRIP_TOP: f32 = 844.0 - TAB_BAR_HEIGHT;
|
|
|
|
struct Driver {
|
|
app: NotesApp,
|
|
ctx: Context,
|
|
time: f64,
|
|
phone: bool,
|
|
}
|
|
|
|
/// An app over a fresh vault holding `files`, with animations off; a phone or a desktop window.
|
|
fn driver(files: &[(&str, &str)], phone: bool) -> Driver {
|
|
let root = std::env::temp_dir().join(format!("notescraft-compact-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
std::fs::create_dir_all(&root).expect("root");
|
|
for (path, text) in files {
|
|
std::fs::write(root.join(path), text).expect("seed");
|
|
}
|
|
let mut session = Session::open(&root).expect("session");
|
|
session.use_settings_dir(root.join("config"));
|
|
let mut app = NotesApp::new(session, notescraft_themes::builtin("catppuccin-mocha").expect("theme"));
|
|
app.state.animations = false;
|
|
Driver { app, ctx: Context::default(), time: 1.0, phone }
|
|
}
|
|
|
|
fn phone(files: &[(&str, &str)]) -> Driver {
|
|
driver(files, true)
|
|
}
|
|
|
|
fn desktop(files: &[(&str, &str)]) -> Driver {
|
|
driver(files, false)
|
|
}
|
|
|
|
/// A painted text: what it says, where it starts.
|
|
struct Painted {
|
|
text: String,
|
|
pos: Pos2,
|
|
}
|
|
|
|
impl Driver {
|
|
/// One frame with `events`; the texts it painted.
|
|
fn frame(&mut self, events: Vec<Event>) -> Vec<Painted> {
|
|
self.time += 0.05;
|
|
let input = RawInput {
|
|
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, if self.phone { PHONE } else { egui::vec2(1440.0, 900.0) })),
|
|
time: Some(self.time),
|
|
events,
|
|
..Default::default()
|
|
};
|
|
let app = &mut self.app;
|
|
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui.ctx()));
|
|
out.textures_delta.clear();
|
|
out.shapes
|
|
.iter()
|
|
.filter_map(|shape| match &shape.shape {
|
|
egui::Shape::Text(text) => Some(Painted { text: text.galley.text().to_owned(), pos: text.pos }),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn settle(&mut self) -> Vec<Painted> {
|
|
let mut last = Vec::new();
|
|
for _ in 0..6 {
|
|
last = self.frame(Vec::new());
|
|
}
|
|
last
|
|
}
|
|
|
|
fn mouse(&mut self, at: Pos2, pressed: Option<bool>) -> Vec<Painted> {
|
|
let mut events = vec![Event::PointerMoved(at)];
|
|
if let Some(pressed) = pressed {
|
|
events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
|
}
|
|
self.frame(events)
|
|
}
|
|
|
|
fn click(&mut self, at: Pos2) {
|
|
self.mouse(at, None);
|
|
self.mouse(at, Some(true));
|
|
self.mouse(at, Some(false));
|
|
self.settle();
|
|
}
|
|
|
|
/// The card rects the last frame laid out, the way the shell publishes them for tests.
|
|
fn rects(&mut self) -> LayoutRects {
|
|
self.frame(Vec::new());
|
|
self.ctx.data(|data| data.get_temp::<LayoutRects>(egui::Id::new("card-frame"))).expect("the shell publishes its rects")
|
|
}
|
|
}
|
|
|
|
/// How many rect slots the layout filled.
|
|
fn card_count(rects: &LayoutRects) -> usize {
|
|
usize::from(rects.sidebar.is_some()) + usize::from(rects.editor.is_some()) + usize::from(rects.right.is_some())
|
|
}
|
|
|
|
/// The painted tab-bar label of `name` (the strip starts at [`TAB_STRIP_TOP`]).
|
|
fn tab_label<'a>(texts: &'a [Painted], wanted: &str) -> &'a Painted {
|
|
texts
|
|
.iter()
|
|
.find(|p| p.text == wanted && p.pos.y > TAB_STRIP_TOP)
|
|
.unwrap_or_else(|| panic!("no tab {wanted:?} in {:?}", texts.iter().map(|p| (p.text.as_str(), p.pos)).collect::<Vec<_>>()))
|
|
}
|
|
|
|
#[test]
|
|
fn compact_shows_one_full_width_card_and_the_desktop_keeps_three() {
|
|
let mut d = phone(&[("a.md", "A"), ("b.md", "B"), ("c.md", "C")]);
|
|
d.settle();
|
|
let rects = d.rects();
|
|
assert_eq!(card_count(&rects), 1, "one page at a time below 600 pt");
|
|
let card = rects.sidebar.or(rects.editor).or(rects.right).expect("the page card");
|
|
assert_eq!(card.left(), notescraft_ui_egui::theme::PANEL_GAP);
|
|
assert_eq!(card.top(), notescraft_ui_egui::theme::PANEL_GAP);
|
|
assert_eq!(card.right(), 390.0 - notescraft_ui_egui::theme::PANEL_GAP, "the page spans the width minus gaps");
|
|
assert_eq!(card.bottom(), 844.0 - TAB_BAR_HEIGHT, "the tab bar takes the bottom");
|
|
let mut desktop = desktop(&[("a.md", "A"), ("b.md", "B"), ("c.md", "C")]);
|
|
desktop.settle();
|
|
let expected = notescraft_ui_egui::layout::compute_rects(
|
|
egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1440.0, 900.0)),
|
|
notescraft_ui_egui::layout::CardVisibility { sidebar: true, right: true },
|
|
);
|
|
let rects = desktop.rects();
|
|
assert_eq!(rects, expected, "at 1440 pt the three cards are laid out exactly as before");
|
|
assert_eq!(card_count(&rects), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tab_tap_changes_the_page_and_tapping_back_returns() {
|
|
let mut d = phone(&[("a.md", "A"), ("b.md", "B"), ("c.md", "C")]);
|
|
let texts = d.settle();
|
|
d.click(tab_label(&texts, "Search").pos + egui::vec2(2.0, 6.0));
|
|
assert_eq!(d.app.state.compact_page, CompactPage::Search, "the Search tab switched the page");
|
|
let texts = d.settle();
|
|
d.click(tab_label(&texts, "Notes").pos + egui::vec2(2.0, 6.0));
|
|
assert_eq!(d.app.state.compact_page, CompactPage::Notes, "the Notes tab returned");
|
|
let texts = d.settle();
|
|
assert!(
|
|
texts.iter().any(|p| p.text == "a" && p.pos.y < TAB_STRIP_TOP),
|
|
"the Explorer tree is the Notes page: {:?}",
|
|
texts.iter().map(|p| p.text.as_str()).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opening_a_note_shows_the_note_page_and_the_back_chevron_returns() {
|
|
let mut d = phone(&[("a.md", "A"), ("b.md", "B"), ("c.md", "C")]);
|
|
d.app.open_note("a.md");
|
|
d.settle();
|
|
assert_eq!(d.app.state.compact_page, CompactPage::Note, "the opened note becomes the page");
|
|
let rects = d.rects();
|
|
let card = rects.editor.expect("the note page card");
|
|
// The chevron sits inside the card's top-left corner, its 44 pt touch target.
|
|
d.click(egui::pos2(card.left() + 16.0 + 22.0, card.top() + 16.0 + 22.0));
|
|
assert_eq!(d.app.state.compact_page, CompactPage::Notes, "the back chevron returned to the Notes page");
|
|
}
|
|
|
|
#[test]
|
|
fn compact_painting_stays_on_screen_and_the_bar_lists_all_five_tabs() {
|
|
let mut d = phone(&[("a.md", "# A\n\nfirst paragraph with several words\n"), ("b.md", "B"), ("c.md", "C")]);
|
|
d.app.open_note("a.md");
|
|
let texts = d.settle();
|
|
let screen = egui::Rect::from_min_size(Pos2::ZERO, PHONE);
|
|
for painted in &texts {
|
|
assert!(screen.contains(painted.pos), "{:?} painted outside the phone screen at {}", painted.text, painted.pos);
|
|
}
|
|
for tab in ["Notes", "Search", "Graph", "Calendar", "More"] {
|
|
assert!(
|
|
texts.iter().any(|p| p.text == tab && p.pos.y > TAB_STRIP_TOP),
|
|
"tab {tab:?} missing: {:?}",
|
|
texts.iter().map(|p| p.text.as_str()).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_compact_page_is_persisted_and_old_state_files_still_load() {
|
|
let mut d = phone(&[("a.md", "A"), ("b.md", "B"), ("c.md", "C")]);
|
|
d.app.state.compact_page = CompactPage::Graph;
|
|
let json = d.app.state.to_json();
|
|
let saved = notescraft_ui_egui::state::UiState::from_json(&json);
|
|
assert_eq!(saved.compact_page, CompactPage::Graph, "the page survives a round trip");
|
|
assert_eq!(notescraft_ui_egui::state::UiState::from_json("{}").compact_page, CompactPage::Notes, "a state file without the field defaults");
|
|
}
|