Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,30 +46,46 @@ sauva
```
Sauva - Terminal Unicode Explorer 🪄

Usage: sauva [OPTIONS] [CODE_POINT]
Usage: sauva [OPTIONS] [INPUT]

Arguments:
[CODE_POINT] Code point to inspect: character, U+XXXX, 0xXXXX, or hexadecimal
[INPUT] Text or code point to inspect

Options:
-t, --text <TEXT> Treat the input as literal text, without code point notation parsing
-g, --graphics <MODE> Control glyph preview graphics [default: auto] [possible values: auto, force, iterm2, off]
-h, --help Print help
-V, --version Print version
```

#### Specifying `CODE_POINT`
#### Specifying `INPUT`

The initial code point can be given as one character, `U+` notation, `0x` notation, or two to six hexadecimal digits.
One character opens its code point directly in the Inspector. A sequence of characters opens the Sequence view, where each constituent code point can be selected and inspected.

```
sauva あ
sauva 'Á👩‍💻'
```

Code points can also be given in `U+` notation, `0x` notation, or as two to six hexadecimal digits.

```
sauva U+2192
sauva 0x1F600
sauva 1F600
```

A one-character argument is treated as the character itself. Use a prefix for a one-digit hexadecimal value, such as `U+A` or `0xA`.

An input made entirely of hexadecimal digits keeps the code point interpretation. Use `--text` to force literal text when the input would otherwise be interpreted as notation:

```
sauva -t 41
sauva --text U+2192
```

`--text` still opens the Inspector directly when its value contains only one code point. Empty text is rejected.

#### `-g, --graphics <MODE>`

- `auto` enables the Kitty graphics protocol for detected kitty and Ghostty terminals.
Expand Down Expand Up @@ -132,13 +148,13 @@ These are the main built-in controls. The available controls depend on the curre
| --- | --- |
| <kbd>F1</kbd> | Open or close contextual help |
| <kbd>Ctrl+c</kbd> | Quit from any view |
| <kbd>q</kbd> | Quit from the Inspector or Browse view |
| <kbd>Esc</kbd> | Cancel Search or Browse; quit from the Inspector |
| <kbd>q</kbd> | Quit from the Inspector, Sequence, or Browse view |
| <kbd>Esc</kbd> | Cancel Search or Browse; quit from the Inspector or Sequence view |
| <kbd>h</kbd> <kbd>j</kbd> <kbd>k</kbd> <kbd>l</kbd> or arrow keys | Move the selection |
| <kbd>/</kbd> | Search by character, code point, or Unicode name |
| <kbd>p</kbd> <kbd>r</kbd> <kbd>b</kbd> <kbd>c</kbd> | Browse planes, ranges, blocks, or code points |
| <kbd>Enter</kbd> | Open the selected item; confirm a code point from the table |
| <kbd>Backspace</kbd> | Return to the previous Browse screen, or cancel from the first screen |
| <kbd>Enter</kbd> | Open the selected item; inspect a code point from Sequence or Browse |
| <kbd>Backspace</kbd> | Return to the previous Browse screen, or return from Inspector to the input Sequence |
| <kbd>y</kbd> | Copy the selected Inspector value |

Press <kbd>F1</kbd> to view all controls for the current screen.
Expand All @@ -156,6 +172,7 @@ help = ["f2"]

