From 11bf37c8a7d470d535338524dbe2c81bbcff29bb Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 28 Sep 2026 05:43:13 -0700 Subject: [PATCH] feat(ios): iterative select all Select All widens from the paragraph to its outline as OneNote does, driven from the same editor step as the desktop. Assisted-by: claude-opus-5.5 --- apps/ios/Snowbound/CanvasView.swift | 3 +- apps/ios/Snowbound/Script.swift | 4 ++- crates/mobile/include/snowbound.h | 1 + crates/mobile/src/lib.rs | 22 +++++++++++-- crates/mobile/src/library.rs | 13 +++++++- crates/mobile/src/tests.rs | 49 ++++++++++++++++++++++++++--- 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index 409d7c20738d158f69e01ebb6953e5543c69331b..3ed7f64baebb62122e3d5f047ac13caf88cd4918 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -606,7 +606,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } override func selectAll(_ sender: Any?) { - edit(external: true) { choose(Range(0, endOfDocument.offset)) } + guard let handle else { return } + edit(external: true) { sb_select_more(handle) } } override func toggleBoldface(_ sender: Any?) { apply(0) } diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift index 144d15f91d2ec924a4b17c257ee8ceca33952c5c..d5bf52cb775dec2fc44a9701a3a95b4f328a87c7 100644 --- a/apps/ios/Snowbound/Script.swift +++ b/apps/ios/Snowbound/Script.swift @@ -9,7 +9,8 @@ extension CanvasView { /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`. /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word` - /// selects a match, `title` edits the title, `picture` inserts a drawn picture; `done` + /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall` + /// is the Select All command; `done` /// ends editing, `tree` saves the view hierarchy to /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no /// screenshot command. @@ -54,6 +55,7 @@ extension CanvasView { let values = argument.split(separator: ",").compactMap { Double($0) } zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) case "done": _ = resignFirstResponder() + case "selectall": selectAll(nil) case "format": apply(UInt8(argument) ?? 0) case "find": _ = find(argument) case "title": _ = focusTitle() diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 7976c1fcf7c50efa2c4a6e083a3ad64100d03bcb..cf388528e8125dd2199307ec2b261d4139e3c277 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -72,6 +72,7 @@ uint32_t sb_text_length(const View *view); char *sb_text(const View *view, uint32_t start, uint32_t end); void sb_selection(const View *view, uint32_t range[2]); bool sb_select(View *view, uint32_t start, uint32_t end); +bool sb_select_more(View *view); bool sb_marked(const View *view, uint32_t range[2]); bool sb_set_marked(View *view, const char *text, uint32_t selected_start, uint32_t selected_end); void sb_unmark(View *view); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 7b6397546d1ae390d4c3559e8ed5b1fb4e864f3b..4297a65306eeb020f9fefc5a29b84395a954cb41 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -13,10 +13,10 @@ use library::Shared; pub use library::{Library, Section, Share}; use canvas::{ + date::DateField, document::TextPosition, editor::{Formatting, NoteTag, Selection, TextOutline, Toggle}, gpu::{Paper, Viewport, page::PageScene}, - date::DateField, interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors}, layout::TextEngine, }; @@ -416,7 +416,8 @@ impl Canvas { /// Selects the first match of `query` on the page, as the search that found it matches. fn find(&mut self, query: &str) -> Result { let query = canvas::search::Query::new(query); - let Some(&(id, selection)) = canvas::search::page_matches(&self.page.editor, &query).first() + let Some(&(id, selection)) = + canvas::search::page_matches(&self.page.editor, &query).first() else { return Ok(false); }; @@ -943,7 +944,9 @@ pub unsafe extern "C" fn sb_view_change_date( date: *const c_char, time: *const c_char, ) -> bool { - let result = view.canvas.change_date(seconds, [string(date), string(time)]); + let result = view + .canvas + .change_date(seconds, [string(date), string(time)]); view.stored(result) } @@ -977,6 +980,19 @@ pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool { report(view.canvas.select([start, end])).is_some() } +/// Widens the selection as OneNote's Ctrl+A does, one enclosing unit per call. +#[unsafe(no_mangle)] +pub extern "C" fn sb_select_more(view: &mut View) -> bool { + report( + view.canvas + .page + .editor + .widen_selection() + .map_err(Into::into), + ) + .is_some() +} + #[unsafe(no_mangle)] pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool { report(view.canvas.marked()) diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index 8967f027835dbcddf1d9c9a6696068e51ebf32ac..aef196ab69b5b8f0464ecd0b43efe391f8a9ac28 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -226,6 +226,15 @@ fn tabs(folder: &Folder, tabs: &mut Vec) { readable, }); } + for entry in folder.unavailable.iter().filter(|entry| !entry.group) { + tabs.push(Tab { + name: stem(&entry.path), + path: entry.path.clone(), + group: folder.path.clone(), + color: rgb(SECTION_COLOR), + readable: false, + }); + } for group in &folder.groups { if !group.path.ends_with("OneNote_RecycleBin") { self::tabs(group, tabs); @@ -891,7 +900,9 @@ pub unsafe extern "C" fn sb_library_new_section( let (folder, name) = (string(folder), string(name)); let page = dated(&string(author), &string(date), &string(time)); report(page.and_then(|page| { - library.with_notebook(true, |notebook| Ok(notebook.create_section(&folder, &name, &page)?)) + library.with_notebook(true, |notebook| { + Ok(notebook.create_section(&folder, &name, &page)?) + }) })) .map_or(std::ptr::null_mut(), owned) } diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 659d7949c781968790d0d998a258600a970112eb..4f092f2a0a99c61076f42b6af266be5878e544b8 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -197,6 +197,33 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { } } +#[test] +fn select_more_widens_from_the_caret_paragraph_to_the_outline() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + let shown = canvas.active().shown_text(); + let units = |text: &str| text.encode_utf16().count() as u32; + let start = units(&shown[..shown.find("Collapsed parent").unwrap()]); + let line = units( + shown + .split('\n') + .find(|line| line.contains("Collapsed parent")) + .unwrap(), + ); + canvas.select([start + 2; 2]).unwrap(); + canvas.page.editor.widen_selection().unwrap(); + let [lo, hi] = canvas.selection().unwrap(); + assert!( + lo == start && [start + line, start + line + 1].contains(&hi), + "{lo}..{hi}" + ); + for _ in 0..8 { + canvas.page.editor.widen_selection().unwrap(); + } + assert_eq!(canvas.selection().unwrap(), [0, canvas.length()]); +} + #[test] fn undo_restores_typing() { let (_directory, section) = features(); @@ -542,13 +569,24 @@ fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() { let mut canvas = canvas(§ion, "Paragraph controls"); focus(&mut canvas, "Collapsed parent"); canvas.insert("Coordinated ".into()).unwrap(); - section.shared.apply(canvas.edit().unwrap().unwrap()).unwrap(); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); assert!(section.flush(Duration::from_secs(20))); let file = root.canonicalize().unwrap().join("Features.one"); let file = file.to_string_lossy(); let coordinated = COORDINATED.lock().unwrap(); - assert!(coordinated.iter().any(|(path, write)| path == &*file && !write)); - assert!(coordinated.iter().any(|(path, write)| path == &*file && *write)); + assert!( + coordinated + .iter() + .any(|(path, write)| path == &*file && !write) + ); + assert!( + coordinated + .iter() + .any(|(path, write)| path == &*file && *write) + ); } #[test] @@ -572,7 +610,10 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { // 2 January 2026, 10:30 UTC. assert!( canvas - .change_date(1_767_349_800, ["Friday, January 2, 2026".into(), "10:30 AM".into()]) + .change_date( + 1_767_349_800, + ["Friday, January 2, 2026".into(), "10:30 AM".into()] + ) .unwrap() ); let edit = canvas.edit().unwrap().unwrap(); -- 2.54.0