diff --git a/README.md b/README.md index ff97a9e..67d2c6e 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,12 @@ These are the main built-in controls. The available controls depend on the curre | F1 | Open or close contextual help | | Ctrl+c | Quit from any view | | q | Quit from the Inspector or Browse view | -| Esc | Close Search or Browse; quit from the Inspector | +| Esc | Cancel Search or Browse; quit from the Inspector | | h j k l or arrow keys | Move the selection | | / | Search by character, code point, or Unicode name | -| p r c | Browse planes, ranges, or code points | -| Enter | Open the selected item | +| p r b c | Browse planes, ranges, blocks, or code points | +| Enter | Open the selected item; confirm a code point from the table | +| Backspace | Return to the previous Browse screen, or cancel from the first screen | | y | Copy the selected Inspector value | Press F1 to view all controls for the current screen. diff --git a/config.schema.json b/config.schema.json index fc60952..b58a39e 100644 --- a/config.schema.json +++ b/config.schema.json @@ -519,6 +519,9 @@ "browse_ranges": { "$ref": "#/definitions/keyArray" }, + "browse_blocks": { + "$ref": "#/definitions/keyArray" + }, "browse_code_points": { "$ref": "#/definitions/keyArray" } @@ -552,6 +555,9 @@ "close": { "$ref": "#/definitions/keyArray" }, + "back": { + "$ref": "#/definitions/keyArray" + }, "move_up": { "$ref": "#/definitions/keyArray" }, @@ -606,6 +612,42 @@ }, "additionalProperties": false }, + "browseBlockKeybindings": { + "type": "object", + "properties": { + "quit": { + "$ref": "#/definitions/keyArray" + }, + "close": { + "$ref": "#/definitions/keyArray" + }, + "back": { + "$ref": "#/definitions/keyArray" + }, + "move_up": { + "$ref": "#/definitions/keyArray" + }, + "move_down": { + "$ref": "#/definitions/keyArray" + }, + "page_up": { + "$ref": "#/definitions/keyArray" + }, + "page_down": { + "$ref": "#/definitions/keyArray" + }, + "first": { + "$ref": "#/definitions/keyArray" + }, + "last": { + "$ref": "#/definitions/keyArray" + }, + "activate": { + "$ref": "#/definitions/keyArray" + } + }, + "additionalProperties": false + }, "browseCodePointsKeybindings": { "type": "object", "properties": { @@ -694,6 +736,9 @@ "browse_range": { "$ref": "#/definitions/browseRangeKeybindings" }, + "browse_block": { + "$ref": "#/definitions/browseBlockKeybindings" + }, "browse_code_points": { "$ref": "#/definitions/browseCodePointsKeybindings" }, diff --git a/img/block-code-points.png b/img/block-code-points.png new file mode 100644 index 0000000..9d59b99 Binary files /dev/null and b/img/block-code-points.png differ diff --git a/img/block.png b/img/block.png new file mode 100644 index 0000000..e57a122 Binary files /dev/null and b/img/block.png differ diff --git a/img/config.toml b/img/config.toml index b449aca..1896437 100644 --- a/img/config.toml +++ b/img/config.toml @@ -1 +1,5 @@ # To prevent loading and overriding local environment config + +[ui] + +input_cursor = { text = "|" } diff --git a/img/demo.gif b/img/demo.gif index 1fe2511..32e8050 100644 Binary files a/img/demo.gif and b/img/demo.gif differ diff --git a/img/demo.tape b/img/demo.tape index f1ec82f..bac5329 100644 --- a/img/demo.tape +++ b/img/demo.tape @@ -107,6 +107,29 @@ Sleep 500ms Type "y" Sleep 1s +# Browse block + +Type "b" +Sleep 500ms + +Type "g" +Sleep 500ms + +Type@200ms "jjj" +Sleep 500ms + +Screenshot "./img/block.png" +Sleep 100ms + +Enter +Sleep 1s + +Screenshot "./img/block-code-points.png" +Sleep 100ms + +Enter +Sleep 500ms + # Filter Type "/" diff --git a/img/filter.png b/img/filter.png index 3d963b5..c7764d2 100644 Binary files a/img/filter.png and b/img/filter.png differ diff --git a/img/inspector.png b/img/inspector.png index 1a7240a..badd513 100644 Binary files a/img/inspector.png and b/img/inspector.png differ diff --git a/img/plane.png b/img/plane.png index 01438c1..885dd0c 100644 Binary files a/img/plane.png and b/img/plane.png differ diff --git a/src/app.rs b/src/app.rs index fe9ab8e..dccef15 100644 --- a/src/app.rs +++ b/src/app.rs @@ -283,45 +283,57 @@ pub fn update(state: &mut AppState, action: Action) { .inspector .resize_viewport(viewport_height, document_height, field_ranges), Action::OpenBrowser(level) if state.view == View::Inspector => { + let previous_preview = state.preview_code_point(); state.browse = Some(BrowseState::at(level, state.selected)); state.view = View::Browser; + refresh_preview_for_change(state, previous_preview); } Action::OpenBrowser(_) => {} Action::AdvanceBrowser if state.view == View::Browser => { - let browse = state - .browse - .as_mut() - .expect("the browser view always has browse state"); - let target = browse.advance(); - let selected = (browse.level() == BrowseLevel::CodePointTable).then(|| browse.cursor()); + let previous_preview = state.preview_code_point(); + let (target, cursor) = { + let browse = state + .browse + .as_mut() + .expect("the browser view always has browse state"); + (browse.advance(), browse.cursor()) + }; if target == BrowseTarget::Inspector { + select_code_point(state, cursor); state.view = View::Inspector; + state.browse = None; } - if let Some(selected) = selected { - select_code_point(state, selected); - } + refresh_preview_for_change(state, previous_preview); } Action::AdvanceBrowser => {} Action::BackBrowser if state.view == View::Browser => { + let previous_preview = state.preview_code_point(); let browse = state .browse .as_mut() .expect("the browser view always has browse state"); - browse.back(); + if browse.back() == BrowseTarget::Inspector { + state.view = View::Inspector; + state.browse = None; + } + refresh_preview_for_change(state, previous_preview); } Action::BackBrowser => {} - Action::CloseBrowser if state.view == View::Browser => state.view = View::Inspector, + Action::CloseBrowser if state.view == View::Browser => { + let previous_preview = state.preview_code_point(); + state.view = View::Inspector; + state.browse = None; + refresh_preview_for_change(state, previous_preview); + } Action::CloseBrowser => {} Action::MoveBrowser(movement) if state.view == View::Browser => { + let previous_preview = state.preview_code_point(); let browse = state .browse .as_mut() .expect("the browser view always has browse state"); browse.move_cursor(movement); - let selected = (browse.level() == BrowseLevel::CodePointTable).then(|| browse.cursor()); - if let Some(selected) = selected { - select_code_point(state, selected); - } + refresh_preview_for_change(state, previous_preview); } Action::MoveBrowser(_) => {} Action::ResizeBrowserViewport(height) => { @@ -379,6 +391,12 @@ fn select_code_point(state: &mut AppState, code_point: CodePoint) { } } +fn refresh_preview_for_change(state: &mut AppState, previous: Option) { + if state.preview_code_point() != previous { + state.glyph_preview.selection_changed(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -633,7 +651,7 @@ mod tests { } #[test] - fn entering_and_moving_in_the_table_updates_the_shared_selection() { + fn table_movement_is_tentative_until_enter() { let mut state = AppState::with_selected(CodePoint::new(0x0041).unwrap()); resize_inspector(&mut state, 5, 20); update(&mut state, Action::MoveInspector(InspectorMove::Last)); @@ -642,13 +660,102 @@ mod tests { update(&mut state, Action::MoveBrowser(BrowseMove::Down)); update(&mut state, Action::AdvanceBrowser); - assert_eq!(state.selected().value(), 0x0141); + assert_eq!(state.selected().value(), 0x0041); + assert_eq!(state.preview_code_point().unwrap().value(), 0x0141); assert_eq!(state.inspector().selected_index(), 19); assert_eq!(state.inspector().offset(), 15); update(&mut state, Action::MoveBrowser(BrowseMove::Right)); assert_eq!(state.browse().unwrap().cursor().value(), 0x0142); + assert_eq!(state.selected().value(), 0x0041); + update(&mut state, Action::AdvanceBrowser); assert_eq!(state.selected().value(), 0x0142); + assert_eq!(state.view(), View::Inspector); + } + + #[test] + fn closing_or_backing_out_of_browse_discards_the_tentative_selection() { + let original = CodePoint::new(0x0041).unwrap(); + let mut state = AppState::with_selected(original); + update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable)); + update(&mut state, Action::MoveBrowser(BrowseMove::Right)); + assert_eq!(state.preview_code_point().unwrap().value(), 0x0042); + update(&mut state, Action::CloseBrowser); + assert_eq!(state.selected(), original); + assert_eq!(state.preview_code_point(), Some(original)); + + update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable)); + update(&mut state, Action::MoveBrowser(BrowseMove::Right)); + update(&mut state, Action::BackBrowser); + assert_eq!(state.view(), View::Inspector); + assert_eq!(state.selected(), original); + assert_eq!(state.preview_code_point(), Some(original)); + } + + #[test] + fn moving_a_tentative_cursor_invalidates_its_glyph_preview() { + use crate::{ + glyph::CanvasSize, + graphics::{GraphicsAvailability, GraphicsProtocol}, + preview::{GlyphPreviewGeometry, GlyphPreviewStatus}, + }; + + let mut state = AppState::new(); + update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable)); + update( + &mut state, + Action::UpdateGlyphPreview(GlyphPreviewUpdate::Configure { + availability: GraphicsAvailability::Available(GraphicsProtocol::Kitty), + image_id: None, + }), + ); + update( + &mut state, + Action::UpdateGlyphPreview(GlyphPreviewUpdate::Prepared { + image_id: None, + geometry: GlyphPreviewGeometry::new(10, 4, CanvasSize::new(80, 64).unwrap()), + status: GlyphPreviewStatus::Ready, + font: None, + }), + ); + + update(&mut state, Action::MoveBrowser(BrowseMove::Right)); + assert_eq!(state.glyph_preview().status(), GlyphPreviewStatus::Pending); + assert_eq!(state.selected().value(), 0x0041); + } + + #[test] + fn backing_from_a_table_preserves_its_tentative_cursor_in_the_previous_list() { + let original = CodePoint::new(0x0041).unwrap(); + let mut state = AppState::with_selected(original); + update(&mut state, Action::OpenBrowser(BrowseLevel::Range)); + update(&mut state, Action::MoveBrowser(BrowseMove::Down)); + update(&mut state, Action::AdvanceBrowser); + update(&mut state, Action::MoveBrowser(BrowseMove::Right)); + update(&mut state, Action::BackBrowser); + + assert_eq!(state.browse().unwrap().level(), BrowseLevel::Range); + assert_eq!(state.browse().unwrap().cursor().value(), 0x0142); + assert_eq!(state.selected(), original); + } + + #[test] + fn block_browse_from_an_unmapped_code_point_commits_only_on_table_enter() { + let original = CodePoint::new(0x2fe0).unwrap(); + let mut state = AppState::with_selected(original); + update(&mut state, Action::OpenBrowser(BrowseLevel::Block)); + assert_eq!(state.browse().unwrap().cursor().value(), 0x2ff0); + assert_eq!(state.selected(), original); + update(&mut state, Action::AdvanceBrowser); + assert_eq!(state.preview_code_point().unwrap().value(), 0x2ff0); + update(&mut state, Action::MoveBrowser(BrowseMove::Right)); + update(&mut state, Action::BackBrowser); + assert_eq!(state.browse().unwrap().level(), BrowseLevel::Block); + assert_eq!(state.selected(), original); + update(&mut state, Action::AdvanceBrowser); + update(&mut state, Action::AdvanceBrowser); + assert_eq!(state.view(), View::Inspector); + assert_eq!(state.selected().value(), 0x2ff1); } #[test] @@ -663,16 +770,25 @@ mod tests { update(&mut state, Action::AdvanceBrowser); assert_eq!(state.view(), View::Inspector); - update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable)); + update(&mut state, Action::OpenBrowser(BrowseLevel::Plane)); + update(&mut state, Action::AdvanceBrowser); + update(&mut state, Action::AdvanceBrowser); update(&mut state, Action::BackBrowser); assert_eq!(state.browse().unwrap().level(), BrowseLevel::Range); update(&mut state, Action::BackBrowser); assert_eq!(state.browse().unwrap().level(), BrowseLevel::Plane); update(&mut state, Action::BackBrowser); - assert_eq!(state.view(), View::Browser); - assert_eq!(state.browse().unwrap().level(), BrowseLevel::Plane); + assert_eq!(state.view(), View::Inspector); - update(&mut state, Action::CloseBrowser); + update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable)); + update(&mut state, Action::BackBrowser); + assert_eq!(state.view(), View::Inspector); + + update(&mut state, Action::OpenBrowser(BrowseLevel::Range)); + update(&mut state, Action::AdvanceBrowser); + update(&mut state, Action::BackBrowser); + assert_eq!(state.browse().unwrap().level(), BrowseLevel::Range); + update(&mut state, Action::BackBrowser); assert_eq!(state.view(), View::Inspector); } @@ -681,8 +797,9 @@ mod tests { let mut state = AppState::new(); update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable)); update(&mut state, Action::MoveBrowser(BrowseMove::Right)); - assert_eq!(state.selected().value(), 0x0042); + assert_eq!(state.selected().value(), 0x0041); update(&mut state, Action::AdvanceBrowser); + assert_eq!(state.selected().value(), 0x0042); update(&mut state, Action::OpenSearch); edit_search_query(&mut state, "→"); diff --git a/src/browser.rs b/src/browser.rs index a0f825d..4b95d83 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -1,5 +1,5 @@ use crate::{ - unicode::{CodePoint, Plane, plane::PlaneRange}, + unicode::{CodePoint, Plane, UnicodeBlock, UnicodeDatabase, plane::PlaneRange}, viewport::ListViewport, }; @@ -7,9 +7,16 @@ use crate::{ pub enum BrowseLevel { Plane, Range, + Block, CodePointTable, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TableSource { + Range, + Block, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowseMove { Up, @@ -30,20 +37,34 @@ pub enum BrowseTarget { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BrowseState { + entry_level: BrowseLevel, level: BrowseLevel, cursor: CodePoint, + block_index: usize, + table_source: TableSource, plane_viewport: ListViewport, range_viewport: ListViewport, + block_viewport: ListViewport, table_viewport: ListViewport, } impl BrowseState { - pub const fn at(level: BrowseLevel, cursor: CodePoint) -> Self { + pub fn at(level: BrowseLevel, cursor: CodePoint) -> Self { + let block = UnicodeDatabase::block_at_or_after(cursor) + .expect("the final Unicode block includes the last code point"); Self { + entry_level: level, level, - cursor, + cursor: if level == BrowseLevel::Block && !block.contains(cursor) { + block.start() + } else { + cursor + }, + block_index: block.index(), + table_source: TableSource::Range, plane_viewport: ListViewport::new(), range_viewport: ListViewport::new(), + block_viewport: ListViewport::new(), table_viewport: ListViewport::new(), } } @@ -56,6 +77,32 @@ impl BrowseState { self.cursor } + pub fn selected_block(self) -> Option { + (self.level == BrowseLevel::Block || self.table_source == TableSource::Block) + .then(|| UnicodeDatabase::block(self.block_index)) + .flatten() + } + + pub fn table_page(self) -> Option<(CodePoint, CodePoint)> { + (self.level == BrowseLevel::CodePointTable).then(|| match self.table_source { + TableSource::Range => { + let range = PlaneRange::for_code_point(self.cursor); + (range.start(), range.end()) + } + TableSource::Block => { + let block = self.block(); + let start = block.start().value() + + ((self.cursor.value() - block.start().value()) / 256) * 256; + let end = (start + 255).min(block.end().value()); + (valid_code_point(start), valid_code_point(end)) + } + }) + } + + pub fn is_block_table(self) -> bool { + self.level == BrowseLevel::CodePointTable && self.table_source == TableSource::Block + } + pub fn resize_list_viewports(&mut self, height: usize) { self.plane_viewport.resize( height, @@ -67,11 +114,11 @@ impl BrowseState { usize::from(PlaneRange::for_code_point(self.cursor).index()), PlaneRange::COUNT_PER_PLANE, ); - self.table_viewport.resize( - height.saturating_sub(1), - usize::from(table_row(self.cursor)), - 16, - ); + self.block_viewport + .resize(height, self.block_index, UnicodeDatabase::blocks().len()); + let table_rows = self.table_row_count(); + self.table_viewport + .resize(height.saturating_sub(1), self.table_row(), table_rows); } pub fn visible_list_items(&self) -> Option> { @@ -81,12 +128,17 @@ impl BrowseState { self.range_viewport .visible_range(PlaneRange::COUNT_PER_PLANE), ), + BrowseLevel::Block => Some( + self.block_viewport + .visible_range(UnicodeDatabase::blocks().len()), + ), BrowseLevel::CodePointTable => None, } } pub fn visible_table_rows(&self) -> Option> { - (self.level == BrowseLevel::CodePointTable).then(|| self.table_viewport.visible_range(16)) + (self.level == BrowseLevel::CodePointTable) + .then(|| self.table_viewport.visible_range(self.table_row_count())) } pub fn advance(&mut self) -> BrowseTarget { @@ -96,6 +148,12 @@ impl BrowseState { self.ensure_current_item_visible(); } BrowseLevel::Range => { + self.table_source = TableSource::Range; + self.level = BrowseLevel::CodePointTable; + self.ensure_current_item_visible(); + } + BrowseLevel::Block => { + self.table_source = TableSource::Block; self.level = BrowseLevel::CodePointTable; self.ensure_current_item_visible(); } @@ -104,18 +162,26 @@ impl BrowseState { BrowseTarget::Browser } - pub fn back(&mut self) { + pub fn back(&mut self) -> BrowseTarget { + if self.level == self.entry_level { + return BrowseTarget::Inspector; + } match self.level { - BrowseLevel::Plane => {} + BrowseLevel::Plane => return BrowseTarget::Inspector, BrowseLevel::Range => { self.level = BrowseLevel::Plane; self.ensure_current_item_visible(); } + BrowseLevel::Block => return BrowseTarget::Inspector, BrowseLevel::CodePointTable => { - self.level = BrowseLevel::Range; + self.level = match self.table_source { + TableSource::Range => BrowseLevel::Range, + TableSource::Block => BrowseLevel::Block, + }; self.ensure_current_item_visible(); } } + BrowseTarget::Browser } pub fn move_cursor(&mut self, movement: BrowseMove) -> bool { @@ -123,10 +189,18 @@ impl BrowseState { self.cursor = match self.level { BrowseLevel::Plane => move_plane(self.cursor, movement), BrowseLevel::Range => move_range(self.cursor, movement), - BrowseLevel::CodePointTable => move_table(self.cursor, movement), + BrowseLevel::Block => self.move_block(movement), + BrowseLevel::CodePointTable => match self.table_source { + TableSource::Range => move_table(self.cursor, movement), + TableSource::Block => move_block_table(self.cursor, movement, self.block()), + }, }; self.ensure_current_item_visible(); + debug_assert!( + !self.is_block_table() || self.block().contains(self.cursor), + "block table cursor must remain within the selected block" + ); debug_assert!(PlaneRange::for_code_point(self.cursor).contains(self.cursor)); self.cursor != previous } @@ -141,12 +215,50 @@ impl BrowseState { usize::from(PlaneRange::for_code_point(self.cursor).index()), PlaneRange::COUNT_PER_PLANE, ), + BrowseLevel::Block => self + .block_viewport + .ensure_visible(self.block_index, UnicodeDatabase::blocks().len()), BrowseLevel::CodePointTable => { self.table_viewport - .ensure_visible(usize::from(table_row(self.cursor)), 16); + .ensure_visible(self.table_row(), self.table_row_count()); } } } + + fn block(self) -> UnicodeBlock { + UnicodeDatabase::block(self.block_index).expect("browser block index is valid") + } + + fn table_row_count(self) -> usize { + self.table_page().map_or(16, |(start, end)| { + ((end.value() - start.value() + 1) / 16) as usize + }) + } + + fn table_row(self) -> usize { + self.table_page() + .map_or(usize::from(table_row(self.cursor)), |(start, _)| { + ((self.cursor.value() - start.value()) / 16) as usize + }) + } + + fn move_block(&mut self, movement: BrowseMove) -> CodePoint { + let last = UnicodeDatabase::blocks().len() - 1; + let target = match movement { + BrowseMove::Up => self.block_index.saturating_sub(1), + BrowseMove::Down => self.block_index.saturating_add(1).min(last), + BrowseMove::LargeBackward => self.block_index.saturating_sub(16), + BrowseMove::LargeForward => self.block_index.saturating_add(16).min(last), + BrowseMove::First => 0, + BrowseMove::Last => last, + _ => self.block_index, + }; + if target == self.block_index { + return self.cursor; + } + self.block_index = target; + self.block().start() + } } fn table_row(cursor: CodePoint) -> u8 { @@ -219,6 +331,29 @@ fn move_table(cursor: CodePoint, movement: BrowseMove) -> CodePoint { target.map_or(cursor, valid_code_point) } +fn move_block_table(cursor: CodePoint, movement: BrowseMove, block: UnicodeBlock) -> CodePoint { + let start = block.start().value(); + let end = block.end().value(); + let value = cursor.value(); + let page = (value - start) / 256; + let target = match movement { + BrowseMove::Left if value > start => Some(value - 1), + BrowseMove::Right if value < end => Some(value + 1), + BrowseMove::Up if value - start >= 16 => Some(value - 16), + BrowseMove::Down if end - value >= 16 => Some(value + 16), + BrowseMove::LargeBackward if page > 0 => { + Some(start + (page - 1) * 256 + (value - start) % 256) + } + BrowseMove::LargeForward if start + (page + 1) * 256 <= end => { + Some((start + (page + 1) * 256 + (value - start) % 256).min(end)) + } + BrowseMove::First => Some(start), + BrowseMove::Last => Some(end), + _ => None, + }; + target.map_or(cursor, valid_code_point) +} + fn range_offset(range: PlaneRange, cursor: CodePoint) -> u8 { range .offset_of(cursor) @@ -244,12 +379,17 @@ mod tests { assert_eq!(state.level(), BrowseLevel::CodePointTable); assert_eq!(state.advance(), BrowseTarget::Inspector); assert_eq!(state.level(), BrowseLevel::CodePointTable); - state.back(); + assert_eq!(state.back(), BrowseTarget::Browser); assert_eq!(state.level(), BrowseLevel::Range); - state.back(); + assert_eq!(state.back(), BrowseTarget::Browser); assert_eq!(state.level(), BrowseLevel::Plane); - state.back(); + assert_eq!(state.back(), BrowseTarget::Inspector); assert_eq!(state.level(), BrowseLevel::Plane); + + let mut direct_range = browse_state_at(BrowseLevel::Range, 0x0041); + assert_eq!(direct_range.back(), BrowseTarget::Inspector); + let mut direct_table = browse_state_at(BrowseLevel::CodePointTable, 0x0041); + assert_eq!(direct_table.back(), BrowseTarget::Inspector); } #[test] @@ -377,13 +517,107 @@ mod tests { assert_eq!(state.visible_table_rows(), Some(0..9)); } + #[test] + fn block_list_selects_the_next_named_block_from_a_gap() { + let mut state = browse_state_at(BrowseLevel::Block, 0x2fe0); + state.resize_list_viewports(10); + + assert_eq!( + state.selected_block().unwrap().name(), + "Ideographic Description Characters" + ); + assert_eq!(state.cursor().value(), 0x2ff0); + assert!(state.move_cursor(BrowseMove::Up)); + assert_eq!(state.selected_block().unwrap().name(), "Kangxi Radicals"); + assert_eq!(state.cursor().value(), 0x2f00); + let index = state.selected_block().unwrap().index(); + assert!(state.move_cursor(BrowseMove::LargeBackward)); + assert_eq!(state.selected_block().unwrap().index(), index - 16); + assert!(state.move_cursor(BrowseMove::LargeForward)); + assert_eq!(state.selected_block().unwrap().index(), index); + assert!(state.move_cursor(BrowseMove::Last)); + assert_eq!( + state.selected_block().unwrap().end().value(), + CodePoint::MAX_VALUE + ); + assert!(!state.move_cursor(BrowseMove::Down)); + assert_eq!( + state.visible_list_items().unwrap().end, + UnicodeDatabase::blocks().len() + ); + assert!(state.move_cursor(BrowseMove::First)); + assert_eq!(state.selected_block().unwrap().name(), "Basic Latin"); + } + + #[test] + fn block_table_limits_movement_to_a_short_block_and_returns_to_its_list() { + let mut state = browse_state_at(BrowseLevel::Block, 0x2ff5); + state.resize_list_viewports(10); + assert_eq!(state.advance(), BrowseTarget::Browser); + assert!(state.is_block_table()); + assert_eq!(state.table_page().unwrap().0.value(), 0x2ff0); + assert_eq!(state.visible_table_rows(), Some(0..1)); + assert!(!state.move_cursor(BrowseMove::LargeForward)); + assert!(!state.move_cursor(BrowseMove::Up)); + assert!(state.move_cursor(BrowseMove::Last)); + assert_eq!(state.cursor().value(), 0x2fff); + assert!(!state.move_cursor(BrowseMove::Right)); + assert!(!state.move_cursor(BrowseMove::Down)); + assert_eq!(state.back(), BrowseTarget::Browser); + assert_eq!(state.level(), BrowseLevel::Block); + assert_eq!( + state.selected_block().unwrap().name(), + "Ideographic Description Characters" + ); + assert_eq!(state.back(), BrowseTarget::Inspector); + } + + #[test] + fn block_table_pages_through_a_long_block_and_clips_the_final_page() { + let mut state = browse_state_at(BrowseLevel::Block, 0x2_00ab); + state.resize_list_viewports(10); + assert_eq!( + state.selected_block().unwrap().name(), + "CJK Unified Ideographs Extension B" + ); + state.advance(); + assert_eq!(state.table_page().unwrap().0.value(), 0x2_0000); + assert_eq!(state.table_page().unwrap().1.value(), 0x2_00ff); + assert!(state.move_cursor(BrowseMove::LargeForward)); + assert_eq!(state.cursor().value(), 0x2_01ab); + assert!(state.move_cursor(BrowseMove::Last)); + assert_eq!(state.cursor().value(), 0x2_a6df); + assert_eq!(state.table_page().unwrap().0.value(), 0x2_a600); + assert_eq!(state.visible_table_rows().unwrap().end, 14); + assert!(!state.move_cursor(BrowseMove::LargeForward)); + assert!(!state.move_cursor(BrowseMove::Right)); + assert!(state.move_cursor(BrowseMove::LargeBackward)); + assert_eq!(state.cursor().value(), 0x2_a5df); + } + + #[test] + fn block_table_pages_are_relative_to_the_block_start() { + let mut state = browse_state_at(BrowseLevel::Block, 0xfb60); + assert_eq!( + state.selected_block().unwrap().name(), + "Arabic Presentation Forms-A" + ); + state.advance(); + assert_eq!(state.table_page().unwrap().0.value(), 0xfb50); + assert_eq!(state.table_page().unwrap().1.value(), 0xfc4f); + assert!(state.move_cursor(BrowseMove::LargeForward)); + assert_eq!(state.cursor().value(), 0xfc60); + assert_eq!(state.table_page().unwrap().0.value(), 0xfc50); + assert!(state.move_cursor(BrowseMove::Last)); + assert_eq!(state.table_page().unwrap().0.value(), 0xfd50); + assert_eq!(state.table_page().unwrap().1.value(), 0xfdff); + } + fn browse_state(value: u32) -> BrowseState { BrowseState::at(BrowseLevel::Plane, CodePoint::new(value).unwrap()) } fn browse_state_at(level: BrowseLevel, value: u32) -> BrowseState { - let mut state = browse_state(value); - state.level = level; - state + BrowseState::at(level, CodePoint::new(value).unwrap()) } } diff --git a/src/cli.rs b/src/cli.rs index bf45ed9..e544cf4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -58,6 +58,7 @@ pub enum Demo { Surrogate, BrowsePlanes, BrowseRanges, + BrowseBlocks, BrowseCodePoints, BrowseSpecial, #[value(name = "browse-plane-16-end")] diff --git a/src/fixtures.rs b/src/fixtures.rs index 4834c91..964a2e4 100644 --- a/src/fixtures.rs +++ b/src/fixtures.rs @@ -204,6 +204,30 @@ pub fn browse_ranges() -> AppState { browse(0x0041, BrowseLevel::Range) } +pub fn browse_blocks() -> AppState { + browse(0x0041, BrowseLevel::Block) +} + +#[cfg(test)] +pub fn browse_blocks_from_gap() -> AppState { + browse(0x2fe0, BrowseLevel::Block) +} + +#[cfg(test)] +pub fn browse_block_code_points_short() -> AppState { + let mut state = browse(0x2ff5, BrowseLevel::Block); + update(&mut state, Action::AdvanceBrowser); + state +} + +#[cfg(test)] +pub fn browse_block_code_points_long_end() -> AppState { + let mut state = browse(0x2_00ab, BrowseLevel::Block); + update(&mut state, Action::AdvanceBrowser); + update(&mut state, Action::MoveBrowser(BrowseMove::Last)); + state +} + pub fn browse_code_points() -> AppState { browse(0x0041, BrowseLevel::CodePointTable) } diff --git a/src/input.rs b/src/input.rs index dd45f4c..2c04fe8 100644 --- a/src/input.rs +++ b/src/input.rs @@ -65,6 +65,7 @@ pub fn context_for_state(state: &AppState) -> Context { { BrowseLevel::Plane => Context::BrowsePlane, BrowseLevel::Range => Context::BrowseRange, + BrowseLevel::Block => Context::BrowseBlock, BrowseLevel::CodePointTable => Context::BrowseCodePoints, }, } @@ -97,22 +98,23 @@ fn action_for_command(context: Context, command: Command) -> Option { (X::Inspector, C::OpenSearch) => Some(Action::OpenSearch), (X::Inspector, C::BrowsePlanes) => Some(Action::OpenBrowser(BrowseLevel::Plane)), (X::Inspector, C::BrowseRanges) => Some(Action::OpenBrowser(BrowseLevel::Range)), + (X::Inspector, C::BrowseBlocks) => Some(Action::OpenBrowser(BrowseLevel::Block)), (X::Inspector, C::BrowseCodePoints) => Some(Action::OpenBrowser(BrowseLevel::CodePointTable)), (X::Search, C::PreviousResult) => Some(Action::MoveSearch(SearchMove::Previous)), (X::Search, C::NextResult) => Some(Action::MoveSearch(SearchMove::Next)), (X::Search, C::InspectResult) => Some(Action::InspectSearchResult), (X::Search, C::Close) => Some(Action::CloseSearch), - (X::BrowsePlane | X::BrowseRange | X::BrowseCodePoints, C::MoveUp) => Some(Action::MoveBrowser(BrowseMove::Up)), - (X::BrowsePlane | X::BrowseRange | X::BrowseCodePoints, C::MoveDown) => Some(Action::MoveBrowser(BrowseMove::Down)), - (X::BrowsePlane | X::BrowseRange | X::BrowseCodePoints, C::First) => Some(Action::MoveBrowser(BrowseMove::First)), - (X::BrowsePlane | X::BrowseRange | X::BrowseCodePoints, C::Last) => Some(Action::MoveBrowser(BrowseMove::Last)), - (X::BrowseRange | X::BrowseCodePoints, C::PageUp) => Some(Action::MoveBrowser(BrowseMove::LargeBackward)), - (X::BrowseRange | X::BrowseCodePoints, C::PageDown) => Some(Action::MoveBrowser(BrowseMove::LargeForward)), + (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::MoveUp) => Some(Action::MoveBrowser(BrowseMove::Up)), + (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::MoveDown) => Some(Action::MoveBrowser(BrowseMove::Down)), + (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::First) => Some(Action::MoveBrowser(BrowseMove::First)), + (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::Last) => Some(Action::MoveBrowser(BrowseMove::Last)), + (X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::PageUp) => Some(Action::MoveBrowser(BrowseMove::LargeBackward)), + (X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::PageDown) => Some(Action::MoveBrowser(BrowseMove::LargeForward)), (X::BrowseCodePoints, C::MoveLeft) => Some(Action::MoveBrowser(BrowseMove::Left)), (X::BrowseCodePoints, C::MoveRight) => Some(Action::MoveBrowser(BrowseMove::Right)), - (X::BrowsePlane | X::BrowseRange | X::BrowseCodePoints, C::Activate) => Some(Action::AdvanceBrowser), - (X::BrowseRange | X::BrowseCodePoints, C::Back) => Some(Action::BackBrowser), - (X::BrowsePlane | X::BrowseRange | X::BrowseCodePoints, C::Close) => Some(Action::CloseBrowser), + (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::Activate) => Some(Action::AdvanceBrowser), + (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::Back) => Some(Action::BackBrowser), + (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::Close) => Some(Action::CloseBrowser), _ => None, } } @@ -164,6 +166,7 @@ mod tests { #[rstest] #[case('p', BrowseLevel::Plane)] #[case('r', BrowseLevel::Range)] + #[case('b', BrowseLevel::Block)] #[case('c', BrowseLevel::CodePointTable)] fn browser_shortcuts_open_the_requested_level(#[case] key: char, #[case] level: BrowseLevel) { let state = AppState::new(); @@ -178,7 +181,6 @@ mod tests { } #[rstest] - #[case('b')] #[case('o')] fn former_shortcuts_are_unassigned(#[case] key: char) { let state = AppState::new(); @@ -474,9 +476,10 @@ mod tests { #[rstest] #[case(BrowseLevel::Range, Some(Action::BackBrowser))] + #[case(BrowseLevel::Block, Some(Action::BackBrowser))] #[case(BrowseLevel::CodePointTable, Some(Action::BackBrowser))] - #[case(BrowseLevel::Plane, None)] - fn backspace_goes_back_from_range_and_table_but_not_plane( + #[case(BrowseLevel::Plane, Some(Action::BackBrowser))] + fn backspace_returns_to_the_previous_screen_or_inspector( #[case] level: BrowseLevel, #[case] expected: Option, ) { @@ -553,6 +556,18 @@ mod tests { KeyModifiers::NONE, None )] + #[case::block_down( + BrowseLevel::Block, + KeyCode::Down, + KeyModifiers::NONE, + Some(BrowseMove::Down) + )] + #[case::block_forward( + BrowseLevel::Block, + KeyCode::Char('d'), + KeyModifiers::CONTROL, + Some(BrowseMove::LargeForward) + )] #[case::table_left( BrowseLevel::CodePointTable, KeyCode::Left, diff --git a/src/keybindings.rs b/src/keybindings.rs index c5a7e68..ccb731e 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use tui_input::backend::crossterm::to_input_request; use umbra::optional; -const ALL_COMMANDS: [Command; 23] = [ +const ALL_COMMANDS: [Command; 24] = [ Command::Quit, Command::Help, Command::Close, @@ -24,6 +24,7 @@ const ALL_COMMANDS: [Command; 23] = [ Command::OpenSearch, Command::BrowsePlanes, Command::BrowseRanges, + Command::BrowseBlocks, Command::BrowseCodePoints, Command::PreviousResult, Command::NextResult, @@ -39,6 +40,7 @@ pub enum Context { Search, BrowsePlane, BrowseRange, + BrowseBlock, BrowseCodePoints, Help, } @@ -51,6 +53,7 @@ impl Context { Self::Search => "search", Self::BrowsePlane => "browse_plane", Self::BrowseRange => "browse_range", + Self::BrowseBlock => "browse_block", Self::BrowseCodePoints => "browse_code_points", Self::Help => "help", } @@ -76,6 +79,7 @@ pub enum Command { OpenSearch, BrowsePlanes, BrowseRanges, + BrowseBlocks, BrowseCodePoints, PreviousResult, NextResult, @@ -122,6 +126,7 @@ impl Command { Self::OpenSearch => "search", Self::BrowsePlanes => "browse_planes", Self::BrowseRanges => "browse_ranges", + Self::BrowseBlocks => "browse_blocks", Self::BrowseCodePoints => "browse_code_points", Self::PreviousResult => "previous_result", Self::NextResult => "next_result", @@ -282,6 +287,9 @@ pub struct Keybindings { browse_range: BrowseRangeKeybindings, #[garde(dive)] #[nested] + browse_block: BrowseBlockKeybindings, + #[garde(dive)] + #[nested] browse_code_points: BrowseCodePointsKeybindings, #[garde(dive)] #[nested] @@ -296,6 +304,7 @@ impl Keybindings { self.search.append_bindings(&mut bindings); self.browse_plane.append_bindings(&mut bindings); self.browse_range.append_bindings(&mut bindings); + self.browse_block.append_bindings(&mut bindings); self.browse_code_points.append_bindings(&mut bindings); self.help.append_bindings(&mut bindings); bindings @@ -357,6 +366,7 @@ keybinding_context!(InspectorKeybindings, Context::Inspector, { search => (Command::OpenSearch, ["/"]), browse_planes => (Command::BrowsePlanes, ["p"]), browse_ranges => (Command::BrowseRanges, ["r"]), + browse_blocks => (Command::BrowseBlocks, ["b"]), browse_code_points => (Command::BrowseCodePoints, ["c"]), }); @@ -370,6 +380,7 @@ keybinding_context!(SearchKeybindings, Context::Search, { keybinding_context!(BrowsePlaneKeybindings, Context::BrowsePlane, { quit => (Command::Quit, ["q"]), close => (Command::Close, ["esc"]), + back => (Command::Back, ["backspace"]), move_up => (Command::MoveUp, ["k", "up"]), move_down => (Command::MoveDown, ["j", "down"]), first => (Command::First, ["g"]), @@ -390,6 +401,19 @@ keybinding_context!(BrowseRangeKeybindings, Context::BrowseRange, { activate => (Command::Activate, ["enter"]), }); +keybinding_context!(BrowseBlockKeybindings, Context::BrowseBlock, { + quit => (Command::Quit, ["q"]), + close => (Command::Close, ["esc"]), + back => (Command::Back, ["backspace"]), + move_up => (Command::MoveUp, ["k", "up"]), + move_down => (Command::MoveDown, ["j", "down"]), + page_up => (Command::PageUp, ["ctrl-u"]), + page_down => (Command::PageDown, ["ctrl-d"]), + first => (Command::First, ["g"]), + last => (Command::Last, ["G"]), + activate => (Command::Activate, ["enter"]), +}); + #[rustfmt::skip] keybinding_context!(BrowseCodePointsKeybindings, Context::BrowseCodePoints, { quit => (Command::Quit, ["q"]), @@ -693,6 +717,7 @@ impl ResolvedKeymap { Context::Search, Context::BrowsePlane, Context::BrowseRange, + Context::BrowseBlock, Context::BrowseCodePoints, Context::Help, ] { @@ -808,6 +833,22 @@ mod tests { ); } + #[test] + fn block_browser_bindings_can_be_customized() { + let keymap = + configured("[inspector]\nbrowse_blocks = ['B']\n[browse_block]\nmove_down = ['n']") + .unwrap(); + + assert_eq!( + keymap.resolve(Context::Inspector, plain('B')), + Some(Command::BrowseBlocks) + ); + assert_eq!( + keymap.resolve(Context::BrowseBlock, plain('n')), + Some(Command::MoveDown) + ); + } + #[test] fn defaults_prefer_vim_keys_for_directional_commands() { let keymap = ResolvedKeymap::default(); @@ -816,6 +857,7 @@ mod tests { Context::Inspector, Context::BrowsePlane, Context::BrowseRange, + Context::BrowseBlock, Context::BrowseCodePoints, Context::Help, ] { diff --git a/src/main.rs b/src/main.rs index 4ae42df..4fac8c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,7 @@ fn initial_state( Some(cli::Demo::Surrogate) => fixtures::surrogate(), Some(cli::Demo::BrowsePlanes) => fixtures::browse_planes(), Some(cli::Demo::BrowseRanges) => fixtures::browse_ranges(), + Some(cli::Demo::BrowseBlocks) => fixtures::browse_blocks(), Some(cli::Demo::BrowseCodePoints) => fixtures::browse_code_points(), Some(cli::Demo::BrowseSpecial) => fixtures::browse_special(), Some(cli::Demo::BrowsePlane16End) => fixtures::browse_plane_16_end(), diff --git a/src/ui.rs b/src/ui.rs index ac14e78..9375d31 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -83,11 +83,17 @@ mod tests { let browse = glyph_preview_request(Rect::new(0, 0, 100, 30), &fixtures::browse_code_points()) .expect("a selected code point has an inline glyph preview"); + let block = glyph_preview_request( + Rect::new(0, 0, 100, 30), + &fixtures::browse_block_code_points_short(), + ) + .expect("a block table selection has an inline glyph preview"); assert_eq!(search.code_point.value(), 0x2192); assert_eq!(search.placeholder, Rect::new(62, 7, 36, 21)); assert_eq!(browse.code_point.value(), 0x0041); assert_eq!(browse.placeholder, Rect::new(62, 9, 36, 19)); + assert_eq!(block.code_point.value(), 0x2ff5); } #[test] diff --git a/src/ui/browser.rs b/src/ui/browser.rs index d891ddd..00481d6 100644 --- a/src/ui/browser.rs +++ b/src/ui/browser.rs @@ -7,7 +7,7 @@ use ratatui::{ use crate::{ app::AppState, - browser::BrowseLevel, + browser::{BrowseLevel, BrowseState}, ui::{ key_value::{self, KeyValue}, layout::browser, @@ -62,11 +62,26 @@ pub fn render( render_range_context(frame, context, browse.cursor(), color_theme); } } + BrowseLevel::Block => { + render_block_navigator( + frame, + layout.navigator, + browse, + browse + .visible_list_items() + .expect("the block level has a list viewport"), + color_theme, + ui, + ); + if let Some(context) = layout.context { + render_block_context(frame, context, browse, color_theme); + } + } BrowseLevel::CodePointTable => { render_code_point_table( frame, layout.navigator, - browse.cursor(), + browse, browse .visible_table_rows() .expect("the code point table level has a row viewport"), @@ -86,6 +101,73 @@ pub fn render( } } +fn render_block_navigator( + frame: &mut Frame, + area: Rect, + browse: &BrowseState, + visible_items: std::ops::Range, + color_theme: &ColorTheme, + ui: &UiSettings, +) { + let selected = browse + .selected_block() + .expect("the block level has a selected block"); + let block = Block::bordered() + .title(" Browse · Blocks ") + .padding(Padding::horizontal(1)); + let content = block.inner(area); + let rows = visible_items + .clone() + .map(|index| { + let item = UnicodeDatabase::block(index).expect("the viewport contains valid blocks"); + let is_selected = item == selected; + let marker = ui.selection_marker(is_selected); + selectable_list_line( + Line::from(format!( + "{marker} {:06X}–{:06X} {}", + item.start().value(), + item.end().value(), + item.name() + )), + is_selected, + content.width, + color_theme.list.selection, + ) + }) + .collect::>(); + + frame.render_widget(Paragraph::new(rows).block(block), area); + frame.render_widget( + ViewportScrollbar::new(UnicodeDatabase::blocks().len(), visible_items) + .style(color_theme.base_style()), + scrollbar::area_after(content), + ); +} + +fn render_block_context( + frame: &mut Frame, + area: Rect, + browse: &BrowseState, + color_theme: &ColorTheme, +) { + let block = browse + .selected_block() + .expect("the block level has a selected block"); + let start = block.start().to_string(); + let end = block.end().to_string(); + let size = (block.end().value() - block.start().value() + 1).to_string(); + let plane = block.start().plane().to_string(); + let entries = [ + KeyValue::new("Name", block.name()), + KeyValue::new("Plane", &plane), + KeyValue::new("Start", &start), + KeyValue::new("End", &end), + KeyValue::new("Size", &size), + ]; + + key_value::render(frame, area, " Block Context ", 7, &entries, color_theme); +} + fn render_plane_navigator( frame: &mut Frame, area: Rect, @@ -218,20 +300,23 @@ fn render_range_context( fn render_code_point_table( frame: &mut Frame, area: Rect, - cursor: CodePoint, + browse: &BrowseState, visible_rows: std::ops::Range, color_theme: &ColorTheme, ui: &UiSettings, ) { - let range = PlaneRange::for_code_point(cursor); + let cursor = browse.cursor(); + let (page_start, page_end) = browse + .table_page() + .expect("the code point table has a visible page"); let mut rows = Vec::with_capacity(visible_rows.len() + 1); rows.push(Line::from(table_column_header())); rows.extend(visible_rows.map(|row| { - let row_start = range.start().value() + row as u32 * 16; + let row_start = page_start.value() + row as u32 * 16; let mut spans = vec![Span::raw(format!("{row_start:06X} "))]; for column in 0..16 { - let offset = (row * 16 + column) as u8; - let code_point = range.code_point(offset); + let code_point = CodePoint::new(row_start + column as u32) + .expect("table pages contain valid code points"); let selected = code_point == cursor; let cell = table_cell(code_point, selected, ui); spans.push(if selected { @@ -243,7 +328,11 @@ fn render_code_point_table( Line::from(spans) })); - let title = format!(" Browse · Code Points · {}–{} ", range.start(), range.end()); + let title = if browse.is_block_table() { + format!(" Browse · Block Code Points · {page_start}–{page_end} ") + } else { + format!(" Browse · Code Points · {page_start}–{page_end} ") + }; frame.render_widget( Paragraph::new(rows).block( Block::bordered() diff --git a/src/ui/help.rs b/src/ui/help.rs index cd5b6a5..2640de3 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -340,6 +340,7 @@ fn help_items(context: Context) -> Vec { item(C::OpenSearch, "Open search"), item(C::BrowsePlanes, "Browse planes"), item(C::BrowseRanges, "Browse ranges"), + item(C::BrowseBlocks, "Browse blocks"), item(C::BrowseCodePoints, "Browse code points"), item(C::Quit, "Quit"), ], @@ -356,7 +357,8 @@ fn help_items(context: Context) -> Vec { item(C::First, "Select the first plane"), item(C::Last, "Select the last plane"), item(C::Activate, "Open ranges in the selected plane"), - item(C::Close, "Close browse"), + item(C::Back, "Return to Inspector"), + item(C::Close, "Cancel browse"), item(C::Quit, "Quit"), ], Context::BrowseRange => vec![ @@ -367,8 +369,20 @@ fn help_items(context: Context) -> Vec { item(C::First, "Select the first range"), item(C::Last, "Select the last range"), item(C::Activate, "Open code points in the selected range"), - item(C::Back, "Return to planes"), - item(C::Close, "Close browse"), + item(C::Back, "Return to the previous screen"), + item(C::Close, "Cancel browse"), + item(C::Quit, "Quit"), + ], + Context::BrowseBlock => vec![ + item(C::MoveUp, "Select the previous block"), + item(C::MoveDown, "Select the next block"), + item(C::PageUp, "Move backward by a large step"), + item(C::PageDown, "Move forward by a large step"), + item(C::First, "Select the first block"), + item(C::Last, "Select the last block"), + item(C::Activate, "Open code points in the selected block"), + item(C::Back, "Return to Inspector"), + item(C::Close, "Cancel browse"), item(C::Quit, "Quit"), ], Context::BrowseCodePoints => vec![ @@ -378,11 +392,14 @@ fn help_items(context: Context) -> Vec { item(C::MoveDown, "Move to the row below"), item(C::PageUp, "Move backward by a large step"), item(C::PageDown, "Move forward by a large step"), - item(C::First, "Move to the first code point in the range"), - item(C::Last, "Move to the last code point in the range"), + item( + C::First, + "Move to the first code point in the range or block", + ), + item(C::Last, "Move to the last code point in the range or block"), item(C::Activate, "Inspect the selected code point"), - item(C::Back, "Return to ranges"), - item(C::Close, "Close browse"), + item(C::Back, "Return to the previous screen"), + item(C::Close, "Cancel browse"), item(C::Quit, "Quit"), ], Context::Help => vec![ @@ -420,7 +437,7 @@ fn short_help_items(context: Context) -> Vec { short(&[C::First, C::Last], "Ends", 3), short(&[C::CopyValue], "Copy", 1), short(&[C::OpenSearch], "Search", 2), - short(&[C::BrowsePlanes, C::BrowseRanges, C::BrowseCodePoints], "Browse", 2), + short(&[C::BrowsePlanes, C::BrowseRanges, C::BrowseBlocks, C::BrowseCodePoints], "Browse", 2), short(&[C::Quit], "Quit", 0), short(&[C::Help], "Help", 0), ], @@ -435,6 +452,7 @@ fn short_help_items(context: Context) -> Vec { short(&[C::MoveUp, C::MoveDown], "Move", 1), short(&[C::First, C::Last], "Ends", 3), short(&[C::Activate], "Ranges", 1), + short(&[C::Back], "Back", 1), short(&[C::Close], "Close", 0), short(&[C::Quit], "Quit", 2), short(&[C::Help], "Help", 0), @@ -449,6 +467,16 @@ fn short_help_items(context: Context) -> Vec { short(&[C::Quit], "Quit", 2), short(&[C::Help], "Help", 0), ], + Context::BrowseBlock => vec![ + short(&[C::MoveUp, C::MoveDown], "Move", 1), + short(&[C::PageUp, C::PageDown], "Jump", 3), + short(&[C::First, C::Last], "Ends", 3), + short(&[C::Activate], "Points", 1), + short(&[C::Back], "Back", 1), + short(&[C::Close], "Close", 0), + short(&[C::Quit], "Quit", 2), + short(&[C::Help], "Help", 0), + ], Context::BrowseCodePoints => vec![ short(&[C::MoveLeft, C::MoveRight, C::MoveUp, C::MoveDown], "Move", 1), short(&[C::PageUp, C::PageDown], "Jump", 3), @@ -520,6 +548,7 @@ fn context_label(context: Context) -> &'static str { Context::Search => "Search", Context::BrowsePlane => "Browse Planes", Context::BrowseRange => "Browse Ranges", + Context::BrowseBlock => "Browse Blocks", Context::BrowseCodePoints => "Browse Code Points", Context::Help => "Help", } diff --git a/src/ui/render.rs b/src/ui/render.rs index 26abbc0..2fdd3c6 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -758,6 +758,38 @@ mod tests { insta::assert_snapshot!(render_to_text(&state, width, height)); } + #[test] + fn browse_blocks_standard() { + let state = fixtures::browse_blocks(); + let (width, height) = STANDARD_SIZE; + + insta::assert_snapshot!(render_to_text(&state, width, height)); + } + + #[test] + fn browse_blocks_from_gap_minimum() { + let state = fixtures::browse_blocks_from_gap(); + let (width, height) = MINIMUM_SIZE; + + insta::assert_snapshot!(render_to_text(&state, width, height)); + } + + #[test] + fn browse_block_code_points_short_minimum() { + let state = fixtures::browse_block_code_points_short(); + let (width, height) = MINIMUM_SIZE; + + insta::assert_snapshot!(render_to_text(&state, width, height)); + } + + #[test] + fn browse_block_code_points_long_end_wide() { + let state = fixtures::browse_block_code_points_long_end(); + let (width, height) = WIDE_SIZE; + + insta::assert_snapshot!(render_to_text(&state, width, height)); + } + #[test] fn browse_code_points_standard() { let state = fixtures::browse_code_points(); diff --git a/src/ui/snapshots/sauva__ui__render__tests__browse_block_code_points_long_end_wide.snap b/src/ui/snapshots/sauva__ui__render__tests__browse_block_code_points_long_end_wide.snap new file mode 100644 index 0000000..7284b98 --- /dev/null +++ b/src/ui/snapshots/sauva__ui__render__tests__browse_block_code_points_long_end_wide.snap @@ -0,0 +1,44 @@ +--- +source: src/ui/render.rs +expression: "render_to_text(&state, width, height)" +--- +┌ Browse · Block Code Points · U+2A600–U+2A6DF ────────────┐┌ Selection Preview ───────────────────────────────────────────────────────────┐ +│ 0 1 2 3 4 5 6 7 8 9 A B C D E F ││ Character 𪛟 │ +│ 02A600 𪘀 𪘁 𪘂 𪘃 𪘄 𪘅 𪘆 𪘇 𪘈 𪘉 𪘊 𪘋 𪘌 𪘍 𪘎 𪘏 ││ Code Point U+2A6DF │ +│ 02A610 𪘐 𪘑 𪘒 𪘓 𪘔 𪘕 𪘖 𪘗 𪘘 𪘙 𪘚 𪘛 𪘜 𪘝 𪘞 𪘟 ││ Primary Name CJK UNIFIED IDEOGRAPH-2A6DF │ +│ 02A620 𪘠 𪘡 𪘢 𪘣 𪘤 𪘥 𪘦 𪘧 𪘨 𪘩 𪘪 𪘫 𪘬 𪘭 𪘮 𪘯 ││ Block CJK Unified Ideographs Extension B │ +│ 02A630 𪘰 𪘱 𪘲 𪘳 𪘴 𪘵 𪘶 𪘷 𪘸 𪘹 𪘺 𪘻 𪘼 𪘽 𪘾 𪘿 ││ General Category Lo — Other Letter │ +│ 02A640 𪙀 𪙁 𪙂 𪙃 𪙄 𪙅 𪙆 𪙇 𪙈 𪙉 𪙊 𪙋 𪙌 𪙍 𪙎 𪙏 ││ │ +│ 02A650 𪙐 𪙑 𪙒 𪙓 𪙔 𪙕 𪙖 𪙗 𪙘 𪙙 𪙚 𪙛 𪙜 𪙝 𪙞 𪙟 ││ │ +│ 02A660 𪙠 𪙡 𪙢 𪙣 𪙤 𪙥 𪙦 𪙧 𪙨 𪙩 𪙪 𪙫 𪙬 𪙭 𪙮 𪙯 ││ │ +│ 02A670 𪙰 𪙱 𪙲 𪙳 𪙴 𪙵 𪙶 𪙷 𪙸 𪙹 𪙺 𪙻 𪙼 𪙽 𪙾 𪙿 ││ │ +│ 02A680 𪚀 𪚁 𪚂 𪚃 𪚄 𪚅 𪚆 𪚇 𪚈 𪚉 𪚊 𪚋 𪚌 𪚍 𪚎 𪚏 ││ │ +│ 02A690 𪚐 𪚑 𪚒 𪚓 𪚔 𪚕 𪚖 𪚗 𪚘 𪚙 𪚚 𪚛 𪚜 𪚝 𪚞 𪚟 ││ │ +│ 02A6A0 𪚠 𪚡 𪚢 𪚣 𪚤 𪚥 𪚦 𪚧 𪚨 𪚩 𪚪 𪚫 𪚬 𪚭 𪚮 𪚯 ││ │ +│ 02A6B0 𪚰 𪚱 𪚲 𪚳 𪚴 𪚵 𪚶 𪚷 𪚸 𪚹 𪚺 𪚻 𪚼 𪚽 𪚾 𪚿 ││ │ +│ 02A6C0 𪛀 𪛁 𪛂 𪛃 𪛄 𪛅 𪛆 𪛇 𪛈 𪛉 𪛊 𪛋 𪛌 𪛍 𪛎 𪛏 ││ │ +│ 02A6D0 𪛐 𪛑 𪛒 𪛓 𪛔 𪛕 𪛖 𪛗 𪛘 𪛙 𪛚 𪛛 𪛜 𪛝 𪛞 𪛟 ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────────────────────────┘ + h/l/k/j: Move C-u/C-d: Jump g/G: Ends Enter: Inspect BS: Back Esc: Close q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__browse_block_code_points_short_minimum.snap b/src/ui/snapshots/sauva__ui__render__tests__browse_block_code_points_short_minimum.snap new file mode 100644 index 0000000..ed1a878 --- /dev/null +++ b/src/ui/snapshots/sauva__ui__render__tests__browse_block_code_points_short_minimum.snap @@ -0,0 +1,20 @@ +--- +source: src/ui/render.rs +expression: "render_to_text(&state, width, height)" +--- +┌ Browse · Block Code Points · U+2FF0–U+2FFF ──────────────┐ +│ 0 1 2 3 4 5 6 7 8 9 A B C D E F │ +│ 002FF0 ⿰ ⿱ ⿲ ⿳ ⿴ ⿵ ⿶ ⿷ ⿸ ⿹ ⿺ ⿻ ⿼ ⿽ ⿾ ⿿ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────┘ + h/l/k/j: Move Enter: Inspect Esc: Close F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__browse_blocks_from_gap_minimum.snap b/src/ui/snapshots/sauva__ui__render__tests__browse_blocks_from_gap_minimum.snap new file mode 100644 index 0000000..2aa9dba --- /dev/null +++ b/src/ui/snapshots/sauva__ui__render__tests__browse_blocks_from_gap_minimum.snap @@ -0,0 +1,20 @@ +--- +source: src/ui/render.rs +expression: "render_to_text(&state, width, height)" +--- +┌ Browse · Blocks ─────────────────────────────────────────┐ +│ 002A00–002AFF Supplemental Mathematical Operators │ +│ 002B00–002BFF Miscellaneous Symbols and Arrows │ +│ 002C00–002C5F Glagolitic │ +│ 002C60–002C7F Latin Extended-C ││ +│ 002C80–002CFF Coptic │ +│ 002D00–002D2F Georgian Supplement │ +│ 002D30–002D7F Tifinagh │ +│ 002D80–002DDF Ethiopic Extended │ +│ 002DE0–002DFF Cyrillic Extended-A │ +│ 002E00–002E7F Supplemental Punctuation │ +│ 002E80–002EFF CJK Radicals Supplement │ +│ 002F00–002FDF Kangxi Radicals │ +│ 002FF0–002FFF Ideographic Description Characters │ +└──────────────────────────────────────────────────────────┘ + k/j: Move Enter: Points BS: Back Esc: Close F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__browse_blocks_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__browse_blocks_standard.snap new file mode 100644 index 0000000..9953ec3 --- /dev/null +++ b/src/ui/snapshots/sauva__ui__render__tests__browse_blocks_standard.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/render.rs +expression: "render_to_text(&state, width, height)" +--- +┌ Browse · Blocks ─────────────────────────────────────────┐┌ Block Context ───────────────────────┐ +│ 000000–00007F Basic Latin │││ Name Basic Latin │ +│ 000080–0000FF Latin-1 Supplement │││ Plane 0 │ +│ 000100–00017F Latin Extended-A ││ Start U+0000 │ +│ 000180–00024F Latin Extended-B ││ End U+007F │ +│ 000250–0002AF IPA Extensions ││ Size 128 │ +│ 0002B0–0002FF Spacing Modifier Letters ││ │ +│ 000300–00036F Combining Diacritical Marks ││ │ +│ 000370–0003FF Greek and Coptic ││ │ +│ 000400–0004FF Cyrillic ││ │ +│ 000500–00052F Cyrillic Supplement ││ │ +│ 000530–00058F Armenian ││ │ +│ 000590–0005FF Hebrew ││ │ +│ 000600–0006FF Arabic ││ │ +│ 000700–00074F Syriac ││ │ +│ 000750–00077F Arabic Supplement ││ │ +│ 000780–0007BF Thaana ││ │ +│ 0007C0–0007FF NKo ││ │ +│ 000800–00083F Samaritan ││ │ +│ 000840–00085F Mandaic ││ │ +│ 000860–00086F Syriac Supplement ││ │ +│ 000870–00089F Arabic Extended-B ││ │ +│ 0008A0–0008FF Arabic Extended-A ││ │ +│ 000900–00097F Devanagari ││ │ +│ 000980–0009FF Bengali ││ │ +│ 000A00–000A7F Gurmukhi ││ │ +│ 000A80–000AFF Gujarati ││ │ +│ 000B00–000B7F Oriya ││ │ +└──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ + k/j: Move C-u/C-d: Jump g/G: Ends Enter: Points BS: Back Esc: Close q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__browse_planes_minimum_at_last_plane.snap b/src/ui/snapshots/sauva__ui__render__tests__browse_planes_minimum_at_last_plane.snap index 8f9ae59..a648513 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__browse_planes_minimum_at_last_plane.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__browse_planes_minimum_at_last_plane.snap @@ -17,4 +17,4 @@ expression: "render_to_text(&state, width, height)" │ Plane 15 Supplementary Private Use Area-A ││ │ Plane 16 Supplementary Private Use Area-B ││ └──────────────────────────────────────────────────────────┘ - k/j: Move Enter: Ranges Esc: Close q: Quit F1: Help + k/j: Move Enter: Ranges BS: Back Esc: Close F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__browse_planes_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__browse_planes_standard.snap index e8c4472..9cfff01 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__browse_planes_standard.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__browse_planes_standard.snap @@ -31,4 +31,4 @@ expression: "render_to_text(&state, width, height)" │ ││ │ │ ││ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ - k/j: Move g/G: Ends Enter: Ranges Esc: Close q: Quit F1: Help + k/j: Move g/G: Ends Enter: Ranges BS: Back Esc: Close q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__browse_planes_wide_at_middle_plane.snap b/src/ui/snapshots/sauva__ui__render__tests__browse_planes_wide_at_middle_plane.snap index 643be65..3a1ec2b 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__browse_planes_wide_at_middle_plane.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__browse_planes_wide_at_middle_plane.snap @@ -41,4 +41,4 @@ expression: "render_to_text(&state, width, height)" │ ││ │ │ ││ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────────────────────────┘ - k/j: Move g/G: Ends Enter: Ranges Esc: Close q: Quit F1: Help + k/j: Move g/G: Ends Enter: Ranges BS: Back Esc: Close q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__details_aliases_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__details_aliases_standard.snap index 7221eed..c32f8c8 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__details_aliases_standard.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__details_aliases_standard.snap @@ -31,4 +31,4 @@ expression: "render_to_text(&state, width, height)" │ Unicode Escape \u0000 ││ │ │ ││ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ - h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/c: Browse q: Quit F1: Help + h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/b/c: Browse q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__details_canonical_decomposition_standard_at_normalization.snap b/src/ui/snapshots/sauva__ui__render__tests__details_canonical_decomposition_standard_at_normalization.snap index 0c79fd8..5edb336 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__details_canonical_decomposition_standard_at_normalization.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__details_canonical_decomposition_standard_at_normalization.snap @@ -31,4 +31,4 @@ expression: "render_after_inspector_move(&state, width, height, InspectorMove::L │ Data │││ │ │ Unicode Version 17.0.0 │││ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ - h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/c: Browse q: Quit F1: Help + h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/b/c: Browse q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__details_compatibility_decomposition_wide.snap b/src/ui/snapshots/sauva__ui__render__tests__details_compatibility_decomposition_wide.snap index 0b8f1e5..dc83269 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__details_compatibility_decomposition_wide.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__details_compatibility_decomposition_wide.snap @@ -41,4 +41,4 @@ expression: "render_to_text(&state, width, height)" │ ││ │ │ ││ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────────────────────────┘ - h/l: Point k/j: Field C-u/C-d: Page g/G: Ends y: Copy /: Search p/r/c: Browse q: Quit F1: Help + h/l: Point k/j: Field C-u/C-d: Page g/G: Ends y: Copy /: Search p/r/b/c: Browse q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__glyph_combining_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__glyph_combining_standard.snap index fb8d122..19415c8 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__glyph_combining_standard.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__glyph_combining_standard.snap @@ -31,4 +31,4 @@ expression: "render_to_text(&state, width, height)" │ ││ 􎻮ͩ̅􎻮ͩ̍􎻮ͩ̎􎻮ͩ̐􎻮ͩ̒􎻮ͩ̽􎻮ͩ̾􎻮ͩ̿􎻮ͩ͆􎻮ͩ͊􎻮ͩ͋􎻮ͩ͌􎻮ͩ͐􎻮ͩ͑􎻮ͩ͒􎻮ͩ͗􎻮ͩ͛􎻮ͩͣ􎻮ͩͤ􎻮ͩͥ􎻮ͩͦ􎻮ͩͧ􎻮ͩͨ􎻮ͩͩ􎻮ͩͪ􎻮ͩͫ􎻮ͩͬ􎻮ͩͭ􎻮ͩͮ􎻮ͩͯ􎻮ͩ҃􎻮ͩ҄􎻮ͩ҅􎻮ͩ҆􎻮ͩ҇􎻮ͩ֒ │ │ Normalization ││ 􎻮ͪ̅􎻮ͪ̍􎻮ͪ̎􎻮ͪ̐􎻮ͪ̒􎻮ͪ̽􎻮ͪ̾􎻮ͪ̿􎻮ͪ͆􎻮ͪ͊􎻮ͪ͋􎻮ͪ͌􎻮ͪ͐􎻮ͪ͑􎻮ͪ͒􎻮ͪ͗􎻮ͪ͛􎻮ͪͣ􎻮ͪͤ􎻮ͪͥ􎻮ͪͦ􎻮ͪͧ􎻮ͪͨ􎻮ͪͩ􎻮ͪͪ􎻮ͪͫ􎻮ͪͬ􎻮ͪͭ􎻮ͪͮ􎻮ͪͯ􎻮ͪ҃􎻮ͪ҄􎻮ͪ҅􎻮ͪ҆􎻮ͪ҇􎻮ͪ֒ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ - h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/c: Browse q: Quit F1: Help + h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/b/c: Browse q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__glyph_missing_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__glyph_missing_standard.snap index 7465d37..da71038 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__glyph_missing_standard.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__glyph_missing_standard.snap @@ -31,4 +31,4 @@ expression: "render_to_text(&state, width, height)" │ Unicode Escape \U0010FFFF ││ │ │ ││ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ - h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/c: Browse q: Quit F1: Help + h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/b/c: Browse q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__glyph_wide.snap b/src/ui/snapshots/sauva__ui__render__tests__glyph_wide.snap index 635da88..d9ffb28 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__glyph_wide.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__glyph_wide.snap @@ -41,4 +41,4 @@ expression: "render_to_text(&state, width, height)" │ ││ 􎻮҆̅􎻮҆̍􎻮҆̎􎻮҆̐􎻮҆̒􎻮҆̽􎻮҆̾􎻮҆̿􎻮҆͆􎻮҆͊􎻮҆͋􎻮҆͌􎻮҆͐􎻮҆͑􎻮҆͒􎻮҆͗􎻮҆͛􎻮҆ͣ􎻮҆ͤ􎻮҆ͥ􎻮҆ͦ􎻮҆ͧ􎻮҆ͨ􎻮҆ͩ􎻮҆ͪ􎻮҆ͫ􎻮҆ͬ􎻮҆ͭ􎻮҆ͮ􎻮҆ͯ􎻮҆҃􎻮҆҄􎻮҆҅􎻮҆҆􎻮҆҇􎻮҆֒􎻮҆֓􎻮҆֔􎻮҆֕􎻮҆֗􎻮҆֘􎻮҆֙􎻮҆֜􎻮҆֝􎻮҆֞􎻮҆֟􎻮҆֠􎻮҆֡􎻮҆֨􎻮҆֩􎻮҆֫􎻮҆֬􎻮҆֯􎻮҆ׄ􎻮҆ؐ􎻮҆ؑ􎻮҆ؒ􎻮҆ؓ􎻮҆ؔ􎻮҆ؕ􎻮҆ؖ􎻮҆ؗ􎻮҆ٗ􎻮҆٘􎻮҆ٙ􎻮҆ٚ􎻮҆ٛ􎻮҆ٝ􎻮҆ٞ􎻮҆ۖ􎻮҆ۗ􎻮҆ۘ􎻮҆ۙ􎻮҆ۚ􎻮҆ۛ􎻮҆ۜ │ │ ││ 􎻮҇̅􎻮҇̍􎻮҇̎􎻮҇̐􎻮҇̒􎻮҇̽􎻮҇̾􎻮҇̿􎻮҇͆􎻮҇͊􎻮҇͋􎻮҇͌􎻮҇͐􎻮҇͑􎻮҇͒􎻮҇͗􎻮҇͛􎻮҇ͣ􎻮҇ͤ􎻮҇ͥ􎻮҇ͦ􎻮҇ͧ􎻮҇ͨ􎻮҇ͩ􎻮҇ͪ􎻮҇ͫ􎻮҇ͬ􎻮҇ͭ􎻮҇ͮ􎻮҇ͯ􎻮҇҃􎻮҇҄􎻮҇҅􎻮҇҆􎻮҇҇􎻮҇֒􎻮҇֓􎻮҇֔􎻮҇֕􎻮҇֗􎻮҇֘􎻮҇֙􎻮҇֜􎻮҇֝􎻮҇֞􎻮҇֟􎻮҇֠􎻮҇֡􎻮҇֨􎻮҇֩􎻮҇֫􎻮҇֬􎻮҇֯􎻮҇ׄ􎻮҇ؐ􎻮҇ؑ􎻮҇ؒ􎻮҇ؓ􎻮҇ؔ􎻮҇ؕ􎻮҇ؖ􎻮҇ؗ􎻮҇ٗ􎻮҇٘􎻮҇ٙ􎻮҇ٚ􎻮҇ٛ􎻮҇ٝ􎻮҇ٞ􎻮҇ۖ􎻮҇ۗ􎻮҇ۘ􎻮҇ۙ􎻮҇ۚ􎻮҇ۛ􎻮҇ۜ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────────────────────────┘ - h/l: Point k/j: Field C-u/C-d: Page g/G: Ends y: Copy /: Search p/r/c: Browse q: Quit F1: Help + h/l: Point k/j: Field C-u/C-d: Page g/G: Ends y: Copy /: Search p/r/b/c: Browse q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__inspector_help_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__inspector_help_standard.snap index bfb55bc..7e926f4 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__inspector_help_standard.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__inspector_help_standard.snap @@ -24,11 +24,11 @@ expression: "render_to_text(&state, width, height)" │ Open search │ │

Browse planes │ │ Browse ranges │ +│ Browse blocks │ │ Browse code points │ │ Quit │ │ Open or close help │ │ │ │ │ -│ │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘ k/j: Move C-u/C-d: Page g/G: Ends Esc/F1: Close C-c: Quit diff --git a/src/ui/snapshots/sauva__ui__render__tests__startup_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__startup_standard.snap index e284954..6f378a7 100644 --- a/src/ui/snapshots/sauva__ui__render__tests__startup_standard.snap +++ b/src/ui/snapshots/sauva__ui__render__tests__startup_standard.snap @@ -31,4 +31,4 @@ expression: "render_to_text(&state, width, height)" │ ││ 􎻮ͩ̅􎻮ͩ̍􎻮ͩ̎􎻮ͩ̐􎻮ͩ̒􎻮ͩ̽􎻮ͩ̾􎻮ͩ̿􎻮ͩ͆􎻮ͩ͊􎻮ͩ͋􎻮ͩ͌􎻮ͩ͐􎻮ͩ͑􎻮ͩ͒􎻮ͩ͗􎻮ͩ͛􎻮ͩͣ􎻮ͩͤ􎻮ͩͥ􎻮ͩͦ􎻮ͩͧ􎻮ͩͨ􎻮ͩͩ􎻮ͩͪ􎻮ͩͫ􎻮ͩͬ􎻮ͩͭ􎻮ͩͮ􎻮ͩͯ􎻮ͩ҃􎻮ͩ҄􎻮ͩ҅􎻮ͩ҆􎻮ͩ҇􎻮ͩ֒ │ │ Normalization ││ 􎻮ͪ̅􎻮ͪ̍􎻮ͪ̎􎻮ͪ̐􎻮ͪ̒􎻮ͪ̽􎻮ͪ̾􎻮ͪ̿􎻮ͪ͆􎻮ͪ͊􎻮ͪ͋􎻮ͪ͌􎻮ͪ͐􎻮ͪ͑􎻮ͪ͒􎻮ͪ͗􎻮ͪ͛􎻮ͪͣ􎻮ͪͤ􎻮ͪͥ􎻮ͪͦ􎻮ͪͧ􎻮ͪͨ􎻮ͪͩ􎻮ͪͪ􎻮ͪͫ􎻮ͪͬ􎻮ͪͭ􎻮ͪͮ􎻮ͪͯ􎻮ͪ҃􎻮ͪ҄􎻮ͪ҅􎻮ͪ҆􎻮ͪ҇􎻮ͪ֒ │ └──────────────────────────────────────────────────────────┘└──────────────────────────────────────┘ - h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/c: Browse q: Quit F1: Help + h/l: Point k/j: Field C-u/C-d: Page y: Copy /: Search p/r/b/c: Browse q: Quit F1: Help diff --git a/src/unicode.rs b/src/unicode.rs index 31083e9..67da757 100644 --- a/src/unicode.rs +++ b/src/unicode.rs @@ -9,7 +9,7 @@ pub mod plane; mod properties; pub use code_point::{CodePoint, CodePointStructure, InvalidCodePoint}; -pub use database::{GeneralCategory, UnicodeDatabase, UnicodeRecord}; +pub use database::{GeneralCategory, UnicodeBlock, UnicodeDatabase, UnicodeRecord}; pub use decomposition::Decomposition; pub use decomposition::DecompositionType; pub use display::{DisplayKind, DisplayRepresentation}; diff --git a/src/unicode/database.rs b/src/unicode/database.rs index fbfdafa..aecaf2e 100644 --- a/src/unicode/database.rs +++ b/src/unicode/database.rs @@ -11,6 +11,33 @@ use crate::unicode::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnicodeDatabase; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnicodeBlock { + index: usize, +} + +impl UnicodeBlock { + pub const fn index(self) -> usize { + self.index + } + + pub fn start(self) -> CodePoint { + CodePoint::new(BLOCKS[self.index].0).expect("generated block starts are valid code points") + } + + pub fn end(self) -> CodePoint { + CodePoint::new(BLOCKS[self.index].1).expect("generated block ends are valid code points") + } + + pub const fn name(self) -> &'static str { + BLOCKS[self.index].2 + } + + pub fn contains(self, code_point: CodePoint) -> bool { + self.start() <= code_point && code_point <= self.end() + } +} + impl UnicodeDatabase { pub const fn version() -> &'static str { UNICODE_VERSION @@ -21,7 +48,7 @@ impl UnicodeDatabase { code_point, primary_name: primary_name(code_point.value()), general_category: general_category(code_point.value()), - block: range_value(BLOCKS, code_point.value()), + block: Self::block_containing(code_point).map(UnicodeBlock::name), script: range_value(SCRIPTS, code_point.value()).unwrap_or("Unknown"), age: range_value(AGES, code_point.value()), default_ignorable: range_contains(DEFAULT_IGNORABLES, code_point.value()), @@ -60,6 +87,23 @@ impl UnicodeDatabase { }) } + pub fn blocks() -> impl ExactSizeIterator { + (0..BLOCKS.len()).map(|index| UnicodeBlock { index }) + } + + pub fn block(index: usize) -> Option { + (index < BLOCKS.len()).then_some(UnicodeBlock { index }) + } + + pub fn block_at_or_after(code_point: CodePoint) -> Option { + let index = BLOCKS.partition_point(|&(_, end, _)| end < code_point.value()); + Self::block(index) + } + + pub fn block_containing(code_point: CodePoint) -> Option { + Self::block_at_or_after(code_point).filter(|block| block.contains(code_point)) + } + pub fn block_names_in_range( start: CodePoint, end: CodePoint, @@ -732,6 +776,43 @@ mod tests { assert_eq!(record.script(), script); } + #[test] + fn exposes_named_blocks_in_code_point_order() { + let mut blocks = UnicodeDatabase::blocks(); + assert_eq!(blocks.len(), 346); + let first = blocks.next().unwrap(); + assert_eq!(first.index(), 0); + assert_eq!(first.name(), "Basic Latin"); + assert_eq!(first.start().value(), 0x0000); + assert_eq!(first.end().value(), 0x007f); + + let last = blocks.last().unwrap(); + assert_eq!(last.name(), "Supplementary Private Use Area-B"); + assert_eq!(last.end().value(), CodePoint::MAX_VALUE); + assert_eq!(UnicodeDatabase::block(346), None); + } + + #[test] + fn distinguishes_block_membership_from_the_next_named_block() { + let member = CodePoint::new(0x2fdf).unwrap(); + let gap = CodePoint::new(0x2fe0).unwrap(); + let next = CodePoint::new(0x2ff0).unwrap(); + let current_block = UnicodeDatabase::block_containing(member).unwrap(); + + assert_eq!(current_block.name(), "Kangxi Radicals"); + assert!(current_block.contains(member)); + assert!(!current_block.contains(gap)); + assert_eq!(UnicodeDatabase::block_containing(gap), None); + assert_eq!( + UnicodeDatabase::block_at_or_after(gap).unwrap().name(), + "Ideographic Description Characters" + ); + assert_eq!( + UnicodeDatabase::block_containing(next), + UnicodeDatabase::block_at_or_after(gap) + ); + } + #[test] fn lists_block_names_that_overlap_a_code_point_range() { let names = UnicodeDatabase::block_names_in_range(