authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 05:43:13-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-28 05:43:14-07:00
log11bf37c8a7d470d535338524dbe2c81bbcff29bb
tree627101d55e619a14342bf7f7ea74cd1a2d4190c0
parent71e4e308b60bf1a52ad839646150a0f9e324ecee
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

6 files changed, 82 insertions(+), 10 deletions(-)

apps/ios/Snowbound/CanvasView.swift+2-1
...@@ -606,7 +606,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -606,7 +606,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
606 }606 }
607607
608 override func selectAll(_ sender: Any?) {608 override func selectAll(_ sender: Any?) {
609 edit(external: true) { choose(Range(0, endOfDocument.offset)) }609 guard let handle else { return }
610 edit(external: true) { sb_select_more(handle) }
610 }611 }
611612
612 override func toggleBoldface(_ sender: Any?) { apply(0) }613 override func toggleBoldface(_ sender: Any?) { apply(0) }
apps/ios/Snowbound/Script.swift+3-1
...@@ -9,7 +9,8 @@ extension CanvasView {...@@ -9,7 +9,8 @@ extension CanvasView {
9 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.9 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.
10 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,10 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
11 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`11 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture; `done`12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall`
13 /// is the Select All command; `done`
13 /// ends editing, `tree` saves the view hierarchy to14 /// ends editing, `tree` saves the view hierarchy to
14 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no15 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
15 /// screenshot command.16 /// screenshot command.
...@@ -54,6 +55,7 @@ extension CanvasView {...@@ -54,6 +55,7 @@ extension CanvasView {
54 let values = argument.split(separator: ",").compactMap { Double($0) }55 let values = argument.split(separator: ",").compactMap { Double($0) }
55 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))56 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
56 case "done": _ = resignFirstResponder()57 case "done": _ = resignFirstResponder()
58 case "selectall": selectAll(nil)
57 case "format": apply(UInt8(argument) ?? 0)59 case "format": apply(UInt8(argument) ?? 0)
58 case "find": _ = find(argument)60 case "find": _ = find(argument)
59 case "title": _ = focusTitle()61 case "title": _ = focusTitle()
crates/mobile/include/snowbound.h+1
...@@ -72,6 +72,7 @@ uint32_t sb_text_length(const View *view);...@@ -72,6 +72,7 @@ uint32_t sb_text_length(const View *view);
72char *sb_text(const View *view, uint32_t start, uint32_t end);72char *sb_text(const View *view, uint32_t start, uint32_t end);
73void sb_selection(const View *view, uint32_t range[2]);73void sb_selection(const View *view, uint32_t range[2]);
74bool sb_select(View *view, uint32_t start, uint32_t end);74bool sb_select(View *view, uint32_t start, uint32_t end);
75bool sb_select_more(View *view);
75bool sb_marked(const View *view, uint32_t range[2]);76bool sb_marked(const View *view, uint32_t range[2]);
76bool sb_set_marked(View *view, const char *text, uint32_t selected_start, uint32_t selected_end);77bool sb_set_marked(View *view, const char *text, uint32_t selected_start, uint32_t selected_end);
77void sb_unmark(View *view);78void sb_unmark(View *view);
crates/mobile/src/lib.rs+19-3
...@@ -13,10 +13,10 @@ use library::Shared;...@@ -13,10 +13,10 @@ use library::Shared;
13pub use library::{Library, Section, Share};13pub use library::{Library, Section, Share};
1414
15use canvas::{15use canvas::{
16 date::DateField,
16 document::TextPosition,17 document::TextPosition,
17 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},18 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},
18 gpu::{Paper, Viewport, page::PageScene},19 gpu::{Paper, Viewport, page::PageScene},
19 date::DateField,
20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors},20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors},
21 layout::TextEngine,21 layout::TextEngine,
22};22};
...@@ -416,7 +416,8 @@ impl Canvas {...@@ -416,7 +416,8 @@ impl Canvas {
416 /// Selects the first match of `query` on the page, as the search that found it matches.416 /// Selects the first match of `query` on the page, as the search that found it matches.
417 fn find(&mut self, query: &str) -> Result<bool> {417 fn find(&mut self, query: &str) -> Result<bool> {
418 let query = canvas::search::Query::new(query);418 let query = canvas::search::Query::new(query);
419 let Some(&(id, selection)) = canvas::search::page_matches(&self.page.editor, &query).first()419 let Some(&(id, selection)) =
420 canvas::search::page_matches(&self.page.editor, &query).first()
420 else {421 else {
421 return Ok(false);422 return Ok(false);
422 };423 };
...@@ -943,7 +944,9 @@ pub unsafe extern "C" fn sb_view_change_date(...@@ -943,7 +944,9 @@ pub unsafe extern "C" fn sb_view_change_date(
943 date: *const c_char,944 date: *const c_char,
944 time: *const c_char,945 time: *const c_char,
945) -> bool {946) -> bool {
946 let result = view.canvas.change_date(seconds, [string(date), string(time)]);947 let result = view
948 .canvas
949 .change_date(seconds, [string(date), string(time)]);
947 view.stored(result)950 view.stored(result)
948}951}
949952
...@@ -977,6 +980,19 @@ pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool {...@@ -977,6 +980,19 @@ pub extern "C" fn sb_select(view: &mut View, start: u32, end: u32) -> bool {
977 report(view.canvas.select([start, end])).is_some()980 report(view.canvas.select([start, end])).is_some()
978}981}
979982
983/// Widens the selection as OneNote's Ctrl+A does, one enclosing unit per call.
984#[unsafe(no_mangle)]
985pub extern "C" fn sb_select_more(view: &mut View) -> bool {
986 report(
987 view.canvas
988 .page
989 .editor
990 .widen_selection()
991 .map_err(Into::into),
992 )
993 .is_some()
994}
995
980#[unsafe(no_mangle)]996#[unsafe(no_mangle)]
981pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool {997pub extern "C" fn sb_marked(view: &View, range: &mut [u32; 2]) -> bool {
982 report(view.canvas.marked())998 report(view.canvas.marked())
crates/mobile/src/library.rs+12-1
...@@ -226,6 +226,15 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {...@@ -226,6 +226,15 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
226 readable,226 readable,
227 });227 });
228 }228 }
229 for entry in folder.unavailable.iter().filter(|entry| !entry.group) {
230 tabs.push(Tab {
231 name: stem(&entry.path),
232 path: entry.path.clone(),
233 group: folder.path.clone(),
234 color: rgb(SECTION_COLOR),
235 readable: false,
236 });
237 }
229 for group in &folder.groups {238 for group in &folder.groups {
230 if !group.path.ends_with("OneNote_RecycleBin") {239 if !group.path.ends_with("OneNote_RecycleBin") {
231 self::tabs(group, tabs);240 self::tabs(group, tabs);
...@@ -891,7 +900,9 @@ pub unsafe extern "C" fn sb_library_new_section(...@@ -891,7 +900,9 @@ pub unsafe extern "C" fn sb_library_new_section(
891 let (folder, name) = (string(folder), string(name));900 let (folder, name) = (string(folder), string(name));
892 let page = dated(&string(author), &string(date), &string(time));901 let page = dated(&string(author), &string(date), &string(time));
893 report(page.and_then(|page| {902 report(page.and_then(|page| {
894 library.with_notebook(true, |notebook| Ok(notebook.create_section(&folder, &name, &page)?))903 library.with_notebook(true, |notebook| {
904 Ok(notebook.create_section(&folder, &name, &page)?)
905 })
895 }))906 }))
896 .map_or(std::ptr::null_mut(), owned)907 .map_or(std::ptr::null_mut(), owned)
897}908}
crates/mobile/src/tests.rs+45-4
...@@ -197,6 +197,33 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {...@@ -197,6 +197,33 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {
197 }197 }
198}198}
199199
200#[test]
201fn select_more_widens_from_the_caret_paragraph_to_the_outline() {
202 let (_directory, section) = features();
203 let mut canvas = canvas(&section, "Paragraph controls");
204 focus(&mut canvas, "Collapsed parent");
205 let shown = canvas.active().shown_text();
206 let units = |text: &str| text.encode_utf16().count() as u32;
207 let start = units(&shown[..shown.find("Collapsed parent").unwrap()]);
208 let line = units(
209 shown
210 .split('\n')
211 .find(|line| line.contains("Collapsed parent"))
212 .unwrap(),
213 );
214 canvas.select([start + 2; 2]).unwrap();
215 canvas.page.editor.widen_selection().unwrap();
216 let [lo, hi] = canvas.selection().unwrap();
217 assert!(
218 lo == start && [start + line, start + line + 1].contains(&hi),
219 "{lo}..{hi}"
220 );
221 for _ in 0..8 {
222 canvas.page.editor.widen_selection().unwrap();
223 }
224 assert_eq!(canvas.selection().unwrap(), [0, canvas.length()]);
225}
226
200#[test]227#[test]
201fn undo_restores_typing() {228fn undo_restores_typing() {
202 let (_directory, section) = features();229 let (_directory, section) = features();
...@@ -542,13 +569,24 @@ fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {...@@ -542,13 +569,24 @@ fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {
542 let mut canvas = canvas(&section, "Paragraph controls");569 let mut canvas = canvas(&section, "Paragraph controls");
543 focus(&mut canvas, "Collapsed parent");570 focus(&mut canvas, "Collapsed parent");
544 canvas.insert("Coordinated ".into()).unwrap();571 canvas.insert("Coordinated ".into()).unwrap();
545 section.shared.apply(canvas.edit().unwrap().unwrap()).unwrap();572 section
573 .shared
574 .apply(canvas.edit().unwrap().unwrap())
575 .unwrap();
546 assert!(section.flush(Duration::from_secs(20)));576 assert!(section.flush(Duration::from_secs(20)));
547 let file = root.canonicalize().unwrap().join("Features.one");577 let file = root.canonicalize().unwrap().join("Features.one");
548 let file = file.to_string_lossy();578 let file = file.to_string_lossy();
549 let coordinated = COORDINATED.lock().unwrap();579 let coordinated = COORDINATED.lock().unwrap();
550 assert!(coordinated.iter().any(|(path, write)| path == &*file && !write));580 assert!(
551 assert!(coordinated.iter().any(|(path, write)| path == &*file && *write));581 coordinated
582 .iter()
583 .any(|(path, write)| path == &*file && !write)
584 );
585 assert!(
586 coordinated
587 .iter()
588 .any(|(path, write)| path == &*file && *write)
589 );
552}590}
553591
554#[test]592#[test]
...@@ -572,7 +610,10 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {...@@ -572,7 +610,10 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
572 // 2 January 2026, 10:30 UTC.610 // 2 January 2026, 10:30 UTC.
573 assert!(611 assert!(
574 canvas612 canvas
575 .change_date(1_767_349_800, ["Friday, January 2, 2026".into(), "10:30 AM".into()])613 .change_date(
614 1_767_349_800,
615 ["Friday, January 2, 2026".into(), "10:30 AM".into()]
616 )
576 .unwrap()617 .unwrap()
577 );618 );
578 let edit = canvas.edit().unwrap().unwrap();619 let edit = canvas.edit().unwrap().unwrap();