[keybindings.inspector]
next_code_point = ["l", "right", "n"]
back = ["backspace"]
browse_planes = []
```

Expand Down Expand Up @@ -190,7 +207,25 @@ For each code point, sauva tries configured normal fonts, the system default tex

## Screenshots

<img src="./img/inspector.png" width="400" alt="Inspector view"> <img src="./img/plane.png" width="400" alt="Plane browser"> <img src="./img/range.png" width="400" alt="Range browser"> <img src="./img/code-points.png" width="400" alt="Code point browser"> <img src="./img/filter.png" width="400" alt="Search results">
### Inspector

<img src="./img/inspector.png" width="400" alt="Inspector view">

### Browse Plane / Range / Code point

<img src="./img/plane.png" width="400" alt="Plane browser"> <img src="./img/range.png" width="400" alt="Range browser"> <img src="./img/code-points.png" width="400" alt="Code point browser">

### Browse Block

<img src="./img/block.png" width="400" alt="Block browser"> <img src="./img/block-code-points.png" width="400" alt="Block code point browser">

### Search

<img src="./img/filter.png" width="400" alt="Search results">

### Sequence

<img src="./img/sequence.png" width="400" alt="Sequence"> <img src="./img/inspector-sequence.png" width="400" alt="Sequence inspector view">

## License

Expand Down
30 changes: 30 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,9 @@
},
"browse_code_points": {
"$ref": "#/definitions/keyArray"
},
"back": {
"$ref": "#/definitions/keyArray"
}
},
"additionalProperties": false
Expand All @@ -546,6 +549,30 @@
},
"additionalProperties": false
},
"sequenceKeybindings": {
"type": "object",
"properties": {
"quit": {
"$ref": "#/definitions/keyArray"
},
"move_up": {
"$ref": "#/definitions/keyArray"
},
"move_down": {
"$ref": "#/definitions/keyArray"
},
"first": {
"$ref": "#/definitions/keyArray"
},
"last": {
"$ref": "#/definitions/keyArray"
},
"activate": {
"$ref": "#/definitions/keyArray"
}
},
"additionalProperties": false
},
"browsePlaneKeybindings": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -730,6 +757,9 @@
"search": {
"$ref": "#/definitions/searchKeybindings"
},
"sequence": {
"$ref": "#/definitions/sequenceKeybindings"
},
"browse_plane": {
"$ref": "#/definitions/browsePlaneKeybindings"
},
Expand Down
Binary file added img/demo-sequence.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions img/demo-sequence.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Set Shell zsh

Output "./img/demo-sequence.gif"

Set FontSize 11
Set Width 800
Set Height 400
Set Padding 15
Set WindowBar Colorful
Set Theme "nord"

Hide

Env SAUVA_CONFIG_FILE "./img/config.toml"
Type "cargo run --release -- --graphics iterm2 --text 'sauva❤️‍🔥'"
Enter
Sleep 2s

Show

Sleep 2s

Screenshot "./img/sequence.png"
Sleep 100ms

Type@300ms "jjjjjjjj"
Sleep 500ms

Enter
Sleep 1s

Screenshot "./img/inspector-sequence.png"
Sleep 100ms

Backspace
Sleep 500ms

Type "k"

Sleep 2s
Binary file added img/inspector-sequence.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/sequence.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
111 changes: 111 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::help::{HelpMove, HelpState};
use crate::inspector::{InspectorField, InspectorFieldId, InspectorMove, InspectorState};
use crate::preview::{GlyphPreviewState, GlyphPreviewUpdate};
use crate::search::{SearchMove, SearchState};
use crate::sequence::{SequenceMove, SequenceState};
use crate::unicode::CodePoint;

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -37,6 +38,10 @@ pub enum Action {
EditSearch(InputRequest),
MoveSearch(SearchMove),
ResizeSearchViewport(usize),
MoveSequence(SequenceMove),
ResizeSequenceViewport(usize),
InspectSequenceCodePoint,
ReturnToSequence,
UpdateGlyphPreview(GlyphPreviewUpdate),
ShowFooterStatus(FooterStatus),
}
Expand All @@ -49,6 +54,7 @@ impl Action {
| Self::ResizeInspectorViewport { .. }
| Self::ResizeBrowserViewport(_)
| Self::ResizeSearchViewport(_)
| Self::ResizeSequenceViewport(_)
| Self::UpdateGlyphPreview(_)
| Self::ShowFooterStatus(_)
)
Expand All @@ -66,6 +72,7 @@ pub enum View {
Inspector,
Browser,
Search,
Sequence,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -138,6 +145,7 @@ pub struct AppState {
inspector: InspectorState,
browse: Option<BrowseState>,
search: Option<SearchState>,
sequence: Option<SequenceState>,
help: HelpState,
glyph_preview: GlyphPreviewState,
clipboard_request: Option<ClipboardRequest>,
Expand All @@ -154,6 +162,7 @@ impl AppState {
inspector: InspectorState::new(),
browse: None,
search: None,
sequence: None,
help: HelpState::new(),
glyph_preview: GlyphPreviewState::new(),
clipboard_request: None,
Expand All @@ -167,6 +176,15 @@ impl AppState {
state
}

pub fn with_sequence(code_points: Vec<CodePoint>) -> Self {
let sequence = SequenceState::new(code_points);
let selected = sequence.selected();
let mut state = Self::with_selected(selected);
state.view = View::Sequence;
state.sequence = Some(sequence);
state
}

pub fn is_running(&self) -> bool {
self.running
}
Expand All @@ -191,6 +209,10 @@ impl AppState {
self.search.as_ref()
}

pub fn sequence(&self) -> Option<&SequenceState> {
self.sequence.as_ref()
}

pub const fn help(&self) -> HelpState {
self.help
}
Expand Down Expand Up @@ -222,6 +244,7 @@ impl AppState {
.as_ref()
.and_then(SearchState::selected_result)
.map(|result| result.code_point()),
View::Sequence => self.sequence.as_ref().map(SequenceState::selected),
}
}
}
Expand Down Expand Up @@ -379,6 +402,37 @@ pub fn update(state: &mut AppState, action: Action) {
search.resize_viewport(height);
}
}
Action::MoveSequence(movement) if state.view == View::Sequence => {
let previous_preview = state.preview_code_point();
state
.sequence
.as_mut()
.expect("the sequence view always has sequence state")
.move_selection(movement);
refresh_preview_for_change(state, previous_preview);
}
Action::MoveSequence(_) => {}
Action::ResizeSequenceViewport(height) => {
if let Some(sequence) = state.sequence.as_mut() {
sequence.resize_viewport(height);
}
}
Action::InspectSequenceCodePoint if state.view == View::Sequence => {
let selected = state
.sequence
.as_ref()
.expect("the sequence view always has sequence state")
.selected();
select_code_point(state, selected);
state.view = View::Inspector;
}
Action::InspectSequenceCodePoint => {}
Action::ReturnToSequence if state.view == View::Inspector && state.sequence.is_some() => {
let previous_preview = state.preview_code_point();
state.view = View::Sequence;
refresh_preview_for_change(state, previous_preview);
}
Action::ReturnToSequence => {}
Action::UpdateGlyphPreview(update) => state.glyph_preview.apply(update),
Action::ShowFooterStatus(status) => state.footer_status = Some(status),
}
Expand Down Expand Up @@ -827,6 +881,63 @@ mod tests {
assert_eq!(state.search().unwrap().selected_result(), None);
}

#[test]
fn sequence_selection_opens_the_inspector_and_returns_to_the_same_position() {
let mut state =
AppState::with_sequence("A→B".chars().map(CodePoint::from).collect::<Vec<_>>());
update(&mut state, Action::ResizeSequenceViewport(2));
update(&mut state, Action::MoveSequence(SequenceMove::Next));

update(&mut state, Action::InspectSequenceCodePoint);
assert_eq!(state.view(), View::Inspector);
assert_eq!(state.selected().value(), 0x2192);

update(&mut state, Action::MoveCodePoint(CodePointMove::Next));
assert_eq!(state.selected().value(), 0x2193);
update(&mut state, Action::ReturnToSequence);

assert_eq!(state.view(), View::Sequence);
assert_eq!(state.sequence().unwrap().selected_index(), 1);
assert_eq!(state.preview_code_point().unwrap().value(), 0x2192);
}

#[test]
fn search_from_a_sequence_inspector_does_not_change_the_sequence_position() {
let mut state =
AppState::with_sequence("A→B".chars().map(CodePoint::from).collect::<Vec<_>>());
update(&mut state, Action::MoveSequence(SequenceMove::Next));
update(&mut state, Action::InspectSequenceCodePoint);
update(&mut state, Action::OpenSearch);
edit_search_query(&mut state, "Ω");
update(&mut state, Action::InspectSearchResult);

assert_eq!(state.view(), View::Inspector);
assert_eq!(state.selected().value(), 0x03a9);
assert_eq!(state.sequence().unwrap().selected_index(), 1);

update(&mut state, Action::ReturnToSequence);
assert_eq!(state.view(), View::Sequence);
assert_eq!(state.preview_code_point().unwrap().value(), 0x2192);
}

#[test]
fn browse_from_a_sequence_inspector_returns_through_the_inspector() {
let mut state =
AppState::with_sequence("AB".chars().map(CodePoint::from).collect::<Vec<_>>());
update(&mut state, Action::InspectSequenceCodePoint);
update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable));
update(&mut state, Action::MoveBrowser(BrowseMove::Right));
update(&mut state, Action::AdvanceBrowser);

assert_eq!(state.view(), View::Inspector);
assert_eq!(state.selected().value(), 0x0042);
assert_eq!(state.sequence().unwrap().selected_index(), 0);

update(&mut state, Action::ReturnToSequence);
assert_eq!(state.view(), View::Sequence);
assert_eq!(state.preview_code_point().unwrap().value(), 0x0041);
}

#[test]
fn search_cannot_open_from_the_browser() {
let mut browser = AppState::new();
Expand Down
Loading
Loading