Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
authors = ["Warp Team <dev@warp.dev>"]
|
||||
edition = "2021"
|
||||
name = "galaxy_terminal"
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
bitflags.workspace = true
|
||||
bitflags-serde-legacy.workspace = true
|
||||
cfg-if.workspace = true
|
||||
channel_versions.workspace = true
|
||||
command-corrections.workspace = true
|
||||
enum-iterator.workspace = true
|
||||
get-size.workspace = true
|
||||
itertools.workspace = true
|
||||
lazy_static.workspace = true
|
||||
log.workspace = true
|
||||
pathfinder_color.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
session-sharing-protocol.workspace = true
|
||||
smol_str.workspace = true
|
||||
string-offset.workspace = true
|
||||
static_assertions.workspace = true
|
||||
thiserror.workspace = true
|
||||
typed-path.workspace = true
|
||||
unicode-width.workspace = true
|
||||
uuid.workspace = true
|
||||
version-compare.workspace = true
|
||||
vte.workspace = true
|
||||
galaxy_completer.workspace = true
|
||||
galaxy_core.workspace = true
|
||||
galaxy_util.workspace = true
|
||||
galaxyui.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
unicode-segmentation = "1.11.0"
|
||||
|
||||
[features]
|
||||
test-util = []
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod model;
|
||||
mod shared_session;
|
||||
pub mod shell;
|
||||
@@ -0,0 +1,104 @@
|
||||
# Escape sequences
|
||||
|
||||
Escape sequence is a group (sequence) of characters that have a special meaning, usually different than the literal meaning of the characters used.
|
||||
In Warp we operate on ANSI escape codes, so they always have a form of:
|
||||
`<ESC> <separating char> <some combination>`
|
||||
|
||||
The `<ESC>` is decimal 27 (0x1b) character.
|
||||
`<separating char>` is usually `[`, but can be something else too. The combination `<ESC><separating char>` is referred to as `C1 (8-Bit) Control Characters`.
|
||||
The rest depends on the actual operation performed. May contain other characters and separators, and is defined on case-by-case basis. Some example operations include mouse tracking, moving the cursor in the apps or handling the special key combinations.
|
||||
|
||||
Note that this doc describes just the combinations **we (on behalf of user) write to pty** and doesn't include description of other sequences written by the applications or read from the shell.
|
||||
|
||||
|
||||
### Helpful reading
|
||||
1. https://vt100.net/docs/vt100-ug/chapter3.html
|
||||
2. https://www.xfree86.org/current/ctlseqs.html#PC-Style%20Function%20Keys
|
||||
3. https://en.wikipedia.org/wiki/ANSI_escape_code
|
||||
|
||||
|
||||
## C1 sequences - what are they and when do we use them?
|
||||
List of all the C1 control characters [source](https://www.xfree86.org/current/ctlseqs.html#C1%20(8-Bit)%20Control%20Characters):
|
||||
|
||||
C1 sequence | Description
|
||||
----------- | -----------
|
||||
ESC D | Index ( IND is 0x84)
|
||||
ESC E | Next Line ( NEL is 0x85)
|
||||
ESC H | Tab Set ( HTS is 0x88)
|
||||
ESC M | Reverse Index ( RI is 0x8d)
|
||||
ESC N | Single Shift Select of G2 Character Set ( SS2 is 0x8e): affects next character only
|
||||
ESC O | Single Shift Select of G3 Character Set ( SS3 is 0x8f): affects next character only
|
||||
ESC P | Device Control String ( DCS is 0x90)
|
||||
ESC V | Start of Guarded Area ( SPA is 0x96)
|
||||
ESC W | End of Guarded Area ( EPA is 0x97)
|
||||
ESC X | Start of String ( SOS is 0x98)
|
||||
ESC Z | Return Terminal ID (DECID is 0x9a). Obsolete form of CSI c (DA).
|
||||
ESC [ | Control Sequence Introducer ( CSI is 0x9b)
|
||||
ESC \ | String Terminator ( ST is 0x9c)
|
||||
ESC ] | Operating System Command ( OSC is 0x9d)
|
||||
ESC ^ | Privacy Message ( PM is 0x9e)
|
||||
ESC _ | Application Program Command ( APC is 0x9f)
|
||||
|
||||
|
||||
So far, we're mostly using 2: CSI (ESC [) or SS3 (ESC O). Below there's a table that shows conditions for when to use each of those sequences:
|
||||
|
||||
| C1 sequence | terminal mode | modifiers (shift, ctrl, alt) | keys |
|
||||
|------------- |--------------- |------------------------------ |--------------------------------------------------- |
|
||||
| CSI | Any | Optional | Any |
|
||||
| SS3 | APP_CURSOR | Not used | Arrow keys (up, down, right, left)<br>Home<br>End |
|
||||
|
||||
In short: `SS3` can only be used iff `TermMode::APP_CURSOR` is set && no modifiers were used and only for a certain group of keys. Otherwise, CSI is most likely the way to go.
|
||||
|
||||
|
||||
## Use cases already covered in Warp
|
||||
|
||||
### Mouse tracking
|
||||
Programs such as `vim` or `tmux` allow users to use the mouse within the app. There's couple modes of operations for mouse tracking (more [here](https://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking)), but the one we care about in Warp is `SGR`.
|
||||
|
||||
Basically, some sort of low-res mouse tracking has been implemented before - it only allowed for tracking the mouse movement up to 223 columns, meaning, it wouldn't work in the bigger terminal window. As of 2012 xterm spec introduced `SGR`, which is supposed to support 'higher resolution' mouse tracking. Each of those modes expect different escape sequences to specify the mouse position, however, it is safe to assume that in modern world applications will favor SGR if supported by the terminal emulator, so we don't worry about the other sequences.
|
||||
|
||||
Below is the explanation of the sequences used:
|
||||
|
||||
`CSI < <button> ; <column> ; <row> ; <action>`
|
||||
|
||||
- `<button>` denotes the mouse button that was used. Left mouse button is 0, right one - 2, wheel has another number, dragging or pressing buttons with modifiers will have another number. As of now we only care about the Left mouse button and the Wheel and mouse dragging.
|
||||
- `<column>` & `<row>` are basically coordinates of the mouse pointer at the moment of performing action.
|
||||
- `<action>` can have 2 values: `M` for pressing and dragging; `m` for releasing the button.
|
||||
|
||||
Note that dragging is essentially *pressing a drag mouse button*.
|
||||
|
||||
### Cursor movement (with keyboard)
|
||||
Regular cursor movement within the terminal - **unmodified** arrows and home/end key press actions - behave differently depending on the terminal mode. The terminal mode is set based on the program Warp is running, for example, long running command such as `vim` or `emacs` will set the `APP_CURSOR` mode (it's set using CSI ? 1h and unset with CSI ? 1l sequences). Warp keeps track of the mode in terminal_model (`is_term_mode_set` method can be of help).
|
||||
|
||||
| | Normal mode | APP_CURSOR mode |
|
||||
|---------------------------- |------------- |----------------- |
|
||||
| Previous line (arrow up) | CSI A | SS3 A |
|
||||
| Next line (arrow down) | CSI B | SS3 B |
|
||||
| Next char (arrow right) | CSI C | SS3 C |
|
||||
| Previous char (arrow left) | CSI D | SS3 D |
|
||||
| First line (home) | CSI H | SS3 H |
|
||||
| Last line (end) | CSI F | SS3 F |
|
||||
|
||||
|
||||
### All the special keys and modifiers
|
||||
Function keys? Function keys with Shift? Arrow with Meta or Alt? Shift + CMD + Key?
|
||||
Unless we explicitly specified the binding somewhere in the `app/src/` code with a custom operation, then it should be handled by a proper escape sequence. This is a work in progress (and so is this README). Each of such sequences starts with `CSI` sequence, followed by proper combinations.
|
||||
|
||||
If modifiers are at play, below is the table with the values that should be used:
|
||||
|
||||
| Code | Modifier |
|
||||
|------ |-------------------- |
|
||||
| 2 | Shift |
|
||||
| 3 | Alt |
|
||||
| 4 | Shift + Alt |
|
||||
| 5 | Ctrl |
|
||||
| 6 | Ctrl + Shift |
|
||||
| 7 | Ctrl + Alt |
|
||||
| 8 | Ctrl + Shift + Alt |
|
||||
|
||||
For example, sequence for arrows with modifiers has the following pattern:
|
||||
`CSI 1 ; <modifier> <arrow code>`
|
||||
|
||||
(TODO: where exactly does `1 ;` come from?)
|
||||
|
||||
Other key combinations can have different values or completely different format. Best to follow the reading materials linked above to determine the right sequence.
|
||||
@@ -0,0 +1,736 @@
|
||||
//! This module exports abstractions for parameters of control sequence actions;
|
||||
//! e.g. actions to be executed after receiving a control sequence from the pty.
|
||||
//!
|
||||
//! Examples of such actions include repositioning the cursor, changing text
|
||||
//! styles, and setting terminal modes.
|
||||
use anyhow::bail;
|
||||
use get_size::GetSize;
|
||||
use log::trace;
|
||||
use pathfinder_color::ColorU;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{convert::TryFrom, iter, str};
|
||||
use thiserror::Error;
|
||||
use vte::ParamsIter;
|
||||
|
||||
/// Terminal cursor configuration.
|
||||
#[derive(Default, Debug, Eq, PartialEq, Copy, Clone, Hash)]
|
||||
pub struct CursorStyle {
|
||||
pub shape: CursorShape,
|
||||
pub blinking: bool,
|
||||
}
|
||||
|
||||
/// Terminal cursor shape.
|
||||
#[derive(Debug, Default, Eq, PartialEq, Copy, Clone, Hash)]
|
||||
pub enum CursorShape {
|
||||
/// Cursor is a block like `▒`.
|
||||
#[default]
|
||||
Block,
|
||||
|
||||
/// Cursor is an underscore like `_`.
|
||||
Underline,
|
||||
|
||||
/// Cursor is a vertical bar `⎸`.
|
||||
Beam,
|
||||
|
||||
/// Cursor is a box like `☐`.
|
||||
HollowBlock,
|
||||
|
||||
/// Invisible cursor.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
/// Terminal modes.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Mode {
|
||||
/// ?1
|
||||
CursorKeys,
|
||||
/// Select 80 or 132 columns per page.
|
||||
///
|
||||
/// CSI ? 3 h -> set 132 column font.
|
||||
/// CSI ? 3 l -> reset 80 column font.
|
||||
///
|
||||
/// Additionally,
|
||||
///
|
||||
/// * set margins to default positions
|
||||
/// * erases all data in page memory
|
||||
/// * resets DECLRMM to unavailable
|
||||
/// * clears data from the status line (if set to host-writable)
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
DECCOLM,
|
||||
/// IRM Insert Mode.
|
||||
///
|
||||
/// NB should be part of non-private mode enum.
|
||||
///
|
||||
/// * `CSI 4 h` change to insert mode
|
||||
/// * `CSI 4 l` reset to replacement mode
|
||||
Insert,
|
||||
/// ?6
|
||||
Origin,
|
||||
/// ?7
|
||||
LineWrap,
|
||||
/// ?12
|
||||
BlinkingCursor,
|
||||
/// 20
|
||||
///
|
||||
/// NB This is actually a private mode. We should consider adding a second
|
||||
/// enumeration for public/private modesets.
|
||||
LineFeedNewLine,
|
||||
/// ?25
|
||||
ShowCursor,
|
||||
/// ?1000
|
||||
ReportMouseClicks,
|
||||
/// ?1002
|
||||
ReportCellMouseMotion,
|
||||
/// ?1003
|
||||
ReportAllMouseMotion,
|
||||
/// ?1004
|
||||
ReportFocusInOut,
|
||||
/// ?1005
|
||||
Utf8Mouse,
|
||||
/// ?1006
|
||||
SgrMouse,
|
||||
/// ?1007
|
||||
AlternateScroll,
|
||||
/// ?1042
|
||||
UrgencyHints,
|
||||
/// ?1049, 47
|
||||
SwapScreen { save_cursor_and_clear_screen: bool },
|
||||
/// ?2004
|
||||
BracketedPaste,
|
||||
/// ?2026
|
||||
/// See https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036.
|
||||
SyncOutput,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// Create mode from a primitive.
|
||||
///
|
||||
/// TODO lots of unhandled values.
|
||||
pub fn from_primitive(intermediate: Option<&u8>, num: u16) -> Option<Mode> {
|
||||
// 0 is not a valid DEC mode.
|
||||
if num == 0 {
|
||||
return None;
|
||||
};
|
||||
|
||||
let private = match intermediate {
|
||||
Some(b'?') => true,
|
||||
None => false,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if private {
|
||||
Some(match num {
|
||||
1 => Mode::CursorKeys,
|
||||
3 => Mode::DECCOLM,
|
||||
6 => Mode::Origin,
|
||||
7 => Mode::LineWrap,
|
||||
12 => Mode::BlinkingCursor,
|
||||
25 => Mode::ShowCursor,
|
||||
1000 => Mode::ReportMouseClicks,
|
||||
1002 => Mode::ReportCellMouseMotion,
|
||||
1003 => Mode::ReportAllMouseMotion,
|
||||
1004 => Mode::ReportFocusInOut,
|
||||
1005 => Mode::Utf8Mouse,
|
||||
1006 => Mode::SgrMouse,
|
||||
1007 => Mode::AlternateScroll,
|
||||
1042 => Mode::UrgencyHints,
|
||||
47 => Mode::SwapScreen {
|
||||
save_cursor_and_clear_screen: false,
|
||||
},
|
||||
1049 => Mode::SwapScreen {
|
||||
save_cursor_and_clear_screen: true,
|
||||
},
|
||||
2004 => Mode::BracketedPaste,
|
||||
2026 => Mode::SyncOutput,
|
||||
_ => {
|
||||
trace!("[unimplemented] primitive mode: {num}");
|
||||
return None;
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Some(match num {
|
||||
4 => Mode::Insert,
|
||||
20 => Mode::LineFeedNewLine,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mode for clearing line.
|
||||
///
|
||||
/// Relative to cursor.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum LineClearMode {
|
||||
/// Clear right of cursor.
|
||||
Right,
|
||||
/// Clear left of cursor.
|
||||
Left,
|
||||
/// Clear entire line.
|
||||
All,
|
||||
}
|
||||
|
||||
/// Mode for clearing terminal.
|
||||
///
|
||||
/// Relative to cursor.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum ClearMode {
|
||||
/// Clear below cursor.
|
||||
Below,
|
||||
/// Clear above cursor.
|
||||
Above,
|
||||
/// Clear entire terminal.
|
||||
All,
|
||||
/// Clear 'saved' lines (scrollback).
|
||||
Saved,
|
||||
/// Clears all the lines in the terminal, putting the prompt on the first line.
|
||||
ResetAndClear,
|
||||
/// A synthetic mode used to clear the active block only.
|
||||
/// When it comes to interacting with the PTY, this is equivalent to a [ClearMode::ResetAndClear].
|
||||
ActiveBlock,
|
||||
}
|
||||
|
||||
/// Mode for clearing tab stops.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum TabulationClearMode {
|
||||
/// Clear stop under cursor.
|
||||
Current,
|
||||
/// Clear all stops.
|
||||
All,
|
||||
}
|
||||
|
||||
/// Standard colors.
|
||||
///
|
||||
/// Note: These are explicitly not given values to match the Color list, as we want this enum to
|
||||
/// fit into a single byte. See the comment on `terminal::model::cell::Cell` for more details about
|
||||
/// the specific memory alignment.
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq, PartialOrd, Ord)]
|
||||
pub enum NamedColor {
|
||||
Black,
|
||||
Red,
|
||||
Green,
|
||||
Yellow,
|
||||
Blue,
|
||||
Magenta,
|
||||
Cyan,
|
||||
White,
|
||||
BrightBlack,
|
||||
BrightRed,
|
||||
BrightGreen,
|
||||
BrightYellow,
|
||||
BrightBlue,
|
||||
BrightMagenta,
|
||||
BrightCyan,
|
||||
BrightWhite,
|
||||
Foreground,
|
||||
Background,
|
||||
Cursor,
|
||||
DimBlack,
|
||||
DimRed,
|
||||
DimGreen,
|
||||
DimYellow,
|
||||
DimBlue,
|
||||
DimMagenta,
|
||||
DimCyan,
|
||||
DimWhite,
|
||||
BrightForeground,
|
||||
DimForeground,
|
||||
}
|
||||
|
||||
impl NamedColor {
|
||||
pub fn to_bright(self) -> Self {
|
||||
match self {
|
||||
NamedColor::Foreground => NamedColor::BrightForeground,
|
||||
NamedColor::Black => NamedColor::BrightBlack,
|
||||
NamedColor::Red => NamedColor::BrightRed,
|
||||
NamedColor::Green => NamedColor::BrightGreen,
|
||||
NamedColor::Yellow => NamedColor::BrightYellow,
|
||||
NamedColor::Blue => NamedColor::BrightBlue,
|
||||
NamedColor::Magenta => NamedColor::BrightMagenta,
|
||||
NamedColor::Cyan => NamedColor::BrightCyan,
|
||||
NamedColor::White => NamedColor::BrightWhite,
|
||||
NamedColor::DimForeground => NamedColor::Foreground,
|
||||
NamedColor::DimBlack => NamedColor::Black,
|
||||
NamedColor::DimRed => NamedColor::Red,
|
||||
NamedColor::DimGreen => NamedColor::Green,
|
||||
NamedColor::DimYellow => NamedColor::Yellow,
|
||||
NamedColor::DimBlue => NamedColor::Blue,
|
||||
NamedColor::DimMagenta => NamedColor::Magenta,
|
||||
NamedColor::DimCyan => NamedColor::Cyan,
|
||||
NamedColor::DimWhite => NamedColor::White,
|
||||
val => val,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_dim(self) -> Self {
|
||||
match self {
|
||||
NamedColor::Black => NamedColor::DimBlack,
|
||||
NamedColor::Red => NamedColor::DimRed,
|
||||
NamedColor::Green => NamedColor::DimGreen,
|
||||
NamedColor::Yellow => NamedColor::DimYellow,
|
||||
NamedColor::Blue => NamedColor::DimBlue,
|
||||
NamedColor::Magenta => NamedColor::DimMagenta,
|
||||
NamedColor::Cyan => NamedColor::DimCyan,
|
||||
NamedColor::White => NamedColor::DimWhite,
|
||||
NamedColor::Foreground => NamedColor::DimForeground,
|
||||
NamedColor::BrightBlack => NamedColor::Black,
|
||||
NamedColor::BrightRed => NamedColor::Red,
|
||||
NamedColor::BrightGreen => NamedColor::Green,
|
||||
NamedColor::BrightYellow => NamedColor::Yellow,
|
||||
NamedColor::BrightBlue => NamedColor::Blue,
|
||||
NamedColor::BrightMagenta => NamedColor::Magenta,
|
||||
NamedColor::BrightCyan => NamedColor::Cyan,
|
||||
NamedColor::BrightWhite => NamedColor::White,
|
||||
NamedColor::BrightForeground => NamedColor::Foreground,
|
||||
val => val,
|
||||
}
|
||||
}
|
||||
|
||||
// This can fail if the caller asks for a background color but self is
|
||||
// NamedColor::Foreground, for example
|
||||
pub fn to_ansi_bg_escape_code(&self) -> anyhow::Result<u8> {
|
||||
let code = match self {
|
||||
NamedColor::Black | NamedColor::DimBlack => 40,
|
||||
NamedColor::Red | NamedColor::DimRed => 41,
|
||||
NamedColor::Green | NamedColor::DimGreen => 42,
|
||||
NamedColor::Yellow | NamedColor::DimYellow => 43,
|
||||
NamedColor::Blue | NamedColor::DimBlue => 44,
|
||||
NamedColor::Magenta | NamedColor::DimMagenta => 45,
|
||||
NamedColor::Cyan | NamedColor::DimCyan => 46,
|
||||
NamedColor::White | NamedColor::DimWhite => 47,
|
||||
NamedColor::Background => 49,
|
||||
NamedColor::BrightBlack => 100,
|
||||
NamedColor::BrightRed => 101,
|
||||
NamedColor::BrightGreen => 102,
|
||||
NamedColor::BrightYellow => 103,
|
||||
NamedColor::BrightBlue => 104,
|
||||
NamedColor::BrightMagenta => 105,
|
||||
NamedColor::BrightCyan => 106,
|
||||
NamedColor::BrightWhite => 107,
|
||||
_ => bail!("{:?} is not a valid background", self),
|
||||
};
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
pub fn to_ansi_fg_escape_code(&self) -> anyhow::Result<u8> {
|
||||
let code = match self {
|
||||
NamedColor::Black | NamedColor::DimBlack => 30,
|
||||
NamedColor::Red | NamedColor::DimRed => 31,
|
||||
NamedColor::Green | NamedColor::DimGreen => 32,
|
||||
NamedColor::Yellow | NamedColor::DimYellow => 33,
|
||||
NamedColor::Blue | NamedColor::DimBlue => 34,
|
||||
NamedColor::Magenta | NamedColor::DimMagenta => 35,
|
||||
NamedColor::Cyan | NamedColor::DimCyan => 36,
|
||||
NamedColor::White | NamedColor::DimWhite => 37,
|
||||
NamedColor::Foreground | NamedColor::BrightForeground | NamedColor::DimForeground => 39,
|
||||
NamedColor::BrightBlack => 90,
|
||||
NamedColor::BrightRed => 91,
|
||||
NamedColor::BrightGreen => 92,
|
||||
NamedColor::BrightYellow => 93,
|
||||
NamedColor::BrightBlue => 94,
|
||||
NamedColor::BrightMagenta => 95,
|
||||
NamedColor::BrightCyan => 96,
|
||||
NamedColor::BrightWhite => 97,
|
||||
_ => bail!("{:?} is not a valid foreground", self),
|
||||
};
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
pub fn into_color_index(self) -> usize {
|
||||
use NamedColor::*;
|
||||
match self {
|
||||
Black => color_index::BLACK,
|
||||
Red => color_index::RED,
|
||||
Green => color_index::GREEN,
|
||||
Yellow => color_index::YELLOW,
|
||||
Blue => color_index::BLUE,
|
||||
Magenta => color_index::MAGENTA,
|
||||
Cyan => color_index::CYAN,
|
||||
White => color_index::WHITE,
|
||||
BrightBlack => color_index::BRIGHT_BLACK,
|
||||
BrightRed => color_index::BRIGHT_RED,
|
||||
BrightGreen => color_index::BRIGHT_GREEN,
|
||||
BrightYellow => color_index::BRIGHT_YELLOW,
|
||||
BrightBlue => color_index::BRIGHT_BLUE,
|
||||
BrightMagenta => color_index::BRIGHT_MAGENTA,
|
||||
BrightCyan => color_index::BRIGHT_CYAN,
|
||||
BrightWhite => color_index::BRIGHT_WHITE,
|
||||
Foreground => color_index::FOREGROUND,
|
||||
Background => color_index::BACKGROUND,
|
||||
Cursor => color_index::CURSOR,
|
||||
DimBlack => color_index::DIM_BLACK,
|
||||
DimRed => color_index::DIM_RED,
|
||||
DimGreen => color_index::DIM_GREEN,
|
||||
DimYellow => color_index::DIM_YELLOW,
|
||||
DimBlue => color_index::DIM_BLUE,
|
||||
DimMagenta => color_index::DIM_MAGENTA,
|
||||
DimCyan => color_index::DIM_CYAN,
|
||||
DimWhite => color_index::DIM_WHITE,
|
||||
BrightForeground => color_index::BRIGHT_FOREGROUND,
|
||||
DimForeground => color_index::DIM_FOREGROUND,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(remote = "ColorU")]
|
||||
//TODO write a deserializer (so #ff00aa could be used)
|
||||
pub struct ColorUDef {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
#[serde(skip, default = "default_alpha")]
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
fn default_alpha() -> u8 {
|
||||
255
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Color {
|
||||
Named(NamedColor),
|
||||
#[serde(with = "ColorUDef")]
|
||||
Spec(ColorU),
|
||||
Indexed(u8),
|
||||
}
|
||||
|
||||
impl GetSize for Color {}
|
||||
|
||||
/// Terminal character attributes.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum Attr {
|
||||
/// Clear all special abilities.
|
||||
Reset,
|
||||
/// Bold text.
|
||||
Bold,
|
||||
/// Dim or secondary color.
|
||||
Dim,
|
||||
/// Italic text.
|
||||
Italic,
|
||||
/// Underline text.
|
||||
Underline,
|
||||
/// Underlined twice.
|
||||
DoubleUnderline,
|
||||
/// Blink cursor slowly.
|
||||
BlinkSlow,
|
||||
/// Blink cursor fast.
|
||||
BlinkFast,
|
||||
/// Invert colors.
|
||||
Reverse,
|
||||
/// Do not display characters.
|
||||
Hidden,
|
||||
/// Strikeout text.
|
||||
Strike,
|
||||
/// Cancel bold.
|
||||
CancelBold,
|
||||
/// Cancel bold and dim.
|
||||
CancelBoldDim,
|
||||
/// Cancel italic.
|
||||
CancelItalic,
|
||||
/// Cancel all underlines.
|
||||
CancelUnderline,
|
||||
/// Cancel blink.
|
||||
CancelBlink,
|
||||
/// Cancel inversion.
|
||||
CancelReverse,
|
||||
/// Cancel text hiding.
|
||||
CancelHidden,
|
||||
/// Cancel strikeout.
|
||||
CancelStrike,
|
||||
/// Set indexed foreground color.
|
||||
Foreground(Color),
|
||||
/// Set indexed background color.
|
||||
Background(Color),
|
||||
}
|
||||
|
||||
/// Identifiers which can be assigned to a graphic character set.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum CharsetIndex {
|
||||
/// Default set, is designated as ASCII at startup.
|
||||
#[default]
|
||||
G0,
|
||||
G1,
|
||||
G2,
|
||||
G3,
|
||||
}
|
||||
|
||||
/// Standard or common character sets which can be designated as G0-G3.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum StandardCharset {
|
||||
#[default]
|
||||
Ascii,
|
||||
SpecialCharacterAndusizeDrawing,
|
||||
}
|
||||
|
||||
impl StandardCharset {
|
||||
/// Switch/Map character to the active charset. Ascii is the common case and
|
||||
/// for that we want to do as little as possible.
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
pub fn map(self, c: char) -> char {
|
||||
match self {
|
||||
StandardCharset::Ascii => c,
|
||||
StandardCharset::SpecialCharacterAndusizeDrawing => match c {
|
||||
'`' => '◆',
|
||||
'a' => '▒',
|
||||
'b' => '\t',
|
||||
'c' => '\u{000c}',
|
||||
'd' => '\r',
|
||||
'e' => '\n',
|
||||
'f' => '°',
|
||||
'g' => '±',
|
||||
'h' => '\u{2424}',
|
||||
'i' => '\u{000b}',
|
||||
'j' => '┘',
|
||||
'k' => '┐',
|
||||
'l' => '┌',
|
||||
'm' => '└',
|
||||
'n' => '┼',
|
||||
'o' => '⎺',
|
||||
'p' => '⎻',
|
||||
'q' => '─',
|
||||
'r' => '⎼',
|
||||
's' => '⎽',
|
||||
't' => '├',
|
||||
'u' => '┤',
|
||||
'v' => '┴',
|
||||
'w' => '┬',
|
||||
'x' => '│',
|
||||
'y' => '≤',
|
||||
'z' => '≥',
|
||||
'{' => 'π',
|
||||
'|' => '≠',
|
||||
'}' => '£',
|
||||
'~' => '·',
|
||||
_ => c,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attrs_from_sgr_parameters(params: &mut ParamsIter<'_>) -> Vec<Option<Attr>> {
|
||||
let mut attrs = Vec::with_capacity(params.size_hint().0);
|
||||
|
||||
while let Some(param) = params.next() {
|
||||
let attr = match param {
|
||||
[0] => Some(Attr::Reset),
|
||||
[1] => Some(Attr::Bold),
|
||||
[2] => Some(Attr::Dim),
|
||||
[3] => Some(Attr::Italic),
|
||||
[4, 0] => Some(Attr::CancelUnderline),
|
||||
[4, 2] => Some(Attr::DoubleUnderline),
|
||||
[4, ..] => Some(Attr::Underline),
|
||||
[5] => Some(Attr::BlinkSlow),
|
||||
[6] => Some(Attr::BlinkFast),
|
||||
[7] => Some(Attr::Reverse),
|
||||
[8] => Some(Attr::Hidden),
|
||||
[9] => Some(Attr::Strike),
|
||||
[21] => Some(Attr::CancelBold),
|
||||
[22] => Some(Attr::CancelBoldDim),
|
||||
[23] => Some(Attr::CancelItalic),
|
||||
[24] => Some(Attr::CancelUnderline),
|
||||
[25] => Some(Attr::CancelBlink),
|
||||
[27] => Some(Attr::CancelReverse),
|
||||
[28] => Some(Attr::CancelHidden),
|
||||
[29] => Some(Attr::CancelStrike),
|
||||
[30] => Some(Attr::Foreground(Color::Named(NamedColor::Black))),
|
||||
[31] => Some(Attr::Foreground(Color::Named(NamedColor::Red))),
|
||||
[32] => Some(Attr::Foreground(Color::Named(NamedColor::Green))),
|
||||
[33] => Some(Attr::Foreground(Color::Named(NamedColor::Yellow))),
|
||||
[34] => Some(Attr::Foreground(Color::Named(NamedColor::Blue))),
|
||||
[35] => Some(Attr::Foreground(Color::Named(NamedColor::Magenta))),
|
||||
[36] => Some(Attr::Foreground(Color::Named(NamedColor::Cyan))),
|
||||
[37] => Some(Attr::Foreground(Color::Named(NamedColor::White))),
|
||||
[38] => {
|
||||
let mut iter = params.map(|param| param[0]);
|
||||
parse_sgr_color(&mut iter).map(Attr::Foreground)
|
||||
}
|
||||
[38, params @ ..] => {
|
||||
let rgb_start = if params.len() > 4 { 2 } else { 1 };
|
||||
let rgb_iter = params[rgb_start..].iter().copied();
|
||||
let mut iter = iter::once(params[0]).chain(rgb_iter);
|
||||
|
||||
parse_sgr_color(&mut iter).map(Attr::Foreground)
|
||||
}
|
||||
[39] => Some(Attr::Foreground(Color::Named(NamedColor::Foreground))),
|
||||
[40] => Some(Attr::Background(Color::Named(NamedColor::Black))),
|
||||
[41] => Some(Attr::Background(Color::Named(NamedColor::Red))),
|
||||
[42] => Some(Attr::Background(Color::Named(NamedColor::Green))),
|
||||
[43] => Some(Attr::Background(Color::Named(NamedColor::Yellow))),
|
||||
[44] => Some(Attr::Background(Color::Named(NamedColor::Blue))),
|
||||
[45] => Some(Attr::Background(Color::Named(NamedColor::Magenta))),
|
||||
[46] => Some(Attr::Background(Color::Named(NamedColor::Cyan))),
|
||||
[47] => Some(Attr::Background(Color::Named(NamedColor::White))),
|
||||
[48] => {
|
||||
let mut iter = params.map(|param| param[0]);
|
||||
parse_sgr_color(&mut iter).map(Attr::Background)
|
||||
}
|
||||
[48, params @ ..] => {
|
||||
let rgb_start = if params.len() > 4 { 2 } else { 1 };
|
||||
let rgb_iter = params[rgb_start..].iter().copied();
|
||||
let mut iter = iter::once(params[0]).chain(rgb_iter);
|
||||
|
||||
parse_sgr_color(&mut iter).map(Attr::Background)
|
||||
}
|
||||
[49] => Some(Attr::Background(Color::Named(NamedColor::Background))),
|
||||
[90] => Some(Attr::Foreground(Color::Named(NamedColor::BrightBlack))),
|
||||
[91] => Some(Attr::Foreground(Color::Named(NamedColor::BrightRed))),
|
||||
[92] => Some(Attr::Foreground(Color::Named(NamedColor::BrightGreen))),
|
||||
[93] => Some(Attr::Foreground(Color::Named(NamedColor::BrightYellow))),
|
||||
[94] => Some(Attr::Foreground(Color::Named(NamedColor::BrightBlue))),
|
||||
[95] => Some(Attr::Foreground(Color::Named(NamedColor::BrightMagenta))),
|
||||
[96] => Some(Attr::Foreground(Color::Named(NamedColor::BrightCyan))),
|
||||
[97] => Some(Attr::Foreground(Color::Named(NamedColor::BrightWhite))),
|
||||
[100] => Some(Attr::Background(Color::Named(NamedColor::BrightBlack))),
|
||||
[101] => Some(Attr::Background(Color::Named(NamedColor::BrightRed))),
|
||||
[102] => Some(Attr::Background(Color::Named(NamedColor::BrightGreen))),
|
||||
[103] => Some(Attr::Background(Color::Named(NamedColor::BrightYellow))),
|
||||
[104] => Some(Attr::Background(Color::Named(NamedColor::BrightBlue))),
|
||||
[105] => Some(Attr::Background(Color::Named(NamedColor::BrightMagenta))),
|
||||
[106] => Some(Attr::Background(Color::Named(NamedColor::BrightCyan))),
|
||||
[107] => Some(Attr::Background(Color::Named(NamedColor::BrightWhite))),
|
||||
_ => None,
|
||||
};
|
||||
attrs.push(attr);
|
||||
}
|
||||
|
||||
attrs
|
||||
}
|
||||
|
||||
/// Parse a color specifier from list of attributes.
|
||||
fn parse_sgr_color(params: &mut dyn Iterator<Item = u16>) -> Option<Color> {
|
||||
match params.next() {
|
||||
Some(2) => Some(Color::Spec(ColorU::new(
|
||||
u8::try_from(params.next()?).ok()?,
|
||||
u8::try_from(params.next()?).ok()?,
|
||||
u8::try_from(params.next()?).ok()?,
|
||||
0xff,
|
||||
))),
|
||||
Some(5) => Some(Color::Indexed(u8::try_from(params.next()?).ok()?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the varieties of prompt marker sequences we can process.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum PromptMarker {
|
||||
/// A marker indicating that the shell is starting to write out
|
||||
/// a prompt of the given kind.
|
||||
StartPrompt { kind: PromptKind },
|
||||
/// A marker indicating that the shell has finished writing out
|
||||
/// the in-progress prompt.
|
||||
EndPrompt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PromptMarkerParseError {
|
||||
#[error("unknown parameter encountered")]
|
||||
UnknownParam,
|
||||
#[error("malformed option encountered")]
|
||||
MalformedOption,
|
||||
}
|
||||
|
||||
impl TryFrom<&[&[u8]]> for PromptMarker {
|
||||
type Error = PromptMarkerParseError;
|
||||
|
||||
/// Try to parse prompt marker information from an OSC 133
|
||||
/// sequence.
|
||||
///
|
||||
/// See the "semantic prompts" spec from terminal-wg for more
|
||||
/// details on the grammar and parameters:
|
||||
/// https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
|
||||
fn try_from(params: &[&[u8]]) -> Result<Self, Self::Error> {
|
||||
match params.first() {
|
||||
Some(&b"A") => Ok(PromptMarker::StartPrompt {
|
||||
kind: PromptKind::Initial,
|
||||
}),
|
||||
Some(&b"B") => Ok(PromptMarker::EndPrompt),
|
||||
Some(&b"P") => {
|
||||
// Default to "Initial" as the kind, if one is not specified as an option.
|
||||
let mut kind = PromptKind::Initial;
|
||||
// Loop through and parse out any options, which are expected to be of the form
|
||||
// "key=value". We ignore unknown options, but return an error for any malformed
|
||||
// ones.
|
||||
for param in ¶ms[1..] {
|
||||
let Some(eq_index) = param.iter().position(|byte| byte == &b"="[0]) else {
|
||||
return Err(Self::Error::MalformedOption);
|
||||
};
|
||||
if eq_index + 1 >= param.len() {
|
||||
return Err(Self::Error::MalformedOption);
|
||||
}
|
||||
let key = ¶m[..eq_index];
|
||||
let value = ¶m[eq_index + 1..];
|
||||
// "k" represents the prompt kind; try to parse the value into our
|
||||
// PromptKind enum.
|
||||
if let b"k" = key {
|
||||
if let Ok(k) = PromptKind::try_from(value) {
|
||||
kind = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(PromptMarker::StartPrompt { kind })
|
||||
}
|
||||
_ => Err(Self::Error::UnknownParam),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An enumeration of the kinds of prompts we support.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PromptKind {
|
||||
/// The initial (left) prompt.
|
||||
Initial,
|
||||
/// The right-side prompt.
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PromptKindParseError {
|
||||
#[error("unknown value")]
|
||||
UnknownValue,
|
||||
}
|
||||
|
||||
impl TryFrom<&[u8]> for PromptKind {
|
||||
type Error = PromptKindParseError;
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
b"i" => Ok(PromptKind::Initial),
|
||||
b"r" => Ok(PromptKind::Right),
|
||||
_ => Err(Self::Error::UnknownValue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod color_index {
|
||||
pub const BLACK: usize = 0;
|
||||
pub const RED: usize = 1;
|
||||
pub const GREEN: usize = 2;
|
||||
pub const YELLOW: usize = 3;
|
||||
pub const BLUE: usize = 4;
|
||||
pub const MAGENTA: usize = 5;
|
||||
pub const CYAN: usize = 6;
|
||||
pub const WHITE: usize = 7;
|
||||
pub const BRIGHT_BLACK: usize = 8;
|
||||
pub const BRIGHT_RED: usize = 9;
|
||||
pub const BRIGHT_GREEN: usize = 10;
|
||||
pub const BRIGHT_YELLOW: usize = 11;
|
||||
pub const BRIGHT_BLUE: usize = 12;
|
||||
pub const BRIGHT_MAGENTA: usize = 13;
|
||||
pub const BRIGHT_CYAN: usize = 14;
|
||||
pub const BRIGHT_WHITE: usize = 15;
|
||||
pub const FOREGROUND: usize = 256;
|
||||
pub const BACKGROUND: usize = 257;
|
||||
pub const CURSOR: usize = 258;
|
||||
pub const DIM_BLACK: usize = 259;
|
||||
pub const DIM_RED: usize = 260;
|
||||
pub const DIM_GREEN: usize = 261;
|
||||
pub const DIM_YELLOW: usize = 262;
|
||||
pub const DIM_BLUE: usize = 263;
|
||||
pub const DIM_MAGENTA: usize = 264;
|
||||
pub const DIM_CYAN: usize = 265;
|
||||
pub const DIM_WHITE: usize = 266;
|
||||
pub const BRIGHT_FOREGROUND: usize = 267;
|
||||
pub const DIM_FOREGROUND: usize = 268;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod control_sequence_parameters;
|
||||
|
||||
pub use control_sequence_parameters::*;
|
||||
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A globally unique ID for the block that is unique across all sessions.
|
||||
/// For a block created as a result of pty output, it takes the form {WARP_SESSION_ID}-{NUM_ID},
|
||||
/// where NUM_ID is a monotonically increasing counter for the session.
|
||||
/// This is because the block ID comes from the precmd in this case, and it is expensive to create a UUID in the bootstrap script.
|
||||
/// For manually created blocks within the app, it is a UUID.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
#[serde(transparent)]
|
||||
pub struct BlockId(String);
|
||||
|
||||
impl std::fmt::Display for BlockId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for BlockId {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockId> for String {
|
||||
fn from(value: BlockId) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockId {
|
||||
/// Should only be used for manually created blocks.
|
||||
/// Blocks created as a result of pty output should get the block ID from the precmd.
|
||||
pub fn new() -> Self {
|
||||
format!("manual-{}", Uuid::new_v4()).into()
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BlockId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockId> for session_sharing_protocol::common::BlockId {
|
||||
fn from(value: BlockId) -> Self {
|
||||
value.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session_sharing_protocol::common::BlockId> for BlockId {
|
||||
fn from(value: session_sharing_protocol::common::BlockId) -> Self {
|
||||
value.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session_sharing_protocol::common::BufferId> for BlockId {
|
||||
fn from(value: session_sharing_protocol::common::BufferId) -> Self {
|
||||
let block_id = session_sharing_protocol::common::BlockId::from(value);
|
||||
block_id.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockId> for session_sharing_protocol::common::BufferId {
|
||||
fn from(value: BlockId) -> Self {
|
||||
let block_id = session_sharing_protocol::common::BlockId::from(value);
|
||||
block_id.into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use std::{
|
||||
fmt::{self, Display, Formatter},
|
||||
ops::{Add, AddAssign, Range, Sub, SubAssign},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(
|
||||
Default, Clone, Copy, Debug, Deserialize, PartialEq, PartialOrd, Eq, Ord, Hash, Serialize,
|
||||
)]
|
||||
pub struct BlockIndex(pub usize);
|
||||
|
||||
impl Display for BlockIndex {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockIndex {
|
||||
pub fn zero() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
pub fn range_as_iter(range: Range<BlockIndex>) -> impl Iterator<Item = BlockIndex> {
|
||||
(range.start.0..range.end.0).map(BlockIndex::from)
|
||||
}
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
Self(self.0 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for BlockIndex {
|
||||
fn from(index: usize) -> Self {
|
||||
BlockIndex(index)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockIndex> for usize {
|
||||
fn from(block_index: BlockIndex) -> usize {
|
||||
block_index.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for BlockIndex {
|
||||
type Output = BlockIndex;
|
||||
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Self(self.0 + rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign for BlockIndex {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
self.0 += rhs.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for BlockIndex {
|
||||
type Output = BlockIndex;
|
||||
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
Self(self.0 - rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign for BlockIndex {
|
||||
fn sub_assign(&mut self, rhs: Self) {
|
||||
self.0 -= rhs.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! This module defines CharOrStr, a wrapper struct to deal with cases where we can have either a
|
||||
//! char or a string. For example, this is used for Cells in Grids where we can have either just a char
|
||||
//! (normal case) or a String (when we have zerowidth characters in a Cell). This structure helps abstract
|
||||
//! away a clean API for dealing with both cases.
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Helper enum to represent either a char or a string, with corresponding API.
|
||||
#[derive(Debug, PartialEq, Copy, Clone)]
|
||||
pub enum CharOrStr<'a> {
|
||||
Char(char),
|
||||
Str(&'a str),
|
||||
}
|
||||
|
||||
pub trait PushCharOrStr {
|
||||
fn push_char_or_str(&mut self, c: CharOrStr);
|
||||
}
|
||||
|
||||
impl PushCharOrStr for String {
|
||||
fn push_char_or_str(&mut self, c: CharOrStr) {
|
||||
match c {
|
||||
CharOrStr::Char(c) => self.push(c),
|
||||
CharOrStr::Str(s) => self.push_str(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CharOrStr<'_> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CharOrStr::Char(c) => write!(f, "{c}"),
|
||||
CharOrStr::Str(s) => write!(f, "{s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
|
||||
use super::{
|
||||
mouse::{MouseAction, MouseButton, MouseState},
|
||||
TermMode,
|
||||
};
|
||||
|
||||
mod kitty_keyboard_protocol;
|
||||
|
||||
use kitty_keyboard_protocol::maybe_convert_keystroke_to_csi_u;
|
||||
pub use kitty_keyboard_protocol::{maybe_kitty_keyboard_escape_sequence, modifier_key_to_csi_u};
|
||||
|
||||
/// C0 set of 7-bit control characters (from ANSI X3.4-1977).
|
||||
#[allow(non_snake_case)]
|
||||
#[allow(dead_code)]
|
||||
pub mod C0 {
|
||||
/// Null filler, terminal should ignore this character.
|
||||
pub const NUL: u8 = 0x00;
|
||||
/// Start of Header.
|
||||
pub const SOH: u8 = 0x01;
|
||||
/// Start of Text, implied end of header.
|
||||
pub const STX: u8 = 0x02;
|
||||
/// End of Text, causes some terminal to respond with ACK or NAK.
|
||||
pub const ETX: u8 = 0x03;
|
||||
/// End of Transmission.
|
||||
pub const EOT: u8 = 0x04;
|
||||
/// Enquiry, causes terminal to send ANSWER-BACK ID.
|
||||
pub const ENQ: u8 = 0x05;
|
||||
/// Acknowledge, usually sent by terminal in response to ETX.
|
||||
pub const ACK: u8 = 0x06;
|
||||
/// Bell, triggers the bell, buzzer, or beeper on the terminal.
|
||||
pub const BEL: u8 = 0x07;
|
||||
/// Backspace, can be used to define overstruck characters.
|
||||
pub const BS: u8 = 0x08;
|
||||
/// Horizontal Tabulation, move to next predetermined position.
|
||||
pub const HT: u8 = 0x09;
|
||||
/// Linefeed, move to same position on next line (see also NL).
|
||||
pub const LF: u8 = 0x0A;
|
||||
/// Vertical Tabulation, move to next predetermined line.
|
||||
pub const VT: u8 = 0x0B;
|
||||
/// Form Feed, move to next form or page.
|
||||
pub const FF: u8 = 0x0C;
|
||||
/// Carriage Return, move to first character of current line.
|
||||
pub const CR: u8 = 0x0D;
|
||||
/// Shift Out, switch to G1 (other half of character set).
|
||||
pub const SO: u8 = 0x0E;
|
||||
/// Shift In, switch to G0 (normal half of character set).
|
||||
pub const SI: u8 = 0x0F;
|
||||
/// Data Link Escape, interpret next control character specially.
|
||||
pub const DLE: u8 = 0x10;
|
||||
/// (DC1) Terminal is allowed to resume transmitting.
|
||||
pub const XON: u8 = 0x11;
|
||||
/// Device Control 2, causes ASR-33 to activate paper-tape reader.
|
||||
pub const DC2: u8 = 0x12;
|
||||
/// (DC3) Terminal must pause and refrain from transmitting.
|
||||
pub const XOFF: u8 = 0x13;
|
||||
/// Device Control 4, causes ASR-33 to deactivate paper-tape reader.
|
||||
pub const DC4: u8 = 0x14;
|
||||
/// Negative Acknowledge, used sometimes with ETX and ACK.
|
||||
pub const NAK: u8 = 0x15;
|
||||
/// Synchronous Idle, used to maintain timing in Sync communication.
|
||||
pub const SYN: u8 = 0x16;
|
||||
/// End of Transmission block.
|
||||
pub const ETB: u8 = 0x17;
|
||||
/// Cancel (makes VT100 abort current escape sequence if any).
|
||||
pub const CAN: u8 = 0x18;
|
||||
/// End of Medium.
|
||||
pub const EM: u8 = 0x19;
|
||||
/// Substitute (VT100 uses this to display parity errors).
|
||||
pub const SUB: u8 = 0x1A;
|
||||
/// Prefix to an escape sequence.
|
||||
pub const ESC: u8 = 0x1B;
|
||||
/// File Separator.
|
||||
pub const FS: u8 = 0x1C;
|
||||
/// Group Separator.
|
||||
pub const GS: u8 = 0x1D;
|
||||
/// Record Separator (sent by VT132 in block-transfer mode).
|
||||
pub const RS: u8 = 0x1E;
|
||||
/// Unit Separator.
|
||||
pub const US: u8 = 0x1F;
|
||||
/// Delete, should be ignored by terminal.
|
||||
pub const DEL: u8 = 0x7f;
|
||||
}
|
||||
|
||||
/// C1 set of control characters. These are set to their 2-byte equivalent representations (rather
|
||||
/// than the 8-bit single byte representation).
|
||||
///
|
||||
/// See https://www.xfree86.org/current/ctlseqs.html#C1%20(8-Bit)%20Control%20Characters.
|
||||
#[allow(non_snake_case)]
|
||||
pub mod C1 {
|
||||
use super::C0::ESC;
|
||||
|
||||
/// Index
|
||||
pub const IND: &[u8] = &[ESC, b'D'];
|
||||
/// Next Line
|
||||
pub const NEL: &[u8] = &[ESC, b'E'];
|
||||
/// Tab Set
|
||||
pub const HTS: &[u8] = &[ESC, b'H'];
|
||||
/// Reverse Index
|
||||
pub const RI: &[u8] = &[ESC, b'M'];
|
||||
/// Single Shift Select of G2 Character Set
|
||||
pub const SS2: &[u8] = &[ESC, b'N'];
|
||||
/// Single Shift Select of G3 Character Set
|
||||
pub const SS3: &[u8] = &[ESC, b'O'];
|
||||
/// Device Control String
|
||||
pub const DCS: &[u8] = &[ESC, b'P'];
|
||||
/// Start of Guarded Area
|
||||
pub const SPA: &[u8] = &[ESC, b'V'];
|
||||
/// End of Guarded Area
|
||||
pub const EPA: &[u8] = &[ESC, b'W'];
|
||||
/// Start of String
|
||||
pub const SOS: &[u8] = &[ESC, b'X'];
|
||||
/// Return Terminal ID
|
||||
pub const DECID: &[u8] = &[ESC, b'Z']; //obsolete form of CSI c
|
||||
/// Control Sequence Introducer
|
||||
pub const CSI: &[u8] = &[ESC, b'['];
|
||||
/// String Terminator
|
||||
pub const ST: &[u8] = &[ESC, b'\\'];
|
||||
/// Operating System Command
|
||||
pub const OSC: &[u8] = &[ESC, b']'];
|
||||
/// Privacy Message
|
||||
pub const PM: &[u8] = &[ESC, b'^'];
|
||||
/// Application Program Command
|
||||
pub const APC: &[u8] = &[ESC, b'_'];
|
||||
|
||||
/// Converts the given `c1_sequence`, which is expected to be one of the constants defined in
|
||||
/// this module, into a string. C1 sequences are ASCII-encoded (which is by definition a subset
|
||||
/// of UTF-8), so no need to return an `Option` or check the result of `std::from_utf8()`.
|
||||
pub fn to_utf8(c1_sequence: &[u8]) -> &str {
|
||||
// We are certain that CSI is valid UTF-8.
|
||||
std::str::from_utf8(c1_sequence).expect(
|
||||
"Called with an invalid C1 sequence.This method should only be called with C1 \
|
||||
sequences defined by constants in the C1 module.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape sequences used to control 'bracketed paste' mode.
|
||||
///
|
||||
/// If the shell supports bracketed paste mode, these control sequences should be inserted at the
|
||||
/// start and end of text written to the pty. See the[xterm spec](http://www.xfree86.org/current/ctlseqs.html#Bracketed%20Paste%20Mode)
|
||||
/// for more details.
|
||||
pub const BRACKETED_PASTE_START: &[u8] = &[C0::ESC, b'[', b'2', b'0', b'0', b'~'];
|
||||
pub const BRACKETED_PASTE_END: &[u8] = &[C0::ESC, b'[', b'2', b'0', b'1', b'~'];
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub mod EscCodes {
|
||||
use super::{ModeProvider, TermMode, C0, C1};
|
||||
|
||||
// Arrows-related escape codes
|
||||
pub const ARROW_UP: u8 = b'A';
|
||||
pub const ARROW_DOWN: u8 = b'B';
|
||||
pub const ARROW_RIGHT: u8 = b'C';
|
||||
pub const ARROW_LEFT: u8 = b'D';
|
||||
|
||||
pub const WORD_LEFT: &[u8] = &[C0::ESC, b'b'];
|
||||
pub const WORD_RIGHT: &[u8] = &[C0::ESC, b'f'];
|
||||
|
||||
// Navigation escape codes
|
||||
pub const PAGE_UP: &[u8] = b"5~";
|
||||
pub const PAGE_DOWN: &[u8] = b"6~";
|
||||
pub const BACKWARD_TABULATION: &[u8] = b"Z";
|
||||
|
||||
// Special keys
|
||||
pub const HOME: u8 = b'H';
|
||||
pub const END: u8 = b'F';
|
||||
|
||||
// Mouse-related escape codes
|
||||
pub const MOUSE_LEFT: u8 = 0;
|
||||
pub const MOUSE_RIGHT: u8 = 2;
|
||||
pub const MOUSE_DRAG: u8 = 32;
|
||||
pub const MOUSE_MOVE: u8 = 35;
|
||||
pub const MOUSE_WHEEL_UP: u8 = 64;
|
||||
pub const MOUSE_WHEEL_DOWN: u8 = 65;
|
||||
|
||||
pub const FOCUS_IN: &[u8] = &[C0::ESC, b'[', b'I'];
|
||||
pub const FOCUS_OUT: &[u8] = &[C0::ESC, b'[', b'O'];
|
||||
|
||||
pub fn build_escape_sequence_with_c1(c1: &[u8], c: &[u8]) -> Vec<u8> {
|
||||
let mut sequence = Vec::new();
|
||||
sequence.extend_from_slice(c1);
|
||||
sequence.extend_from_slice(c);
|
||||
sequence
|
||||
}
|
||||
|
||||
pub fn build_escape_sequence(mode_provider: &impl ModeProvider, c: &[u8]) -> Vec<u8> {
|
||||
let c1 = get_c1_sequence(mode_provider);
|
||||
build_escape_sequence_with_c1(c1, c)
|
||||
}
|
||||
|
||||
/// Returns the C1 code that should be used to start an escape sequence based on terminal's
|
||||
/// term_mode.
|
||||
pub fn get_c1_sequence(mode_provider: &impl ModeProvider) -> &'static [u8] {
|
||||
// Usually we use CSI for most escape sequences.
|
||||
// However, for programs that set CursorKeys mode we should use SS3 instead.
|
||||
// This difference is critical when we want the arrow keys to work in the interactive
|
||||
// programs as well as alt_screen during the long running commands.
|
||||
// Check https://en.wikipedia.org/wiki/ANSI_escape_code#Fe_Escape_sequences for more
|
||||
// information about CSI/SS3 and others.
|
||||
if mode_provider.is_term_mode_set(TermMode::APP_CURSOR) {
|
||||
return C1::SS3;
|
||||
}
|
||||
C1::CSI
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for objects that can provide information about the terminal's mode.
|
||||
pub trait ModeProvider {
|
||||
fn is_term_mode_set(&self, mode: TermMode) -> bool;
|
||||
}
|
||||
|
||||
/// To be implemented on event objects (e.g. Keystroke or MouseState) that may be converted to
|
||||
/// escape sequences to be sent to the pty.
|
||||
pub trait ToEscapeSequence<T> {
|
||||
/// Returns the appropriate escape code to be passed to the pty corresponding to this event, if
|
||||
/// any.
|
||||
fn to_escape_sequence(&self, mode_provider: &T) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Pairs a keystroke with platform-provided key details for accurate escape sequence encoding.
|
||||
pub struct KeystrokeWithDetails<'a> {
|
||||
pub keystroke: &'a Keystroke,
|
||||
pub key_without_modifiers: Option<&'a str>,
|
||||
/// The text that this key event would insert, as provided by the OS input system.
|
||||
/// Used for the REPORT_ASSOCIATED_TEXT enhancement (Kitty flag 16).
|
||||
pub chars: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<T: ModeProvider> ToEscapeSequence<T> for KeystrokeWithDetails<'_> {
|
||||
fn to_escape_sequence(&self, mode_provider: &T) -> Option<Vec<u8>> {
|
||||
if let Some(csi_u) = maybe_convert_keystroke_to_csi_u(
|
||||
self.keystroke,
|
||||
self.key_without_modifiers,
|
||||
self.chars,
|
||||
mode_provider,
|
||||
) {
|
||||
return Some(csi_u);
|
||||
}
|
||||
|
||||
// Legacy encoding fallback.
|
||||
// NOTE: Order matters! We assume all fn keystrokes have been handled by the
|
||||
// time we reach meta_keystroke_to_escape_sequence.
|
||||
let keystroke = self.keystroke;
|
||||
fn_keystroke_to_escape_sequence(keystroke, mode_provider)
|
||||
.or_else(|| keystroke_to_c0_control_code(keystroke, mode_provider))
|
||||
.or_else(|| cursor_movement_keystroke_to_escape_sequence(keystroke, mode_provider))
|
||||
.or_else(|| meta_keystroke_to_escape_sequence(keystroke, mode_provider))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ModeProvider> ToEscapeSequence<T> for MouseState {
|
||||
fn to_escape_sequence(&self, _mode_provider: &T) -> Option<Vec<u8>> {
|
||||
let action = match self.action() {
|
||||
MouseAction::Released => 'm',
|
||||
_ => 'M',
|
||||
};
|
||||
let (button, repeats) = match self.button() {
|
||||
MouseButton::Left => (EscCodes::MOUSE_LEFT, 1),
|
||||
MouseButton::Right => (EscCodes::MOUSE_RIGHT, 1),
|
||||
MouseButton::LeftDrag => (EscCodes::MOUSE_DRAG, 1),
|
||||
MouseButton::Move => (EscCodes::MOUSE_MOVE, 1),
|
||||
MouseButton::Wheel => {
|
||||
if let MouseAction::Scrolled { delta } = self.action() {
|
||||
let lines = delta.unsigned_abs() as usize;
|
||||
if *delta > 0 {
|
||||
(EscCodes::MOUSE_WHEEL_UP, lines)
|
||||
} else {
|
||||
(EscCodes::MOUSE_WHEEL_DOWN, lines)
|
||||
}
|
||||
} else {
|
||||
panic!("Currently only scroll is supported for the Wheel button")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let point = self.maybe_point()?;
|
||||
let msg = format!(
|
||||
"{}<{};{};{}{}",
|
||||
C1::to_utf8(C1::CSI),
|
||||
button,
|
||||
point.col + 1,
|
||||
point.row + 1,
|
||||
action
|
||||
)
|
||||
.repeat(repeats);
|
||||
Some(msg.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToModifierEscapeByte {
|
||||
/// Returns the modifier escape byte represented by this T.
|
||||
///
|
||||
/// The returned modifier byte is typically meant to be inserted into escape sequence
|
||||
/// corresponding to the keystroke. See the implementation of this trait for
|
||||
/// `Keystroke` for more details.
|
||||
fn to_modifier_escape_byte(&self) -> Option<u8>;
|
||||
}
|
||||
|
||||
impl ToModifierEscapeByte for Keystroke {
|
||||
// Mirrors the [xterm implementation](https://www.xfree86.org/current/ctlseqs.html#PC-Style%20Function%20Keys).
|
||||
fn to_modifier_escape_byte(&self) -> Option<u8> {
|
||||
match self {
|
||||
Keystroke {
|
||||
shift: true,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
meta: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'2'),
|
||||
Keystroke {
|
||||
shift: false,
|
||||
alt: true,
|
||||
ctrl: false,
|
||||
meta: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'3'),
|
||||
Keystroke {
|
||||
shift: true,
|
||||
alt: true,
|
||||
ctrl: false,
|
||||
meta: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'4'),
|
||||
Keystroke {
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
meta: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'5'),
|
||||
Keystroke {
|
||||
shift: true,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
meta: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'6'),
|
||||
Keystroke {
|
||||
shift: false,
|
||||
alt: true,
|
||||
ctrl: true,
|
||||
meta: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'7'),
|
||||
Keystroke {
|
||||
shift: true,
|
||||
alt: true,
|
||||
ctrl: true,
|
||||
meta: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'8'),
|
||||
// meta can be basically treated the same way as alt...
|
||||
Keystroke {
|
||||
meta: true,
|
||||
ctrl: _,
|
||||
alt: _,
|
||||
shift: _,
|
||||
cmd: _,
|
||||
key: _,
|
||||
} => Some(b'3'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate escape sequence for the given fn key, which may or may not be modified
|
||||
/// via modifier key(s).
|
||||
///
|
||||
/// If the given keystroke is not an fn key, returns None.
|
||||
fn fn_keystroke_to_escape_sequence(
|
||||
keystroke: &Keystroke,
|
||||
_mode_provider: &impl ModeProvider,
|
||||
) -> Option<Vec<u8>> {
|
||||
match keystroke.key.as_str() {
|
||||
"f1" | "f2" | "f3" | "f4" | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" | "f12"
|
||||
| "f13" | "f14" | "f15" | "f16" | "f17" | "f18" | "f19" | "f20" => {
|
||||
let modifier_byte = keystroke.to_modifier_escape_byte();
|
||||
match modifier_byte {
|
||||
Some(modifier_byte) => fn_keystroke_with_modifier_to_escape_sequence(
|
||||
keystroke.key.as_str(),
|
||||
modifier_byte,
|
||||
),
|
||||
None => fn_keystroke_without_modifier_to_escape_sequence(keystroke.key.as_str()),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the escape sequence for the given fn key with no additional modifier key. If `key` is
|
||||
/// not a fn key, returns None.
|
||||
///
|
||||
/// Mapping from key to sequence is adapted from the xterm spec
|
||||
/// [here](https://www.xfree86.org/current/ctlseqs.html).
|
||||
fn fn_keystroke_without_modifier_to_escape_sequence(key: &str) -> Option<Vec<u8>> {
|
||||
match key {
|
||||
"f1" => Some([C1::SS3, b"P"].concat()),
|
||||
"f2" => Some([C1::SS3, b"Q"].concat()),
|
||||
"f3" => Some([C1::SS3, b"R"].concat()),
|
||||
"f4" => Some([C1::SS3, b"S"].concat()),
|
||||
"f5" => Some([C1::CSI, b"15~"].concat()),
|
||||
"f6" => Some([C1::CSI, b"17~"].concat()),
|
||||
"f7" => Some([C1::CSI, b"18~"].concat()),
|
||||
"f8" => Some([C1::CSI, b"19~"].concat()),
|
||||
"f9" => Some([C1::CSI, b"20~"].concat()),
|
||||
"f10" => Some([C1::CSI, b"21~"].concat()),
|
||||
"f11" => Some([C1::CSI, b"23~"].concat()),
|
||||
"f12" => Some([C1::CSI, b"24~"].concat()),
|
||||
"f13" => Some([C1::CSI, b"25~"].concat()),
|
||||
"f14" => Some([C1::CSI, b"26~"].concat()),
|
||||
"f15" => Some([C1::CSI, b"28~"].concat()),
|
||||
"f16" => Some([C1::CSI, b"29~"].concat()),
|
||||
"f17" => Some([C1::CSI, b"31~"].concat()),
|
||||
"f18" => Some([C1::CSI, b"32~"].concat()),
|
||||
"f19" => Some([C1::CSI, b"33~"].concat()),
|
||||
"f20" => Some([C1::CSI, b"34~"].concat()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the escape sequence for the given fn key with the given modifier_byte, which is mapped
|
||||
/// from the modifiers in the original keystroke. If `key` is not a function key, returns None.
|
||||
///
|
||||
/// Mapping from key to sequence is adapted from the xterm spec
|
||||
/// [here](https://www.xfree86.org/current/ctlseqs.html).
|
||||
fn fn_keystroke_with_modifier_to_escape_sequence(key: &str, modifier_byte: u8) -> Option<Vec<u8>> {
|
||||
match key {
|
||||
"f1" => Some([C1::CSI, format!("1;{}P", modifier_byte as char).as_bytes()].concat()),
|
||||
"f2" => Some([C1::CSI, format!("1;{}Q", modifier_byte as char).as_bytes()].concat()),
|
||||
"f3" => Some([C1::CSI, format!("1;{}R", modifier_byte as char).as_bytes()].concat()),
|
||||
"f4" => Some([C1::CSI, format!("1;{}S", modifier_byte as char).as_bytes()].concat()),
|
||||
"f5" => Some([C1::CSI, format!("15;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f6" => Some([C1::CSI, format!("17;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f7" => Some([C1::CSI, format!("18;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f8" => Some([C1::CSI, format!("19;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f9" => Some([C1::CSI, format!("20;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f10" => Some([C1::CSI, format!("21;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f11" => Some([C1::CSI, format!("23;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f12" => Some([C1::CSI, format!("24;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f13" => Some([C1::CSI, format!("25;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f14" => Some([C1::CSI, format!("26;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f15" => Some([C1::CSI, format!("28;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f16" => Some([C1::CSI, format!("29;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f17" => Some([C1::CSI, format!("31;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f18" => Some([C1::CSI, format!("32;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f19" => Some([C1::CSI, format!("33;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
"f20" => Some([C1::CSI, format!("34;{}~", modifier_byte as char).as_bytes()].concat()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the C0 control code for the given keystroke.
|
||||
///
|
||||
/// These control codes are emitted on ctrl-modified keystrokes. Note that the spec explicitly
|
||||
/// specifies ctrl-only modified keystrokes. The excat control code mapping is taken from the
|
||||
/// VT-220 spec [here](https://vt100.net/docs/vt220-rm/chapter3.html#S3.2.5).
|
||||
///
|
||||
/// Note that C0 control codes are (definitionally) a single byte, so the returned vector, if any,
|
||||
/// is always length 1.
|
||||
fn keystroke_to_c0_control_code(
|
||||
keystroke: &Keystroke,
|
||||
_mode_provider: &impl ModeProvider,
|
||||
) -> Option<Vec<u8>> {
|
||||
lazy_static! {
|
||||
static ref KEYSTROKE_TO_C0_CODE: HashMap<&'static str, u8> = HashMap::from([
|
||||
(" ", C0::NUL),
|
||||
("2", C0::NUL),
|
||||
("3", C0::ESC),
|
||||
("4", C0::FS),
|
||||
("5", C0::GS),
|
||||
("6", C0::RS),
|
||||
("7", C0::US),
|
||||
("8", C0::DEL),
|
||||
]);
|
||||
}
|
||||
|
||||
// Only emit C0 codes on ctrl-modified keystrokes, without other modifiers, per the VT-220
|
||||
// spec.
|
||||
if !(keystroke.ctrl && !keystroke.alt && !keystroke.shift && !keystroke.meta) {
|
||||
// Return None if the keystroke is not ctrl-key.
|
||||
return None;
|
||||
}
|
||||
|
||||
if KEYSTROKE_TO_C0_CODE.contains_key(keystroke.key.as_str()) {
|
||||
return Some(vec![KEYSTROKE_TO_C0_CODE[keystroke.key.as_str()]]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the appropriate escape sequence for the given "cursor movement" keystroke.
|
||||
///
|
||||
/// "cursor movement" keystroke is defined as one of the arrow keys, "home" or "end". If the given
|
||||
/// keystroke is not a "cursor movement" keystroke, returns None.
|
||||
///
|
||||
/// Mapping from button to sequence is adapted from the xterm spec
|
||||
/// [here](https://www.xfree86.org/current/ctlseqs.html).
|
||||
fn cursor_movement_keystroke_to_escape_sequence(
|
||||
keystroke: &Keystroke,
|
||||
mode_provider: &impl ModeProvider,
|
||||
) -> Option<Vec<u8>> {
|
||||
lazy_static! {
|
||||
static ref CURSOR_KEYSTROKE_TO_CONTROL_CODE: HashMap<&'static str, u8> = HashMap::from([
|
||||
("up", b'A'),
|
||||
("down", b'B'),
|
||||
("right", b'C'),
|
||||
("left", b'D'),
|
||||
("home", b'H'),
|
||||
("end", b'F')
|
||||
]);
|
||||
}
|
||||
|
||||
let key = keystroke.key.as_str();
|
||||
if !CURSOR_KEYSTROKE_TO_CONTROL_CODE.contains_key(key) {
|
||||
return None;
|
||||
}
|
||||
let modifier_bytes = keystroke.to_modifier_escape_byte();
|
||||
match modifier_bytes {
|
||||
Some(modifier_bytes) => Some(
|
||||
[
|
||||
C1::CSI,
|
||||
b"1;",
|
||||
&[modifier_bytes, CURSOR_KEYSTROKE_TO_CONTROL_CODE[key]],
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
None => Some(
|
||||
[
|
||||
EscCodes::get_c1_sequence(mode_provider),
|
||||
&[CURSOR_KEYSTROKE_TO_CONTROL_CODE[key]],
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the byte array corresponding to a special key, if a special key is provided.
|
||||
/// Otherwise, returns None.
|
||||
/// We prefer using match over a HashMap due to LLVM being able to optimize this
|
||||
/// further than a HashMap.
|
||||
fn map_special_key_to_bytes(key: &str) -> Option<&[u8]> {
|
||||
match key {
|
||||
"backspace" => Some("\x7f".as_bytes()),
|
||||
"insert" => Some("\x1b[2~".as_bytes()),
|
||||
"delete" => Some("\x1b[3~".as_bytes()),
|
||||
"pageup" => Some("\x1b[5~".as_bytes()),
|
||||
"pagedown" => Some("\x1b[6~".as_bytes()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate escape sequence for the given meta-modified keystroke.
|
||||
///
|
||||
/// If the given keystroke is not meta-modified, returns None.
|
||||
fn meta_keystroke_to_escape_sequence(
|
||||
keystroke: &Keystroke,
|
||||
_mode_provider: &impl ModeProvider,
|
||||
) -> Option<Vec<u8>> {
|
||||
// On mac, we have a setting that allows users to map the Option keys to
|
||||
// meta.
|
||||
if OperatingSystem::get().is_mac() {
|
||||
if !keystroke.meta {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
// On other platforms, interpret the alt key as the meta modifier.
|
||||
if !keystroke.alt {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let key = &keystroke.key;
|
||||
|
||||
// We check if the key pressed was a special key i.e. not a normal character first.
|
||||
// If it is, we look up the correct byte sequence for that special key and combine that with Meta.
|
||||
// Note that we purposely do not check for fn keys here since we expect fn_keystroke_to_escape_sequence
|
||||
// already captured fn + Meta combos!
|
||||
if let Some(bytes) = map_special_key_to_bytes(key) {
|
||||
Some([&[C0::ESC], bytes].concat())
|
||||
} else {
|
||||
Some([&[C0::ESC], key.as_bytes()].concat())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "escape_sequences_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,325 @@
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::keyboard::KeyCode;
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
|
||||
use super::{ModeProvider, TermMode};
|
||||
|
||||
/// Checks whether the Kitty keyboard protocol requires CSI u encoding for this
|
||||
/// keystroke, and if so, returns the encoded sequence.
|
||||
///
|
||||
/// Under REPORT_ALL_KEYS_AS_ESCAPE (flag 8), all keys use CSI u.
|
||||
/// Under DISAMBIGUATE_ESCAPE_CODES (flag 1), only ambiguous keys use CSI u.
|
||||
/// Returns None if CSI u is not needed (caller should fall back to legacy encoding).
|
||||
pub(super) fn maybe_convert_keystroke_to_csi_u(
|
||||
keystroke: &Keystroke,
|
||||
key_without_modifiers: Option<&str>,
|
||||
chars: Option<&str>,
|
||||
mode_provider: &dyn ModeProvider,
|
||||
) -> Option<Vec<u8>> {
|
||||
if !mode_provider.is_term_mode_set(TermMode::KEYBOARD_PROTOCOL) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Under the DISAMBIGUATE_ESCAPE_CODES flag (flag 1), the Kitty keyboard
|
||||
// protocol says to use CSI u encoding for keys that would otherwise be
|
||||
// ambiguous in legacy terminal encoding. This includes:
|
||||
// - The Escape key (ESC byte 0x1B is also the start of all escape sequences)
|
||||
// - Modified keys where the modifier is lost in legacy encoding (e.g.,
|
||||
// Ctrl+A → C0 code 0x01, Alt+a → ESC a on non-macOS)
|
||||
//
|
||||
// On macOS, Alt is excluded because Option generates composed characters
|
||||
// (e.g., Option+a → å) via the IME rather than acting as a modifier.
|
||||
//
|
||||
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#disambiguate
|
||||
let mut is_ambiguous = keystroke.key == "escape" || keystroke.ctrl || keystroke.meta;
|
||||
if !OperatingSystem::get().is_mac() {
|
||||
is_ambiguous = is_ambiguous || keystroke.alt;
|
||||
}
|
||||
// Shift alone is NOT ambiguous for printable keys — Shift changes the
|
||||
// character itself (e.g., Shift+a → A). But for functional keys like
|
||||
// Enter, Tab, and Backspace, legacy encoding drops Shift entirely
|
||||
// (Shift+Enter sends the same bytes as Enter), making them ambiguous.
|
||||
if keystroke.shift {
|
||||
is_ambiguous =
|
||||
is_ambiguous || matches!(keystroke.key.as_str(), "enter" | "tab" | "backspace");
|
||||
}
|
||||
|
||||
// With flag 8 (REPORT_ALL_KEYS_AS_ESC): use CSI u for all keys.
|
||||
// With flag 1 (DISAMBIGUATE_ESC_CODES): use CSI u only when ambiguous.
|
||||
let should_use_csi_u = mode_provider.is_term_mode_set(TermMode::KEYBOARD_REPORT_ALL_AS_ESCAPE)
|
||||
|| (mode_provider.is_term_mode_set(TermMode::KEYBOARD_DISAMBIGUATE_ESCAPE) && is_ambiguous);
|
||||
|
||||
if !should_use_csi_u {
|
||||
return None;
|
||||
}
|
||||
|
||||
keystroke_to_csi_u(keystroke, key_without_modifiers, chars, mode_provider)
|
||||
}
|
||||
|
||||
/// Encodes a keystroke to a CSI u escape sequence for the Kitty keyboard protocol.
|
||||
///
|
||||
/// Full format: CSI unicode-key-code[:shifted-key] ; modifiers[:event_type] ; text-as-codepoints u
|
||||
/// where modifiers is: 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0) + (super ? 8 : 0)
|
||||
///
|
||||
/// Key codes follow the Kitty protocol specification:
|
||||
/// - Standard keys use their Unicode codepoints (Enter=13, Tab=9, etc.)
|
||||
/// - Function keys F13-F35 use codes 57376-57398
|
||||
/// - When REPORT_ALTERNATE_KEYS (flag 4) is active and shift is held, the shifted key
|
||||
/// code is appended after a colon (e.g., `97:65` for shift+a).
|
||||
/// - When REPORT_ASSOCIATED_TEXT (flag 16) is active, the OS-provided text (`chars`)
|
||||
/// is appended as a colon-separated list of Unicode codepoints (e.g., `;65` for "A").
|
||||
/// - Event type encoding (press=1, repeat=2, release=3) is omitted for press events
|
||||
/// since press is the default. Repeat/release are not yet handled here; see
|
||||
/// `modifier_key_to_csi_u` for modifier key press/release.
|
||||
///
|
||||
/// See functional key definitions: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional
|
||||
///
|
||||
/// Returns None if the key cannot be encoded as a CSI u sequence.
|
||||
fn keystroke_to_csi_u(
|
||||
keystroke: &Keystroke,
|
||||
key_without_modifiers: Option<&str>,
|
||||
chars: Option<&str>,
|
||||
mode_provider: &(impl ModeProvider + ?Sized),
|
||||
) -> Option<Vec<u8>> {
|
||||
let report_alternate = mode_provider.is_term_mode_set(TermMode::KEYBOARD_REPORT_ALTERNATE_KEYS);
|
||||
let report_text = mode_provider.is_term_mode_set(TermMode::KEYBOARD_REPORT_ASSOCIATED_TEXT);
|
||||
|
||||
// Track the original (possibly shifted) character for alternate key / text reporting.
|
||||
let original_char: Option<char> = match keystroke.key.as_str() {
|
||||
key if key.chars().count() == 1 => key.chars().next(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Map keys to their key codes following the Kitty protocol specification.
|
||||
let key_code: u32 = match keystroke.key.as_str() {
|
||||
// Control characters (C0 codes)
|
||||
"enter" => 13,
|
||||
"tab" => 9,
|
||||
"escape" => 27,
|
||||
"backspace" => 127,
|
||||
"space" => 32,
|
||||
|
||||
// Function keys F13-F35 (Kitty-specific codes).
|
||||
// F1-F12 are intentionally omitted: the Kitty spec keeps their legacy encoding
|
||||
// format (SS3 P/Q/R/S for F1-F4, CSI <code> ~ for F5-F12) rather than CSI u.
|
||||
// They fall through to the legacy escape sequence encoding which already handles
|
||||
// modifier encoding correctly (CSI 1;mod P/Q/R/S for F1-F4 with modifiers).
|
||||
"f13" => 57376,
|
||||
"f14" => 57377,
|
||||
"f15" => 57378,
|
||||
"f16" => 57379,
|
||||
"f17" => 57380,
|
||||
"f18" => 57381,
|
||||
"f19" => 57382,
|
||||
"f20" => 57383,
|
||||
"f21" => 57384,
|
||||
"f22" => 57385,
|
||||
"f23" => 57386,
|
||||
"f24" => 57387,
|
||||
"f25" => 57388,
|
||||
"f26" => 57389,
|
||||
"f27" => 57390,
|
||||
"f28" => 57391,
|
||||
"f29" => 57392,
|
||||
"f30" => 57393,
|
||||
"f31" => 57394,
|
||||
"f32" => 57395,
|
||||
"f33" => 57396,
|
||||
"f34" => 57397,
|
||||
"f35" => 57398,
|
||||
|
||||
// For single printable characters, use the platform-provided base key
|
||||
// (without any modifiers) to get the correct Unicode codepoint.
|
||||
// Falls back to lowercasing ASCII letters when platform info isn't available.
|
||||
key if key.chars().count() == 1 => {
|
||||
if let Some(base) = key_without_modifiers.and_then(|k| k.chars().next()) {
|
||||
// Platform provided the unmodified key (e.g., '1' for Shift+1 on US layout)
|
||||
// CapsLock can cause key_without_modifiers to still report an
|
||||
// uppercase letter, so normalise to lowercase for the key code.
|
||||
let base = base.to_ascii_lowercase();
|
||||
base as u32
|
||||
} else {
|
||||
// No platform info available (e.g., tests, WASM). Lowercase ASCII letters
|
||||
// since that mapping is universal, but use the key as-is for symbols.
|
||||
let c = key.chars().next()?;
|
||||
if c.is_ascii_uppercase() {
|
||||
c.to_ascii_lowercase() as u32
|
||||
} else {
|
||||
c as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported keys
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Build the key code portion: `key_code[:shifted_key]`
|
||||
// Per spec: "the shifted key must be present only if shift is also present in the modifiers"
|
||||
let alternate_key_code = if report_alternate && keystroke.shift {
|
||||
original_char.and_then(|c| {
|
||||
let shifted = c as u32;
|
||||
// Only include alternate if it differs from the base key code.
|
||||
if shifted != key_code {
|
||||
Some(shifted)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let key_part = match alternate_key_code {
|
||||
Some(alt) => format!("{key_code}:{alt}"),
|
||||
None => key_code.to_string(),
|
||||
};
|
||||
|
||||
// Calculate modifier value per the Kitty protocol.
|
||||
// Kitty modifier bits: shift=1, alt=2, ctrl=4, super=8, hyper=16, meta=32
|
||||
// The wire value is 1 + (sum of active modifier bits).
|
||||
//
|
||||
// Keystroke field mapping:
|
||||
// keystroke.alt → Alt bit (2): raw Option key on macOS, Alt on other platforms
|
||||
// keystroke.meta → Alt bit (2): "Option-as-Meta" on macOS (terminal Alt)
|
||||
// keystroke.cmd → Super bit (8): Cmd on macOS, Super/Win on other platforms
|
||||
//
|
||||
// Both `alt` and `meta` map to the Kitty Alt bit because they both represent
|
||||
// the terminal concept of Alt — `meta` is just the macOS user preference that
|
||||
// remaps Option to behave as a terminal Meta/Alt key. They cannot both be true
|
||||
// simultaneously in practice (Option is either raw or Meta, never both).
|
||||
let mut modifiers = 1u32;
|
||||
if keystroke.shift {
|
||||
modifiers += 1;
|
||||
}
|
||||
if keystroke.alt || keystroke.meta {
|
||||
modifiers += 2;
|
||||
}
|
||||
if keystroke.ctrl {
|
||||
modifiers += 4;
|
||||
}
|
||||
if keystroke.cmd {
|
||||
modifiers += 8;
|
||||
}
|
||||
|
||||
// Compute associated text if REPORT_ASSOCIATED_TEXT is active.
|
||||
// Per spec: "The associated text must not contain control codes (control codes are code
|
||||
// points below U+0020 and codepoints in the C0 and C1 blocks)."
|
||||
let associated_text: Option<String> = if report_text {
|
||||
chars
|
||||
.filter(|text| !text.is_empty() && !text.chars().any(|c| c.is_control()))
|
||||
.map(|text| {
|
||||
text.chars()
|
||||
.map(|c| (c as u32).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Per the Kitty spec, event type 1 (press) is the default and can be omitted.
|
||||
// We only handle press events for regular keystrokes here; repeat/release events
|
||||
// are not yet plumbed through KeystrokeWithDetails. Modifier key press/release
|
||||
// events are handled separately in modifier_key_to_csi_u.
|
||||
let modifier_part = modifiers.to_string();
|
||||
|
||||
// Build the full sequence: CSI key_part [; modifiers [; text]] u
|
||||
// When associated text is present, the modifiers field must always be included
|
||||
// (even if it's 1) so the text field is correctly positioned.
|
||||
let sequence = if let Some(text) = &associated_text {
|
||||
format!("\x1b[{key_part};{modifier_part};{text}u")
|
||||
} else if modifiers > 1 {
|
||||
format!("\x1b[{key_part};{modifier_part}u")
|
||||
} else {
|
||||
format!("\x1b[{key_part}u")
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"Generated CSI u sequence for key '{}': {}",
|
||||
keystroke.key,
|
||||
sequence.escape_default()
|
||||
);
|
||||
Some(sequence.into_bytes())
|
||||
}
|
||||
|
||||
/// Encodes a modifier key press/release to a CSI u escape sequence for the Kitty keyboard protocol.
|
||||
///
|
||||
/// This is used when REPORT_ALL_KEYS_AS_ESC mode is active to report standalone modifier key
|
||||
/// press and release events. The format follows the Kitty protocol specification:
|
||||
/// - Format: CSI <key_code> ; <modifiers> [: <event_type>] u
|
||||
/// - Modifier key codes: ShiftLeft=57441, ControlLeft=57442, AltLeft=57443, SuperLeft=57444,
|
||||
/// ShiftRight=57447, ControlRight=57448, AltRight=57449, SuperRight=57450
|
||||
/// - Event types: 1=press, 2=repeat, 3=release
|
||||
///
|
||||
/// Returns None if the key is not a modifier key.
|
||||
pub fn modifier_key_to_csi_u(
|
||||
key_code: &KeyCode,
|
||||
is_press: bool,
|
||||
report_event_types: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let kitty_key_code = match key_code {
|
||||
KeyCode::ShiftLeft => 57441,
|
||||
KeyCode::ControlLeft => 57442,
|
||||
KeyCode::AltLeft => 57443,
|
||||
KeyCode::SuperLeft => 57444,
|
||||
KeyCode::ShiftRight => 57447,
|
||||
KeyCode::ControlRight => 57448,
|
||||
KeyCode::AltRight => 57449,
|
||||
KeyCode::SuperRight => 57450,
|
||||
KeyCode::CapsLock => 57358,
|
||||
KeyCode::NumLock => 57360,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Per the Kitty spec, when a modifier key is pressed alone, its own modifier
|
||||
// bit must be included (the "self" bit). For example, pressing Left Shift
|
||||
// produces modifiers = 1 + 1 = 2 (base 1 + shift bit 1).
|
||||
let modifiers = 1u32
|
||||
+ match key_code {
|
||||
KeyCode::ShiftLeft | KeyCode::ShiftRight => 1,
|
||||
KeyCode::AltLeft | KeyCode::AltRight => 2,
|
||||
KeyCode::ControlLeft | KeyCode::ControlRight => 4,
|
||||
KeyCode::SuperLeft | KeyCode::SuperRight => 8,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let sequence = if report_event_types {
|
||||
// Event type: 1 = press, 3 = release
|
||||
let event_type = if is_press { 1 } else { 3 };
|
||||
// With event types, we need the modifier field for the colon syntax
|
||||
format!("\x1b[{};{}:{}u", kitty_key_code, modifiers, event_type)
|
||||
} else {
|
||||
// Without event type reporting, only report press events
|
||||
if !is_press {
|
||||
return None;
|
||||
}
|
||||
// Modifiers always > 1 for modifier keys due to the self-bit
|
||||
format!("\x1b[{};{}u", kitty_key_code, modifiers)
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"Generated CSI u sequence for modifier key {:?}: {}",
|
||||
key_code,
|
||||
sequence.escape_default()
|
||||
);
|
||||
Some(sequence.into_bytes())
|
||||
}
|
||||
|
||||
/// Returns a CSI u escape sequence for a modifier key event if the terminal mode requires it.
|
||||
///
|
||||
/// Checks whether the REPORT_ALL_KEYS_AS_ESCAPE flag is active (which means standalone
|
||||
/// modifier key presses should be reported) and, if so, encodes the modifier key event.
|
||||
/// Returns `None` if the mode is not active or the key is not a modifier key.
|
||||
pub fn maybe_kitty_keyboard_escape_sequence(
|
||||
mode_provider: &dyn ModeProvider,
|
||||
key_code: &KeyCode,
|
||||
is_press: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
if !mode_provider.is_term_mode_set(TermMode::KEYBOARD_REPORT_ALL_AS_ESCAPE) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let report_event_types = mode_provider.is_term_mode_set(TermMode::KEYBOARD_REPORT_EVENT_TYPES);
|
||||
modifier_key_to_csi_u(key_code, is_press, report_event_types)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
use std::boxed::Box;
|
||||
|
||||
use bitflags::bitflags;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::ansi::{Color, NamedColor};
|
||||
use crate::model::char_or_str::CharOrStr;
|
||||
use crate::model::grid::row::Row;
|
||||
|
||||
/// The character set as the content in new, not-yet-set cells. This can be
|
||||
/// used to disambiguate between a cell which has never had any content and one
|
||||
/// which has had a space character written into it.
|
||||
pub const DEFAULT_CHAR: char = '\0';
|
||||
pub const DEFAULT_CHAR_BYTE: u8 = b'\0';
|
||||
pub const DEFAULT_CHAR_STR: &str = "\0";
|
||||
|
||||
/// Maximum byte length of a single cell's accumulated grapheme cluster
|
||||
/// (the base character plus any zero-width characters attached to it).
|
||||
///
|
||||
/// This is chosen to be:
|
||||
///
|
||||
/// 1. Well above the size of any legitimate grapheme cluster. Unicode's
|
||||
/// Stream-Safe Text Format (UAX #15) restricts runs of non-starters to
|
||||
/// at most 30 codepoints, which caps out around 120 bytes in UTF-8; the
|
||||
/// longest standardized emoji ZWJ sequences (e.g. multi-person family
|
||||
/// emoji with skin-tone modifiers) fit well below 100 bytes.
|
||||
/// 2. Well below the per-chunk size used by flat scrollback storage, so
|
||||
/// that pushing a cell's grapheme into scrollback can never produce an
|
||||
/// oversized grapheme that would violate the chunk-size invariant.
|
||||
pub const MAX_GRAPHEME_BYTES: usize = 256;
|
||||
|
||||
/// Soft threshold for warning about an unusually large accumulated
|
||||
/// grapheme cluster on a single cell. See [`Cell::push_zerowidth`].
|
||||
const WARN_GRAPHEME_BYTES: usize = 128;
|
||||
|
||||
bitflags! {
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Flags: u16 {
|
||||
const INVERSE = 0b0000_0000_0000_0001;
|
||||
const BOLD = 0b0000_0000_0000_0010;
|
||||
const ITALIC = 0b0000_0000_0000_0100;
|
||||
const BOLD_ITALIC = 0b0000_0000_0000_0110;
|
||||
const UNDERLINE = 0b0000_0000_0000_1000;
|
||||
const WRAPLINE = 0b0000_0000_0001_0000;
|
||||
const WIDE_CHAR = 0b0000_0000_0010_0000;
|
||||
const WIDE_CHAR_SPACER = 0b0000_0000_0100_0000;
|
||||
const DIM = 0b0000_0000_1000_0000;
|
||||
const DIM_BOLD = 0b0000_0000_1000_0010;
|
||||
const HIDDEN = 0b0000_0001_0000_0000;
|
||||
const STRIKEOUT = 0b0000_0010_0000_0000;
|
||||
const LEADING_WIDE_CHAR_SPACER = 0b0000_0100_0000_0000;
|
||||
const DOUBLE_UNDERLINE = 0b0000_1000_0000_0000;
|
||||
/// Set on cells which are the locations of cursor points and should be
|
||||
/// tracked through grid resizes.
|
||||
const HAS_CURSOR = 0b0001_0000_0000_0000;
|
||||
/// Equivalent to the union of all of the following: Flags::UNDERLINE,
|
||||
/// Flags::STRIKEOUT, Flags::DOUBLE_UNDERLINE.
|
||||
const CELL_DECORATIONS = 0b0000_1010_0000_1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the legacy serialization strategy for bitflags. The 2.XX version of bitflags has a different
|
||||
// serialization strategy, which would require us to update our ref tests.
|
||||
impl serde::Serialize for Flags {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
bitflags_serde_legacy::serialize(self, "Flags", serializer)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the legacy serialization strategy for bitflags. The 2.XX version of bitflags has a different
|
||||
// serialization strategy, which would require us to update our ref tests.
|
||||
impl<'de> serde::Deserialize<'de> for Flags {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
bitflags_serde_legacy::deserialize("Flags", deserializer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for determining if a reset should be performed.
|
||||
pub trait ResetDiscriminant<T> {
|
||||
/// Value based on which equality for the reset will be determined.
|
||||
fn discriminant(&self) -> T;
|
||||
}
|
||||
|
||||
impl<T: Copy> ResetDiscriminant<T> for T {
|
||||
fn discriminant(&self) -> T {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl ResetDiscriminant<Color> for Cell {
|
||||
fn discriminant(&self) -> Color {
|
||||
self.bg
|
||||
}
|
||||
}
|
||||
|
||||
/// Struct used simply as a "marker" for indicating whether a cell is at the end of the prompt.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub(super) struct EndOfPromptMarker {
|
||||
/// Defined in the case of EndOfPromptMarker being at the end of a line. Indicates whether
|
||||
/// the prompt has a trailing newline (that isn't covered in the marker, which is inclusive of
|
||||
/// printable characters only).
|
||||
pub has_extra_trailing_newline: bool,
|
||||
}
|
||||
|
||||
/// Dynamically allocated cell content.
|
||||
///
|
||||
/// This storage is reserved for cell attributes which are rarely set. This allows reducing the
|
||||
/// allocation required ahead of time for every cell, with some additional overhead when the extra
|
||||
/// storage is actually required.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)]
|
||||
struct CellExtra {
|
||||
/// Zerowidth characters stored in this cell WITH the base character at the start. This helps
|
||||
/// optimize reads on this data structure (we don't need to allocate a new string to join the
|
||||
/// base character and zerowidth characters).
|
||||
cell_with_zero_width: Option<String>,
|
||||
end_of_prompt: Option<EndOfPromptMarker>,
|
||||
}
|
||||
|
||||
/// Content and attributes of a single cell in the terminal grid.
|
||||
/// NOTE: Many cells are allocated per grid, so this should be as memory compact as possible. Fields
|
||||
/// that may be optional, or set for only a few cells, should go into the `CellExtra` instead.
|
||||
///
|
||||
/// Additional memory usage note: Due to holding a pointer (Box), this struct has an alignment of
|
||||
/// 8 bytes. This means that the total size taken up by the struct in memory will be an even
|
||||
/// multiple of 8; if the data is not an even multiple then padding will be added to reach an even
|
||||
/// value. Currently, this holds exactly 24 bytes, so it is tightly packed and does not need any
|
||||
/// padding:
|
||||
///
|
||||
/// * c: 4 bytes (equivalent to a u32)
|
||||
/// * fg: 5 bytes (the data contains 4 bytes plus a discriminator for the enum variant. Since it
|
||||
/// has an alignment of 1, the extra space required by the discriminator is only 1
|
||||
/// byte. Altering the data could change the alignment, which could then result in
|
||||
/// more padding and the total size of `Color` increasing)
|
||||
/// * bg: 5 bytes (Same as fg)
|
||||
/// * flags: 2 bytes (stored as a u16)
|
||||
/// * extra: 8 bytes (pointer with null representing None)
|
||||
///
|
||||
/// Increasing any of these values by even 1 byte will cause `Cell` to ultimately take up 32 bytes
|
||||
/// instead of 24, an increase of 33%.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
|
||||
pub struct Cell {
|
||||
pub c: char,
|
||||
pub fg: Color,
|
||||
pub bg: Color,
|
||||
pub flags: Flags,
|
||||
extra: Option<Box<CellExtra>>,
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
#[inline]
|
||||
fn default() -> Cell {
|
||||
Cell {
|
||||
c: DEFAULT_CHAR,
|
||||
bg: Color::Named(NamedColor::Background),
|
||||
fg: Color::Named(NamedColor::Foreground),
|
||||
flags: Flags::empty(),
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
/// Cell's character followed by all zerowidth characters stored in this cell. This
|
||||
/// is only Some if the cell has zerowidth characters.
|
||||
#[inline]
|
||||
fn content_with_zerowidth(&self) -> Option<&str> {
|
||||
self.extra
|
||||
.as_ref()
|
||||
.and_then(|extra| extra.cell_with_zero_width.as_deref())
|
||||
}
|
||||
|
||||
/// Returns the content of the cell that should be used for display
|
||||
/// purposes, e.g.: rendering or stringification.
|
||||
#[inline]
|
||||
pub fn content_for_display(&self) -> CharOrStr<'_> {
|
||||
match self.raw_content() {
|
||||
CharOrStr::Char(DEFAULT_CHAR) => CharOrStr::Char(' '),
|
||||
content => content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw cell content.
|
||||
///
|
||||
/// This may include non-printable marker characters.
|
||||
///
|
||||
/// TODO(visibility): This should be changed to `pub(super)` when possible.
|
||||
pub fn raw_content(&self) -> CharOrStr<'_> {
|
||||
match self.content_with_zerowidth() {
|
||||
Some(content_with_zerowidth) => CharOrStr::Str(content_with_zerowidth),
|
||||
None => CharOrStr::Char(self.c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a new zerowidth character to this cell.
|
||||
///
|
||||
/// Accumulated zero-width content is capped at [`MAX_GRAPHEME_BYTES`]
|
||||
/// so that adversarial or buggy input streams cannot produce a single
|
||||
/// grapheme cluster larger than the scrollback chunk size. See
|
||||
/// [`MAX_GRAPHEME_BYTES`] for details.
|
||||
///
|
||||
/// If `log_long_grapheme_warnings` is true, a [`log::warn!`] is
|
||||
/// emitted on the push that first takes this cell's accumulated
|
||||
/// grapheme across [`WARN_GRAPHEME_BYTES`]. Callers that are
|
||||
/// replaying already-validated grapheme content (e.g. materializing
|
||||
/// a row from flat scrollback storage, where the stored content was
|
||||
/// already capped on the way in) should pass `false` to suppress
|
||||
/// that redundant warning.
|
||||
#[inline]
|
||||
pub fn push_zerowidth(&mut self, c: char, log_long_grapheme_warnings: bool) {
|
||||
// If we're adding a zero-width character to this cell, but it has not
|
||||
// had any content set yet, set the content to a space. This preserves
|
||||
// its visual appearance, but clearly marks the cell as having been
|
||||
// modified from its default "empty" state.
|
||||
if self.c == DEFAULT_CHAR {
|
||||
self.c = ' ';
|
||||
}
|
||||
|
||||
let extra = self.extra.get_or_insert_with(Box::default);
|
||||
match &mut extra.cell_with_zero_width {
|
||||
Some(zerowidth) => {
|
||||
let old_len = zerowidth.len();
|
||||
let new_len = old_len + c.len_utf8();
|
||||
if new_len > MAX_GRAPHEME_BYTES {
|
||||
// The accumulated grapheme cluster would exceed our
|
||||
// per-cell cap, which is in turn well below the
|
||||
// scrollback chunk size. Silently drop additional
|
||||
// zero-width characters: logging every dropped
|
||||
// character would produce a flood of spam for
|
||||
// pathological streams.
|
||||
return;
|
||||
}
|
||||
zerowidth.push(c);
|
||||
// Log exactly once, on the push that first takes this cell
|
||||
// across the soft threshold. This surfaces unusually-large
|
||||
// graphemes in logs without producing per-character spam.
|
||||
if log_long_grapheme_warnings
|
||||
&& old_len < WARN_GRAPHEME_BYTES
|
||||
&& new_len >= WARN_GRAPHEME_BYTES
|
||||
{
|
||||
log::warn!(
|
||||
"cell grapheme has accumulated {new_len} bytes of zero-width content (base char {:?}); further zero-width pushes beyond {MAX_GRAPHEME_BYTES} bytes will be dropped",
|
||||
self.c,
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// First zero-width push seeds the string with the base
|
||||
// character. The base character is always a single `char`,
|
||||
// so it cannot by itself exceed the cap.
|
||||
extra.cell_with_zero_width = Some(format!("{}{}", self.c, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether cell is the end of prompt content (contains `EndOfPromptMarker`).
|
||||
#[inline]
|
||||
pub fn is_end_of_prompt(&self) -> bool {
|
||||
self.end_of_prompt_marker().is_some()
|
||||
}
|
||||
|
||||
/// Returns information about the end-of-prompt marker in this cell, if any.
|
||||
pub(super) fn end_of_prompt_marker(&self) -> Option<EndOfPromptMarker> {
|
||||
self.extra.as_ref()?.end_of_prompt
|
||||
}
|
||||
|
||||
/// Mark cell as the end of prompt content.
|
||||
#[inline]
|
||||
pub fn mark_end_of_prompt(&mut self, has_extra_trailing_newline: bool) {
|
||||
self.extra
|
||||
.get_or_insert_with(Default::default)
|
||||
.end_of_prompt = Some(EndOfPromptMarker {
|
||||
has_extra_trailing_newline,
|
||||
});
|
||||
}
|
||||
|
||||
/// Free all dynamically allocated cell storage. Preserves EndOfPromptMarker if present.
|
||||
#[inline]
|
||||
pub fn drop_extra(&mut self) {
|
||||
if let Some(extra) = self.extra.take() {
|
||||
if let Some(end_of_prompt_marker) = extra.end_of_prompt {
|
||||
// If we had a end of prompt marker, we preserve it (re-insert into extras).
|
||||
self.mark_end_of_prompt(end_of_prompt_marker.has_extra_trailing_newline);
|
||||
}
|
||||
// If `end_of_prompt` is None, `extra` is dropped here and not put back.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
#[inline]
|
||||
// TODO(visibility): This should be changed to `pub(crate)` when possible.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
// TODO(vorporeal): can this be a simple equality check vs. Cell::default()?
|
||||
self.c == DEFAULT_CHAR
|
||||
&& self.bg == Color::Named(NamedColor::Background)
|
||||
&& self.fg == Color::Named(NamedColor::Foreground)
|
||||
&& !self.flags.intersects(
|
||||
Flags::INVERSE
|
||||
| Flags::UNDERLINE
|
||||
| Flags::DOUBLE_UNDERLINE
|
||||
| Flags::STRIKEOUT
|
||||
| Flags::WRAPLINE
|
||||
| Flags::WIDE_CHAR_SPACER
|
||||
| Flags::LEADING_WIDE_CHAR_SPACER
|
||||
| Flags::HAS_CURSOR,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns whether or not rendering the cell would produce anything visible.
|
||||
pub fn is_visible(&self) -> bool {
|
||||
!self.is_empty() && !self.c.is_ascii_whitespace()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
// TODO(visibility): This should be changed to `pub(crate)` when possible.
|
||||
pub fn flags(&self) -> &Flags {
|
||||
&self.flags
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
// TODO(visibility): This should be changed to `pub(crate)` when possible.
|
||||
pub fn flags_mut(&mut self) -> &mut Flags {
|
||||
&mut self.flags
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn reset(&mut self, template: &Self) {
|
||||
*self = Cell {
|
||||
bg: template.bg,
|
||||
..Cell::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Color> for Cell {
|
||||
#[inline]
|
||||
fn from(color: Color) -> Self {
|
||||
Self {
|
||||
bg: color,
|
||||
..Cell::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the length of occupied cells in a line.
|
||||
pub trait LineLength {
|
||||
/// Calculate the occupied line length.
|
||||
fn line_length(&self) -> usize;
|
||||
}
|
||||
|
||||
impl LineLength for Row {
|
||||
fn line_length(&self) -> usize {
|
||||
// If the row has no cells, then the line length is 0, by definition.
|
||||
if self.len() == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut length = 0;
|
||||
|
||||
if self[self.len() - 1].flags.contains(Flags::WRAPLINE) {
|
||||
return self.len();
|
||||
}
|
||||
|
||||
for (index, cell) in self[..].iter().rev().enumerate() {
|
||||
if cell.c != DEFAULT_CHAR {
|
||||
length = self.len() - index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
length
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl From<char> for Cell {
|
||||
fn from(c: char) -> Self {
|
||||
Cell {
|
||||
c,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cell_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::{Cell, LineLength};
|
||||
|
||||
use crate::model::{
|
||||
char_or_str::CharOrStr,
|
||||
grid::{
|
||||
cell::{Flags, MAX_GRAPHEME_BYTES},
|
||||
row::Row,
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn verify_cell_size() {
|
||||
// If this test fails, then something has changed about Cell that alters its memory layout and
|
||||
// causes it to be a different size than expected. Verify carefully if that is expected before
|
||||
// updating the constant value.
|
||||
const EXPECTED_CELL_SIZE_IN_BYTES: usize = 24;
|
||||
|
||||
assert_eq!(std::mem::size_of::<Cell>(), EXPECTED_CELL_SIZE_IN_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_length_works() {
|
||||
let mut row = Row::new(10);
|
||||
row[5].c = 'a';
|
||||
|
||||
assert_eq!(row.line_length(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_length_works_with_wrapline() {
|
||||
let mut row = Row::new(10);
|
||||
row[9].flags.insert(super::Flags::WRAPLINE);
|
||||
|
||||
assert_eq!(row.line_length(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_length_works_with_empty_line() {
|
||||
let mut row = Row::new(1);
|
||||
row.shrink(0);
|
||||
assert_eq!(row.line_length(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_cell_decorations() {
|
||||
assert!(Flags::UNDERLINE.intersects(Flags::CELL_DECORATIONS));
|
||||
assert!(Flags::STRIKEOUT.intersects(Flags::CELL_DECORATIONS));
|
||||
assert!(Flags::DOUBLE_UNDERLINE.intersects(Flags::CELL_DECORATIONS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_zerowidth_caps_accumulated_grapheme() {
|
||||
// A ZWJ (U+200D) is three bytes in UTF-8. Push enough of them to go
|
||||
// well past `MAX_GRAPHEME_BYTES`, and verify that the accumulated
|
||||
// content stops growing at the cap.
|
||||
let mut cell = Cell {
|
||||
c: 'e',
|
||||
..Cell::default()
|
||||
};
|
||||
let zwj = '\u{200D}';
|
||||
let zwj_bytes = zwj.len_utf8();
|
||||
let pushes = (MAX_GRAPHEME_BYTES * 10) / zwj_bytes;
|
||||
for _ in 0..pushes {
|
||||
cell.push_zerowidth(zwj, /* log_long_grapheme_warnings */ true);
|
||||
}
|
||||
|
||||
let CharOrStr::Str(content) = cell.raw_content() else {
|
||||
panic!("cell should have accumulated zero-width content as a string");
|
||||
};
|
||||
// The stored content is "base char + N zero-width chars". The total
|
||||
// length in bytes must fit within the cap.
|
||||
assert!(
|
||||
content.len() <= MAX_GRAPHEME_BYTES,
|
||||
"expected stored content length {} to be <= cap {}",
|
||||
content.len(),
|
||||
MAX_GRAPHEME_BYTES,
|
||||
);
|
||||
// We also want the cap to actually be approached: the stored content
|
||||
// should contain many zero-width characters and not have been truncated
|
||||
// early.
|
||||
let zero_width_bytes = content.len() - 'e'.len_utf8();
|
||||
let zero_width_count = zero_width_bytes / zwj_bytes;
|
||||
assert!(
|
||||
zero_width_count >= 80,
|
||||
"expected at least 80 zero-width chars to fit, got {zero_width_count}",
|
||||
);
|
||||
assert!(content.starts_with('e'));
|
||||
assert!(content[1..].chars().all(|c| c == zwj));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_zerowidth_seeds_base_char_on_first_push() {
|
||||
// Before any zero-width char is pushed, the cell's raw content is just
|
||||
// the base char. After the first push, the content becomes a string
|
||||
// consisting of the base char plus the pushed zero-width char.
|
||||
let mut cell = Cell {
|
||||
c: 'x',
|
||||
..Cell::default()
|
||||
};
|
||||
assert_eq!(cell.raw_content(), CharOrStr::Char('x'));
|
||||
|
||||
cell.push_zerowidth('\u{0301}', /* log_long_grapheme_warnings */ true);
|
||||
assert_eq!(cell.raw_content(), CharOrStr::Str("x\u{0301}"));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::cell::{Cell, Flags};
|
||||
|
||||
/// The type of a cell.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum CellType {
|
||||
/// A cell containing a standard single-width character.
|
||||
RegularChar,
|
||||
/// The first cell in a double-width "wide" character.
|
||||
WideChar,
|
||||
/// The second cell in a double-width "wide" character.
|
||||
WideCharSpacer,
|
||||
/// A spacer at the end of a row where a wide character had to be wrapped
|
||||
/// to the next row due to having a cell width of 2 but only one cell was
|
||||
/// left in the row.
|
||||
LeadingWideCharSpacer,
|
||||
}
|
||||
|
||||
impl From<&Cell> for CellType {
|
||||
fn from(cell: &Cell) -> Self {
|
||||
// First, check if the cell has _any_ of the relevant flags. If not,
|
||||
// we're able to return NarrowChar with only one comparison/branch.
|
||||
// The other cell types are much less common, so we don't care as much
|
||||
// about the cost of extra comparisons for them.
|
||||
if !cell.flags().intersects(
|
||||
Flags::WIDE_CHAR | Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER,
|
||||
) {
|
||||
Self::RegularChar
|
||||
} else if cell.flags().intersects(Flags::WIDE_CHAR) {
|
||||
Self::WideChar
|
||||
} else if cell.flags().intersects(Flags::WIDE_CHAR_SPACER) {
|
||||
Self::WideCharSpacer
|
||||
} else {
|
||||
// At this point, there are no other possible cell types.
|
||||
Self::LeadingWideCharSpacer
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// Grid dimensions.
|
||||
pub trait Dimensions {
|
||||
/// Total number of lines in the buffer, this includes scrollback and visible lines.
|
||||
fn total_rows(&self) -> usize;
|
||||
|
||||
/// Number of rows in the viewport
|
||||
#[allow(dead_code)]
|
||||
fn visible_rows(&self) -> usize;
|
||||
|
||||
/// Width of the terminal in columns.
|
||||
fn columns(&self) -> usize;
|
||||
|
||||
/// Number of invisible lines part of the scrollback history.
|
||||
#[inline]
|
||||
fn history_size(&self) -> usize {
|
||||
self.total_rows() - self.visible_rows()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Dimensions for (usize, usize) {
|
||||
fn total_rows(&self) -> usize {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn visible_rows(&self) -> usize {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn columns(&self) -> usize {
|
||||
self.1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Dimensions for (crate::model::VisibleRow, usize) {
|
||||
fn total_rows(&self) -> usize {
|
||||
self.0 .0
|
||||
}
|
||||
|
||||
fn visible_rows(&self) -> usize {
|
||||
self.0 .0
|
||||
}
|
||||
|
||||
fn columns(&self) -> usize {
|
||||
self.1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use std::{
|
||||
collections::{btree_map, BTreeMap},
|
||||
ops::RangeFrom,
|
||||
};
|
||||
|
||||
use get_size::GetSize;
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
/// A structure that efficiently stores and retrieves the value of some grid
|
||||
/// attribute.
|
||||
///
|
||||
/// This internally coalesces ranges to store the data in a space-efficient
|
||||
/// manner.
|
||||
///
|
||||
/// A [`BTreeMap`] is used to achieve great performance both for looking up
|
||||
/// a value in the map and scanning forward from that point.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct AttributeMap<A> {
|
||||
/// Stores a mapping between an _ending_ byte offset (inclusive) and the
|
||||
/// attribute value for the range ending at the given offset.
|
||||
map: BTreeMap<ByteOffset, A>,
|
||||
/// The attribute value for all offsets beyond the last end offset stored
|
||||
/// in the map.
|
||||
tail_value: A,
|
||||
}
|
||||
|
||||
impl<A> AttributeMap<A> {
|
||||
pub fn new(starting_value: A) -> Self {
|
||||
Self {
|
||||
map: Default::default(),
|
||||
tail_value: starting_value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncates the attribute map to the given content offset.
|
||||
pub fn truncate(&mut self, new_len: ByteOffset) {
|
||||
// Split off any ranges that end after our new length.
|
||||
let mut truncated_ranges = self.map.split_off(&new_len);
|
||||
// If we split off any ranges, the first range defines our new tail value.
|
||||
if let Some((_, tail_value)) = truncated_ranges.pop_first() {
|
||||
self.tail_value = tail_value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncates the attribute map to start at the given content offset.
|
||||
pub fn truncate_front(&mut self, new_start_offset: ByteOffset) {
|
||||
self.map = self.map.split_off(&new_start_offset);
|
||||
}
|
||||
|
||||
/// Returns the end offset of the last range in the map.
|
||||
fn last_end_offset(&self) -> ByteOffset {
|
||||
if let Some((k, _v)) = self.map.last_key_value() {
|
||||
*k
|
||||
} else {
|
||||
ByteOffset::zero()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: PartialEq + std::fmt::Debug> AttributeMap<A> {
|
||||
/// Updates the map with the fact that the attribute value changes at the
|
||||
/// given byte offset.
|
||||
///
|
||||
/// The start of the provided range must be after the end of the last range
|
||||
/// in the map.
|
||||
pub fn push_attribute_change(&mut self, range: RangeFrom<ByteOffset>, value: A) {
|
||||
if value == self.tail_value {
|
||||
return;
|
||||
}
|
||||
|
||||
let prev_tail_value = std::mem::replace(&mut self.tail_value, value);
|
||||
|
||||
if range.start == ByteOffset::zero() {
|
||||
debug_assert!(self.map.last_key_value().is_none());
|
||||
} else {
|
||||
debug_assert!(
|
||||
range.start > self.last_end_offset(),
|
||||
"cannot push attribute change starting at {} when last end offset is {}. attribute map: {:?}",
|
||||
range.start,
|
||||
self.last_end_offset(),
|
||||
self.map,
|
||||
);
|
||||
self.map.insert(range.start - 1, prev_tail_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: GetSize> GetSize for AttributeMap<A> {
|
||||
fn get_heap_size(&self) -> usize {
|
||||
self.map.get_heap_size()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Copy> AttributeMap<A> {
|
||||
/// Returns an iterator over per-byte attribute values starting at the
|
||||
/// given byte offset.
|
||||
pub fn iter_from(&self, start_offset: ByteOffset) -> impl Iterator<Item = A> + '_ {
|
||||
Iter::new(self, start_offset)
|
||||
}
|
||||
|
||||
/// Returns the tail (current) value of the given attribute.
|
||||
pub fn tail(&self) -> A {
|
||||
self.tail_value
|
||||
}
|
||||
}
|
||||
|
||||
/// An iterator over an attribute map.
|
||||
struct Iter<'a, A> {
|
||||
cur_offset: ByteOffset,
|
||||
cur_range: (ByteOffset, A),
|
||||
inner: btree_map::Range<'a, ByteOffset, A>,
|
||||
tail_value: A,
|
||||
}
|
||||
|
||||
impl<'a, A: Copy> Iter<'a, A> {
|
||||
fn new(map: &'a AttributeMap<A>, start_offset: ByteOffset) -> Self {
|
||||
let mut inner = map.map.range(start_offset..);
|
||||
let cur_range = Self::next_range(&mut inner, map.tail_value);
|
||||
|
||||
Self {
|
||||
cur_offset: start_offset,
|
||||
cur_range,
|
||||
inner,
|
||||
tail_value: map.tail_value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the end point and value for the next range.
|
||||
fn next_range(inner: &mut btree_map::Range<ByteOffset, A>, tail: A) -> (ByteOffset, A) {
|
||||
inner
|
||||
.next()
|
||||
.map(|(k, v)| (*k, *v))
|
||||
// If there are no more ranges in the map, return an "open" range
|
||||
// with the tail attribute value.
|
||||
.unwrap_or((ByteOffset::from(usize::MAX), tail))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> Iterator for Iter<'_, A>
|
||||
where
|
||||
A: Copy,
|
||||
{
|
||||
type Item = A;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.nth(0)
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
// Skip over the next n items.
|
||||
self.cur_offset += n;
|
||||
// While the offset of the next item is outside the current range, get
|
||||
// the next range from the iterator over our BTreeMap.
|
||||
while self.cur_offset > self.cur_range.0 {
|
||||
self.cur_range = Self::next_range(&mut self.inner, self.tail_value);
|
||||
}
|
||||
|
||||
// Get the value and advance the iterator by one, in preparation for
|
||||
// the next call.
|
||||
let val = self.cur_range.1;
|
||||
self.cur_offset += 1;
|
||||
Some(val)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "attribute_map_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,34 @@
|
||||
use itertools::Itertools as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
type TestAttributeMap = AttributeMap<usize>;
|
||||
|
||||
#[test]
|
||||
fn test_iterate_over_empty_map() {
|
||||
// Values:
|
||||
// * [0, ): 0
|
||||
let map = TestAttributeMap::new(0);
|
||||
|
||||
assert_eq!(
|
||||
map.iter_from(ByteOffset::zero()).next(),
|
||||
Some(usize::default())
|
||||
);
|
||||
assert_eq!(
|
||||
map.iter_from(ByteOffset::from(25625)).next(),
|
||||
Some(usize::default())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iterate_across_attribute_change() {
|
||||
// Values:
|
||||
// * [0, 2): 0
|
||||
// * [2, ): 1
|
||||
let mut map = TestAttributeMap::new(0);
|
||||
map.push_attribute_change(ByteOffset::from(2).., 1);
|
||||
|
||||
let iter = map.iter_from(ByteOffset::zero());
|
||||
|
||||
assert_eq!(iter.take(4).collect_vec(), vec![0, 0, 1, 1]);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Structures for storing the grid contents in a flat buffer.
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ops::{Index, Range},
|
||||
};
|
||||
|
||||
use get_size::GetSize;
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
use crate::model::char_or_str::CharOrStr;
|
||||
|
||||
use super::grapheme::Grapheme;
|
||||
|
||||
/// A helper structure that wraps the underlying grid content and provides
|
||||
/// higher-level APIs for accessing the data.
|
||||
///
|
||||
/// Internally, this stores content in a series of chunks. By chunking up
|
||||
/// the content, we can efficiently trim data off the _front_ of the content
|
||||
/// string without having to make any (expensive) copies.
|
||||
///
|
||||
/// From the outside, this structure is best conceptualized as a non-circular
|
||||
/// circular buffer, keyed by content offset. The start of the buffer is
|
||||
/// pointed to by `active_chunk.start_offset + active_chunk.len`, and the end
|
||||
/// of the buffer is pointed to by the `content_offset` of the first entry in
|
||||
/// the flat storage [`Index`](super::Index). We never "re-zero" the offsets
|
||||
/// in the buffer, and so the tail pointer only ever moves forward. (The head
|
||||
/// pointer can move backwards when we pop rows off of the end of flat storage,
|
||||
/// and so remove content from the end of the buffer.)
|
||||
///
|
||||
/// [`Content::push_grapheme`] inserts new content and moves the head pointer
|
||||
/// forwards; [`Content::truncate`] moves the head pointer backwards. The tail
|
||||
/// pointer isn't directly modified within this structure, as it is owned by
|
||||
/// [`super::Index`]. When rows are dropped from the index, this structure is
|
||||
/// notified via a call to [`Content::truncate_front`], which drops any chunks
|
||||
/// which entirely precede the new tail pointer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Content {
|
||||
/// The already-full chunks of content, keyed by the offset of the final
|
||||
/// byte of the chunk, inclusive. This offset is from the start of the
|
||||
/// content.
|
||||
///
|
||||
/// Keying by the offset of the final byte of the chunk means we can
|
||||
/// easily find all chunks starting from a given offset using the
|
||||
/// [`BTreeMap::range`] API, passing an open range starting from the
|
||||
/// target offset.
|
||||
filled_chunks: BTreeMap<ByteOffset, Chunk>,
|
||||
|
||||
/// The current chunk being built.
|
||||
active_chunk: Chunk,
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
filled_chunks: Default::default(),
|
||||
active_chunk: Chunk::new(ByteOffset::zero()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes the given grapheme onto the end of the content.
|
||||
pub fn push_grapheme(&mut self, grapheme: &Grapheme) {
|
||||
let grapheme_len = grapheme.len().as_usize();
|
||||
assert!(
|
||||
grapheme_len < Chunk::CHUNK_SIZE,
|
||||
"grapheme with length {grapheme_len} exceeds chunk size of {}",
|
||||
Chunk::CHUNK_SIZE
|
||||
);
|
||||
|
||||
// Check to make sure there's enough room in the active chunk.
|
||||
if self.active_chunk.len() + grapheme_len > self.active_chunk.capacity() {
|
||||
// Set a new chunk as the last chunk.
|
||||
let new_start_offset = self.active_chunk.content_range().end;
|
||||
let full_chunk =
|
||||
std::mem::replace(&mut self.active_chunk, Chunk::new(new_start_offset));
|
||||
|
||||
// Insert the now-full chunk into the map.
|
||||
let chunk_end_byte_offset = full_chunk.content_range().end - 1;
|
||||
self.filled_chunks.insert(chunk_end_byte_offset, full_chunk);
|
||||
}
|
||||
|
||||
self.active_chunk.push_char_or_str(grapheme.content());
|
||||
}
|
||||
|
||||
/// Truncates the content to the given length, in bytes.
|
||||
pub fn truncate(&mut self, new_len: ByteOffset) {
|
||||
// Split off any chunks that end after our new length.
|
||||
let mut truncated_chunks = self.filled_chunks.split_off(&new_len);
|
||||
// If we split off any chunks, the first removed chunk is our new active chunk.
|
||||
if let Some((_, active_chunk)) = truncated_chunks.pop_first() {
|
||||
self.active_chunk = active_chunk;
|
||||
}
|
||||
|
||||
// Shorten the active chunk accordingly.
|
||||
self.active_chunk.truncate(new_len);
|
||||
|
||||
debug_assert_eq!(self.end_offset(), new_len.as_usize());
|
||||
}
|
||||
|
||||
/// Drops chunks that entirely precede the given start offset.
|
||||
pub fn truncate_front(&mut self, new_start_offset: ByteOffset) {
|
||||
// We use this approach as opposed to something like `split_off` because
|
||||
// in the common case, we remove 0 or 1 chunks, which is more efficient
|
||||
// to do explicitly.
|
||||
loop {
|
||||
match self.filled_chunks.first_entry() {
|
||||
Some(entry) if entry.key() < &new_start_offset => {
|
||||
entry.remove();
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the offset of the end of the content (exclusive).
|
||||
///
|
||||
/// If a new grapheme is pushed into the buffer, this is the offset at
|
||||
/// which it will start.
|
||||
///
|
||||
/// This is somewhat similar to [`String::len`], but due to the circular
|
||||
/// nature of the buffer, it is not a true measure of the actual amount of
|
||||
/// content stored.
|
||||
pub fn end_offset(&self) -> usize {
|
||||
self.active_chunk.start_offset.as_usize() + self.active_chunk.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<Range<ByteOffset>> for Content {
|
||||
type Output = str;
|
||||
|
||||
fn index(&self, index: Range<ByteOffset>) -> &Self::Output {
|
||||
// Get the chunk that contains the start of the range, and the offset
|
||||
// of the start of that chunk.
|
||||
let chunk = self
|
||||
.filled_chunks
|
||||
.range(index.start..)
|
||||
.next()
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or(&self.active_chunk);
|
||||
|
||||
// Compute the start and end of the range within the chunk.
|
||||
debug_assert!(
|
||||
index.start >= chunk.start_offset,
|
||||
"grapheme start offset ({}) must be >= chunk.start_offset ({})",
|
||||
index.start,
|
||||
chunk.start_offset
|
||||
);
|
||||
debug_assert!(
|
||||
index.end <= chunk.content_range().end,
|
||||
"grapheme end offset ({}) must be <= chunk.end_offset ({})",
|
||||
index.end,
|
||||
chunk.content_range().end
|
||||
);
|
||||
let start = index.start - chunk.start_offset;
|
||||
let end = index.end - chunk.start_offset;
|
||||
|
||||
// Return the requested slice.
|
||||
&chunk[start.as_usize()..end.as_usize()]
|
||||
}
|
||||
}
|
||||
|
||||
// Manually implement `GetSize` instead of using the derive macro because
|
||||
// actually traversing the BTreeMap is expensive, and using this estimate
|
||||
// is close enough for our purposes.
|
||||
impl GetSize for Content {
|
||||
fn get_heap_size(&self) -> usize {
|
||||
self.active_chunk.get_heap_size() * (1 + self.filled_chunks.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// A single chunk of content, backed by a byte array.
|
||||
#[derive(Debug, Clone, GetSize)]
|
||||
struct Chunk {
|
||||
/// The number of bytes stored within the `content` array.
|
||||
len: usize,
|
||||
|
||||
/// The actual content of the chunk.
|
||||
///
|
||||
/// We store this inline as a byte array rather than using a
|
||||
/// dynamically-sized type to avoid the extra heap allocation
|
||||
/// and pointer dereference needed to access it.
|
||||
content: [u8; Chunk::CHUNK_SIZE],
|
||||
|
||||
/// The offset of the start of this chunk, in bytes.
|
||||
start_offset: ByteOffset,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
const CHUNK_SIZE: usize = 1024;
|
||||
|
||||
/// Creates a new chunk with the given start offset.
|
||||
fn new(start_offset: ByteOffset) -> Self {
|
||||
Self {
|
||||
len: 0,
|
||||
content: [0; Chunk::CHUNK_SIZE],
|
||||
start_offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes the given string into the chunk.
|
||||
///
|
||||
/// Panics if there is not enough space left in the chunk.
|
||||
fn push_char_or_str(&mut self, char_or_str: CharOrStr<'_>) {
|
||||
match char_or_str {
|
||||
CharOrStr::Char(c) => {
|
||||
let len = c.len_utf8();
|
||||
match len {
|
||||
1 => self.content[self.len] = c as u8,
|
||||
len => self.content[self.len..self.len + len]
|
||||
.copy_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
}
|
||||
self.len += len;
|
||||
}
|
||||
CharOrStr::Str(s) => {
|
||||
let len = s.len();
|
||||
self.content[self.len..self.len + len].copy_from_slice(s.as_bytes());
|
||||
self.len += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncates the chunk to the given content offset.
|
||||
///
|
||||
/// While the bytes stored in the chunk are interpreted as UTF-8, this
|
||||
/// function does not perform any validation, and so can end up producing
|
||||
/// a chunk that cannot parse as UTF-8. It is already the caller's
|
||||
/// responsibility to not index into the chunk at non-UTF-8 offsets, so
|
||||
/// this does not introduce any additional safety concerns.
|
||||
fn truncate(&mut self, offset: ByteOffset) {
|
||||
debug_assert!(
|
||||
offset >= self.start_offset,
|
||||
"cannot apply truncate({offset:?}) to chunk with start_offset of {:?}",
|
||||
self.start_offset
|
||||
);
|
||||
let new_chunk_len = offset - self.start_offset;
|
||||
self.len = new_chunk_len.as_usize();
|
||||
}
|
||||
|
||||
/// Returns the length of the chunk, in bytes.
|
||||
fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Returns the capacity of the chunk, in bytes.
|
||||
fn capacity(&self) -> usize {
|
||||
Self::CHUNK_SIZE
|
||||
}
|
||||
|
||||
/// Returns the range of bytes covered by the chunk.
|
||||
fn content_range(&self) -> Range<ByteOffset> {
|
||||
self.start_offset..self.start_offset + self.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<Range<usize>> for Chunk {
|
||||
type Output = str;
|
||||
|
||||
fn index(&self, index: Range<usize>) -> &Self::Output {
|
||||
// SAFETY: We know that the content is valid UTF-8, and are trusting
|
||||
// the caller to ensure that the index is valid. Validating that the
|
||||
// contents are valid UTF-8 is too expensive to do on every access.
|
||||
unsafe { std::str::from_utf8_unchecked(&self.content[index]) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,46 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_large_grapheme_starts_new_chunk() {
|
||||
let mut content = Content::new();
|
||||
let a = Grapheme::new_from_str("a");
|
||||
for _ in 0..Chunk::CHUNK_SIZE - 1 {
|
||||
content.push_grapheme(&a);
|
||||
}
|
||||
|
||||
assert!(content.filled_chunks.is_empty());
|
||||
|
||||
let grapheme = Grapheme::new_from_str("🚀");
|
||||
assert!(grapheme.len().as_usize() > 1);
|
||||
assert!(content.end_offset() + grapheme.len().as_usize() > Chunk::CHUNK_SIZE);
|
||||
|
||||
content.push_grapheme(&grapheme);
|
||||
|
||||
assert_eq!(content.filled_chunks.len(), 1);
|
||||
assert_eq!(grapheme.len().as_usize(), content.active_chunk.len());
|
||||
assert_eq!(
|
||||
content.active_chunk.start_offset,
|
||||
ByteOffset::from(Chunk::CHUNK_SIZE - 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_front_drops_old_chunks() {
|
||||
let mut content = Content::new();
|
||||
let a = Grapheme::new_from_str("a");
|
||||
for _ in 0..Chunk::CHUNK_SIZE - 1 {
|
||||
content.push_grapheme(&a);
|
||||
}
|
||||
let grapheme = Grapheme::new_from_str("🚀");
|
||||
content.push_grapheme(&grapheme);
|
||||
|
||||
// Drop everything before the active chunk except for one byte.
|
||||
content.truncate_front(content.active_chunk.start_offset - 1);
|
||||
// This should't affect the one filled chunk.
|
||||
assert_eq!(content.filled_chunks.len(), 1);
|
||||
|
||||
// Drop everything before the active chunk.
|
||||
content.truncate_front(content.active_chunk.start_offset);
|
||||
// Ensure the filled chunk was dropped.
|
||||
assert!(content.filled_chunks.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use std::num::NonZeroU16;
|
||||
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
use crate::model::{
|
||||
char_or_str::CharOrStr,
|
||||
grid::cell::{self, Cell},
|
||||
};
|
||||
|
||||
use super::index::GraphemeInfo;
|
||||
|
||||
/// A grapheme is a collection of [`char`]s that, together, represent a single
|
||||
/// "user-perceived character".
|
||||
///
|
||||
/// This wrapper exposes a number of helper methods for working with graphemes,
|
||||
/// such as exposing the number of grid cells that the grapheme will take up in
|
||||
/// a terminal grid.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Will panic if constructed for a grapheme with a length of zero bytes.
|
||||
#[derive(Debug)]
|
||||
pub struct Grapheme<'a> {
|
||||
info: GraphemeInfo,
|
||||
content: CharOrStr<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Grapheme<'a> {
|
||||
pub const EMPTY_CELL: Grapheme<'static> = Grapheme {
|
||||
info: GraphemeInfo {
|
||||
cell_width: 1,
|
||||
utf8_bytes: NonZeroU16::new(1).expect("1 != 0"),
|
||||
},
|
||||
content: CharOrStr::Char(cell::DEFAULT_CHAR),
|
||||
};
|
||||
|
||||
pub const NEWLINE: Grapheme<'static> = Grapheme {
|
||||
info: GraphemeInfo {
|
||||
cell_width: 0,
|
||||
utf8_bytes: NonZeroU16::new(1).expect("1 != 0"),
|
||||
},
|
||||
content: CharOrStr::Char('\n'),
|
||||
};
|
||||
|
||||
/// Constructs a new [`Grapheme`] from a [`Cell`].
|
||||
pub fn new_from_cell(cell: &'a Cell) -> Self {
|
||||
let cell_width = 1 + cell.flags().contains(cell::Flags::WIDE_CHAR) as u8;
|
||||
|
||||
let content = cell.raw_content();
|
||||
let utf8_bytes = match content {
|
||||
CharOrStr::Char(c) => c.len_utf8(),
|
||||
CharOrStr::Str(s) => s.len(),
|
||||
};
|
||||
let utf8_bytes = u16::try_from(utf8_bytes).expect("grapheme length should fit in a u16");
|
||||
let utf8_bytes = NonZeroU16::new(utf8_bytes).expect("grapheme string should be non-empty");
|
||||
|
||||
let info = GraphemeInfo {
|
||||
cell_width,
|
||||
utf8_bytes,
|
||||
};
|
||||
Self { info, content }
|
||||
}
|
||||
|
||||
/// Constructs a new [`Grapheme`] from a string slice and already-computed
|
||||
/// [`GraphemeInfo`].
|
||||
///
|
||||
/// This is useful when assembling [`Row`](crate::model::grid::row::Row)s
|
||||
/// a flat content string and an [`Index`](super::Index).
|
||||
pub fn new_from_str_and_info(grapheme: &'a str, info: GraphemeInfo) -> Self {
|
||||
Self {
|
||||
info,
|
||||
content: CharOrStr::Str(grapheme),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new [`Grapheme`] from a string slice.
|
||||
#[cfg(test)]
|
||||
pub fn new_from_str(grapheme: &'a str) -> Self {
|
||||
let cell_width = str_to_cell_width(grapheme);
|
||||
let utf8_bytes =
|
||||
NonZeroU16::new(grapheme.len() as u16).expect("grapheme string should be non-empty");
|
||||
let info = GraphemeInfo {
|
||||
cell_width,
|
||||
utf8_bytes,
|
||||
};
|
||||
Self {
|
||||
info,
|
||||
content: CharOrStr::Str(grapheme),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns information about the grapheme cell width and byte length.
|
||||
pub fn sizing_info(&self) -> GraphemeInfo {
|
||||
self.info
|
||||
}
|
||||
|
||||
/// Returns the number of cells that this grapheme will take up in a
|
||||
/// terminal grid.
|
||||
///
|
||||
/// This can return 0 if the grapheme is not user-visible.
|
||||
pub fn cell_width(&self) -> u8 {
|
||||
self.info.cell_width
|
||||
}
|
||||
|
||||
/// Returns the length of this grapheme, in bytes.
|
||||
pub fn len(&self) -> ByteOffset {
|
||||
ByteOffset::from(self.info.utf8_bytes.get() as usize)
|
||||
}
|
||||
|
||||
/// Returns the grapheme content.
|
||||
pub fn content(&self) -> CharOrStr<'_> {
|
||||
self.content
|
||||
}
|
||||
|
||||
/// Returns an iterator over the characters in this grapheme.
|
||||
pub fn chars(&self) -> impl Iterator<Item = char> + 'a {
|
||||
match self.content {
|
||||
CharOrStr::Char(c) => itertools::Either::Left(std::iter::once(c)),
|
||||
CharOrStr::Str(s) => itertools::Either::Right(s.chars()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this grapheme triggers the start of a new grid row.
|
||||
pub fn starts_new_row(&self) -> bool {
|
||||
match self.content {
|
||||
CharOrStr::Char(c) => c == '\n',
|
||||
CharOrStr::Str(s) => s == "\n",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the cell width for a grapheme.
|
||||
#[cfg(test)]
|
||||
fn str_to_cell_width(grapheme: &str) -> u8 {
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
|
||||
let first_byte = grapheme.as_bytes()[0];
|
||||
if grapheme.len() == 1
|
||||
&& ((32..127).contains(&first_byte)
|
||||
|| first_byte == cell::DEFAULT_CHAR_BYTE
|
||||
|| first_byte == b'\t')
|
||||
{
|
||||
1
|
||||
} else {
|
||||
grapheme
|
||||
.width()
|
||||
.try_into()
|
||||
.expect("cell width of a grapheme should never be larger than 2^8")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! Logic related to indexing a grid's content by (soft-wrapped) row.
|
||||
//!
|
||||
//! The index provides an efficient way to map from a point in the grid to the
|
||||
//! content offset at which that point's content begins. Its design allows for
|
||||
//! efficient reconstruction with a different number of columns, without any
|
||||
//! need to re-parse the grid contents.
|
||||
//!
|
||||
//! ## Content offsets
|
||||
//!
|
||||
//! A content offset is the byte offset of a character in the overall set of
|
||||
//! content that the grid has _ever_ seen. When content is removed from the
|
||||
//! front of the grid, the offset of all remaining content is left unchanged,
|
||||
//! allowing us to avoid modifying any of the data structures that are keyed
|
||||
//! on content offsets.
|
||||
//!
|
||||
//! Content offsets are used throughout the flat storage implementation as the
|
||||
//! primary key for looking up metadata, as they are stable even if rows are
|
||||
//! dropped from the front or back of the grid. To this end, the only thing
|
||||
//! in the entire flat storage implementation that should be keyed on anything
|
||||
//! other than content offsets is the [`rows`](Index::rows) field of the index.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
num::NonZeroU16,
|
||||
ops::Range,
|
||||
};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
use get_size::GetSize;
|
||||
use string_offset::ByteOffset;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::model::{grid::CellType, Point};
|
||||
|
||||
use super::grapheme::Grapheme;
|
||||
|
||||
#[derive(Debug, Clone, GetSize)]
|
||||
/// A structure to help index into a grid's content by (soft-wrapped) row.
|
||||
pub struct Index {
|
||||
/// A "mapping" from row index to metadata about that row.
|
||||
rows: VecDeque<Entry>,
|
||||
/// The number of columns in the grid.
|
||||
columns: usize,
|
||||
/// The total length of the underlying content.
|
||||
content_len: usize,
|
||||
/// Holds grapheme sizing information for runs with non-uniform sizing.
|
||||
///
|
||||
/// Each entry in the map is a row, keyed by its start offset (so that the
|
||||
/// map is stable even if rows are dropped from the front).
|
||||
grapheme_sizing: BTreeMap<ByteOffset, GraphemeRuns>,
|
||||
}
|
||||
|
||||
/// An entry in the row index.
|
||||
#[derive(Debug, Clone, Copy, GetSize)]
|
||||
pub struct Entry {
|
||||
/// The offset into the content at which this row's data begins.
|
||||
///
|
||||
/// TODO(vorporeal): ByteOffset should probably store a u64, not a usize?
|
||||
content_offset: ByteOffset,
|
||||
/// Information about the sizing of graphemes in this row.
|
||||
grapheme_sizing: GraphemeSizing,
|
||||
/// Whether or not the row's backing content includes a trailing newline.
|
||||
pub has_trailing_newline: bool,
|
||||
/// Whether or not the row ends with a leading wide character spacer (i.e.:
|
||||
/// the next row starts with a wide char that there wasn't room for in this
|
||||
/// row).
|
||||
pub ends_with_leading_wide_char_spacer: bool,
|
||||
}
|
||||
|
||||
// Assert that an `Entry` has the size we expect.
|
||||
//
|
||||
// If `Entry` grows in size, it will significantly impact perforamnce due
|
||||
// to fitting fewer instances in a single 64-byte cache line.
|
||||
//
|
||||
// This is smaller on wasm due to it using a 32-bit usize (other platforms
|
||||
// have a 64-bit usize).
|
||||
cfg_if! {
|
||||
if #[cfg(target_family = "wasm")] {
|
||||
static_assertions::assert_eq_size!(Entry, [u8; 16]);
|
||||
} else {
|
||||
static_assertions::assert_eq_size!(Entry, [u8; 24]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Creates a new empty index for a grid with the given number of columns.
|
||||
///
|
||||
/// `initial_capacity` can be provided in order to reduce the likelihood
|
||||
/// that additional heap allocations will be necessary as content gets
|
||||
/// added to the index.
|
||||
pub fn new(columns: usize, initial_capacity: Option<usize>) -> Self {
|
||||
Self {
|
||||
rows: VecDeque::with_capacity(initial_capacity.unwrap_or_default()),
|
||||
columns,
|
||||
content_len: 0,
|
||||
grapheme_sizing: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds an [`Index`] to wrap lines at a different number of columns.
|
||||
pub fn rebuild(old_index: &Index, columns: usize) -> Self {
|
||||
let mut index = Self::new(columns, Some(old_index.len()));
|
||||
// Update the content length to be the start offset of the first row,
|
||||
// to ensure we properly handle resizing after truncation.
|
||||
index.content_len = old_index
|
||||
.rows
|
||||
.front()
|
||||
.map(|entry| entry.content_offset)
|
||||
.unwrap_or_default()
|
||||
.as_usize();
|
||||
|
||||
let mut entry_builder = EntryBuilder::new();
|
||||
|
||||
// Loop over rows in the old index, processing each grapheme in order
|
||||
// and adding newlines where appropriate.
|
||||
//
|
||||
// TODO(vorporeal): This can be significantly optimized - processing
|
||||
// each grapheme individually is a clearly poor choice in the (common)
|
||||
// case of a grid that contains only ASCII text. We could take more
|
||||
// advantage of the run-length encoded `GraphemeRun` structure here.
|
||||
for row_idx in 0..old_index.len() {
|
||||
if let Some(grapheme_infos) = old_index.grapheme_infos_for_row(row_idx) {
|
||||
for info in grapheme_infos {
|
||||
entry_builder.process_grapheme_info(info, &mut index);
|
||||
}
|
||||
}
|
||||
if old_index
|
||||
.get_entry(row_idx)
|
||||
.expect("row should have an entry")
|
||||
.has_trailing_newline
|
||||
{
|
||||
entry_builder.process_grapheme(&Grapheme::NEWLINE, &mut index);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the final entry to the index.
|
||||
//
|
||||
// If reflowing the content led to some trailing empty cells being
|
||||
// pushed onto a new row, don't add that empty row to the index.
|
||||
entry_builder.append_to_index_if_nonempty(&mut index);
|
||||
|
||||
if index.content_len > old_index.content_len {
|
||||
log::error!("somehow ended up with too much flat storage content!");
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
/// Truncates the index to the given number of rows, returning the new
|
||||
/// content length.
|
||||
pub fn truncate(&mut self, new_len: usize) -> ByteOffset {
|
||||
// Update our content length to be the start of the first row we're truncating.
|
||||
let Some(new_content_len) = self.content_offset_for_row(new_len) else {
|
||||
// If the new length is longer than our current length, we have no work to do.
|
||||
return ByteOffset::from(self.content_len);
|
||||
};
|
||||
|
||||
// Truncate the index to the new length.
|
||||
self.rows.truncate(new_len);
|
||||
// Drop any grapheme sizing metadata for the truncated rows.
|
||||
let _ = self.grapheme_sizing.split_off(&new_content_len);
|
||||
|
||||
self.content_len = new_content_len.as_usize();
|
||||
|
||||
new_content_len
|
||||
}
|
||||
|
||||
/// Removes the first `count` rows from the index, returning the new start
|
||||
/// offset for the remaining content.
|
||||
pub fn truncate_front(&mut self, count: usize) -> ByteOffset {
|
||||
let new_start_offset = self.content_offset_for_row(count).unwrap_or_else(|| {
|
||||
if count > self.rows.len() {
|
||||
log::error!(
|
||||
"should not attempt to truncate more rows than exist in flat storage; \
|
||||
have {} rows, trying to truncate {}",
|
||||
self.rows.len(),
|
||||
count
|
||||
);
|
||||
}
|
||||
self.content_len.into()
|
||||
});
|
||||
|
||||
for _ in 0..count {
|
||||
self.rows.pop_front();
|
||||
}
|
||||
self.grapheme_sizing = self.grapheme_sizing.split_off(&new_start_offset);
|
||||
|
||||
new_start_offset
|
||||
}
|
||||
|
||||
pub fn start_row(&mut self) -> EntryBuilder {
|
||||
EntryBuilder::new()
|
||||
}
|
||||
|
||||
/// Returns the total number of rows in the index.
|
||||
pub fn len(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
/// Returns the content [`ByteOffset`] for the given point.
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// 1. The point is outside the bounds of the structure, or
|
||||
/// 2. Points at an empty cell after the end of a hard-wrapped line.
|
||||
///
|
||||
/// TODO(vorporeal): Write tests to cover the following cases:
|
||||
/// * Points at valid content
|
||||
/// * Points at content after the end of a hard-wrapped line
|
||||
/// * Points at a WIDE_CHAR_SPACER cell
|
||||
/// * Points at a LEADING_WIDE_CHAR_SPACER cell
|
||||
/// * Points at column 0
|
||||
pub fn content_offset_at_point(
|
||||
&self,
|
||||
point: Point,
|
||||
) -> Result<ByteOffset, ContentOffsetToPointError> {
|
||||
let entry =
|
||||
self.rows
|
||||
.get(point.row)
|
||||
.ok_or_else(|| ContentOffsetToPointError::RowOutOfBounds {
|
||||
row: point.row,
|
||||
max_row: self.rows.len().saturating_sub(1),
|
||||
})?;
|
||||
|
||||
let runs = match &entry.grapheme_sizing {
|
||||
GraphemeSizing::Uniform(grapheme_run) => std::slice::from_ref(grapheme_run),
|
||||
GraphemeSizing::NonUniform => self
|
||||
.grapheme_sizing
|
||||
.get(&entry.content_offset)
|
||||
.ok_or(ContentOffsetToPointError::MissingGraphemeSizing {
|
||||
content_offset: entry.content_offset,
|
||||
})?
|
||||
.as_slice(),
|
||||
GraphemeSizing::EmptyRow => {
|
||||
if point.col == 0 {
|
||||
return Ok(entry.content_offset);
|
||||
} else {
|
||||
return Err(ContentOffsetToPointError::NonZeroColumnInEmptyRow {
|
||||
row: point.row,
|
||||
col: point.col,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut offset = entry.content_offset;
|
||||
let mut cols_remaining = point.col;
|
||||
|
||||
for run in runs {
|
||||
if cols_remaining == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let cols_from_run = run.cols().min(cols_remaining);
|
||||
let graphemes_from_run = cols_from_run / run.info.cell_width as usize;
|
||||
|
||||
offset += graphemes_from_run * run.info.utf8_bytes.get() as usize;
|
||||
cols_remaining -= cols_from_run;
|
||||
}
|
||||
|
||||
if cols_remaining == 0 {
|
||||
return Ok(offset);
|
||||
}
|
||||
|
||||
// If we get to this point, the provided column index exceeded the
|
||||
// number of content-ful cells in this row.
|
||||
Err(ContentOffsetToPointError::ColumnExceedsContent {
|
||||
row: point.row,
|
||||
col: point.col,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn content_offset_to_point(
|
||||
&self,
|
||||
offset: ByteOffset,
|
||||
) -> Result<Point, PointFromContentOffsetError> {
|
||||
let partition = self
|
||||
.rows
|
||||
.partition_point(|entry| entry.content_offset <= offset);
|
||||
let row = match partition.checked_sub(1) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
let first_row_offset = self
|
||||
.rows
|
||||
.front()
|
||||
.map(|e| e.content_offset)
|
||||
.unwrap_or_default();
|
||||
return Err(PointFromContentOffsetError::OffsetBeforeFirstRow {
|
||||
offset,
|
||||
first_row_offset,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let entry = self
|
||||
.get_entry(row)
|
||||
.ok_or(PointFromContentOffsetError::RowOutOfBounds { row })?;
|
||||
|
||||
let runs = match &entry.grapheme_sizing {
|
||||
GraphemeSizing::Uniform(grapheme_run) => std::slice::from_ref(grapheme_run),
|
||||
GraphemeSizing::NonUniform => self
|
||||
.grapheme_sizing
|
||||
.get(&entry.content_offset)
|
||||
.ok_or(PointFromContentOffsetError::MissingGraphemeSizing {
|
||||
content_offset: entry.content_offset,
|
||||
})?
|
||||
.as_slice(),
|
||||
GraphemeSizing::EmptyRow => {
|
||||
// The only valid content offset for an empty row is the offset
|
||||
// of the start of the row.
|
||||
assert_eq!(offset, entry.content_offset);
|
||||
return Ok(Point { row, col: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
let mut column = 0;
|
||||
let mut remaining_offset = offset - entry.content_offset;
|
||||
|
||||
for run in runs {
|
||||
let graphemes_in_run = run.cols() / run.info.cell_width as usize;
|
||||
let content_in_run =
|
||||
ByteOffset::from(graphemes_in_run * run.info.utf8_bytes.get() as usize);
|
||||
|
||||
let remaining_offset_in_run = remaining_offset.min(content_in_run);
|
||||
let remaining_graphemes_in_run =
|
||||
remaining_offset_in_run.as_usize() / run.info.utf8_bytes.get() as usize;
|
||||
let remaining_cells_in_run = remaining_graphemes_in_run * run.info.cell_width as usize;
|
||||
|
||||
column += remaining_cells_in_run;
|
||||
remaining_offset -= remaining_offset_in_run;
|
||||
|
||||
if remaining_offset == ByteOffset::zero() {
|
||||
return Ok(Point { row, col: column });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
log::warn!(
|
||||
"tried to convert content offset to point but was past the end of the content in a row"
|
||||
);
|
||||
Err(PointFromContentOffsetError::OffsetDoesNotMapToCellInRow { row, offset })
|
||||
}
|
||||
|
||||
/// Returns the range of content that represents this row.
|
||||
pub fn content_range_for_row(&self, row: usize) -> Option<Range<ByteOffset>> {
|
||||
let start = self.content_offset_for_row(row)?;
|
||||
let end = self
|
||||
.content_offset_for_row(row + 1)
|
||||
.unwrap_or(ByteOffset::from(self.content_len));
|
||||
Some(start..end)
|
||||
}
|
||||
|
||||
/// Returns the byte offset at which the given row's content begins.
|
||||
fn content_offset_for_row(&self, row: usize) -> Option<ByteOffset> {
|
||||
Some(self.rows.get(row)?.content_offset)
|
||||
}
|
||||
|
||||
pub fn get_entry(&self, row: usize) -> Option<&Entry> {
|
||||
self.rows.get(row)
|
||||
}
|
||||
|
||||
/// Returns the [`CellType`] for the cell at the given (row, col), or
|
||||
/// [`None`] if that point is outside of the grid bounds.
|
||||
pub fn cell_type(&self, row: usize, col: usize) -> Option<CellType> {
|
||||
let entry = self.get_entry(row)?;
|
||||
|
||||
if entry.ends_with_leading_wide_char_spacer && col == self.columns - 1 {
|
||||
return Some(CellType::LeadingWideCharSpacer);
|
||||
}
|
||||
|
||||
let Some(grapheme_runs) = (match &entry.grapheme_sizing {
|
||||
GraphemeSizing::Uniform(run) => {
|
||||
// For a row with only wide characters, make sure blank
|
||||
// space at the end of the line isn't counted as a wide
|
||||
// character.
|
||||
if col >= run.cols() {
|
||||
return Some(CellType::RegularChar);
|
||||
}
|
||||
return run.cell_type_at_offset(col);
|
||||
}
|
||||
GraphemeSizing::NonUniform => self.grapheme_sizing.get(&entry.content_offset),
|
||||
GraphemeSizing::EmptyRow => return Some(CellType::RegularChar),
|
||||
}) else {
|
||||
log::error!(
|
||||
"Found entry with non-uniform grapheme sizing and no grapheme run information!"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut start_col: usize = 0;
|
||||
for run in grapheme_runs.iter() {
|
||||
let run_end_col = start_col + run.cols();
|
||||
if run_end_col > col {
|
||||
return run.cell_type_at_offset(col - start_col);
|
||||
}
|
||||
start_col = run_end_col;
|
||||
}
|
||||
|
||||
// If the column is part of the blank space at the end of a
|
||||
// hard-wrapped line, we should treat it as a narrow char.
|
||||
Some(CellType::RegularChar)
|
||||
}
|
||||
|
||||
/// Returns a slice of grapheme runs for the given row.
|
||||
///
|
||||
/// Returns [`None`] if the provided row index is out-of-bounds.
|
||||
pub(super) fn grapheme_runs_for_row(&self, row_idx: usize) -> Option<&[GraphemeRun]> {
|
||||
let entry = self.get_entry(row_idx)?;
|
||||
|
||||
let runs = match &entry.grapheme_sizing {
|
||||
GraphemeSizing::Uniform(grapheme_run) => std::slice::from_ref(grapheme_run),
|
||||
GraphemeSizing::NonUniform => {
|
||||
self.grapheme_sizing.get(&entry.content_offset)?.as_slice()
|
||||
}
|
||||
GraphemeSizing::EmptyRow => &[],
|
||||
};
|
||||
|
||||
Some(runs)
|
||||
}
|
||||
|
||||
/// Returns an iterator over the sizing information for each individual
|
||||
/// grapheme in the given row.
|
||||
///
|
||||
/// Returns [`None`] if the provided row index is out-of-bounds.
|
||||
pub fn grapheme_infos_for_row(
|
||||
&self,
|
||||
row_idx: usize,
|
||||
) -> Option<impl Iterator<Item = GraphemeInfo> + '_> {
|
||||
let runs = self.grapheme_runs_for_row(row_idx)?;
|
||||
|
||||
Some(
|
||||
runs.iter()
|
||||
.flat_map(|run| std::iter::repeat_n(run.info, run.count.get() as usize)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when converting a point to a content offset.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ContentOffsetToPointError {
|
||||
/// The point's row is outside the bounds of the index.
|
||||
#[error("Point row {row} is outside the bounds of the index (max: {max_row})")]
|
||||
RowOutOfBounds { row: usize, max_row: usize },
|
||||
/// Missing grapheme sizing data for a non-uniform row.
|
||||
#[error("Missing grapheme sizing data for non-uniform row at content offset {content_offset}")]
|
||||
MissingGraphemeSizing { content_offset: ByteOffset },
|
||||
/// Point column is not 0 for an empty row.
|
||||
#[error("Point column {col} is not 0 for empty row {row}")]
|
||||
NonZeroColumnInEmptyRow { row: usize, col: usize },
|
||||
/// Point column exceeds the number of content cells in the row.
|
||||
#[error("Point column {col} exceeds the number of content cells in row {row}")]
|
||||
ColumnExceedsContent { row: usize, col: usize },
|
||||
}
|
||||
|
||||
/// Errors that can occur when converting a content offset to a point.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PointFromContentOffsetError {
|
||||
/// The provided offset is before the start of the first row.
|
||||
#[error("Offset {offset} is before the start of the first row (first row starts at {first_row_offset})")]
|
||||
OffsetBeforeFirstRow {
|
||||
offset: ByteOffset,
|
||||
first_row_offset: ByteOffset,
|
||||
},
|
||||
/// The computed row index was out of bounds.
|
||||
#[error("Computed row index {row} is out of bounds")]
|
||||
RowOutOfBounds { row: usize },
|
||||
/// Missing grapheme sizing data for a non-uniform row.
|
||||
#[error("Missing grapheme sizing data for non-uniform row at content offset {content_offset}")]
|
||||
MissingGraphemeSizing { content_offset: ByteOffset },
|
||||
/// The provided offset does not map to a cell in the computed row.
|
||||
#[error("Content offset {offset} does not map to a cell in row {row}")]
|
||||
OffsetDoesNotMapToCellInRow { row: usize, offset: ByteOffset },
|
||||
}
|
||||
|
||||
/// A helper structure for building up an [`Entry`] while iterating through a
|
||||
/// list of `Cell`s in a `Row`.
|
||||
#[derive(Default)]
|
||||
pub struct EntryBuilder {
|
||||
num_cells: usize,
|
||||
incr_content_offset: ByteOffset,
|
||||
has_trailing_newline: bool,
|
||||
ends_with_leading_wide_char_spacer: bool,
|
||||
#[cfg(debug_assertions)]
|
||||
was_processed: bool,
|
||||
grapheme_runs: GraphemeRuns,
|
||||
}
|
||||
|
||||
impl EntryBuilder {
|
||||
fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Processes the next [`Grapheme`] in the row.
|
||||
pub fn process_grapheme(&mut self, grapheme: &Grapheme, index: &mut Index) {
|
||||
if grapheme.starts_new_row() {
|
||||
self.add_trailing_newline();
|
||||
std::mem::take(self).append_to_index(index);
|
||||
return;
|
||||
}
|
||||
|
||||
self.process_grapheme_info(grapheme.sizing_info(), index);
|
||||
}
|
||||
|
||||
/// Processes the next grapheme in the row, based only on its sizing
|
||||
/// information (and not its content).
|
||||
fn process_grapheme_info(&mut self, info: GraphemeInfo, index: &mut Index) {
|
||||
let grapheme_len = info.utf8_bytes.get() as usize;
|
||||
debug_assert!(
|
||||
grapheme_len > 0,
|
||||
"should not process an empty string as a grapheme"
|
||||
);
|
||||
|
||||
if info.cell_width == 0 {
|
||||
#[cfg(debug_assertions)]
|
||||
log::error!("encountered unexpected grapheme with a computed cell width of zero!");
|
||||
return;
|
||||
}
|
||||
debug_assert!(
|
||||
info.cell_width <= 2,
|
||||
"graphemes should not be more than two cells wide, but encountered one with width {}",
|
||||
info.cell_width
|
||||
);
|
||||
|
||||
// If there isn't enough room in the row for this grapheme, cut off
|
||||
// the row here, starting the new row with the _current_ grapheme.
|
||||
if self.num_cells + info.cell_width as usize > index.columns {
|
||||
// If this is a non-full row and we've got a wide char, mark
|
||||
// the fact that we have a leading wide char spacer.
|
||||
if info.cell_width > 1 && self.num_cells != index.columns {
|
||||
self.add_leading_wide_char_spacer();
|
||||
}
|
||||
std::mem::take(self).append_to_index(index);
|
||||
debug_assert_eq!(self.incr_content_offset, ByteOffset::zero());
|
||||
}
|
||||
|
||||
self.num_cells += info.cell_width as usize;
|
||||
|
||||
self.process_grapheme_info_unchecked(info);
|
||||
}
|
||||
|
||||
/// Processes the next grapheme in the row, without performing any checks
|
||||
/// around whether or not the row is full.
|
||||
///
|
||||
/// This is intended to be used when building up an [`Entry`] from an
|
||||
/// existing [`Row`], as the row can't have more cells than fit in it.
|
||||
///
|
||||
/// Callers will need to invoke [`Self::add_leading_wide_char_spacer`] and
|
||||
/// [`Self::append_to_index`] as appropriate.
|
||||
pub fn process_grapheme_info_unchecked(&mut self, info: GraphemeInfo) {
|
||||
let grapheme_len = info.utf8_bytes.get() as usize;
|
||||
|
||||
self.incr_content_offset += grapheme_len;
|
||||
|
||||
// Store information about this grapheme's cell width and UTF-8 length.
|
||||
match self.grapheme_runs.last_mut() {
|
||||
Some(last_run) if last_run.info == info => {
|
||||
// TODO(vorporeal): might be able to eke out some extra performance
|
||||
// if we remove the error checking here.
|
||||
last_run.count = last_run
|
||||
.count
|
||||
.checked_add(1)
|
||||
.expect("should not have more than 2^16 graphemes in a single row");
|
||||
}
|
||||
_ => {
|
||||
self.grapheme_runs.push(GraphemeRun {
|
||||
count: unsafe { NonZeroU16::new_unchecked(1) },
|
||||
info,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the [`Entry`]'s row as containing a trailing newline.
|
||||
pub fn add_trailing_newline(&mut self) {
|
||||
self.incr_content_offset += '\n'.len_utf8();
|
||||
self.has_trailing_newline = true;
|
||||
}
|
||||
|
||||
/// Marks the [`Entry`]'s row as ending with a leading wide-char spacer
|
||||
/// (i.e.: a wide char was wrapped to the next line due to there only being
|
||||
/// one cell of space).
|
||||
pub fn add_leading_wide_char_spacer(&mut self) {
|
||||
self.ends_with_leading_wide_char_spacer = true;
|
||||
}
|
||||
|
||||
/// Builds an [`Entry`] and appends it to the provided index, or simply
|
||||
/// drops `self` if the [`Entry`] would be empty.
|
||||
pub fn append_to_index_if_nonempty(mut self, index: &mut Index) {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
self.was_processed = true;
|
||||
}
|
||||
|
||||
if !self.is_empty() {
|
||||
self.append_to_index(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an [`Entry`] and appends it to the provided index.
|
||||
pub fn append_to_index(mut self, index: &mut Index) {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
self.was_processed = true;
|
||||
}
|
||||
|
||||
let content_offset = index.content_len.into();
|
||||
|
||||
let grapheme_sizing = if self.grapheme_runs.len() == 1 {
|
||||
GraphemeSizing::Uniform(
|
||||
// SAFETY: Checked the length of self.grapheme_runs above.
|
||||
unsafe { self.grapheme_runs.pop().unwrap_unchecked() },
|
||||
)
|
||||
} else if self.grapheme_runs.is_empty() {
|
||||
GraphemeSizing::EmptyRow
|
||||
} else {
|
||||
index
|
||||
.grapheme_sizing
|
||||
.insert(content_offset, std::mem::take(&mut self.grapheme_runs));
|
||||
GraphemeSizing::NonUniform
|
||||
};
|
||||
|
||||
index.content_len += self.incr_content_offset.as_usize();
|
||||
index.rows.push_back(Entry {
|
||||
content_offset,
|
||||
grapheme_sizing,
|
||||
has_trailing_newline: self.has_trailing_newline,
|
||||
ends_with_leading_wide_char_spacer: self.ends_with_leading_wide_char_spacer,
|
||||
});
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.incr_content_offset == ByteOffset::zero()
|
||||
&& !self.has_trailing_newline
|
||||
&& !self.ends_with_leading_wide_char_spacer
|
||||
&& self.grapheme_runs.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EntryBuilder {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(
|
||||
self.was_processed,
|
||||
"EntryBuilder must be processed before it is dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run-length encoded information about grapheme sizes.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub(super) struct GraphemeRun {
|
||||
/// The number of consecutive graphemes for which `info` is accurate.
|
||||
count: NonZeroU16,
|
||||
/// Metadata that applies to each grapheme in this run.
|
||||
info: GraphemeInfo,
|
||||
}
|
||||
|
||||
impl GraphemeRun {
|
||||
fn cols(&self) -> usize {
|
||||
self.count.get() as usize * self.info.cell_width as usize
|
||||
}
|
||||
|
||||
fn cell_type_at_offset(&self, offset: usize) -> Option<CellType> {
|
||||
if self.info.cell_width == 1 {
|
||||
Some(CellType::RegularChar)
|
||||
} else {
|
||||
assert!(
|
||||
offset < self.cols(),
|
||||
"cannot compute cell type for offset {offset} in run that spans {} columns",
|
||||
self.cols()
|
||||
);
|
||||
if offset.is_multiple_of(2) {
|
||||
Some(CellType::WideChar)
|
||||
} else {
|
||||
Some(CellType::WideCharSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`GraphemeRun`] is entirely stack-allocated, so the default impl is
|
||||
/// sufficient.
|
||||
impl GetSize for GraphemeRun {}
|
||||
|
||||
/// Type alias for a list of grapheme runs.
|
||||
type GraphemeRuns = Vec<GraphemeRun>;
|
||||
|
||||
/// Information about sizing of graphemes in a single grid row.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
enum GraphemeSizing {
|
||||
/// All graphemes in the row have the same sizing information.
|
||||
Uniform(GraphemeRun),
|
||||
/// Grapheme sizing is non-uniform, with the details stored in the index's
|
||||
/// `grapheme_sizing` map.
|
||||
NonUniform,
|
||||
/// The row contains no graphemes.
|
||||
EmptyRow,
|
||||
}
|
||||
|
||||
/// [`GraphemeSizing`] is entirely stack-allocated, so the default impl is
|
||||
/// sufficient.
|
||||
impl GetSize for GraphemeSizing {}
|
||||
|
||||
/// Metadata about a grapheme.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub struct GraphemeInfo {
|
||||
/// The width of the grapheme, in cells.
|
||||
pub cell_width: u8,
|
||||
/// The length, in bytes, of this grapheme using a UTF-8 encoding.
|
||||
pub utf8_bytes: NonZeroU16,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "index_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,377 @@
|
||||
use std::num::NonZeroU16;
|
||||
|
||||
use crate::model::grid::FlatStorage;
|
||||
|
||||
use super::*;
|
||||
|
||||
const ASCII_GRAPHEME_INFO: GraphemeInfo = GraphemeInfo {
|
||||
cell_width: 1,
|
||||
utf8_bytes: NonZeroU16::new(1).unwrap(),
|
||||
};
|
||||
|
||||
const EMOJI_GRAPHEME_INFO: GraphemeInfo = GraphemeInfo {
|
||||
cell_width: 2,
|
||||
utf8_bytes: NonZeroU16::new(4).unwrap(),
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_index_with_empty_string() {
|
||||
// 1: \n
|
||||
let storage = FlatStorage::from_content_using_rows("\n", 5, Some(1));
|
||||
assert_eq!(storage.index.rows.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_consistent_one_byte_length_and_cell_width() {
|
||||
// 1: abcde
|
||||
// 2: fgh\n
|
||||
let storage = FlatStorage::from_content_using_rows("abcdefgh\n", 5, Some(2));
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
|
||||
assert_eq!(storage.index.rows[0].content_offset, ByteOffset::zero());
|
||||
assert_eq!(
|
||||
storage.index.rows[0].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(5).unwrap(),
|
||||
info: ASCII_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(storage.index.rows[1].content_offset, ByteOffset::from(5));
|
||||
assert_eq!(
|
||||
storage.index.rows[1].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(3).unwrap(),
|
||||
info: ASCII_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_consistent_two_cell_width_and_four_byte_length() {
|
||||
// 1: 😀😃😄😁
|
||||
// 2: 😆😅😂\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀😃😄😁😆😅😂\n", 8, Some(2));
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
|
||||
assert_eq!(storage.index.rows[0].content_offset, ByteOffset::zero());
|
||||
assert_eq!(
|
||||
storage.index.rows[0].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(4).unwrap(),
|
||||
info: EMOJI_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(storage.index.rows[1].content_offset, ByteOffset::from(16));
|
||||
assert_eq!(
|
||||
storage.index.rows[1].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(3).unwrap(),
|
||||
info: EMOJI_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_grapheme_overflowing_end_of_row() {
|
||||
// 1: 😀😃
|
||||
// 2: 😄\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀😃😄\n", 5, Some(2));
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
|
||||
assert_eq!(storage.index.rows[0].content_offset, ByteOffset::zero());
|
||||
assert_eq!(
|
||||
storage.index.rows[0].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(2).unwrap(),
|
||||
info: EMOJI_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(storage.index.rows[1].content_offset, ByteOffset::from(8));
|
||||
assert_eq!(
|
||||
storage.index.rows[1].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(1).unwrap(),
|
||||
info: EMOJI_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_inconsistent_cell_widths() {
|
||||
// 1: 😀a😃
|
||||
// 2: 😄\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀a😃😄\n", 5, Some(2));
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
|
||||
assert_eq!(storage.index.rows[0].content_offset, ByteOffset::zero());
|
||||
assert_eq!(
|
||||
storage.index.rows[0].grapheme_sizing,
|
||||
GraphemeSizing::NonUniform
|
||||
);
|
||||
let grapheme_runs = storage
|
||||
.index
|
||||
.grapheme_sizing
|
||||
.get(&ByteOffset::zero())
|
||||
.expect("index should have grapheme run info");
|
||||
assert_eq!(grapheme_runs.len(), 3);
|
||||
assert_eq!(
|
||||
grapheme_runs[0],
|
||||
GraphemeRun {
|
||||
count: NonZeroU16::new(1).unwrap(),
|
||||
info: EMOJI_GRAPHEME_INFO,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
grapheme_runs[1],
|
||||
GraphemeRun {
|
||||
count: NonZeroU16::new(1).unwrap(),
|
||||
info: ASCII_GRAPHEME_INFO,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
grapheme_runs[2],
|
||||
GraphemeRun {
|
||||
count: NonZeroU16::new(1).unwrap(),
|
||||
info: EMOJI_GRAPHEME_INFO,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(storage.index.rows[1].content_offset, ByteOffset::from(9));
|
||||
assert_eq!(
|
||||
storage.index.rows[1].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(1).unwrap(),
|
||||
info: EMOJI_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_newlines() {
|
||||
// 1: abc\n
|
||||
// 2: defgh
|
||||
let storage = FlatStorage::from_content_using_rows("abc\ndefgh", 5, Some(2));
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
|
||||
assert_eq!(storage.index.rows[0].content_offset, ByteOffset::zero());
|
||||
assert_eq!(
|
||||
storage.index.rows[0].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(3).unwrap(),
|
||||
info: ASCII_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(storage.index.rows[1].content_offset, ByteOffset::from(4));
|
||||
assert_eq!(
|
||||
storage.index.rows[1].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(5).unwrap(),
|
||||
info: ASCII_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_repeated_newlines() {
|
||||
// 1: abc\n
|
||||
// 2: \n
|
||||
// 3: defgh
|
||||
let storage = FlatStorage::from_content_using_rows("abc\n\ndefgh", 5, Some(3));
|
||||
assert_eq!(storage.index.rows.len(), 3);
|
||||
|
||||
assert_eq!(storage.index.rows[0].content_offset, ByteOffset::zero());
|
||||
assert_eq!(
|
||||
storage.index.rows[0].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(3).unwrap(),
|
||||
info: ASCII_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(storage.index.rows[1].content_offset, ByteOffset::from(4));
|
||||
assert_eq!(
|
||||
storage.index.rows[1].grapheme_sizing,
|
||||
GraphemeSizing::EmptyRow
|
||||
);
|
||||
|
||||
assert_eq!(storage.index.rows[2].content_offset, ByteOffset::from(5));
|
||||
assert_eq!(
|
||||
storage.index.rows[2].grapheme_sizing,
|
||||
GraphemeSizing::Uniform(GraphemeRun {
|
||||
count: NonZeroU16::new(5).unwrap(),
|
||||
info: ASCII_GRAPHEME_INFO
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_exactly_full_row() {
|
||||
// 1: abc
|
||||
let storage = FlatStorage::from_content_using_rows("abc", 3, Some(1));
|
||||
assert_eq!(storage.index.rows.len(), 1);
|
||||
assert_eq!(storage.index.content_len, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_with_full_row_and_newline() {
|
||||
// The newline shouldn't start a new row; it should only affect whether the
|
||||
// single row soft or hard wraps.
|
||||
//
|
||||
// 1: abc\n
|
||||
let storage = FlatStorage::from_content_using_rows("abc\n", 3, Some(1));
|
||||
assert_eq!(storage.index.rows.len(), 1);
|
||||
assert_eq!(storage.index.content_len, 4);
|
||||
|
||||
// 1: abc
|
||||
// 2: d\n
|
||||
let storage = FlatStorage::from_content_using_rows("abcd\n", 3, Some(1));
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
assert_eq!(storage.index.content_len, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_extra_row_onto_index() {
|
||||
// 1: abc\n
|
||||
let mut storage = FlatStorage::from_content_using_rows("abc\n", 5, Some(1));
|
||||
assert_eq!(storage.index.rows.len(), 1);
|
||||
|
||||
// Adding a second hard-wrapped line of text to the index should give us a
|
||||
// total of 3 lines (not 4).
|
||||
//
|
||||
// 1: abc\n
|
||||
// 2: def\n
|
||||
storage.push_rows_from_string("def\n");
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_extra_row_onto_index_with_softwrapped_first_line() {
|
||||
// 1: abcde
|
||||
let mut storage = FlatStorage::from_content_using_rows("abcde", 5, Some(1));
|
||||
assert_eq!(storage.index.rows.len(), 1);
|
||||
|
||||
// Adding a hard-wrapped line of text to the index should give us a
|
||||
// total of 2 lines.
|
||||
//
|
||||
// 1: abcde
|
||||
// 2: 123\n
|
||||
storage.push_rows_from_string("123\n");
|
||||
assert_eq!(storage.index.rows.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cell_type() {
|
||||
// 1: 😀😃
|
||||
// 2: 😄\n
|
||||
// 3: a😄\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀😃😄\na😄\n", 5, Some(2));
|
||||
assert_eq!(storage.index.rows.len(), 3);
|
||||
|
||||
assert_eq!(storage.cell_type(0, 0), Some(CellType::WideChar));
|
||||
assert_eq!(storage.cell_type(0, 1), Some(CellType::WideCharSpacer));
|
||||
|
||||
assert_eq!(
|
||||
storage.cell_type(0, 4),
|
||||
Some(CellType::LeadingWideCharSpacer)
|
||||
);
|
||||
|
||||
// Empty cells at the end of a hard-wrapped line are narrow.
|
||||
// We test both the first empty cell (to check off-by-one errors) and
|
||||
// a later cell (for completeness).
|
||||
assert_eq!(storage.cell_type(1, 2), Some(CellType::RegularChar));
|
||||
assert_eq!(storage.cell_type(1, 4), Some(CellType::RegularChar));
|
||||
|
||||
// Make sure we properly handle rows with non-uniform grapheme sizing.
|
||||
assert_eq!(storage.cell_type(2, 0), Some(CellType::RegularChar));
|
||||
assert_eq!(storage.cell_type(2, 1), Some(CellType::WideChar));
|
||||
assert_eq!(storage.cell_type(2, 2), Some(CellType::WideCharSpacer));
|
||||
}
|
||||
|
||||
mod offset_point_conversion {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_normal_cell() {
|
||||
// 1: 😀😃
|
||||
// 2: 😄\n
|
||||
// 3: a😄\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀😃😄\na😄\n", 5, Some(2));
|
||||
|
||||
let original_point = Point::new(2, 0);
|
||||
|
||||
let offset = storage
|
||||
.content_offset_at_point(original_point)
|
||||
.expect("should be able to convert point to offset");
|
||||
assert_eq!(offset, ByteOffset::from(13));
|
||||
|
||||
let point = storage
|
||||
.content_offset_to_point(offset)
|
||||
.expect("should be able to convert offset back to point");
|
||||
assert_eq!(point, original_point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wide_char() {
|
||||
// 1: 😀😃
|
||||
// 2: 😄\n
|
||||
// 3: a😄\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀😃😄\na😄\n", 5, Some(2));
|
||||
|
||||
let original_point = Point::new(0, 2);
|
||||
|
||||
let offset = storage
|
||||
.content_offset_at_point(original_point)
|
||||
.expect("should be able to convert point to offset");
|
||||
assert_eq!(offset, ByteOffset::from(4));
|
||||
|
||||
let point = storage
|
||||
.content_offset_to_point(offset)
|
||||
.expect("should be able to convert offset back to point");
|
||||
assert_eq!(point, original_point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "does not work properly; will re-enable once content offset/point conversion uses a custom type"]
|
||||
fn test_wide_char_spacer() {
|
||||
// 1: 😀😃
|
||||
// 2: 😄\n
|
||||
// 3: a😄\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀😃😄\na😄\n", 5, Some(2));
|
||||
|
||||
let original_point = Point::new(0, 3);
|
||||
|
||||
let offset = storage
|
||||
.content_offset_at_point(original_point)
|
||||
.expect("should be able to convert point to offset");
|
||||
assert_eq!(offset, ByteOffset::from(4));
|
||||
|
||||
let point = storage
|
||||
.content_offset_to_point(offset)
|
||||
.expect("should be able to convert offset back to point");
|
||||
assert_eq!(point, original_point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonuniform_row() {
|
||||
// 1: 😀😃
|
||||
// 2: 😄\n
|
||||
// 3: a😄\n
|
||||
let storage = FlatStorage::from_content_using_rows("😀😃😄\na😄\n", 5, Some(2));
|
||||
|
||||
let original_point = Point::new(2, 1);
|
||||
|
||||
let offset = storage
|
||||
.content_offset_at_point(original_point)
|
||||
.expect("should be able to convert point to offset");
|
||||
assert_eq!(offset, ByteOffset::from(14));
|
||||
|
||||
let point = storage
|
||||
.content_offset_to_point(offset)
|
||||
.expect("should be able to convert offset back to point");
|
||||
assert_eq!(point, original_point);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! A space-efficient grid storage implementation optimized for scrollback
|
||||
//! buffer use-cases.
|
||||
//!
|
||||
//! This set of data structures is designed to provide space- and CPU-efficient
|
||||
//! support for the following operations:
|
||||
//!
|
||||
//! * `Index`
|
||||
//! * `Scan`/`Iterate`
|
||||
//! * `Push`
|
||||
//! * `Pop`
|
||||
//!
|
||||
//! Notably, `Insert`` is not in the above list, as inserting something in the
|
||||
//! middle of a flat array is relatively expensive (requires shifting all
|
||||
//! data after the insertion point). That said, for grids that are immutable,
|
||||
//! or for the portion of a grid that cannot be accessed via the cursor, this
|
||||
//! structure provides great performance without compromising on space
|
||||
//! efficiency.
|
||||
|
||||
mod attribute_map;
|
||||
mod content;
|
||||
mod grapheme;
|
||||
mod index;
|
||||
mod row_iterator;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
mod testing;
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use attribute_map::AttributeMap;
|
||||
use content::Content;
|
||||
use get_size::GetSize;
|
||||
use grapheme::Grapheme;
|
||||
use index::Index;
|
||||
use itertools::Itertools;
|
||||
use string_offset::ByteOffset;
|
||||
use style::BgAndStyle;
|
||||
|
||||
use crate::model::{ansi, Point};
|
||||
|
||||
use super::{cell, row::Row, CellType};
|
||||
|
||||
const DEFAULT_FG_COLOR: ansi::Color = ansi::Color::Named(ansi::NamedColor::Foreground);
|
||||
|
||||
/// A grid storage implementation that stores content in a flat buffer.
|
||||
#[derive(Debug, Clone, GetSize)]
|
||||
pub struct FlatStorage {
|
||||
/// The grid content.
|
||||
content: Content,
|
||||
|
||||
/// A helper structure for mapping a row index to an offset into the
|
||||
/// content buffer.
|
||||
index: Index,
|
||||
|
||||
/// The width of the grid.
|
||||
columns: usize,
|
||||
|
||||
/// An interval map storing information about cell fg color.
|
||||
fg_color_map: style::FgColorMap,
|
||||
|
||||
/// An interval map storing additional styling information.
|
||||
bg_and_style_map: style::BgAndStyleMap,
|
||||
|
||||
/// The content offset with the end of prompt marker, if any.
|
||||
end_of_prompt_marker: Option<EndOfPromptMarker>,
|
||||
|
||||
/// The maximum number of rows that can be stored.
|
||||
max_rows: Option<usize>,
|
||||
|
||||
/// The number of rows that were truncated due to the `max_rows` limit.
|
||||
num_truncated_rows: u64,
|
||||
}
|
||||
|
||||
impl FlatStorage {
|
||||
/// Constructs a new [`FlatStorage`].
|
||||
///
|
||||
/// `initial_capacity` can be provided to minimize heap allocations
|
||||
/// performed while building backing data structures.
|
||||
pub fn new(columns: usize, max_rows: Option<usize>, initial_capacity: Option<usize>) -> Self {
|
||||
let index = Index::new(columns, initial_capacity);
|
||||
Self {
|
||||
content: Content::new(),
|
||||
index,
|
||||
columns,
|
||||
fg_color_map: AttributeMap::new(DEFAULT_FG_COLOR),
|
||||
bg_and_style_map: AttributeMap::new(BgAndStyle::default()),
|
||||
end_of_prompt_marker: None,
|
||||
max_rows,
|
||||
num_truncated_rows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes new rows into storage.
|
||||
pub fn push_rows<'a>(&mut self, rows: impl IntoIterator<Item = &'a Row>) {
|
||||
self.push_rows_internal(&mut rows.into_iter());
|
||||
|
||||
// If we've exceeded the maximum number of rows, drop the excess.
|
||||
self.apply_max_rows();
|
||||
}
|
||||
|
||||
/// Pushes new rows into storage without applying max row limits.
|
||||
///
|
||||
/// This should be used for cases where we may temporarily exceed the
|
||||
/// maximum number of rows, such as hybrid grid resizing.
|
||||
pub fn push_rows_without_truncation<'a>(&mut self, rows: impl IntoIterator<Item = &'a Row>) {
|
||||
self.push_rows_internal(&mut rows.into_iter());
|
||||
}
|
||||
|
||||
/// Applies the maximum row limit to the grid.
|
||||
///
|
||||
/// This should be called after running logic that uses
|
||||
/// `push_rows_without_truncation` to ensure that we end up in a state
|
||||
/// where the maximum row limit is applied.
|
||||
pub fn apply_max_rows(&mut self) {
|
||||
if let Some(num_excess_rows) = self
|
||||
.index
|
||||
.len()
|
||||
.checked_sub(self.max_rows.unwrap_or(usize::MAX))
|
||||
{
|
||||
self.truncate_rows_front(num_excess_rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pops the last `count` rows off of storage and returns them.
|
||||
///
|
||||
/// May return fewer than `count` rows if there are fewer rows stored.
|
||||
pub fn pop_rows(&mut self, count: usize) -> Vec<Row> {
|
||||
let start_row = self.total_rows().saturating_sub(count);
|
||||
|
||||
// Materialize the rows that we're popping off.
|
||||
let rows = self
|
||||
.rows_from(start_row)
|
||||
.map(Rc::unwrap_or_clone)
|
||||
.collect_vec();
|
||||
|
||||
// Truncate internal data structures to exclude the rows we're
|
||||
// popping off.
|
||||
self.truncate(start_row);
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
/// Truncates internal data structures based on the length of the grid (in
|
||||
/// rows).
|
||||
fn truncate(&mut self, new_len: usize) {
|
||||
// Truncate the index.
|
||||
let new_content_len = self.index.truncate(new_len);
|
||||
// Using the new content length, truncate other internal structures.
|
||||
self.content.truncate(new_content_len);
|
||||
self.fg_color_map.truncate(new_content_len);
|
||||
self.bg_and_style_map.truncate(new_content_len);
|
||||
|
||||
// Clear out the end-of-prompt marker if it was in a row we just
|
||||
// popped.
|
||||
match self.end_of_prompt_marker {
|
||||
Some(EndOfPromptMarker { offset, .. }) if offset >= new_content_len => {
|
||||
self.end_of_prompt_marker = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the first `count` rows from storage.
|
||||
pub fn truncate_rows_front(&mut self, count: usize) {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure we don't truncate more rows than we have.
|
||||
let count = count.min(self.total_rows());
|
||||
|
||||
let new_start_offset = self.index.truncate_front(count);
|
||||
|
||||
self.content.truncate_front(new_start_offset);
|
||||
self.fg_color_map.truncate_front(new_start_offset);
|
||||
self.bg_and_style_map.truncate_front(new_start_offset);
|
||||
|
||||
self.num_truncated_rows += count as u64;
|
||||
}
|
||||
|
||||
/// Pushes new rows into storage.
|
||||
///
|
||||
/// This contains the actual logic, taking in a non-generic [`Iterator`] to
|
||||
/// avoid creating copies of all of the code in this function.
|
||||
fn push_rows_internal(&mut self, rows: &mut dyn Iterator<Item = &Row>) {
|
||||
let mut fg_color = self.fg_color_map.tail();
|
||||
let mut bg_and_style = self.bg_and_style_map.tail();
|
||||
|
||||
for row in rows {
|
||||
let start_offset = ByteOffset::from(self.content().end_offset());
|
||||
let mut entry_builder = self.index.start_row();
|
||||
|
||||
let mut last_cell: isize = -1;
|
||||
|
||||
// Use an empty but pre-allocated buffer to collect characters from
|
||||
// the cells.
|
||||
let mut offset = start_offset;
|
||||
|
||||
// We track index manually here instead of creating an iterator and
|
||||
// using enumerate as this is slightly more performant.
|
||||
let mut idx: isize = -1;
|
||||
for cell in row.dirty_cells() {
|
||||
idx += 1;
|
||||
|
||||
// Skip over cells that don't contain any actual content.
|
||||
if cell.flags().intersects(
|
||||
cell::Flags::WIDE_CHAR_SPACER | cell::Flags::LEADING_WIDE_CHAR_SPACER,
|
||||
) {
|
||||
if cell
|
||||
.flags()
|
||||
.intersects(cell::Flags::LEADING_WIDE_CHAR_SPACER)
|
||||
{
|
||||
entry_builder.add_leading_wide_char_spacer();
|
||||
}
|
||||
last_cell = idx;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut needs_processing = !cell.is_empty();
|
||||
if cell.fg != fg_color {
|
||||
needs_processing = true;
|
||||
fg_color = cell.fg;
|
||||
self.fg_color_map.push_attribute_change(offset.., fg_color);
|
||||
}
|
||||
if bg_and_style != cell {
|
||||
needs_processing = true;
|
||||
bg_and_style = cell.into();
|
||||
self.bg_and_style_map
|
||||
.push_attribute_change(offset.., bg_and_style);
|
||||
}
|
||||
if let Some(marker) = cell.end_of_prompt_marker() {
|
||||
needs_processing = true;
|
||||
self.end_of_prompt_marker = Some(EndOfPromptMarker {
|
||||
offset,
|
||||
has_extra_trailing_newline: marker.has_extra_trailing_newline,
|
||||
});
|
||||
}
|
||||
|
||||
let grapheme = Grapheme::new_from_cell(cell);
|
||||
offset += grapheme.len().as_usize();
|
||||
|
||||
if needs_processing {
|
||||
for _ in last_cell..(idx - 1) {
|
||||
// We skipped a bunch of empty cells, but having hit a
|
||||
// content-ful cell, we need to add them back in.
|
||||
entry_builder
|
||||
.process_grapheme_info_unchecked(Grapheme::EMPTY_CELL.sizing_info());
|
||||
self.content.push_grapheme(&Grapheme::EMPTY_CELL);
|
||||
}
|
||||
last_cell = idx;
|
||||
entry_builder.process_grapheme_info_unchecked(grapheme.sizing_info());
|
||||
self.content.push_grapheme(&grapheme);
|
||||
}
|
||||
}
|
||||
|
||||
// If the grid row soft wraps, the last cell will be marked
|
||||
// with the WRAPLINE flag.
|
||||
let row_soft_wraps = row.occ == self.columns
|
||||
&& row[self.columns - 1]
|
||||
.flags()
|
||||
.intersects(cell::Flags::WRAPLINE);
|
||||
if !row_soft_wraps {
|
||||
entry_builder.add_trailing_newline();
|
||||
self.content.push_grapheme(&Grapheme::NEWLINE);
|
||||
}
|
||||
|
||||
entry_builder.append_to_index(&mut self.index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears out the contents of flat storage.
|
||||
pub fn clear(&mut self) {
|
||||
// Drop all rows from the index.
|
||||
self.truncate(0);
|
||||
}
|
||||
|
||||
/// Updates the width of the grid.
|
||||
pub fn set_columns(&mut self, new_columns: usize) {
|
||||
if self.columns == new_columns {
|
||||
return;
|
||||
}
|
||||
|
||||
self.columns = new_columns;
|
||||
// Rebuild the index to account for the updated width.
|
||||
self.index = Index::rebuild(&self.index, new_columns);
|
||||
}
|
||||
|
||||
/// Returns the total number of rows in the grid.
|
||||
pub fn total_rows(&self) -> usize {
|
||||
self.index.len()
|
||||
}
|
||||
|
||||
/// Returns the maximum number of rows that can be stored.
|
||||
pub fn max_rows(&self) -> Option<usize> {
|
||||
self.max_rows
|
||||
}
|
||||
|
||||
/// Sets the maximum number of rows that can be stored.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn set_max_rows(&mut self, max_rows: Option<usize>) {
|
||||
self.max_rows = max_rows;
|
||||
}
|
||||
|
||||
/// Returns the number of rows that were truncated due to exceeding the
|
||||
/// `max_rows` limit.
|
||||
pub fn num_truncated_rows(&self) -> u64 {
|
||||
self.num_truncated_rows
|
||||
}
|
||||
|
||||
/// Returns an iterator over rows in the grid, starting at the given row.
|
||||
pub fn rows_from(&self, start_row: usize) -> impl Iterator<Item = Rc<Row>> + '_ {
|
||||
row_iterator::RowIterator::new(self, start_row)
|
||||
}
|
||||
|
||||
/// Returns the structure holding all of the grid's string content.
|
||||
fn content(&self) -> &Content {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Returns an estimate of the structure's total memory usage, in bytes.
|
||||
pub fn estimated_memory_usage_bytes(&self) -> usize {
|
||||
self.get_size()
|
||||
}
|
||||
|
||||
/// Returns the content [`ByteOffset`] for the given point.
|
||||
///
|
||||
/// Returns an error if the point is outside the bounds of the structure or
|
||||
/// points at an empty cell after the end of a hard-wrapped line.
|
||||
pub fn content_offset_at_point(
|
||||
&self,
|
||||
point: Point,
|
||||
) -> Result<ByteOffset, index::ContentOffsetToPointError> {
|
||||
self.index.content_offset_at_point(point)
|
||||
}
|
||||
|
||||
/// Returns the grid [`Point`] where the content at a given offset is
|
||||
/// located.
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// 1. The content offset is smaller or larger than the stored content, or
|
||||
/// 2. The content offset points at something that doesn't map to a
|
||||
/// particular cell, such as a newline character.
|
||||
pub fn content_offset_to_point(
|
||||
&self,
|
||||
offset: ByteOffset,
|
||||
) -> Result<Point, index::PointFromContentOffsetError> {
|
||||
self.index.content_offset_to_point(offset)
|
||||
}
|
||||
|
||||
/// Returns the type of the cell at (row, col).
|
||||
pub fn cell_type(&self, row: usize, col: usize) -> Option<CellType> {
|
||||
self.index.cell_type(row, col)
|
||||
}
|
||||
|
||||
pub fn row_wraps(&self, row: usize) -> bool {
|
||||
self.index
|
||||
.get_entry(row)
|
||||
.is_some_and(|entry| !entry.has_trailing_newline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about the end-of-prompt marker.
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
struct EndOfPromptMarker {
|
||||
/// The content offset of the marker.
|
||||
offset: ByteOffset,
|
||||
/// Whether or not the prompt has an extra newline after its content.
|
||||
has_extra_trailing_newline: bool,
|
||||
}
|
||||
|
||||
/// [`GetSize`] is entirely stack-allocated, so the default impl is
|
||||
/// sufficient.
|
||||
impl GetSize for EndOfPromptMarker {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,256 @@
|
||||
use itertools::Itertools;
|
||||
use testing::{assert_rows_equal, ToRows as _};
|
||||
|
||||
use crate::model::{
|
||||
char_or_str::CharOrStr,
|
||||
grid::cell::{Cell, Flags},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_row_iteration() {
|
||||
let storage = FlatStorage::from_content_using_rows("hello world\n", 7, Some(2));
|
||||
|
||||
let mut rows = storage.rows_from(0);
|
||||
|
||||
let row1 = rows
|
||||
.next()
|
||||
.expect("should be able to get first row from storage");
|
||||
assert_eq!(row1.occ, 7);
|
||||
assert_eq!(row1[0].c, 'h');
|
||||
assert_eq!(row1[6].c, 'w');
|
||||
|
||||
let row2 = rows
|
||||
.next()
|
||||
.expect("should be able to get first row from storage");
|
||||
assert_eq!(row2.occ, 4);
|
||||
assert_eq!(row2[0].c, 'o');
|
||||
assert_eq!(row2[3].c, 'd');
|
||||
|
||||
assert!(rows.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_row_with_double_width_char() {
|
||||
let storage = FlatStorage::from_content_using_rows("hi 😀 hello\n", 6, Some(2));
|
||||
|
||||
let mut rows = storage.rows_from(0);
|
||||
|
||||
let row1 = rows
|
||||
.next()
|
||||
.expect("should be able to get first row from storage");
|
||||
assert_eq!(row1.occ, 6);
|
||||
assert_eq!(row1[0].c, 'h');
|
||||
assert_eq!(row1[3].c, '😀');
|
||||
assert!(row1[4].flags().contains(Flags::WIDE_CHAR_SPACER));
|
||||
assert_eq!(row1[5].c, ' ');
|
||||
|
||||
let row2 = rows
|
||||
.next()
|
||||
.expect("should be able to get first row from storage");
|
||||
assert_eq!(row2.occ, 5);
|
||||
assert_eq!(row2[0].c, 'h');
|
||||
|
||||
assert!(rows.next().is_none());
|
||||
}
|
||||
|
||||
/// This test validates our handling of complex emoji sequences.
|
||||
///
|
||||
/// The three graphemes here are comprised of a number of Unicode characters.
|
||||
/// Below are the individual characters that comprise the test string, with
|
||||
/// "---" denoting how the string gets segmented into graphemes.
|
||||
///
|
||||
/// 1. 🧑 1F9D1 ADULT
|
||||
/// 2. 1F3FF EMOJI MODIFIER FITZPATRICK TYPE-6
|
||||
/// 3. 200D ZERO WIDTH JOINER
|
||||
/// 4. 🦰 1F9B0 EMOJI COMPONENT RED HAIR
|
||||
/// ---
|
||||
/// 1. 👩 1F469 WOMAN
|
||||
/// 2. 200D ZERO WIDTH JOINER
|
||||
/// 3. 🦲 1F9B2 EMOJI COMPONENT BALD
|
||||
/// ---
|
||||
/// 1. 🧔 1F9D4 BEARDED PERSON
|
||||
/// 2. 🏿 1F3FF EMOJI MODIFIER FITZPATRICK TYPE-6
|
||||
/// 3. 200D ZERO WIDTH JOINER
|
||||
/// 4. ♂ 2642 MALE SIGN
|
||||
/// 5. ️ FE0F VARIATION SELECTOR-16
|
||||
#[test]
|
||||
#[ignore = "will not pass until using a version of unicode-width that includes commit afab363"]
|
||||
fn test_row_with_complex_emoji() {
|
||||
let storage = FlatStorage::from_content_using_rows("🧑🏿🦰👩🦲🧔🏿♂️", 6, Some(1));
|
||||
|
||||
let mut rows = storage.rows_from(0);
|
||||
let row1 = rows
|
||||
.next()
|
||||
.expect("should be able to get first row from storage");
|
||||
assert_eq!(row1.occ, 6);
|
||||
|
||||
assert_eq!(row1[0].c, '🧑');
|
||||
assert!(matches!(
|
||||
row1[0].content_for_display(),
|
||||
CharOrStr::Str("🧑🏿🦰")
|
||||
));
|
||||
|
||||
assert!(row1[1].flags().contains(Flags::WIDE_CHAR_SPACER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_rows_with_color() {
|
||||
let mut storage = FlatStorage::new(5, None, Some(2));
|
||||
|
||||
let mut fg_cell = Cell::default();
|
||||
fg_cell.c = 'f';
|
||||
|
||||
let mut red_cell = Cell::default();
|
||||
red_cell.c = 'r';
|
||||
red_cell.fg = ansi::Color::Named(ansi::NamedColor::Red);
|
||||
|
||||
let row = Row::from_vec(
|
||||
vec![
|
||||
Cell::default(),
|
||||
Cell::default(),
|
||||
red_cell.clone(),
|
||||
red_cell,
|
||||
Cell::default(),
|
||||
],
|
||||
5,
|
||||
);
|
||||
storage.push_rows([&row]);
|
||||
|
||||
assert_eq!(storage.rows_from(0).next().unwrap().as_ref(), &row);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_rows_with_color_and_multibyte_chars() {
|
||||
let mut storage = FlatStorage::new(5, None, Some(2));
|
||||
|
||||
let mut fg_cell = Cell::default();
|
||||
fg_cell.c = '❤';
|
||||
|
||||
let mut red_cell = Cell::default();
|
||||
red_cell.c = 'r';
|
||||
red_cell.fg = ansi::Color::Named(ansi::NamedColor::Red);
|
||||
|
||||
let row = Row::from_vec(
|
||||
vec![
|
||||
fg_cell.clone(),
|
||||
fg_cell.clone(),
|
||||
red_cell.clone(),
|
||||
red_cell,
|
||||
fg_cell,
|
||||
],
|
||||
5,
|
||||
);
|
||||
storage.push_rows([&row]);
|
||||
|
||||
assert_eq!(storage.rows_from(0).next().unwrap().as_ref(), &row);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_row_roundtrip_and_resize() {
|
||||
let num_cols = 5;
|
||||
let rows = "😀😃😄ag\na😁😆~!!\n😅sdf😂\n".to_rows(num_cols);
|
||||
|
||||
// Build FlatStorage from the set of rows.
|
||||
let mut storage = FlatStorage::new(num_cols, None, None);
|
||||
storage.push_rows(&rows);
|
||||
|
||||
// Make sure the generated rows match the original input.
|
||||
let flat_rows = storage
|
||||
.rows_from(0)
|
||||
.map(|row| row.as_ref().clone())
|
||||
.collect_vec();
|
||||
|
||||
assert_rows_equal(&flat_rows, &rows);
|
||||
|
||||
// "Resize" the storage, keeping the number of columns the same.
|
||||
storage.set_columns(num_cols);
|
||||
|
||||
// Make sure the generated rows match the original input.
|
||||
let flat_rows = storage
|
||||
.rows_from(0)
|
||||
.map(|row| row.as_ref().clone())
|
||||
.collect_vec();
|
||||
|
||||
assert_rows_equal(&flat_rows, &rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_styling_change_within_trailing_empty_cells() {
|
||||
let num_cols = 5;
|
||||
let mut rows = "a\nb\n".to_rows(num_cols);
|
||||
|
||||
// Make the final cell in the first row bold.
|
||||
rows[0][num_cols - 1].flags.insert(Flags::BOLD);
|
||||
|
||||
// Push the rows into storage. This should produce a first row that is 5
|
||||
// cells long (the "a" followed by 3 empty cells followed by a bold empty
|
||||
// cell) and then clear the bold styling on the first cell of the second
|
||||
// line.
|
||||
let mut storage = FlatStorage::new(num_cols, None, None);
|
||||
storage.push_rows(&rows);
|
||||
|
||||
let flat_rows = storage
|
||||
.rows_from(0)
|
||||
.map(|row| row.as_ref().clone())
|
||||
.collect_vec();
|
||||
|
||||
// The first row's content should be 5 characters + a trailing newline.
|
||||
assert_eq!(flat_rows[0][0].c, 'a');
|
||||
assert_eq!(flat_rows[0][1].c, '\0');
|
||||
assert_eq!(flat_rows[0][2].c, '\0');
|
||||
assert_eq!(flat_rows[0][3].c, '\0');
|
||||
assert_eq!(flat_rows[0][4].c, '\0');
|
||||
assert!(!flat_rows[0][4].flags.contains(Flags::WRAPLINE));
|
||||
|
||||
// The final cell in the first row should be bold, but the first cell in
|
||||
// the second row should not.
|
||||
assert!(flat_rows[0][num_cols - 1].flags.intersects(Flags::BOLD));
|
||||
assert!(!flat_rows[1][0].flags.intersects(Flags::BOLD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_after_truncate_front() {
|
||||
let num_cols = 20;
|
||||
let rows = "abcd\n789\n1 overflow\n2 overflow\n".to_rows(num_cols);
|
||||
|
||||
let mut storage = FlatStorage::new(num_cols, Some(2), None);
|
||||
storage.push_rows(&rows);
|
||||
|
||||
// We pushed 4 rows, and the limit is 2, so we should have truncated 2 rows.
|
||||
assert_eq!(storage.total_rows(), 2);
|
||||
assert_eq!(storage.num_truncated_rows(), 2);
|
||||
|
||||
// Make sure the truncated rows are what we expect.
|
||||
assert_eq!(
|
||||
storage.rows_from(0).next().expect("should have a row")[0].c,
|
||||
'1'
|
||||
);
|
||||
assert_eq!(
|
||||
storage.rows_from(1).next().expect("should have a row")[0].c,
|
||||
'2'
|
||||
);
|
||||
|
||||
// Clear flat storage, and ensure the state is as we expect.
|
||||
storage.clear();
|
||||
assert_eq!(storage.total_rows(), 0);
|
||||
// Should still have 2 truncated rows, as clearing storage doesn't affect
|
||||
// the number of rows we've truncated in total so far.
|
||||
assert_eq!(storage.num_truncated_rows(), 2);
|
||||
|
||||
// Make sure we can push new rows.
|
||||
storage.push_rows(&rows);
|
||||
assert_eq!(storage.total_rows(), 2);
|
||||
assert_eq!(storage.num_truncated_rows(), 4);
|
||||
|
||||
// Make sure remaining truncated rows are what we expect.
|
||||
assert_eq!(
|
||||
storage.rows_from(0).next().expect("should have a row")[0].c,
|
||||
'1'
|
||||
);
|
||||
assert_eq!(
|
||||
storage.rows_from(1).next().expect("should have a row")[0].c,
|
||||
'2'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::model::grid::{
|
||||
cell::{Cell, Flags},
|
||||
row::Row,
|
||||
};
|
||||
|
||||
use super::{grapheme::Grapheme, style::BgAndStyle, EndOfPromptMarker, FlatStorage};
|
||||
|
||||
/// An iterator over [`Row`]s in the grid.
|
||||
///
|
||||
/// The item type is [`Rc<Row>`] so that we can minimize the number of heap
|
||||
/// allocations performed during iteration. If the `Rc<Row>` returned by a
|
||||
/// call to `next()` is always dropped before the next call, only one [`Row`]
|
||||
/// will be allocated for the entire lifetime of the [`RowIterator`].
|
||||
pub struct RowIterator<'s> {
|
||||
/// A reference to the backing grid storage.
|
||||
storage: &'s FlatStorage,
|
||||
/// The index of the next row to return.
|
||||
row_index: usize,
|
||||
/// The [`Row`] that we will return to the caller.
|
||||
row: Rc<Row>,
|
||||
/// A template for what empty cells in the row should look like.
|
||||
template: Cell,
|
||||
}
|
||||
|
||||
impl<'s> RowIterator<'s> {
|
||||
/// Constructs a new [`RowIterator`] that starts at the given row index.
|
||||
pub fn new(storage: &'s FlatStorage, start_row: usize) -> Self {
|
||||
Self {
|
||||
storage,
|
||||
row_index: start_row,
|
||||
row: Row::new(storage.columns).into(),
|
||||
template: Cell::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for RowIterator<'_> {
|
||||
type Item = Rc<Row>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let start_offset = self
|
||||
.storage
|
||||
.index
|
||||
.content_range_for_row(self.row_index)?
|
||||
.start;
|
||||
|
||||
let mut fg_color_iter = self.storage.fg_color_map.iter_from(start_offset);
|
||||
let mut bg_and_style_iter = self.storage.bg_and_style_map.iter_from(start_offset);
|
||||
|
||||
let row = Rc::make_mut(&mut self.row);
|
||||
row.reset(&self.template);
|
||||
|
||||
let mut current_offset = start_offset;
|
||||
for grapheme_info in self.storage.index.grapheme_infos_for_row(self.row_index)? {
|
||||
let content = {
|
||||
let start = current_offset;
|
||||
let end = start + grapheme_info.utf8_bytes.get() as usize;
|
||||
&self.storage.content()[start..end]
|
||||
};
|
||||
let grapheme = Grapheme::new_from_str_and_info(content, grapheme_info);
|
||||
|
||||
if grapheme.starts_new_row() {
|
||||
break;
|
||||
}
|
||||
|
||||
// We must advance content offset-based iterators before any uses
|
||||
// of the continue keyword to ensure those iterators are in sync
|
||||
// with our content offset position.
|
||||
//
|
||||
// TODO(vorporeal): Figure out a cleaner way to handle advancing the
|
||||
// iterator by grapheme byte length. My initial implementation advanced
|
||||
// the iterator once per grapheme instead of once per byte, which was
|
||||
// incorrect (but easy to get wrong). This works, but I wonder if the
|
||||
// iterator returned by `AttributeMap` shouldn't actually implement
|
||||
// `Iterator` and should provide its own `next(&Grapheme)` function.
|
||||
let fg = next_attribute(&mut fg_color_iter, &grapheme);
|
||||
let BgAndStyle { bg, flags } = next_attribute(&mut bg_and_style_iter, &grapheme);
|
||||
|
||||
let cell_width = grapheme.cell_width();
|
||||
if cell_width == 0 {
|
||||
current_offset += grapheme.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
// The next cell to fill is the first untouched one. This allows
|
||||
// us to cleanly handle wide chars, which modify multiple cells
|
||||
// in the row.
|
||||
let idx = row.occ;
|
||||
let Some(cell) = row.get_mut(idx) else {
|
||||
log::warn!(
|
||||
"Tried to mutate cell past the end of a row in RowIterator::next!\n\
|
||||
\tidx: {idx}\n\
|
||||
\tlen: {}\n\
|
||||
\tgrapheme runs: {:?}",
|
||||
row.len(),
|
||||
self.storage.index.grapheme_runs_for_row(self.row_index)?
|
||||
);
|
||||
panic!("Tried to mutate cell past the end of a row in RowIterator::next!")
|
||||
};
|
||||
|
||||
let mut chars = grapheme.chars();
|
||||
// SAFETY: Grapheme::new() asserts that the grapheme is non-empty.
|
||||
cell.c = chars.next().unwrap();
|
||||
// Add any remaining chars in the grapheme to the cell as zero-width
|
||||
// characters. We suppress `Cell::push_zerowidth`'s
|
||||
// long-grapheme warning on this path: we're replaying chars
|
||||
// from an already-stored grapheme that was capped when it was
|
||||
// first seen on the ANSI-input path, so a warning here would
|
||||
// be redundant and would fire every time a row is
|
||||
// rematerialized (e.g. on scroll or resize).
|
||||
chars.for_each(|c| cell.push_zerowidth(c, /* log_long_grapheme_warnings */ false));
|
||||
|
||||
cell.fg = fg;
|
||||
cell.bg = bg;
|
||||
cell.flags = flags;
|
||||
|
||||
match self.storage.end_of_prompt_marker {
|
||||
Some(EndOfPromptMarker {
|
||||
offset,
|
||||
has_extra_trailing_newline,
|
||||
}) if offset == current_offset => {
|
||||
cell.mark_end_of_prompt(has_extra_trailing_newline);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// If the grapheme takes up two cells, mark the following cell as
|
||||
// a spacer.
|
||||
if cell_width == 2 {
|
||||
row[idx].flags.insert(Flags::WIDE_CHAR);
|
||||
row[idx + 1].flags.insert(Flags::WIDE_CHAR_SPACER);
|
||||
}
|
||||
|
||||
current_offset += grapheme.len();
|
||||
}
|
||||
|
||||
let entry = self
|
||||
.storage
|
||||
.index
|
||||
.get_entry(self.row_index)
|
||||
.expect("should not fail to get entry for row");
|
||||
if !entry.has_trailing_newline {
|
||||
row.last_mut().unwrap().flags_mut().insert(Flags::WRAPLINE);
|
||||
}
|
||||
if entry.ends_with_leading_wide_char_spacer {
|
||||
row.last_mut()
|
||||
.unwrap()
|
||||
.flags_mut()
|
||||
.insert(Flags::LEADING_WIDE_CHAR_SPACER);
|
||||
}
|
||||
|
||||
self.row_index += 1;
|
||||
Some(self.row.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next value for the given attribute iterator, given the current
|
||||
/// grapheme.
|
||||
///
|
||||
/// This must be used instead of [`Iterator::next`] in order to handle
|
||||
/// multi-byte graphemes properly.
|
||||
fn next_attribute<T>(iter: &mut impl Iterator<Item = T>, grapheme: &Grapheme) -> T {
|
||||
iter.nth(grapheme.len().as_usize() - 1)
|
||||
.expect("should never fail to provide value")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Types relating to styling.
|
||||
//!
|
||||
//! The split between foreground color and other styling is a performance
|
||||
//! optimization. Foreground color changes more frequently than other style
|
||||
//! attribues, so combining all styling into one map means we're storing 16b
|
||||
//! of data whenever foreground color changes, instead of only 8b. Splitting
|
||||
//! these into two maps improves cache line efficiency.
|
||||
//!
|
||||
//! TODO(vorporeal): Concretely validate the above assertion using benchmarks.
|
||||
|
||||
use get_size::GetSize;
|
||||
|
||||
use crate::model::{ansi, grid::cell};
|
||||
|
||||
use super::attribute_map::AttributeMap;
|
||||
|
||||
/// A map that holds foreground color information.
|
||||
pub type FgColorMap = AttributeMap<ansi::Color>;
|
||||
|
||||
/// A map that holds background color and other styling information.
|
||||
pub type BgAndStyleMap = AttributeMap<BgAndStyle>;
|
||||
|
||||
/// A bitmask for flags that represent style information.
|
||||
const STYLE_MASK: cell::Flags = cell::Flags::from_bits_truncate(
|
||||
cell::Flags::INVERSE.bits()
|
||||
| cell::Flags::BOLD.bits()
|
||||
| cell::Flags::ITALIC.bits()
|
||||
| cell::Flags::UNDERLINE.bits()
|
||||
| cell::Flags::DOUBLE_UNDERLINE.bits()
|
||||
| cell::Flags::DIM.bits()
|
||||
| cell::Flags::HIDDEN.bits()
|
||||
| cell::Flags::STRIKEOUT.bits(),
|
||||
);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BgAndStyle {
|
||||
/// The background color for a cell.
|
||||
pub bg: ansi::Color,
|
||||
|
||||
/// Additional styling-related flags.
|
||||
pub flags: cell::Flags,
|
||||
}
|
||||
|
||||
impl Default for BgAndStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bg: ansi::Color::Named(ansi::NamedColor::Background),
|
||||
flags: cell::Flags::empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GetSize for BgAndStyle {}
|
||||
|
||||
impl From<&cell::Cell> for BgAndStyle {
|
||||
fn from(value: &cell::Cell) -> Self {
|
||||
Self {
|
||||
bg: value.bg,
|
||||
flags: value.flags & STYLE_MASK,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&cell::Cell> for BgAndStyle {
|
||||
fn eq(&self, other: &&cell::Cell) -> bool {
|
||||
self.bg == other.bg && self.flags == (other.flags & STYLE_MASK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
|
||||
use crate::model::grid::{cell::Flags, row::Row};
|
||||
|
||||
use super::{grapheme::Grapheme, FlatStorage};
|
||||
|
||||
pub fn assert_rows_equal(actual: &[Row], expected: &[Row]) {
|
||||
assert_eq!(
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
"Expected to have {} rows but got {}. Got: {actual:?}; expected {expected:?}",
|
||||
expected.len(),
|
||||
actual.len()
|
||||
);
|
||||
actual.iter().zip(expected.iter())
|
||||
.enumerate()
|
||||
.for_each(|(row_idx, (actual, expected))| {
|
||||
assert_eq!(actual.occ, expected.occ, "Expected row {row_idx} to have {} occupied cells but got {}", expected.occ, actual.occ);
|
||||
for col_idx in 0..actual.occ {
|
||||
let actual = &actual[col_idx];
|
||||
let expected = &expected[col_idx];
|
||||
|
||||
let actual_content = actual.raw_content();
|
||||
let expected_content = expected.raw_content();
|
||||
assert_eq!(actual_content, expected_content, "Expected ({row_idx}, {col_idx}) to contain {expected_content:?} but got {actual_content:?}");
|
||||
|
||||
assert_eq!(actual.fg, expected.fg, "Expected ({row_idx}, {col_idx}) to have fg {:?} but got {:?}", expected.fg, actual.fg);
|
||||
assert_eq!(actual.bg, expected.bg, "Expected ({row_idx}, {col_idx}) to have bg {:?} but got {:?}", expected.bg, actual.bg);
|
||||
|
||||
assert_eq!(actual.flags(), expected.flags(), "Expected ({row_idx}, {col_idx}) to have flags {:?} but got {:?}", expected.flags(), actual.flags());
|
||||
|
||||
// TODO(vorporeal): Check CellExtra::end_of_prompt.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Extra functions on [`FlatStorage`] that are useful for testing purposes.
|
||||
impl FlatStorage {
|
||||
pub fn push_rows_from_string(&mut self, string: &str) {
|
||||
let rows = string.to_rows(self.columns);
|
||||
self.push_rows(&rows);
|
||||
}
|
||||
|
||||
pub fn from_content_using_rows(
|
||||
content: &str,
|
||||
columns: usize,
|
||||
initial_capacity: Option<usize>,
|
||||
) -> Self {
|
||||
let mut storage = Self::new(columns, None, initial_capacity);
|
||||
storage.push_rows_from_string(content);
|
||||
storage
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper trait for converting something to a set of [`Row`]s.
|
||||
///
|
||||
/// Ultimately, we want to be testing equivalence of behavior between grid and
|
||||
/// flat storage at a higher level, but this is still useful for quick-to-run
|
||||
/// unit tests.
|
||||
///
|
||||
/// TODO(vorporeal): Eliminate this and use existing string-to-row machinery
|
||||
/// (e.g.: session restoration logic) once that logic is in this crate.
|
||||
pub trait ToRows {
|
||||
fn to_rows(&self, columns: usize) -> Vec<Row>;
|
||||
}
|
||||
|
||||
impl ToRows for &str {
|
||||
#[allow(unused_assignments)]
|
||||
fn to_rows(&self, columns: usize) -> Vec<Row> {
|
||||
let mut rows = vec![Row::new(columns)];
|
||||
|
||||
let mut needs_new_row = false;
|
||||
|
||||
for grapheme in self.graphemes(true).map(Grapheme::new_from_str) {
|
||||
let mut cell_idx = rows.last().unwrap().occ;
|
||||
|
||||
macro_rules! new_row {
|
||||
() => {
|
||||
rows.push(Row::new(columns));
|
||||
cell_idx = 0;
|
||||
};
|
||||
}
|
||||
|
||||
if needs_new_row {
|
||||
needs_new_row = false;
|
||||
new_row!();
|
||||
}
|
||||
|
||||
if grapheme.starts_new_row() {
|
||||
// Don't immediately start a new row - if this is the last
|
||||
// grapheme, we shouldn't append an extra empty row.
|
||||
needs_new_row = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell_width = grapheme.cell_width();
|
||||
if cell_width == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cell_idx + cell_width as usize > columns {
|
||||
// If the row is full and we have another character to add to
|
||||
// it, set the WRAPLINE flag on the final cell and then start
|
||||
// a new row.
|
||||
let mut flags = Flags::WRAPLINE;
|
||||
if cell_width > 1 && cell_idx < columns {
|
||||
cell_idx += cell_width as usize - 1;
|
||||
// If this is a wide character and there isn't enough space
|
||||
// for it, also add the appropriate flag.
|
||||
flags |= Flags::LEADING_WIDE_CHAR_SPACER;
|
||||
}
|
||||
|
||||
rows.last_mut().unwrap()[columns - 1].flags.insert(flags);
|
||||
new_row!();
|
||||
}
|
||||
|
||||
let row = rows.last_mut().unwrap();
|
||||
let cell = &mut row[cell_idx];
|
||||
|
||||
let mut chars = grapheme.chars();
|
||||
cell.c = chars.next().unwrap();
|
||||
// Add any remaining chars in the grapheme to the cell as zero-width
|
||||
// characters.
|
||||
chars.for_each(|c| cell.push_zerowidth(c, /* log_long_grapheme_warnings */ true));
|
||||
|
||||
// If the grapheme takes up two cells, mark the following cell as
|
||||
// a spacer.
|
||||
if cell_width == 2 {
|
||||
row[cell_idx].flags.insert(Flags::WIDE_CHAR);
|
||||
row[cell_idx + 1].flags.insert(Flags::WIDE_CHAR_SPACER);
|
||||
}
|
||||
}
|
||||
|
||||
// If the last row didn't end in a newline character, assert that it
|
||||
// was a full row, and add the WRAPLINE (soft wrap) flag.
|
||||
let last_row = rows.last_mut().unwrap();
|
||||
let occupied_cells = last_row.occ;
|
||||
if !needs_new_row {
|
||||
assert!(
|
||||
occupied_cells == last_row.len(),
|
||||
"All non-filled rows must explicitly end in a newline to avoid surprises and incorrect tests."
|
||||
);
|
||||
last_row
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.flags_mut()
|
||||
.insert(Flags::WRAPLINE);
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod cell;
|
||||
mod cell_type;
|
||||
mod dimensions;
|
||||
pub mod flat_storage;
|
||||
pub mod row;
|
||||
|
||||
pub use cell_type::CellType;
|
||||
pub use dimensions::Dimensions;
|
||||
pub use flat_storage::FlatStorage;
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Defines the Row type which makes up lines in the grid.
|
||||
use std::cmp::{max, min};
|
||||
use std::ops::{Index, IndexMut};
|
||||
use std::ops::{Range, RangeFrom, RangeFull, RangeTo, RangeToInclusive};
|
||||
use std::ptr;
|
||||
use std::slice;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::grid::cell::{Cell, ResetDiscriminant};
|
||||
|
||||
/// A row in the grid.
|
||||
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
|
||||
pub struct Row {
|
||||
inner: Vec<Cell>,
|
||||
|
||||
/// Maximum number of occupied entries.
|
||||
///
|
||||
/// This is the upper bound on the number of elements in the row, which have been modified
|
||||
/// since the last reset. All cells after this point are guaranteed to be equal.
|
||||
///
|
||||
/// TODO(visibility): This should be changed to `pub(crate)` when possible.
|
||||
pub occ: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for Row {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.inner == other.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// Create a new terminal row.
|
||||
pub fn new(usizes: usize) -> Row {
|
||||
debug_assert!(usizes >= 1);
|
||||
|
||||
let mut inner: Vec<Cell> = Vec::with_capacity(usizes);
|
||||
|
||||
// This is a slightly optimized version of `std::vec::Vec::resize`.
|
||||
unsafe {
|
||||
let mut ptr = inner.as_mut_ptr();
|
||||
|
||||
for _ in 1..usizes {
|
||||
ptr::write(ptr, Cell::default());
|
||||
ptr = ptr.offset(1);
|
||||
}
|
||||
ptr::write(ptr, Cell::default());
|
||||
|
||||
inner.set_len(usizes);
|
||||
}
|
||||
|
||||
Row { inner, occ: 0 }
|
||||
}
|
||||
|
||||
/// Increase the number of usizes in the row.
|
||||
#[inline]
|
||||
pub fn grow(&mut self, cols: usize) {
|
||||
if self.inner.len() >= cols {
|
||||
return;
|
||||
}
|
||||
|
||||
self.inner.resize_with(cols, Cell::default);
|
||||
}
|
||||
|
||||
/// Reduce the number of usizes in the row.
|
||||
///
|
||||
/// This will return all non-empty cells that were removed.
|
||||
pub fn shrink(&mut self, cols: usize) -> Option<Vec<Cell>> {
|
||||
if self.inner.len() <= cols {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Split off cells for a new row.
|
||||
let mut new_row = self.inner.split_off(cols);
|
||||
let index = new_row
|
||||
.iter()
|
||||
// NOTE: We do NOT want to treat the "end of prompt" cell as empty in this case - we
|
||||
// want to preserve the marker in the Cell's `extra` field and carry it over for the row shrinking
|
||||
// in the context of a resize.
|
||||
.rposition(|c| !c.is_empty() || c.is_end_of_prompt())
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
new_row.truncate(index);
|
||||
|
||||
self.occ = min(self.occ, cols);
|
||||
|
||||
if new_row.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(new_row)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate row to desired number of columns.
|
||||
pub fn truncate(&mut self, cols: usize) {
|
||||
self.inner.truncate(cols);
|
||||
}
|
||||
|
||||
/// Reset all cells in the row to the `template` cell.
|
||||
#[inline]
|
||||
pub fn reset(&mut self, template: &Cell) {
|
||||
debug_assert!(!self.inner.is_empty());
|
||||
|
||||
// Mark all cells as dirty if template cell changed.
|
||||
let len = self.inner.len();
|
||||
if self.inner[len - 1].discriminant() != template.discriminant() {
|
||||
self.occ = len;
|
||||
}
|
||||
|
||||
// Reset every dirty cell in the row.
|
||||
for item in &mut self.inner[0..self.occ] {
|
||||
item.reset(template);
|
||||
}
|
||||
|
||||
self.occ = 0;
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Option<&Cell> {
|
||||
if index < self.len() {
|
||||
Some(&self[index])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, index: usize) -> Option<&mut Cell> {
|
||||
if index < self.len() {
|
||||
Some(&mut self[index])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimated_heap_usage_bytes(&self) -> usize {
|
||||
self.inner.capacity() * std::mem::size_of::<Cell>()
|
||||
}
|
||||
|
||||
pub fn estimated_memory_usage_bytes(&self) -> usize {
|
||||
// size of struct on the stack
|
||||
std::mem::size_of::<Self>()
|
||||
// size of heap-allocated data in self.inner
|
||||
+ self.estimated_heap_usage_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
impl Row {
|
||||
#[inline]
|
||||
pub fn from_vec(vec: Vec<Cell>, occ: usize) -> Row {
|
||||
Row { inner: vec, occ }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn last(&self) -> Option<&Cell> {
|
||||
self.inner.last()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn last_mut(&mut self) -> Option<&mut Cell> {
|
||||
self.occ = self.inner.len();
|
||||
self.inner.last_mut()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn append(&mut self, vec: &mut Vec<Cell>) {
|
||||
self.occ += vec.len();
|
||||
self.inner.append(vec);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn append_front(&mut self, mut vec: Vec<Cell>) {
|
||||
self.occ += vec.len();
|
||||
|
||||
vec.append(&mut self.inner);
|
||||
self.inner = vec;
|
||||
}
|
||||
|
||||
/// Check if all cells in the row are empty.
|
||||
#[inline]
|
||||
pub fn is_clear(&self) -> bool {
|
||||
self.inner.iter().all(Cell::is_empty)
|
||||
}
|
||||
|
||||
/// Returns `true` if no cells in the row contain an end of prompt marker
|
||||
/// and `false` otherwise.
|
||||
#[inline]
|
||||
pub fn has_no_end_of_prompt_marker(&self) -> bool {
|
||||
self.inner.iter().all(|cell| !Cell::is_end_of_prompt(cell))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn front_split_off(&mut self, at: usize) -> Vec<Cell> {
|
||||
self.occ = self.occ.saturating_sub(at);
|
||||
|
||||
let mut split = self.inner.split_off(at);
|
||||
std::mem::swap(&mut split, &mut self.inner);
|
||||
split
|
||||
}
|
||||
|
||||
/// Returns the set of cells that have been dirtied since the row was last
|
||||
/// reset.
|
||||
///
|
||||
/// This is guaranteed to return all cells that contain content that should
|
||||
/// be rendered, but may also return some additional cells after the last
|
||||
/// actually-relevant cell.
|
||||
///
|
||||
/// This returns a slice instead of an [`Iterator`] as a small performance
|
||||
/// optimization.
|
||||
pub(crate) fn dirty_cells(&self) -> &[Cell] {
|
||||
&self.inner[0..self.occ]
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a mut Row {
|
||||
type Item = &'a mut Cell;
|
||||
type IntoIter = slice::IterMut<'a, Cell>;
|
||||
|
||||
#[inline]
|
||||
fn into_iter(self) -> slice::IterMut<'a, Cell> {
|
||||
self.occ = self.len();
|
||||
self.inner.iter_mut()
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<usize> for Row {
|
||||
type Output = Cell;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: usize) -> &Cell {
|
||||
&self.inner[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<usize> for Row {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: usize) -> &mut Cell {
|
||||
self.occ = max(self.occ, index + 1);
|
||||
&mut self.inner[index]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<Range<usize>> for Row {
|
||||
type Output = [Cell];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: Range<usize>) -> &[Cell] {
|
||||
&self.inner[(index.start)..(index.end)]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<Range<usize>> for Row {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: Range<usize>) -> &mut [Cell] {
|
||||
self.occ = max(self.occ, index.end);
|
||||
&mut self.inner[(index.start)..(index.end)]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<RangeTo<usize>> for Row {
|
||||
type Output = [Cell];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: RangeTo<usize>) -> &[Cell] {
|
||||
&self.inner[..(index.end)]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<RangeTo<usize>> for Row {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: RangeTo<usize>) -> &mut [Cell] {
|
||||
self.occ = max(self.occ, index.end);
|
||||
&mut self.inner[..(index.end)]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<RangeFrom<usize>> for Row {
|
||||
type Output = [Cell];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: RangeFrom<usize>) -> &[Cell] {
|
||||
&self.inner[(index.start)..]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<RangeFrom<usize>> for Row {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: RangeFrom<usize>) -> &mut [Cell] {
|
||||
self.occ = self.len();
|
||||
&mut self.inner[(index.start)..]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<RangeFull> for Row {
|
||||
type Output = [Cell];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, _: RangeFull) -> &[Cell] {
|
||||
&self.inner[..]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<RangeFull> for Row {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, _: RangeFull) -> &mut [Cell] {
|
||||
self.occ = self.len();
|
||||
&mut self.inner[..]
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<RangeToInclusive<usize>> for Row {
|
||||
type Output = [Cell];
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: RangeToInclusive<usize>) -> &[Cell] {
|
||||
&self.inner[..=(index.end)]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<RangeToInclusive<usize>> for Row {
|
||||
#[inline]
|
||||
fn index_mut(&mut self, index: RangeToInclusive<usize>) -> &mut [Cell] {
|
||||
self.occ = max(self.occ, index.end);
|
||||
&mut self.inner[..=(index.end)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Types relating to indexing into a terminal grid.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::ops::{Add, AddAssign, Range, Sub, SubAssign};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use galaxyui::units::Lines;
|
||||
|
||||
use super::grid::Dimensions;
|
||||
|
||||
/// Behavior for handling grid boundaries.
|
||||
pub enum Boundary {
|
||||
/// Clamp to grid boundaries.
|
||||
///
|
||||
/// When an operation exceeds the grid boundaries, the last point will be returned no matter
|
||||
/// how far the boundaries were exceeded.
|
||||
Clamp,
|
||||
|
||||
/// Wrap around grid bondaries.
|
||||
///
|
||||
/// When an operation exceeds the grid boundaries, the point will wrap around the entire grid
|
||||
/// history and continue at the other side.
|
||||
Wrap,
|
||||
}
|
||||
|
||||
/// An integral index representing a row or column in a grid.
|
||||
///
|
||||
/// This exists to encapsulate logic needed to account for floating point error
|
||||
/// when converting from a floating-point grid position to an integral one.
|
||||
/// Accumulation of small errors over time can cause a simple truncation from an
|
||||
/// f32 to a usize to produce an off-by-one error, so when constructing an
|
||||
/// `Index` from `Lines`, we adjust the value upwards by a small amount before
|
||||
/// truncating.
|
||||
pub struct Index(usize);
|
||||
|
||||
impl Index {
|
||||
/// The amount by which we are willing to round up when converting from the
|
||||
/// floating-point `Lines` to an integral grid row/column index. This helps
|
||||
/// account for small errors that accumulate as arithmetic operations are
|
||||
/// performed on floating-point values.
|
||||
const FLOATING_POINT_ERROR_ADJUSTMENT: f64 = 0.0001;
|
||||
}
|
||||
|
||||
impl From<usize> for Index {
|
||||
fn from(value: usize) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Lines> for Index {
|
||||
fn from(value: Lines) -> Self {
|
||||
// Adjust the value upwards slightly before truncating, to round up
|
||||
// when the value is sufficiently close to the next integer boundary.
|
||||
let error_adjusted = value.as_f64() + Self::FLOATING_POINT_ERROR_ADJUSTMENT;
|
||||
Self(error_adjusted as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// Index in the grid using row, column notation.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, Eq, PartialEq, Hash)]
|
||||
pub struct Point {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub fn new(line: impl Into<Index>, col: impl Into<Index>) -> Point {
|
||||
Point {
|
||||
row: line.into().0,
|
||||
col: col.into().0,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn zero() -> Self {
|
||||
Self { row: 0, col: 0 }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use = "this returns the result of the operation, without modifying the original"]
|
||||
/// Increments the `Point` by `distance` cells, wrapping around if the column value exceeds
|
||||
/// `num_cols`.
|
||||
pub fn wrapping_add(mut self, num_cols: usize, distance: usize) -> Point {
|
||||
self.row += (distance + self.col) / num_cols;
|
||||
self.col = (self.col + distance) % num_cols;
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use = "this returns the result of the operation, without modifying the original"]
|
||||
/// Decrements the `Point` by `distance` cells, wrapping around if the column value would drop
|
||||
/// below zero.
|
||||
///
|
||||
/// Note: This will also saturate at (0, 0) as a minimum value.
|
||||
pub fn wrapping_sub(mut self, num_cols: usize, distance: usize) -> Point {
|
||||
let line_changes = (distance + num_cols - 1).saturating_sub(self.col) / num_cols;
|
||||
if self.row >= line_changes {
|
||||
self.row -= line_changes;
|
||||
self.col = (num_cols + self.col - distance % num_cols) % num_cols;
|
||||
self
|
||||
} else {
|
||||
Point::new(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns 1D representation of point, as an index into a 1D array of cells (assumes left-to-right, top-to-bottom order).
|
||||
fn as_one_dimensional_index(&self, num_cols: usize) -> usize {
|
||||
self.row * num_cols + self.col
|
||||
}
|
||||
|
||||
/// Compares Point against another Point, returning the one that is maximal, given the number of
|
||||
/// columns in the grid being considered.
|
||||
pub fn max_point<'a>(&'a self, other: &'a Point, num_cols: usize) -> &'a Point {
|
||||
if self.as_one_dimensional_index(num_cols) >= other.as_one_dimensional_index(num_cols) {
|
||||
self
|
||||
} else {
|
||||
other
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes left-to-right distance between two points. The result is an absolute value.
|
||||
pub fn distance(&self, num_cols: usize, other: &Point) -> usize {
|
||||
let this_index = self.as_one_dimensional_index(num_cols);
|
||||
let other_index = other.as_one_dimensional_index(num_cols);
|
||||
|
||||
this_index.abs_diff(other_index)
|
||||
}
|
||||
|
||||
pub fn to_visible_point(&self, history_size: usize) -> VisiblePoint {
|
||||
VisiblePoint {
|
||||
row: VisibleRow(self.row.saturating_sub(history_size)),
|
||||
col: self.col,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Point {
|
||||
#[inline]
|
||||
#[must_use = "this returns the result of the operation, without modifying the original"]
|
||||
pub fn sub_absolute<D>(mut self, dimensions: &D, boundary: Boundary, rhs: usize) -> Point
|
||||
where
|
||||
D: Dimensions,
|
||||
{
|
||||
let total_lines = dimensions.total_rows();
|
||||
let num_cols = dimensions.columns();
|
||||
|
||||
self.row += (rhs + num_cols - 1).saturating_sub(self.col) / num_cols;
|
||||
self.col = (num_cols + self.col - rhs % num_cols) % num_cols;
|
||||
|
||||
if self.row >= total_lines {
|
||||
match boundary {
|
||||
Boundary::Clamp => Point::new(total_lines - 1, 0),
|
||||
Boundary::Wrap => Point::new(self.row - total_lines, self.col),
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use = "this returns the result of the operation, without modifying the original"]
|
||||
pub fn add_absolute<D>(mut self, dimensions: &D, boundary: Boundary, rhs: usize) -> Point
|
||||
where
|
||||
D: Dimensions,
|
||||
{
|
||||
let num_cols = dimensions.columns();
|
||||
|
||||
let line_delta = (rhs + self.col) / num_cols;
|
||||
|
||||
if self.row >= line_delta {
|
||||
self.row -= line_delta;
|
||||
self.col = (self.col + rhs) % num_cols;
|
||||
self
|
||||
} else {
|
||||
match boundary {
|
||||
Boundary::Clamp => Point::new(0, num_cols - 1),
|
||||
Boundary::Wrap => {
|
||||
let col = (self.col + rhs) % num_cols;
|
||||
let line = dimensions.total_rows() + self.row - line_delta;
|
||||
Point::new(line, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Point {
|
||||
fn partial_cmp(&self, other: &Point) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Point {
|
||||
fn cmp(&self, other: &Point) -> Ordering {
|
||||
match (self.row.cmp(&other.row), self.col.cmp(&other.col)) {
|
||||
(Ordering::Equal, ord) | (ord, _) => ord,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct VisibleRow(pub usize);
|
||||
|
||||
impl Sub<usize> for VisibleRow {
|
||||
type Output = VisibleRow;
|
||||
|
||||
fn sub(self, rhs: usize) -> Self::Output {
|
||||
Self(self.0 - rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign<Self> for VisibleRow {
|
||||
fn sub_assign(&mut self, rhs: Self) {
|
||||
self.0 -= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<Self> for VisibleRow {
|
||||
type Output = usize;
|
||||
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
self.0 - rhs.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<Self> for VisibleRow {
|
||||
type Output = VisibleRow;
|
||||
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Self(self.0 + rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<usize> for VisibleRow {
|
||||
type Output = VisibleRow;
|
||||
|
||||
fn add(self, rhs: usize) -> Self::Output {
|
||||
Self(self.0 + rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<usize> for VisibleRow {
|
||||
fn add_assign(&mut self, rhs: usize) {
|
||||
self.0 += rhs;
|
||||
}
|
||||
}
|
||||
|
||||
impl VisibleRow {
|
||||
pub fn saturating_sub(&self, rhs: usize) -> VisibleRow {
|
||||
VisibleRow(self.0.saturating_sub(rhs))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn steps_between(start: VisibleRow, end: VisibleRow, by: VisibleRow) -> Option<usize> {
|
||||
if by == VisibleRow(0) {
|
||||
return None;
|
||||
}
|
||||
if start < end {
|
||||
// Note: We assume $t <= usize here.
|
||||
let diff = end - start;
|
||||
let by = by.0;
|
||||
if !diff.is_multiple_of(by) {
|
||||
Some(diff / by + 1)
|
||||
} else {
|
||||
Some(diff / by)
|
||||
}
|
||||
} else {
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn steps_between_by_one(start: VisibleRow, end: VisibleRow) -> Option<usize> {
|
||||
Self::steps_between(start, end, VisibleRow(1))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VisibleRow {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
|
||||
pub struct VisiblePoint {
|
||||
pub row: VisibleRow,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl VisiblePoint {
|
||||
pub fn zero() -> Self {
|
||||
Self {
|
||||
row: VisibleRow(0),
|
||||
col: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use = "this returns the result of the operation, without modifying the original"]
|
||||
/// Increments the `VisiblePoint` by `distance` cells, wrapping around if the column value
|
||||
/// exceeds `num_cols`.
|
||||
pub fn wrapping_add(mut self, num_cols: usize, distance: usize) -> VisiblePoint {
|
||||
self.row += (distance + self.col) / num_cols;
|
||||
self.col = (self.col + distance) % num_cols;
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use = "this returns the result of the operation, without modifying the original"]
|
||||
/// Decrements the `VisiblePoint` by `distance` cells, wrapping around if the column value
|
||||
/// would drop below zero.
|
||||
///
|
||||
/// Note: This will also saturate at (0, 0) as a minimum value.
|
||||
pub fn wrapping_sub(mut self, num_cols: usize, distance: usize) -> VisiblePoint {
|
||||
let line_changes = (distance + num_cols - 1).saturating_sub(self.col) / num_cols;
|
||||
if self.row >= VisibleRow(line_changes) {
|
||||
self.row = self.row - line_changes;
|
||||
self.col = (num_cols + self.col - distance % num_cols) % num_cols;
|
||||
self
|
||||
} else {
|
||||
VisiblePoint {
|
||||
row: VisibleRow(0),
|
||||
col: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap this point to a maximum width of `num_cols`, incrementing the row if it is beyond
|
||||
/// that value
|
||||
pub fn wrap(self, num_cols: usize) -> VisiblePoint {
|
||||
self.wrapping_add(num_cols, 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for VisiblePoint {
|
||||
fn partial_cmp(&self, other: &VisiblePoint) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for VisiblePoint {
|
||||
fn cmp(&self, other: &VisiblePoint) -> Ordering {
|
||||
match (self.row.cmp(&other.row), self.col.cmp(&other.col)) {
|
||||
(Ordering::Equal, ord) | (ord, _) => ord,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This exists because we can't implement Iterator on Range
|
||||
/// and the existing impl needs the unstable Step trait
|
||||
/// This should be removed and replaced with a Step impl
|
||||
/// in the ops macro when `step_by` is stabilized.
|
||||
pub struct IndexRange<T>(pub Range<T>);
|
||||
|
||||
impl<T> From<Range<T>> for IndexRange<T> {
|
||||
fn from(from: Range<T>) -> Self {
|
||||
IndexRange(from)
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for IndexRange<usize> {
|
||||
type Item = usize;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
if self.0.start < self.0.end {
|
||||
let old = self.0.start;
|
||||
self.0.start = old + 1;
|
||||
Some(old)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DoubleEndedIterator for IndexRange<usize> {
|
||||
#[inline]
|
||||
fn next_back(&mut self) -> Option<usize> {
|
||||
if self.0.start < self.0.end {
|
||||
let new = self.0.end - 1;
|
||||
self.0.end = new;
|
||||
Some(new)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for IndexRange<VisibleRow> {
|
||||
type Item = VisibleRow;
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<VisibleRow> {
|
||||
if self.0.start < self.0.end {
|
||||
let old = self.0.start;
|
||||
self.0.start = old + 1;
|
||||
Some(old)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
match Self::Item::steps_between_by_one(self.0.start, self.0.end) {
|
||||
Some(hint) => (hint, Some(hint)),
|
||||
None => (0, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DoubleEndedIterator for IndexRange<VisibleRow> {
|
||||
#[inline]
|
||||
fn next_back(&mut self) -> Option<VisibleRow> {
|
||||
if self.0.start < self.0.end {
|
||||
let new = self.0.end - 1;
|
||||
self.0.end = new;
|
||||
Some(new)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "indexing_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,174 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn location_ordering() {
|
||||
assert!(Point::new(0, 0) == Point::new(0, 0));
|
||||
assert!(Point::new(1, 0) > Point::new(0, 0));
|
||||
assert!(Point::new(0, 1) > Point::new(0, 0));
|
||||
assert!(Point::new(1, 1) > Point::new(0, 0));
|
||||
assert!(Point::new(1, 1) > Point::new(0, 1));
|
||||
assert!(Point::new(1, 1) > Point::new(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_sub() {
|
||||
let num_cols = 42;
|
||||
let point = Point::new(0, 13);
|
||||
|
||||
let result = point.wrapping_sub(num_cols, 1);
|
||||
|
||||
assert_eq!(result, Point::new(0, point.col - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_sub_wrap() {
|
||||
let num_cols = 42;
|
||||
let point = Point::new(1, 0);
|
||||
|
||||
let result = point.wrapping_sub(num_cols, 1);
|
||||
|
||||
assert_eq!(result, Point::new(0, num_cols - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_sub_clamp() {
|
||||
let num_cols = 42;
|
||||
let point = Point::new(0, 0);
|
||||
|
||||
let result = point.wrapping_sub(num_cols, 1);
|
||||
|
||||
assert_eq!(result, point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_add() {
|
||||
let num_cols = 42;
|
||||
let point = Point::new(0, 13);
|
||||
|
||||
let result = point.wrapping_add(num_cols, 1);
|
||||
|
||||
assert_eq!(result, Point::new(0, point.col + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_add_wrap() {
|
||||
let num_cols = 42;
|
||||
let point = Point::new(0, num_cols - 1);
|
||||
|
||||
let result = point.wrapping_add(num_cols, 1);
|
||||
|
||||
assert_eq!(result, Point::new(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_absolute() {
|
||||
let point = Point::new(0, 13);
|
||||
|
||||
let result = point.add_absolute(&(1, 42), Boundary::Clamp, 1);
|
||||
|
||||
assert_eq!(result, Point::new(0, point.col + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_absolute_wrapline() {
|
||||
let point = Point::new(1, 41);
|
||||
|
||||
let result = point.add_absolute(&(2, 42), Boundary::Clamp, 1);
|
||||
|
||||
assert_eq!(result, Point::new(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_absolute_multiline_wrapline() {
|
||||
let point = Point::new(2, 9);
|
||||
|
||||
let result = point.add_absolute(&(3, 10), Boundary::Clamp, 11);
|
||||
|
||||
assert_eq!(result, Point::new(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_absolute_clamp() {
|
||||
let point = Point::new(0, 41);
|
||||
|
||||
let result = point.add_absolute(&(1, 42), Boundary::Clamp, 1);
|
||||
|
||||
assert_eq!(result, point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_absolute_wrap() {
|
||||
let point = Point::new(0, 41);
|
||||
|
||||
let result = point.add_absolute(&(3, 42), Boundary::Wrap, 1);
|
||||
|
||||
assert_eq!(result, Point::new(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_absolute_multiline_wrap() {
|
||||
let point = Point::new(0, 9);
|
||||
|
||||
let result = point.add_absolute(&(3, 10), Boundary::Wrap, 11);
|
||||
|
||||
assert_eq!(result, Point::new(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_absolute() {
|
||||
let point = Point::new(0, 13);
|
||||
|
||||
let result = point.sub_absolute(&(1, 42), Boundary::Clamp, 1);
|
||||
|
||||
assert_eq!(result, Point::new(0, point.col - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_absolute_wrapline() {
|
||||
let point = Point::new(0, 0);
|
||||
|
||||
let result = point.sub_absolute(&(2, 42), Boundary::Clamp, 1);
|
||||
|
||||
assert_eq!(result, Point::new(1, 41));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_absolute_multiline_wrapline() {
|
||||
let point = Point::new(0, 0);
|
||||
|
||||
let result = point.sub_absolute(&(3, 10), Boundary::Clamp, 11);
|
||||
|
||||
assert_eq!(result, Point::new(2, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_absolute_wrap() {
|
||||
let point = Point::new(2, 0);
|
||||
|
||||
let result = point.sub_absolute(&(3, 42), Boundary::Wrap, 1);
|
||||
|
||||
assert_eq!(result, Point::new(0, 41));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_absolute_multiline_wrap() {
|
||||
let point = Point::new(2, 0);
|
||||
|
||||
let result = point.sub_absolute(&(3, 10), Boundary::Wrap, 11);
|
||||
|
||||
assert_eq!(result, Point::new(1, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_difference() {
|
||||
let a = Point::new(3, 10);
|
||||
assert_eq!(a.distance(30, &a), 0);
|
||||
|
||||
let b = Point::new(3, 6);
|
||||
assert_eq!(a.distance(30, &b), 4);
|
||||
assert_eq!(b.distance(30, &a), 4);
|
||||
|
||||
let c = Point::new(4, 2);
|
||||
assert_eq!(a.distance(30, &c), 22);
|
||||
assert_eq!(c.distance(30, &a), 22);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod ansi;
|
||||
mod block_id;
|
||||
mod block_index;
|
||||
pub mod char_or_str;
|
||||
pub mod escape_sequences;
|
||||
pub mod grid;
|
||||
mod indexing;
|
||||
mod mode;
|
||||
pub mod mouse;
|
||||
|
||||
pub use block_id::BlockId;
|
||||
pub use block_index::BlockIndex;
|
||||
pub use indexing::*;
|
||||
pub use mode::{KeyboardModes, KeyboardModesApplyBehavior, TermMode};
|
||||
@@ -0,0 +1,140 @@
|
||||
use bitflags::bitflags;
|
||||
|
||||
bitflags! {
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct TermMode: u32 {
|
||||
const NONE = 0;
|
||||
const SHOW_CURSOR = 0b0000_0000_0000_0000_0001;
|
||||
const APP_CURSOR = 0b0000_0000_0000_0000_0010;
|
||||
const APP_KEYPAD = 0b0000_0000_0000_0000_0100;
|
||||
const MOUSE_REPORT_CLICK = 0b0000_0000_0000_0000_1000;
|
||||
const BRACKETED_PASTE = 0b0000_0000_0000_0001_0000;
|
||||
const SGR_MOUSE = 0b0000_0000_0000_0010_0000;
|
||||
const MOUSE_MOTION = 0b0000_0000_0000_0100_0000;
|
||||
const LINE_WRAP = 0b0000_0000_0000_1000_0000;
|
||||
const LINE_FEED_NEW_LINE = 0b0000_0000_0001_0000_0000;
|
||||
const ORIGIN = 0b0000_0000_0010_0000_0000;
|
||||
const INSERT = 0b0000_0000_0100_0000_0000;
|
||||
const FOCUS_IN_OUT = 0b0000_0000_1000_0000_0000;
|
||||
const MOUSE_DRAG = 0b0000_0010_0000_0000_0000;
|
||||
const MOUSE_MODE = 0b0000_0010_0000_0100_1000;
|
||||
const UTF8_MOUSE = 0b0000_0100_0000_0000_0000;
|
||||
const ALTERNATE_SCROLL = 0b0000_1000_0000_0000_0000;
|
||||
const VI = 0b0001_0000_0000_0000_0000;
|
||||
const URGENCY_HINTS = 0b0010_0000_0000_0000_0000;
|
||||
|
||||
// Kitty keyboard protocol enhancement flags
|
||||
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
|
||||
|
||||
/// Flag 1: Disambiguate escape codes.
|
||||
/// Encodes ambiguous keys using CSI u sequences instead of legacy encodings:
|
||||
/// - Esc key → CSI 27 u (distinguishes standalone Esc from escape sequence starts)
|
||||
/// - Modified keys (Alt+key, Ctrl+key, Ctrl+Alt+key, Shift+Alt+key) → CSI u format
|
||||
const KEYBOARD_DISAMBIGUATE_ESCAPE = 0b0100_0000_0000_0000_0000;
|
||||
|
||||
/// Flag 2: Report event types.
|
||||
/// Enables reporting of key press, repeat, and release events, not just key presses.
|
||||
/// Format includes event type in the CSI u sequence.
|
||||
const KEYBOARD_REPORT_EVENT_TYPES = 0b1000_0000_0000_0000_0000;
|
||||
|
||||
/// Flag 4: Report alternate keys.
|
||||
/// Reports the unshifted key alongside the shifted key. For example, when Shift+2 is
|
||||
/// pressed, reports both "@" (shifted) and "2" (unshifted) for layout-independent
|
||||
/// keybinding support.
|
||||
const KEYBOARD_REPORT_ALTERNATE_KEYS = 0b0001_0000_0000_0000_0000_0000;
|
||||
|
||||
/// Flag 8: Report all keys as escape codes.
|
||||
/// Forces all keys, including printable characters, to be encoded as CSI u sequences.
|
||||
/// Provides a uniform encoding format. For example, plain 'a' becomes ESC[97;1u instead
|
||||
/// of just 0x61. Often combined with flag 1 for "full CSI u" mode (flags = 9).
|
||||
const KEYBOARD_REPORT_ALL_AS_ESCAPE = 0b0010_0000_0000_0000_0000_0000;
|
||||
|
||||
/// Flag 16: Report associated text.
|
||||
/// Includes the text that would be generated by the key event, useful for applications
|
||||
/// that want both the semantic key information and the resulting text.
|
||||
const KEYBOARD_REPORT_ASSOCIATED_TEXT = 0b0100_0000_0000_0000_0000_0000;
|
||||
|
||||
const KEYBOARD_PROTOCOL = Self::KEYBOARD_DISAMBIGUATE_ESCAPE.bits()
|
||||
| Self::KEYBOARD_REPORT_EVENT_TYPES.bits()
|
||||
| Self::KEYBOARD_REPORT_ALTERNATE_KEYS.bits()
|
||||
| Self::KEYBOARD_REPORT_ALL_AS_ESCAPE.bits()
|
||||
| Self::KEYBOARD_REPORT_ASSOCIATED_TEXT.bits();
|
||||
|
||||
const ANY = u32::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TermMode {
|
||||
fn default() -> TermMode {
|
||||
TermMode::SHOW_CURSOR
|
||||
| TermMode::LINE_WRAP
|
||||
| TermMode::ALTERNATE_SCROLL
|
||||
| TermMode::URGENCY_HINTS
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
/// Kitty keyboard protocol modes.
|
||||
/// These map to the flags sent via CSI > flags u, CSI = flags u, etc.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct KeyboardModes: u32 {
|
||||
const NO_MODE = 0;
|
||||
const DISAMBIGUATE_ESC_CODES = 0b0000_0001;
|
||||
const REPORT_EVENT_TYPES = 0b0000_0010;
|
||||
const REPORT_ALTERNATE_KEYS = 0b0000_0100;
|
||||
const REPORT_ALL_KEYS_AS_ESC = 0b0000_1000;
|
||||
const REPORT_ASSOCIATED_TEXT = 0b0001_0000;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert KeyboardModes to TermMode flags
|
||||
impl From<KeyboardModes> for TermMode {
|
||||
fn from(modes: KeyboardModes) -> Self {
|
||||
let mut term_mode = TermMode::NONE;
|
||||
|
||||
if modes.contains(KeyboardModes::DISAMBIGUATE_ESC_CODES) {
|
||||
term_mode |= TermMode::KEYBOARD_DISAMBIGUATE_ESCAPE;
|
||||
}
|
||||
if modes.contains(KeyboardModes::REPORT_EVENT_TYPES) {
|
||||
term_mode |= TermMode::KEYBOARD_REPORT_EVENT_TYPES;
|
||||
}
|
||||
if modes.contains(KeyboardModes::REPORT_ALTERNATE_KEYS) {
|
||||
term_mode |= TermMode::KEYBOARD_REPORT_ALTERNATE_KEYS;
|
||||
}
|
||||
if modes.contains(KeyboardModes::REPORT_ALL_KEYS_AS_ESC) {
|
||||
term_mode |= TermMode::KEYBOARD_REPORT_ALL_AS_ESCAPE;
|
||||
}
|
||||
if modes.contains(KeyboardModes::REPORT_ASSOCIATED_TEXT) {
|
||||
term_mode |= TermMode::KEYBOARD_REPORT_ASSOCIATED_TEXT;
|
||||
}
|
||||
|
||||
term_mode
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum KeyboardModesApplyBehavior {
|
||||
/// Replace all current modes with new ones
|
||||
Replace,
|
||||
/// Union (add) new modes to existing ones
|
||||
Union,
|
||||
/// Difference (remove) modes from existing ones
|
||||
Difference,
|
||||
}
|
||||
|
||||
impl KeyboardModesApplyBehavior {
|
||||
/// Maps kitty keyboard protocol apply-mode values to behavior.
|
||||
///
|
||||
/// From kitty's `CSI = flags ; mode u` command:
|
||||
/// - `1` (or omitted) = replace current flags
|
||||
/// - `2` = union/add flags
|
||||
/// - `3` = difference/remove flags
|
||||
pub fn from_kitty_apply_mode(mode: u16) -> Option<Self> {
|
||||
match mode {
|
||||
1 => Some(Self::Replace),
|
||||
2 => Some(Self::Union),
|
||||
3 => Some(Self::Difference),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use galaxyui::event::ModifiersState;
|
||||
|
||||
use super::indexing::Point;
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Wheel,
|
||||
LeftDrag,
|
||||
Move, // Used for mouse hover events (when cursor is moving)
|
||||
}
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum MouseAction {
|
||||
Pressed,
|
||||
Released,
|
||||
Scrolled { delta: i32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct MouseState {
|
||||
button: MouseButton,
|
||||
action: MouseAction,
|
||||
point: Option<Point>,
|
||||
modifiers: ModifiersState,
|
||||
}
|
||||
|
||||
impl MouseState {
|
||||
pub fn new(button: MouseButton, action: MouseAction, modifiers: ModifiersState) -> Self {
|
||||
Self {
|
||||
button,
|
||||
action,
|
||||
point: None,
|
||||
modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_point(mut self, p: Point) -> Self {
|
||||
self.point = Some(p);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn button(&self) -> &MouseButton {
|
||||
&self.button
|
||||
}
|
||||
pub fn action(&self) -> &MouseAction {
|
||||
&self.action
|
||||
}
|
||||
|
||||
pub fn maybe_point(&self) -> Option<Point> {
|
||||
self.point
|
||||
}
|
||||
|
||||
pub fn modifiers(&self) -> &ModifiersState {
|
||||
&self.modifiers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::model::Point;
|
||||
|
||||
impl From<Point> for session_sharing_protocol::common::Point {
|
||||
fn from(val: Point) -> Self {
|
||||
session_sharing_protocol::common::Point {
|
||||
row: val.row,
|
||||
col: val.col,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<session_sharing_protocol::common::Point> for Point {
|
||||
fn from(value: session_sharing_protocol::common::Point) -> Self {
|
||||
Self {
|
||||
row: value.row,
|
||||
col: value.col,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
mod unescape;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use channel_versions::overrides::TargetOS;
|
||||
use enum_iterator::Sequence;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol_str::SmolStr;
|
||||
use typed_path::{TypedPath, TypedPathBuf, WindowsPath};
|
||||
use version_compare::{Cmp, Version};
|
||||
use galaxy_completer::completer::{CommandExitStatus, CommandOutput};
|
||||
#[cfg(windows)]
|
||||
use galaxy_core::paths::base_config_dir;
|
||||
use galaxy_core::platform::SessionPlatform;
|
||||
use galaxy_util::path::{
|
||||
convert_msys2_to_windows_native_path, convert_wsl_to_windows_host_path, msys2_exe_to_root,
|
||||
};
|
||||
|
||||
use crate::model::escape_sequences;
|
||||
|
||||
use self::unescape::unescape_quotes;
|
||||
|
||||
const ZSH_META: u8 = 0x83;
|
||||
|
||||
/// These are file extensions of executable files on Windows.
|
||||
///
|
||||
/// Commands ending with any of these extensions may be executed with the extension elided, e.g.
|
||||
/// you can type `git` in a shell instead of `git.exe`.
|
||||
/// This is the contents of `$env:PATHEXT` on a default Windows 11 installation. See docs:
|
||||
/// https://renenyffenegger.ch/notes/Windows/development/environment-variables/PATHEXT
|
||||
/// TODO(CORE-2948) Fetch this dynamically instead.
|
||||
const PATHEXT: [&str; 12] = [
|
||||
".COM", ".EXE", ".BAT", ".CMD", ".VBS", ".VBE", ".JS", ".JSE", ".WSF", ".WSH", ".MSC", ".CPL",
|
||||
];
|
||||
|
||||
lazy_static! {
|
||||
static ref BASH_INPUT_REPORTING_MINIMUM_VERSION: Version<'static> =
|
||||
Version::from("4.0").expect("version parses successfully");
|
||||
}
|
||||
|
||||
/// Strips the extended history prefix from a zsh history line, if present.
|
||||
///
|
||||
/// The zsh history can have two types of output depending on if `extended_history` mode is enabled.
|
||||
/// * If enabled, the history is of the form: `: <beginning time>:<elapsed seconds>;<command>`.
|
||||
/// * If not enabled, history is simply the command on each line with no additional metadata.
|
||||
///
|
||||
/// We avoid using a regex here in favor of simple string manipulation for better performance in this
|
||||
/// hot path.
|
||||
fn strip_zsh_extended_prefix(line: &str) -> &str {
|
||||
let Some(rest) = line.strip_prefix(": ") else {
|
||||
return line;
|
||||
};
|
||||
|
||||
let Some(semi_idx) = rest.find(';') else {
|
||||
return line;
|
||||
};
|
||||
|
||||
let prefix = &rest[..semi_idx];
|
||||
let Some((timestamp, elapsed)) = prefix.split_once(':') else {
|
||||
return line;
|
||||
};
|
||||
|
||||
if !timestamp.is_empty()
|
||||
&& timestamp.bytes().all(|b| b.is_ascii_digit())
|
||||
&& !elapsed.is_empty()
|
||||
&& elapsed.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
&rest[semi_idx + 1..]
|
||||
} else {
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a shell and its configuration.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Shell {
|
||||
shell_type: ShellType,
|
||||
version: Option<String>,
|
||||
options: Option<HashSet<String>>,
|
||||
|
||||
/// Shell plugins like Powerlevel10k that we autodetect while bootstrapping.
|
||||
/// This is not at all exhaustive, it's just for common plugins that need
|
||||
/// special handling (like warning the user that they're incompatible).
|
||||
plugins: HashSet<String>,
|
||||
|
||||
/// The full path to the running shell binary on the host (e.g. "/usr/bin/zsh").
|
||||
/// Populated from the `Bootstrapped` DCS payload. For local sessions this is
|
||||
/// redundant with `ShellLaunchData::executable_path`; for SSH sessions this
|
||||
/// is the authoritative path on the remote host.
|
||||
shell_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
pub fn new(
|
||||
shell_type: ShellType,
|
||||
version: Option<String>,
|
||||
options: Option<HashSet<String>>,
|
||||
plugins: HashSet<String>,
|
||||
shell_path: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
shell_type,
|
||||
version,
|
||||
options,
|
||||
plugins,
|
||||
shell_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shell_path(&self) -> &Option<String> {
|
||||
&self.shell_path
|
||||
}
|
||||
|
||||
pub fn shell_type(&self) -> ShellType {
|
||||
self.shell_type
|
||||
}
|
||||
|
||||
pub fn version(&self) -> &Option<String> {
|
||||
&self.version
|
||||
}
|
||||
|
||||
pub fn options(&self) -> &Option<HashSet<String>> {
|
||||
&self.options
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &HashSet<String> {
|
||||
&self.plugins
|
||||
}
|
||||
|
||||
/// Returns true if the shell requires the use of an in-band command executor.
|
||||
/// This applies to both local and remote sessions.
|
||||
pub fn force_in_band_command_executor(&self) -> bool {
|
||||
self.shell_type.force_in_band_command_executor()
|
||||
}
|
||||
|
||||
/// Returns whether the current shell supports native shell completions.
|
||||
pub fn supports_native_shell_completions(&self) -> bool {
|
||||
self.shell_type.supports_native_shell_completions()
|
||||
}
|
||||
|
||||
/// Whether the shell supports "autocd" (`cd`ing into a directory without specifying
|
||||
/// `cd`).
|
||||
pub fn supports_autocd(&self) -> bool {
|
||||
match self.shell_type {
|
||||
ShellType::Zsh | ShellType::Bash => self
|
||||
.options
|
||||
.as_ref()
|
||||
.is_some_and(|map| map.contains("autocd")),
|
||||
// autocd is always enabled in Fish, see https://fishshell.com/docs/current/cmds/cd.html.
|
||||
ShellType::Fish => true,
|
||||
ShellType::PowerShell => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// If the particular version of this shell supports input reporting, return the byte sequence
|
||||
/// to trigger input reporting.
|
||||
///
|
||||
/// These sequences are bound to Warp shell functions during session bootstrap that print the
|
||||
/// shell's input buffer, wrapped within the 'InputBuffer' DCS hook when triggered. PowerShell
|
||||
/// cannot use a binding that contains the letter "i" because it does virtual key code
|
||||
/// translation based on the current layout, and not all layouts have the letter "i".
|
||||
pub fn input_reporting_sequence(&self) -> Option<[u8; 2]> {
|
||||
match self.shell_type {
|
||||
ShellType::PowerShell => Some([escape_sequences::C0::ESC, b'1']),
|
||||
ShellType::Fish | ShellType::Zsh => Some([escape_sequences::C0::ESC, b'i']),
|
||||
ShellType::Bash => self
|
||||
.version
|
||||
.as_ref()
|
||||
.and_then(|version| Version::from(version.as_str()))
|
||||
.and_then(|version| {
|
||||
version
|
||||
.compare_to(&*BASH_INPUT_REPORTING_MINIMUM_VERSION, Cmp::Ge)
|
||||
.then_some([escape_sequences::C0::ESC, b'i'])
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the given command should be written to history based on the shell's
|
||||
/// options.
|
||||
pub fn should_add_command_to_history(&self, command: &str) -> bool {
|
||||
if command.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
match &self.options {
|
||||
Some(options) => {
|
||||
if !command.starts_with(' ') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the command starts with a space, check the shell's options to determine if it
|
||||
// should be added to history.
|
||||
match self.shell_type {
|
||||
ShellType::Zsh => !options.contains("histignorespace"),
|
||||
ShellType::Bash => {
|
||||
// Look for our fake option that contains the value of the HISTCONTROL
|
||||
// environment variable.
|
||||
if let Some(histcontrol) =
|
||||
options.iter().find(|opt| opt.starts_with("!histcontrol"))
|
||||
{
|
||||
// HISTCONTROL can contain a single value or a list of values separated
|
||||
// by colons. In either case, we want to know whether the "ignorespace"
|
||||
// or "ignoreboth" (ignorespace+ignoredups) values are present.
|
||||
!histcontrol.contains("ignorespace")
|
||||
&& !histcontrol.contains("ignoreboth")
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brief, human-readable description of a shell session.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ShellName {
|
||||
/// The description isn't more descriptive than the [`ShellType`], so the [`ShellType`] should
|
||||
/// take precedent if that is known.
|
||||
LessDescriptive(String),
|
||||
/// The description is more descriptive than the [`ShellType`], so it should take precendent
|
||||
/// over the [`ShellType`] even if it is known.
|
||||
MoreDescriptive(String),
|
||||
}
|
||||
|
||||
impl Deref for ShellName {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
match self {
|
||||
Self::MoreDescriptive(name) | Self::LessDescriptive(name) => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellName {
|
||||
pub fn blank() -> Self {
|
||||
Self::MoreDescriptive(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize, Sequence)]
|
||||
pub enum ShellType {
|
||||
Zsh,
|
||||
Bash,
|
||||
Fish,
|
||||
PowerShell,
|
||||
}
|
||||
|
||||
impl From<ShellType> for command_corrections::Shell {
|
||||
fn from(s: ShellType) -> command_corrections::Shell {
|
||||
match s {
|
||||
ShellType::Bash => command_corrections::Shell::Bash,
|
||||
ShellType::Zsh => command_corrections::Shell::Zsh,
|
||||
ShellType::Fish => command_corrections::Shell::Fish,
|
||||
ShellType::PowerShell => command_corrections::Shell::PowerShell,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ShellType> for galaxy_util::path::ShellFamily {
|
||||
fn from(value: ShellType) -> Self {
|
||||
match value {
|
||||
ShellType::Zsh | ShellType::Bash | ShellType::Fish => Self::Posix,
|
||||
ShellType::PowerShell => Self::PowerShell,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellType {
|
||||
// Returns a shell type from a shell executable name
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
// Support (/usr/bin/zsh /bin/zsh -zsh or zsh)
|
||||
if name == "bash"
|
||||
|| name == "-bash"
|
||||
|| name.ends_with("/bash")
|
||||
|| name.ends_with("bash.exe")
|
||||
{
|
||||
Some(ShellType::Bash)
|
||||
} else if name == "zsh" || name == "-zsh" || name.ends_with("/zsh") {
|
||||
Some(ShellType::Zsh)
|
||||
} else if name == "fish" || name == "-fish" || name.ends_with("/fish") {
|
||||
Some(ShellType::Fish)
|
||||
} else if name == "pwsh"
|
||||
|| name.ends_with("/pwsh")
|
||||
|| name.ends_with("pwsh.exe")
|
||||
|| name == "powershell"
|
||||
|| name.ends_with("/powershell")
|
||||
|| name.ends_with("powershell.exe")
|
||||
{
|
||||
Some(ShellType::PowerShell)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a shell type from a markdown code block language specifier
|
||||
pub fn from_markdown_language_spec(language: &str) -> Option<Self> {
|
||||
match language {
|
||||
"bash" | "shell" | "sh" => Some(ShellType::Bash),
|
||||
"zsh" => Some(ShellType::Zsh),
|
||||
"fish" => Some(ShellType::Fish),
|
||||
"powershell" | "pwsh" => Some(ShellType::PowerShell),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns locations of history files in order of search precedence
|
||||
pub fn history_files(self) -> Vec<String> {
|
||||
match self {
|
||||
ShellType::Zsh => vec!["~/.zsh_history".to_string(), "~/.zhistory".to_string()],
|
||||
ShellType::Bash => vec!["~/.bash_history".to_string()],
|
||||
ShellType::Fish => vec!["~/.local/share/fish/fish_history".to_string()],
|
||||
#[cfg(not(windows))]
|
||||
ShellType::PowerShell => {
|
||||
vec!["~/.local/share/powershell/PSReadLine/ConsoleHost_history.txt".to_string()]
|
||||
}
|
||||
#[cfg(windows)]
|
||||
ShellType::PowerShell => {
|
||||
vec![base_config_dir()
|
||||
.join("Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt")
|
||||
.display()
|
||||
.to_string()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the potential paths to the RC file relative to the `home` directory.
|
||||
pub fn rc_file_paths(&self, os: TargetOS) -> Vec<PathBuf> {
|
||||
let home_dir = Path::new(match os {
|
||||
TargetOS::Windows => "$HOME",
|
||||
_ => "~",
|
||||
});
|
||||
let relative_paths = match (self, os) {
|
||||
(ShellType::PowerShell, TargetOS::Windows) => {
|
||||
vec![Path::new(
|
||||
".config/powershell/Microsoft.PowerShell_profile.ps1",
|
||||
)]
|
||||
}
|
||||
// We need to make sure this works for either editor of PowerShell (PowerShell Core or
|
||||
// Windows PowerShell) so just write the file to both.
|
||||
(ShellType::PowerShell, _) => vec![
|
||||
Path::new("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
|
||||
Path::new("Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1"),
|
||||
],
|
||||
(_, TargetOS::Windows) => vec![],
|
||||
(ShellType::Bash, _) => vec![Path::new(".bashrc")],
|
||||
(ShellType::Zsh, _) => vec![Path::new(".zshrc")],
|
||||
(ShellType::Fish, _) => vec![Path::new(".config/fish/config.fish")],
|
||||
};
|
||||
relative_paths
|
||||
.iter()
|
||||
.map(|relative_path| home_dir.join(relative_path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the syntax to use to run a second command only if the first one succeeds.
|
||||
/// NOTE: Guarded with `cfg(unix)` b/c PowerShell didn't have the `&&` operator until v7. On
|
||||
/// Unix, we can safely assume v7, but Windows comes with PowerShell v5 out of the box.
|
||||
#[cfg(unix)]
|
||||
pub fn and_combiner(self) -> &'static str {
|
||||
match self {
|
||||
ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => " && ",
|
||||
ShellType::Fish => "; and ",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the current shell supports native shell completions.
|
||||
fn supports_native_shell_completions(&self) -> bool {
|
||||
matches!(self, ShellType::Zsh)
|
||||
}
|
||||
|
||||
/// Returns the syntax to run a second command regardless if the first one succeeds.
|
||||
pub fn or_combiner(self) -> &'static str {
|
||||
match self {
|
||||
ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => " ; ",
|
||||
ShellType::Fish => "; or ",
|
||||
}
|
||||
}
|
||||
|
||||
/// Given the output of the `alias` command, returns a map of alias keys to values.
|
||||
pub fn aliases(self, alias_output: &str) -> HashMap<SmolStr, String> {
|
||||
match self {
|
||||
ShellType::Zsh => alias_output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
// ZSH outputs aliases in a key pair of `name=val`, with both the name and value
|
||||
// optionally escaped. Aliases that span multiple lines are properly escaped.
|
||||
|
||||
line.split_once('=')
|
||||
.and_then(|(key, value)| unescape_alias_key_value(key, value))
|
||||
})
|
||||
.collect(),
|
||||
ShellType::Bash => {
|
||||
// Bash outputs aliases in a key pair of `alias name=val`. Alias values that
|
||||
// span multiple lines are not escaped. For simplicity in parsing this, append a
|
||||
// newline on the front of the string and then split on "\nalias"
|
||||
let alias_output = format!("\n{alias_output}");
|
||||
alias_output
|
||||
.split("\nalias ")
|
||||
.filter_map(|line| {
|
||||
line.split_once('=')
|
||||
.and_then(|(key, value)| unescape_alias_key_value(key, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
ShellType::Fish => {
|
||||
let alias_output = format!("\n{alias_output}");
|
||||
alias_output
|
||||
.split("\nalias ")
|
||||
.filter_map(|line| {
|
||||
// Fish outputs aliases in the form:
|
||||
// alias name val
|
||||
// For simplicity in parsing this, append a newline on the front of
|
||||
// the string and then split on "\nalias"
|
||||
// Note: Currently doesn't support alias values that span multiple
|
||||
// lines due to fish not respecting their specified format for
|
||||
// outputing alias values with multiple lines.
|
||||
line.split_once(' ')
|
||||
.and_then(|(key, value)| unescape_alias_key_value(key, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
ShellType::PowerShell => alias_output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
// PowerShell outputs aliases in a key pair of `name -> val`, with both the name
|
||||
// and value optionally escaped. Aliases that span multiple lines are properly
|
||||
// escaped.
|
||||
line.split_once(" -> ")
|
||||
.filter(|(_, value)| !value.trim().is_empty())
|
||||
.and_then(|(key, value)| unescape_alias_key_value(key, value))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abbreviations(self, abbr_output: &str) -> HashMap<SmolStr, String> {
|
||||
match self {
|
||||
ShellType::Fish => {
|
||||
abbr_output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
// Fish outputs abbreviations in the form:
|
||||
// abbr -a -U -- name val # optional comment
|
||||
// First, strip off the comment at the end if it exists. Then,
|
||||
// strip off the preamble characters and separate the key and value
|
||||
// Note: The key cannot have spaces in it (fish won't allow you to define an
|
||||
// abbreviation with a space in the name)
|
||||
line.split_once(" #")
|
||||
.map_or(line, |split_line| split_line.0)
|
||||
.split_once(" -- ")
|
||||
.and_then(|(_, abbr)| abbr.split_once(' '))
|
||||
})
|
||||
.filter_map(|(key, value)| unescape_alias_key_value(key, value))
|
||||
.collect()
|
||||
}
|
||||
// Abbreviations are currently only supported in fish
|
||||
_ => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the contents of the shell's history file
|
||||
pub fn parse_history(self, history_file_bytes: &[u8]) -> Vec<String> {
|
||||
let mut history_lines: Vec<String> = Vec::new();
|
||||
match self {
|
||||
ShellType::Zsh => {
|
||||
let mut current_line = String::new();
|
||||
|
||||
let unmetafied_content = zsh_unmetafy(history_file_bytes);
|
||||
|
||||
for line in unmetafied_content.lines() {
|
||||
// Only strip the extended history prefix on the first line of each command.
|
||||
// Continuation lines are raw command text and should not be stripped.
|
||||
let command_part = if current_line.is_empty() {
|
||||
strip_zsh_extended_prefix(line)
|
||||
} else {
|
||||
line
|
||||
};
|
||||
current_line.push_str(command_part);
|
||||
|
||||
// ZSH considers a command to be multi-line if it ends in a backslash, see
|
||||
// https://github.com/johan/zsh/blob/master/Src/hist.c#L2192-L2220.
|
||||
if line.ends_with('\\') {
|
||||
// Replace the last backslash with a new line.
|
||||
current_line.pop();
|
||||
current_line.push('\n');
|
||||
} else if !current_line.is_empty() {
|
||||
history_lines.push(current_line.clone());
|
||||
current_line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
ShellType::Bash => {
|
||||
let history_file_contents = String::from_utf8_lossy(history_file_bytes);
|
||||
// Bash format if HISTTIMEFORMAT not set
|
||||
// <command>
|
||||
// Bash format if HISTTIMEFORMAT set
|
||||
// #<timestamp>
|
||||
// <command>
|
||||
history_lines = history_file_contents
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
if line.starts_with('#') || line.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(line.to_owned())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
ShellType::Fish => {
|
||||
let history_file_contents = String::from_utf8_lossy(history_file_bytes);
|
||||
// fish has psuedo-yaml.
|
||||
// The commands start with "- cmd: ".
|
||||
history_lines = history_file_contents
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("- cmd: "))
|
||||
.map(fish_unescape_history_yaml)
|
||||
.collect()
|
||||
}
|
||||
|
||||
ShellType::PowerShell => {
|
||||
let history_file_contents = String::from_utf8_lossy(history_file_bytes);
|
||||
history_lines = history_file_contents
|
||||
.lines()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
history_lines
|
||||
}
|
||||
|
||||
/// Bytes used to notify the shell to delete the current buffer.
|
||||
///
|
||||
/// In zsh this is implemented by killing the ZLE buffer and must match the `bindkey` call in
|
||||
/// the boostrap `zsh.sh`. In bash this is implemented via a custom `bind` that calls
|
||||
/// `kill-whole-line` and must match the `bind` in `bash.sh`. In fish this is a custom `bind`
|
||||
/// that clears the command line. PowerShell cannot use a binding that contains the letter "p"
|
||||
/// (DLE maps to ctrl-p) because it does virtual key code translation based on the current
|
||||
/// layout, and not all layouts have the letter "p".
|
||||
pub fn kill_buffer_bytes(self) -> &'static [u8] {
|
||||
const POWERSHELL_BINDING: [u8; 2] = [escape_sequences::C0::ESC, b'2'];
|
||||
const OTHER_BINDING: [u8; 1] = [escape_sequences::C0::DLE];
|
||||
match self {
|
||||
ShellType::PowerShell => POWERSHELL_BINDING.as_slice(),
|
||||
ShellType::Zsh | ShellType::Bash | ShellType::Fish => OTHER_BINDING.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes used to execute a command, once the command text is sent
|
||||
pub fn execute_command_bytes(self) -> &'static [u8] {
|
||||
match self {
|
||||
ShellType::Bash | ShellType::Zsh => &b"\n"[..],
|
||||
ShellType::PowerShell => &b"\r"[..],
|
||||
// For Fish, we send an extra space, immediately followed by backspace, and then
|
||||
// the newline character. The backspace ensures that any autosuggestions are
|
||||
// suppressed, so we don't get erroneous ghosted autosuggestion text in the command
|
||||
// grid.
|
||||
ShellType::Fish => &b" \x7f\n"[..],
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the shell
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ShellType::Zsh => "zsh",
|
||||
ShellType::Bash => "bash",
|
||||
ShellType::Fish => "fish",
|
||||
ShellType::PowerShell => "pwsh",
|
||||
}
|
||||
}
|
||||
|
||||
/// If true, Warp will bootstrap the shell if it's the login shell on the remote host.
|
||||
pub fn is_fully_supported_remotely(&self) -> bool {
|
||||
match self {
|
||||
ShellType::Zsh | ShellType::Bash => true,
|
||||
ShellType::Fish | ShellType::PowerShell => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shell_command_to_get_executables(&self) -> &'static str {
|
||||
match self {
|
||||
ShellType::Bash => {
|
||||
// Since `compgen -c` returns more than just executables (and the output itself
|
||||
// doesn't include any info about what the type of the word is), we filter down to
|
||||
// executable "file"s. Note that we do this at the shell level since if we did this
|
||||
// as a post-process step, it would require N filesystem calls, which would not scale
|
||||
// well for remote sessions. Additionally, we invoke `type` once, passing it the full
|
||||
// list of commands, to avoid a lot of overhead invoking it thousands of times for
|
||||
// systems with a lot of installed commands.
|
||||
r#"COMMANDS=($(compgen -c)); TYPES=($(type -t ${COMMANDS[@]})); for i in "${!COMMANDS[@]}"; do if [[ ${TYPES[$i]} == "file" ]]; then echo ${COMMANDS[$i]}; fi; done"#
|
||||
}
|
||||
ShellType::Fish => {
|
||||
// Although `complete -C` returns more than just executables, we don't check the type here
|
||||
// since the output of `complete` already tells us what is an executable ('command') and what isn't
|
||||
// (whereas the output of `compgen` doesn't so we need to check the `type` there).
|
||||
// Instead we post-process the output below. We try to use the `--escape` argument, but
|
||||
// if we're running a version of fish that doesn't support it, try again without it.
|
||||
"complete -C --escape '' || complete -C ''"
|
||||
}
|
||||
ShellType::Zsh => {
|
||||
// zsh is cool and has an API for just fetching executables
|
||||
"builtin print -l -- ${(ok)commands}"
|
||||
}
|
||||
ShellType::PowerShell => {
|
||||
// PowerShell does not deal in strings, but in Objects and Object lists
|
||||
// Get-Command and Select-Object each return a list of Objects. In the shell,
|
||||
// this will print one item per line. However, when it is converted to a string,
|
||||
// it will join the entries together with a space. So to make sure we get one item
|
||||
// per line, we explicitly join the results with a newline.
|
||||
"Get-Command -CommandType Application | Select-Object -ExpandProperty Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a Vec containing executable commands parsed from the given `output`.
|
||||
///
|
||||
/// If `output` is `Err(..)`, returns an empty Vec.
|
||||
pub fn executables_from_shell_command_output(
|
||||
&self,
|
||||
output: Result<CommandOutput>,
|
||||
is_msys2: bool,
|
||||
) -> Vec<SmolStr> {
|
||||
match output {
|
||||
Ok(command_output) if command_output.status == CommandExitStatus::Success => {
|
||||
let Ok(output_string) = command_output.to_string() else {
|
||||
return Vec::new();
|
||||
};
|
||||
match self {
|
||||
ShellType::Bash | ShellType::Zsh => {
|
||||
// For bash and zsh, we wrote the command such that the output is just
|
||||
// a list of executable files.
|
||||
if !is_msys2 {
|
||||
return output_string.lines().map(Into::into).collect();
|
||||
}
|
||||
|
||||
// TODO add this to fish
|
||||
output_string
|
||||
.lines()
|
||||
// Remove all `.dll` files.
|
||||
.filter(|line| !line.to_lowercase().ends_with("dll"))
|
||||
.flat_map(|line| {
|
||||
// Those suffixes are contained in `PATHEXT`.
|
||||
for ext in PATHEXT {
|
||||
// If the command ends with one of those suffixes, tell
|
||||
// Warp about this command as-is and also sans-suffix, e.g.
|
||||
// "git" and "git.exe".
|
||||
if line.to_lowercase().ends_with(&ext.to_lowercase()) {
|
||||
let trimmed = &line[..line.len() - ext.len()];
|
||||
return Box::<[&str]>::from([trimmed, line]);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, pass it through unaltered.
|
||||
Box::<[&str]>::from([line])
|
||||
})
|
||||
.map_into()
|
||||
.collect()
|
||||
}
|
||||
ShellType::Fish => {
|
||||
// This is the post-processing for Fish explained above.
|
||||
output_string
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
line.split_once(char::is_whitespace).and_then(
|
||||
|(command, command_type)| {
|
||||
let is_executable = command_type == "command"
|
||||
|| command_type == "command link"
|
||||
|| command_type.starts_with("Executable");
|
||||
is_executable.then_some(command.into())
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
ShellType::PowerShell => {
|
||||
// Windows allows certain suffixes, e.g. "exe", to be elided.
|
||||
if cfg!(windows) {
|
||||
output_string
|
||||
.lines()
|
||||
.flat_map(|line| {
|
||||
// Those suffixes are contained in `PATHEXT`.
|
||||
for ext in PATHEXT {
|
||||
// If the command ends with one of those suffixes, tell
|
||||
// Warp about this command as-is and also sans-suffix, e.g.
|
||||
// "git" and "git.exe".
|
||||
if line.to_lowercase().ends_with(&ext.to_lowercase()) {
|
||||
let trimmed = &line[..line.len() - ext.len()];
|
||||
return Box::<[&str]>::from([trimmed, line]);
|
||||
}
|
||||
}
|
||||
// Otherwise, pass it through unaltered.
|
||||
Box::<[&str]>::from([line])
|
||||
})
|
||||
.map_into()
|
||||
.collect()
|
||||
} else {
|
||||
output_string.lines().map_into().collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(output) => {
|
||||
log::warn!("Generator for executable names failed");
|
||||
if let Ok(output_string) = output.to_string() {
|
||||
log::warn!("{output_string}");
|
||||
};
|
||||
Vec::new()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Generator for executable names failed: {e:#}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn force_in_band_command_executor(&self) -> bool {
|
||||
// TODO: Remove this function once we have confidence in using a local executor in
|
||||
// powershell.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the necessary info to be able to launch and bootstrap the selected AvailableShell. For
|
||||
/// executables, this is the path to the executable and the shell type. For WSL, this is the distro
|
||||
/// name. For Docker sandboxes, this is the `sbx` CLI path plus the base Docker
|
||||
/// image; the shell inside the container is whatever the image provides.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ShellLaunchData {
|
||||
Executable {
|
||||
executable_path: PathBuf,
|
||||
shell_type: ShellType,
|
||||
},
|
||||
/// Windows Subsystem for Linux.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
WSL { distro: String },
|
||||
MSYS2 {
|
||||
executable_path: PathBuf,
|
||||
shell_type: ShellType,
|
||||
},
|
||||
/// A shell running inside a `sbx`-managed Docker sandbox container.
|
||||
///
|
||||
/// A dedicated variant ensures callers can't accidentally execute the
|
||||
/// sandbox as if it were a regular local shell: the `sbx` binary at
|
||||
/// `sbx_path` is not a shell, it's the CLI we use to enter the container.
|
||||
DockerSandbox {
|
||||
sbx_path: PathBuf,
|
||||
/// Base Docker image to use when creating the sandbox (passed as
|
||||
/// `sbx run --template <image>`). `None` means "use sbx's default
|
||||
/// image".
|
||||
base_image: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ShellLaunchData {
|
||||
/// Converts the given path string to a OS-native PathBuf, performing any necessary shell-informed conversions.
|
||||
pub fn maybe_convert_absolute_path(&self, path_str: &str) -> Option<PathBuf> {
|
||||
match self {
|
||||
ShellLaunchData::Executable { .. } => Some(PathBuf::from(path_str)),
|
||||
ShellLaunchData::WSL { distro } => {
|
||||
let unix_path = TypedPath::unix(path_str);
|
||||
convert_wsl_to_windows_host_path(&unix_path, distro).ok()
|
||||
}
|
||||
ShellLaunchData::MSYS2 {
|
||||
executable_path, ..
|
||||
} => {
|
||||
let unix_path = TypedPath::unix(path_str);
|
||||
convert_msys2_to_windows_native_path(
|
||||
&unix_path,
|
||||
&msys2_exe_to_root(WindowsPath::new(
|
||||
executable_path.as_os_str().as_encoded_bytes(),
|
||||
)),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
// Paths inside the sandbox container are plain Unix paths.
|
||||
ShellLaunchData::DockerSandbox { .. } => Some(PathBuf::from(path_str)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a shell-encoded [`typed_path::TypedPathBuf`] into an OS-native path.
|
||||
fn maybe_convert_shell_encoded_path(
|
||||
&self,
|
||||
shell_encoded_path: TypedPathBuf,
|
||||
) -> Option<PathBuf> {
|
||||
match self {
|
||||
ShellLaunchData::Executable { .. } | ShellLaunchData::DockerSandbox { .. } => {
|
||||
PathBuf::try_from(shell_encoded_path).ok()
|
||||
}
|
||||
ShellLaunchData::WSL { distro } => {
|
||||
convert_wsl_to_windows_host_path(&shell_encoded_path.to_path(), distro).ok()
|
||||
}
|
||||
ShellLaunchData::MSYS2 {
|
||||
executable_path, ..
|
||||
} => convert_msys2_to_windows_native_path(
|
||||
&shell_encoded_path.to_path(),
|
||||
&msys2_exe_to_root(WindowsPath::new(
|
||||
executable_path.as_os_str().as_encoded_bytes(),
|
||||
)),
|
||||
)
|
||||
.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Naively changes the path string to an OS-native encoding, without performing shell-informed conversions.
|
||||
fn to_native_path_encoding(&self, path_str: &str) -> Option<PathBuf> {
|
||||
match self {
|
||||
ShellLaunchData::Executable { .. } => Some(PathBuf::from(path_str)),
|
||||
ShellLaunchData::WSL { .. } | ShellLaunchData::MSYS2 { .. } => {
|
||||
let windows_encoding = TypedPath::unix(path_str).with_windows_encoding();
|
||||
PathBuf::try_from(windows_encoding).ok()
|
||||
}
|
||||
// The container is Unix; Warp runs on the host, so paths are
|
||||
// already in the host's native encoding. Pass through unchanged.
|
||||
ShellLaunchData::DockerSandbox { .. } => Some(PathBuf::from(path_str)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a path string to a shell's encoding.
|
||||
fn to_shell_encoding<'a>(&self, path_str: &'a str) -> TypedPath<'a> {
|
||||
match self {
|
||||
ShellLaunchData::Executable { .. } => {
|
||||
if cfg!(unix) {
|
||||
TypedPath::unix(path_str)
|
||||
} else {
|
||||
TypedPath::windows(path_str)
|
||||
}
|
||||
}
|
||||
ShellLaunchData::WSL { .. }
|
||||
| ShellLaunchData::MSYS2 { .. }
|
||||
| ShellLaunchData::DockerSandbox { .. } => TypedPath::unix(path_str),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to append the relative path to the base path and convert it into a OS-native PathBuf,
|
||||
/// performing any necessary shell-informed conversions.
|
||||
pub fn maybe_convert_relative_path(
|
||||
&self,
|
||||
base_path_str: &str,
|
||||
relative_path_str: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let base_path = self.to_shell_encoding(base_path_str);
|
||||
let rest_of_path = self.to_shell_encoding(relative_path_str);
|
||||
let absolute_typed_path = base_path.join(rest_of_path);
|
||||
self.maybe_convert_shell_encoded_path(absolute_typed_path)
|
||||
}
|
||||
|
||||
/// Joins the given path string to the base path, ensuring the given path string is encoded correctly.
|
||||
pub fn join_to_native_path(&self, base_path: &Path, path_str: &str) -> Option<PathBuf> {
|
||||
self.to_native_path_encoding(path_str)
|
||||
.map(|rest_of_path| base_path.join(rest_of_path))
|
||||
}
|
||||
|
||||
/// How to present this data to the user for error messages.
|
||||
pub fn shell_detail(&self) -> String {
|
||||
match self {
|
||||
Self::Executable {
|
||||
executable_path, ..
|
||||
}
|
||||
| Self::MSYS2 {
|
||||
executable_path, ..
|
||||
} => executable_path.to_string_lossy().into_owned(),
|
||||
Self::WSL { distro } => distro.to_owned(),
|
||||
Self::DockerSandbox { base_image, .. } => match base_image {
|
||||
Some(image) => format!("Docker sandbox ({image})"),
|
||||
None => "Docker sandbox".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ShellLaunchData> for SessionPlatform {
|
||||
fn from(data: ShellLaunchData) -> Self {
|
||||
match data {
|
||||
ShellLaunchData::Executable { .. } => SessionPlatform::Native,
|
||||
ShellLaunchData::WSL { .. } => SessionPlatform::WSL,
|
||||
ShellLaunchData::MSYS2 { .. } => SessionPlatform::MSYS2,
|
||||
ShellLaunchData::DockerSandbox { .. } => SessionPlatform::DockerSandbox,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unescape the key and value for an alias, returning None if either fails
|
||||
fn unescape_alias_key_value(key: &str, value: &str) -> Option<(SmolStr, String)> {
|
||||
let key = unescape_quotes(key)
|
||||
.map_err(|e| {
|
||||
log::error!("Unable to unescape key for alias: {e}");
|
||||
e
|
||||
})
|
||||
.ok()?;
|
||||
let value = unescape_quotes(value)
|
||||
.map_err(|e| {
|
||||
log::error!("Unable to unescape value for alias: {e}");
|
||||
e
|
||||
})
|
||||
.ok()?;
|
||||
Some((key.into(), value))
|
||||
}
|
||||
|
||||
// fish history replaces newlines with \n, and \ with \\.
|
||||
// This does the reverse.
|
||||
fn fish_unescape_history_yaml(line: &str) -> String {
|
||||
let mut result = String::new();
|
||||
result.reserve(line.len());
|
||||
let mut escaped = false;
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
'\\' => {
|
||||
if escaped {
|
||||
result.push('\\');
|
||||
}
|
||||
escaped = !escaped;
|
||||
}
|
||||
'n' if escaped => {
|
||||
result.push('\n');
|
||||
escaped = false;
|
||||
}
|
||||
_ => {
|
||||
result.push(ch);
|
||||
escaped = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Basically a translation of this function into rust:
|
||||
// http://mika.l3ib.org/code/unmetafy.c
|
||||
// To unmetafy a string from zsh's internal format, we need to skip each meta symbol
|
||||
// and XOR the next symbol with 32.
|
||||
fn zsh_unmetafy(content: &[u8]) -> String {
|
||||
let mut unmetafied = Vec::new();
|
||||
|
||||
match content.last() {
|
||||
None => "".into(),
|
||||
Some(byte) => {
|
||||
let mut following_byte = *byte;
|
||||
|
||||
content.iter().rev().skip(1).for_each(|current_byte| {
|
||||
if *current_byte == ZSH_META {
|
||||
following_byte ^= 32;
|
||||
} else {
|
||||
unmetafied.push(following_byte);
|
||||
following_byte = *current_byte;
|
||||
}
|
||||
});
|
||||
|
||||
unmetafied.push(following_byte);
|
||||
unmetafied.reverse();
|
||||
String::from_utf8_lossy(&unmetafied).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_history_bash() {
|
||||
let history_lines = "cat ~/.bash_history
|
||||
#1618089175
|
||||
ls
|
||||
#1618089176
|
||||
pwd";
|
||||
assert_eq!(
|
||||
ShellType::Bash.parse_history(history_lines.as_bytes()),
|
||||
vec![
|
||||
"cat ~/.bash_history".to_string(),
|
||||
"ls".to_string(),
|
||||
"pwd".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_zsh_extended_prefix() {
|
||||
// Extended history format.
|
||||
assert_eq!(
|
||||
strip_zsh_extended_prefix(": 1699251735:0;an extended history command"),
|
||||
"an extended history command"
|
||||
);
|
||||
|
||||
// Non-extended format (no prefix to strip).
|
||||
assert_eq!(
|
||||
strip_zsh_extended_prefix("cat ~/.zsh_history"),
|
||||
"cat ~/.zsh_history"
|
||||
);
|
||||
|
||||
// Edge cases that should NOT be stripped.
|
||||
assert_eq!(
|
||||
strip_zsh_extended_prefix(": not_a_timestamp"),
|
||||
": not_a_timestamp"
|
||||
);
|
||||
assert_eq!(strip_zsh_extended_prefix(": 123;"), ": 123;"); // Missing second number.
|
||||
assert_eq!(strip_zsh_extended_prefix(": :0;cmd"), ": :0;cmd"); // Empty timestamp.
|
||||
assert_eq!(strip_zsh_extended_prefix(": 123:;cmd"), ": 123:;cmd"); // Empty elapsed.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_history_zsh() {
|
||||
let history_lines = "
|
||||
cat ~/.zsh_history
|
||||
a multi-line\\
|
||||
command
|
||||
: 1699251735:0;an extended history command
|
||||
: 1699251735:0;a multi-line extended\\
|
||||
history command
|
||||
";
|
||||
assert_eq!(
|
||||
ShellType::Zsh.parse_history(history_lines.as_bytes()),
|
||||
vec![
|
||||
"cat ~/.zsh_history".to_string(),
|
||||
"a multi-line\ncommand".to_string(),
|
||||
"an extended history command".to_string(),
|
||||
"a multi-line extended\nhistory command".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_history_zsh_continuation_line_looks_like_prefix() {
|
||||
// Regression test: a continuation line that happens to match the extended history
|
||||
// prefix pattern should NOT be stripped.
|
||||
let history_lines = ": 1699251735:0;echo '\\
|
||||
: 9999:0;fake prefix'";
|
||||
assert_eq!(
|
||||
ShellType::Zsh.parse_history(history_lines.as_bytes()),
|
||||
vec!["echo '\n: 9999:0;fake prefix'".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zsh_unmetafy() {
|
||||
let test_zsh_history = [
|
||||
227, 129, 131, 179, 227, 130, 131, 172, 227, 129, 175, 230, 131, 183, 165, 230, 131, 188,
|
||||
172, 232, 170, 131, 190, 227, 129, 167, 227, 129, 131, 185,
|
||||
];
|
||||
let unmetafied_test = zsh_unmetafy(&test_zsh_history);
|
||||
assert_eq!("これは日本語です", &unmetafied_test);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fish_unescape_history_yaml() {
|
||||
assert_eq!(fish_unescape_history_yaml("foo"), "foo");
|
||||
assert_eq!(fish_unescape_history_yaml("foo\\nbar"), "foo\nbar");
|
||||
assert_eq!(fish_unescape_history_yaml("foo\\\\"), "foo\\");
|
||||
assert_eq!(fish_unescape_history_yaml("foo\\"), "foo"); // trailing escape dropped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_name() {
|
||||
assert_eq!(Some(ShellType::Bash), ShellType::from_name("bash"));
|
||||
assert_eq!(Some(ShellType::Bash), ShellType::from_name("-bash"));
|
||||
assert_eq!(Some(ShellType::Bash), ShellType::from_name("/bin/bash"));
|
||||
assert_eq!(Some(ShellType::Bash), ShellType::from_name("/usr/bin/bash"));
|
||||
assert_eq!(Some(ShellType::Zsh), ShellType::from_name("/bin/zsh"));
|
||||
assert_eq!(None, ShellType::from_name("/bin/zsh/foo"));
|
||||
assert_eq!(None, ShellType::from_name("/bin/zsh/-bash"));
|
||||
assert_eq!(None, ShellType::from_name("rezsh"));
|
||||
assert_eq!(Some(ShellType::Fish), ShellType::from_name("fish"));
|
||||
assert_eq!(
|
||||
Some(ShellType::Fish),
|
||||
ShellType::from_name("/usr/local/bin/fish")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::PowerShell),
|
||||
ShellType::from_name("pwsh.exe")
|
||||
);
|
||||
assert_eq!(None, ShellType::from_name("pwsh.bat"));
|
||||
assert_eq!(
|
||||
Some(ShellType::PowerShell),
|
||||
ShellType::from_name("/usr/bin/env/powershell")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::PowerShell),
|
||||
ShellType::from_name("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
);
|
||||
assert_eq!(None, ShellType::from_name("psh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_markdown_language_spec() {
|
||||
// Standard shell languages
|
||||
assert_eq!(
|
||||
Some(ShellType::Bash),
|
||||
ShellType::from_markdown_language_spec("bash")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::Bash),
|
||||
ShellType::from_markdown_language_spec("shell")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::Bash),
|
||||
ShellType::from_markdown_language_spec("sh")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::Zsh),
|
||||
ShellType::from_markdown_language_spec("zsh")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::Fish),
|
||||
ShellType::from_markdown_language_spec("fish")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::PowerShell),
|
||||
ShellType::from_markdown_language_spec("powershell")
|
||||
);
|
||||
assert_eq!(
|
||||
Some(ShellType::PowerShell),
|
||||
ShellType::from_markdown_language_spec("pwsh")
|
||||
);
|
||||
|
||||
// Non-shell languages and invalid inputs
|
||||
assert_eq!(None, ShellType::from_markdown_language_spec("python"));
|
||||
assert_eq!(None, ShellType::from_markdown_language_spec("rust"));
|
||||
assert_eq!(None, ShellType::from_markdown_language_spec(""));
|
||||
// Paths and executable names should not match (use from_name for those)
|
||||
assert_eq!(None, ShellType::from_markdown_language_spec("/bin/bash"));
|
||||
assert_eq!(None, ShellType::from_markdown_language_spec("-bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fish_parse_abbrs() {
|
||||
let raw_abbrs = "abbr -a -U -- gco 'git checkout'
|
||||
abbr -a -g -- gq 'git commit'
|
||||
abbr -a -U -- ehw 'echo \"Hello, world\"'
|
||||
abbr -a -- ga 'git add' # imported from a universal variable, see `help abbr`";
|
||||
let abbrs = ShellType::Fish.abbreviations(raw_abbrs);
|
||||
|
||||
assert_eq!(abbrs.len(), 4);
|
||||
assert_eq!(abbrs.get("gco").unwrap(), "git checkout");
|
||||
assert_eq!(abbrs.get("gq").unwrap(), "git commit");
|
||||
assert_eq!(abbrs.get("ehw").unwrap(), r#"echo "Hello, world""#);
|
||||
assert_eq!(abbrs.get("ga").unwrap(), "git add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fish_parse_aliases() {
|
||||
let raw_aliases = "alias g git
|
||||
alias rmi 'rm -i'
|
||||
alias ehw 'echo \"Hello, world\"'";
|
||||
let aliases = ShellType::Fish.aliases(raw_aliases);
|
||||
|
||||
assert_eq!(aliases.len(), 3);
|
||||
assert_eq!(aliases.get("g").unwrap(), "git");
|
||||
assert_eq!(aliases.get("rmi").unwrap(), "rm -i");
|
||||
assert_eq!(aliases.get("ehw").unwrap(), r#"echo "Hello, world""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_add_command_to_history() {
|
||||
{
|
||||
// Test zsh's "histignorespace" option.
|
||||
let options = HashSet::from(["histignorespace".to_string()]);
|
||||
let shell = Shell::new(
|
||||
ShellType::Zsh,
|
||||
None,
|
||||
Some(options),
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(shell.should_add_command_to_history("asdf"));
|
||||
assert!(!shell.should_add_command_to_history(" asdf"));
|
||||
|
||||
let shell = Shell::new(ShellType::Zsh, None, None, Default::default(), None);
|
||||
assert!(shell.should_add_command_to_history("asdf"));
|
||||
assert!(shell.should_add_command_to_history(" asdf"));
|
||||
}
|
||||
|
||||
// Test our "!histcontrol_" faked option for bash.
|
||||
{
|
||||
for variant in [
|
||||
"!histcontrol_ignorespace",
|
||||
"!histcontrol_ignoreboth",
|
||||
"!histcontrol_testing:ignorespace",
|
||||
"!histcontrol_testing:ignoreboth:testing",
|
||||
] {
|
||||
let options = HashSet::from([variant.to_string()]);
|
||||
let bash_shell = Shell::new(
|
||||
ShellType::Bash,
|
||||
None,
|
||||
Some(options.clone()),
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(bash_shell.should_add_command_to_history("asdf"));
|
||||
assert!(!bash_shell.should_add_command_to_history(" asdf"));
|
||||
|
||||
// Make sure that option only takes effect when the shell is bash.
|
||||
let zsh_shell = Shell::new(
|
||||
ShellType::Zsh,
|
||||
None,
|
||||
Some(options),
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert!(zsh_shell.should_add_command_to_history(" asdf"));
|
||||
|
||||
let bash_shell_no_options =
|
||||
Shell::new(ShellType::Bash, None, None, Default::default(), None);
|
||||
assert!(bash_shell_no_options.should_add_command_to_history("asdf"));
|
||||
assert!(bash_shell_no_options.should_add_command_to_history(" asdf"));
|
||||
}
|
||||
|
||||
// Ensure we're not only looking for "!histcontrol".
|
||||
{
|
||||
let options = HashSet::from(["!histcontrol_testing".to_string()]);
|
||||
let bash_shell = Shell::new(
|
||||
ShellType::Bash,
|
||||
None,
|
||||
Some(options),
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert!(bash_shell.should_add_command_to_history(" asdf"));
|
||||
}
|
||||
}
|
||||
|
||||
// Fish has no shell options that prevent a command from being written to history.
|
||||
{
|
||||
let fish_shell = Shell::new(ShellType::Fish, None, None, Default::default(), None);
|
||||
assert!(fish_shell.should_add_command_to_history("asdf"));
|
||||
assert!(fish_shell.should_add_command_to_history(" asdf"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
enum CurrentQuoteStrategy {
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
AnsiC,
|
||||
}
|
||||
|
||||
/// Unescape alias outputs in single quoting and ANSI-C quoting format.
|
||||
/// For single quoting, we should take the literal meaning of all characters within
|
||||
/// the quoting. For ANSI-C quoting, we need to translate escape sequences to their
|
||||
/// unicode values.
|
||||
///
|
||||
/// Note that we don't unescape double quotes here as they require the knowledge of
|
||||
/// shell variables' values. E.g. If we have the following string `echo "'$apple'"`,
|
||||
/// we could unescape it without knowing what the value of $apple.
|
||||
pub fn unescape_quotes(s: &str) -> Result<String> {
|
||||
let mut current_quoting = CurrentQuoteStrategy::None;
|
||||
|
||||
let mut chars = s.chars().enumerate().peekable();
|
||||
let mut res = String::with_capacity(s.len());
|
||||
|
||||
while let Some((idx, c)) = chars.next() {
|
||||
match (c, ¤t_quoting) {
|
||||
// If in single / Ansi-C quote, end the quote escaping.
|
||||
('\'', CurrentQuoteStrategy::Single | CurrentQuoteStrategy::AnsiC) => {
|
||||
current_quoting = CurrentQuoteStrategy::None
|
||||
}
|
||||
('\'', CurrentQuoteStrategy::None) => current_quoting = CurrentQuoteStrategy::Single,
|
||||
('\"', CurrentQuoteStrategy::Double) => current_quoting = CurrentQuoteStrategy::None,
|
||||
('\"', CurrentQuoteStrategy::None) => current_quoting = CurrentQuoteStrategy::Double,
|
||||
('\\', CurrentQuoteStrategy::AnsiC) => {
|
||||
match chars.next() {
|
||||
None => {
|
||||
return Err(anyhow!("invalid escape at char {} in string {}", idx, s));
|
||||
}
|
||||
Some((_, next_character)) => {
|
||||
// Referenced from the table here: https://en.wikipedia.org/wiki/Escape_sequences_in_C
|
||||
res.push(match next_character {
|
||||
'a' => '\u{07}',
|
||||
'b' => '\u{08}',
|
||||
'e' | 'E' => '\u{1B}',
|
||||
'f' => '\u{0C}',
|
||||
'n' => '\n',
|
||||
'r' => '\r',
|
||||
't' => '\t',
|
||||
'v' => '\u{0B}',
|
||||
// TODO(kevin): Need to add escaping to unicode
|
||||
// characters here. But this should be rare.
|
||||
next_character => next_character,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
('\\', CurrentQuoteStrategy::Double) => {
|
||||
match chars.next() {
|
||||
None => {
|
||||
return Err(anyhow!("invalid escape at char {} in string {}", idx, s));
|
||||
}
|
||||
Some((_, next_character)) => {
|
||||
// The backslash retains special meaning when followed by
|
||||
// ‘$’, ‘`’, ‘"’, ‘\’. Otherwise is treated as a literal.
|
||||
// Referenced from here:
|
||||
// https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
|
||||
match next_character {
|
||||
'$' | '`' | '"' | '\\' => res.push(next_character),
|
||||
// Newlines are ignored after a backslash, see:
|
||||
// https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Escape-Character
|
||||
'\n' => {}
|
||||
_ => {
|
||||
res.push('\\');
|
||||
res.push(next_character);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
('\\', CurrentQuoteStrategy::None) => match chars.next() {
|
||||
None => {
|
||||
return Err(anyhow!("invalid escape at char {} in string {}", idx, s));
|
||||
}
|
||||
Some((_, next_character)) => match next_character {
|
||||
// Newlines are ignored after a backslash, see:
|
||||
// https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Escape-Character
|
||||
'\n' => {}
|
||||
_ => res.push(next_character),
|
||||
},
|
||||
},
|
||||
('$', CurrentQuoteStrategy::None) => {
|
||||
match chars.peek() {
|
||||
// ANSI-C quoting starts with $'
|
||||
Some((_, '\'')) => {
|
||||
current_quoting = CurrentQuoteStrategy::AnsiC;
|
||||
chars.next();
|
||||
}
|
||||
_ => res.push('$'),
|
||||
}
|
||||
}
|
||||
_ => res.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_quotes() {
|
||||
assert_eq!(unescape_quotes("東方").unwrap(), "東方".to_string());
|
||||
assert_eq!(unescape_quotes(r#"$'\"\"'"#).unwrap(), r#""""#.to_string());
|
||||
assert_eq!(unescape_quotes(r#"'"'"#).unwrap(), r#"""#.to_string());
|
||||
assert_eq!(
|
||||
unescape_quotes(r#"$'foo"barbaz\'quux'"#).unwrap(),
|
||||
r#"foo"barbaz'quux"#.to_string()
|
||||
);
|
||||
// Every escape between ANSI-C quoting.
|
||||
assert_eq!(
|
||||
unescape_quotes(r"$'\a\b\v\f\n\r\t\e\E'").unwrap(),
|
||||
"\u{07}\u{08}\u{0b}\u{0c}\u{0a}\u{0d}\u{09}\u{1b}\u{1b}".to_string()
|
||||
);
|
||||
// Failure case when the escape character is at end of string.
|
||||
assert!(unescape_quotes(r"$'\").is_err());
|
||||
|
||||
// Chars following escape chars should be taken literally when not currently
|
||||
// in a quote strategy.
|
||||
assert_eq!(
|
||||
unescape_quotes(r"'echo '\''hello\nworld'\'").unwrap(),
|
||||
"echo 'hello\\nworld'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_double_quotes() {
|
||||
assert_eq!(unescape_quotes("\"hello world\"").unwrap(), "hello world");
|
||||
assert_eq!(unescape_quotes(r#""hello\$world""#).unwrap(), "hello$world");
|
||||
assert_eq!(unescape_quotes(r#""hello\`world""#).unwrap(), "hello`world");
|
||||
assert_eq!(
|
||||
unescape_quotes(r#""hello\"world""#).unwrap(),
|
||||
"hello\"world"
|
||||
);
|
||||
assert_eq!(
|
||||
unescape_quotes(r#""hello\\world""#).unwrap(),
|
||||
"hello\\world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_double_quotes_nonspecial_chars() {
|
||||
assert_eq!(
|
||||
unescape_quotes(r#""hello\aworld""#).unwrap(),
|
||||
r"hello\aworld"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_backslash_with_newline() {
|
||||
// With no quoting strategy.
|
||||
assert_eq!(unescape_quotes("hello\\\nworld").unwrap(), "helloworld");
|
||||
// With double quotes.
|
||||
assert_eq!(unescape_quotes("\"hello\\\nworld\"").unwrap(), "helloworld");
|
||||
}
|
||||
Reference in New Issue
Block a user