Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
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>
2026-10-20 07:46:24 -03:00

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");
}