Skip to content

Commit b07705d

Browse files
committed
Replace Crossterm with Termina
This change switches out the terminal manipulation library to one I've been working on: Termina. It's somewhat similar to Crossterm API-wise but is a bit lower-level. It's also influenced a lot by TermWiz - the terminal manipulation library in WezTerm which we've considered switching to a few times. Termina is more verbose than Crossterm as it has a lower level interface that exposes escape sequences and pushes handling to the application. API-wise the important piece is that the equivalents of Crossterm's `poll_internal` / `read_internal` are exposed. This is used for reading the cursor position in both Crossterm and Termina, for example, but also now can be used to detect features like the Kitty keyboard protocol and synchronized output sequences simultaneously.
1 parent 863f7c2 commit b07705d

File tree

21 files changed

+826
-1112
lines changed

21 files changed

+826
-1112
lines changed

Cargo.lock

Lines changed: 60 additions & 139 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ unicode-segmentation = "1.2"
4747
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
4848
foldhash = "0.1"
4949
parking_lot = "0.12"
50+
# TODO: publish v0.1.0.
51+
termina = "0.1.0-beta.1"
52+
# termina = { git = "https://github.com/helix-editor/termina" }
53+
# termina.path = "../termina"
5054

5155
[workspace.package]
5256
version = "25.1.1"

helix-term/Cargo.toml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ anyhow = "1"
5454
once_cell = "1.21"
5555

5656
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] }
57-
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] }
58-
crossterm = { version = "0.28", features = ["event-stream"] }
57+
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["termina"] }
58+
termina.workspace = true
5959
signal-hook = "0.3"
6060
tokio-stream = "0.1"
6161
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
@@ -95,9 +95,6 @@ grep-searcher = "0.1.14"
9595
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
9696
libc = "0.2.171"
9797

98-
[target.'cfg(target_os = "macos")'.dependencies]
99-
crossterm = { version = "0.28", features = ["event-stream", "use-dev-tty", "libc"] }
100-
10198
[build-dependencies]
10299
helix-loader = { path = "../helix-loader" }
103100

helix-term/src/application.rs

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,28 +30,27 @@ use crate::{
3030
};
3131

3232
use log::{debug, error, info, warn};
33-
#[cfg(not(feature = "integration"))]
34-
use std::io::stdout;
35-
use std::{io::stdin, path::Path, sync::Arc};
33+
use std::{
34+
io::{stdin, IsTerminal},
35+
path::Path,
36+
sync::Arc,
37+
};
3638

37-
#[cfg(not(windows))]
38-
use anyhow::Context;
39-
use anyhow::Error;
39+
use anyhow::{Context, Error};
4040

41-
use crossterm::{event::Event as CrosstermEvent, tty::IsTty};
4241
#[cfg(not(windows))]
4342
use {signal_hook::consts::signal, signal_hook_tokio::Signals};
4443
#[cfg(windows)]
4544
type Signals = futures_util::stream::Empty<()>;
4645

4746
#[cfg(not(feature = "integration"))]
48-
use tui::backend::CrosstermBackend;
47+
use tui::backend::TerminaBackend;
4948

5049
#[cfg(feature = "integration")]
5150
use tui::backend::TestBackend;
5251

5352
#[cfg(not(feature = "integration"))]
54-
type TerminalBackend = CrosstermBackend<std::io::Stdout>;
53+
type TerminalBackend = TerminaBackend;
5554

5655
#[cfg(feature = "integration")]
5756
type TerminalBackend = TestBackend;
@@ -104,7 +103,8 @@ impl Application {
104103
let theme_loader = theme::Loader::new(&theme_parent_dirs);
105104

106105
#[cfg(not(feature = "integration"))]
107-
let backend = CrosstermBackend::new(stdout(), (&config.editor).into());
106+
let backend = TerminaBackend::new((&config.editor).into())
107+
.context("failed to create terminal backend")?;
108108

109109
#[cfg(feature = "integration")]
110110
let backend = TestBackend::new(120, 150);
@@ -123,7 +123,11 @@ impl Application {
123123
})),
124124
handlers,
125125
);
126-
Self::load_configured_theme(&mut editor, &config.load());
126+
Self::load_configured_theme(
127+
&mut editor,
128+
&config.load(),
129+
terminal.backend().supports_true_color(),
130+
);
127131

128132
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
129133
&config.keys
@@ -214,7 +218,7 @@ impl Application {
214218
} else {
215219
editor.new_file(Action::VerticalSplit);
216220
}
217-
} else if stdin().is_tty() || cfg!(feature = "integration") {
221+
} else if stdin().is_terminal() || cfg!(feature = "integration") {
218222
editor.new_file(Action::VerticalSplit);
219223
} else {
220224
editor
@@ -282,7 +286,7 @@ impl Application {
282286

283287
pub async fn event_loop<S>(&mut self, input_stream: &mut S)
284288
where
285-
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
289+
S: Stream<Item = std::io::Result<termina::Event>> + Unpin,
286290
{
287291
self.render().await;
288292

@@ -295,7 +299,7 @@ impl Application {
295299

296300
pub async fn event_loop_until_idle<S>(&mut self, input_stream: &mut S) -> bool
297301
where
298-
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
302+
S: Stream<Item = std::io::Result<termina::Event>> + Unpin,
299303
{
300304
loop {
301305
if self.editor.should_close() {
@@ -408,7 +412,11 @@ impl Application {
408412
.map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?;
409413
self.refresh_language_config()?;
410414
// Refresh theme after config change
411-
Self::load_configured_theme(&mut self.editor, &default_config);
415+
Self::load_configured_theme(
416+
&mut self.editor,
417+
&default_config,
418+
self.terminal.backend().supports_true_color(),
419+
);
412420
self.terminal.reconfigure((&default_config.editor).into())?;
413421
// Store new config
414422
self.config.store(Arc::new(default_config));
@@ -426,8 +434,8 @@ impl Application {
426434
}
427435

428436
/// Load the theme set in configuration
429-
fn load_configured_theme(editor: &mut Editor, config: &Config) {
430-
let true_color = config.editor.true_color || crate::true_color();
437+
fn load_configured_theme(editor: &mut Editor, config: &Config, terminal_true_color: bool) {
438+
let true_color = terminal_true_color || config.editor.true_color || crate::true_color();
431439
let theme = config
432440
.theme
433441
.as_ref()
@@ -623,29 +631,29 @@ impl Application {
623631
false
624632
}
625633

626-
pub async fn handle_terminal_events(&mut self, event: std::io::Result<CrosstermEvent>) {
634+
pub async fn handle_terminal_events(&mut self, event: std::io::Result<termina::Event>) {
627635
let mut cx = crate::compositor::Context {
628636
editor: &mut self.editor,
629637
jobs: &mut self.jobs,
630638
scroll: None,
631639
};
632640
// Handle key events
633641
let should_redraw = match event.unwrap() {
634-
CrosstermEvent::Resize(width, height) => {
642+
termina::Event::WindowResized { rows, cols } => {
635643
self.terminal
636-
.resize(Rect::new(0, 0, width, height))
644+
.resize(Rect::new(0, 0, cols, rows))
637645
.expect("Unable to resize terminal");
638646

639647
let area = self.terminal.size().expect("couldn't get terminal size");
640648

641649
self.compositor.resize(area);
642650

643651
self.compositor
644-
.handle_event(&Event::Resize(width, height), &mut cx)
652+
.handle_event(&Event::Resize(cols, rows), &mut cx)
645653
}
646654
// Ignore keyboard release events.
647-
CrosstermEvent::Key(crossterm::event::KeyEvent {
648-
kind: crossterm::event::KeyEventKind::Release,
655+
termina::Event::Key(termina::event::KeyEvent {
656+
kind: termina::event::KeyEventKind::Release,
649657
..
650658
}) => false,
651659
event => self.compositor.handle_event(&event.into(), &mut cx),
@@ -1096,22 +1104,26 @@ impl Application {
10961104
self.terminal.restore()
10971105
}
10981106

1107+
#[cfg(not(feature = "integration"))]
1108+
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin {
1109+
use termina::Terminal as _;
1110+
self.terminal
1111+
.backend()
1112+
.terminal()
1113+
.event_stream(|event| !event.is_escape())
1114+
}
1115+
1116+
#[cfg(feature = "integration")]
1117+
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin {
1118+
termina::DummyEventStream
1119+
}
1120+
10991121
pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error>
11001122
where
1101-
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
1123+
S: Stream<Item = std::io::Result<termina::Event>> + Unpin,
11021124
{
11031125
self.terminal.claim()?;
11041126

1105-
// Exit the alternate screen and disable raw mode before panicking
1106-
let hook = std::panic::take_hook();
1107-
std::panic::set_hook(Box::new(move |info| {
1108-
// We can't handle errors properly inside this closure. And it's
1109-
// probably not a good idea to `unwrap()` inside a panic handler.
1110-
// So we just ignore the `Result`.
1111-
let _ = TerminalBackend::force_restore();
1112-
hook(info);
1113-
}));
1114-
11151127
self.event_loop(input_stream).await;
11161128

11171129
let close_errs = self.close().await;

helix-term/src/health.rs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
use crate::config::{Config, ConfigLoadError};
2-
use crossterm::{
3-
style::{Color, StyledContent, Stylize},
4-
tty::IsTty,
5-
};
62
use helix_core::config::{default_lang_config, user_lang_config};
73
use helix_loader::grammar::load_runtime_file;
8-
use std::io::Write;
4+
use std::io::{IsTerminal, Write};
5+
use termina::{
6+
style::{ColorSpec, StyleExt as _, Stylized},
7+
Terminal as _,
8+
};
99

1010
#[derive(Copy, Clone)]
1111
pub enum TsFeature {
@@ -160,21 +160,24 @@ pub fn languages_all() -> std::io::Result<()> {
160160
headings.push(feat.short_title())
161161
}
162162

163-
let terminal_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
163+
let terminal_cols = termina::PlatformTerminal::new()
164+
.and_then(|terminal| terminal.get_dimensions())
165+
.map(|(_rows, cols)| cols)
166+
.unwrap_or(80);
164167
let column_width = terminal_cols as usize / headings.len();
165-
let is_terminal = std::io::stdout().is_tty();
168+
let is_terminal = std::io::stdout().is_terminal();
166169

167-
let fit = |s: &str| -> StyledContent<String> {
170+
let fit = |s: &str| -> Stylized<'static> {
168171
format!(
169172
"{:column_width$}",
170173
s.get(..column_width - 2)
171174
.map(|s| format!("{}…", s))
172175
.unwrap_or_else(|| s.to_string())
173176
)
174-
.stylize()
177+
.stylized()
175178
};
176-
let color = |s: StyledContent<String>, c: Color| if is_terminal { s.with(c) } else { s };
177-
let bold = |s: StyledContent<String>| if is_terminal { s.bold() } else { s };
179+
let color = |s: Stylized<'static>, c: ColorSpec| if is_terminal { s.foreground(c) } else { s };
180+
let bold = |s: Stylized<'static>| if is_terminal { s.bold() } else { s };
178181

179182
for heading in headings {
180183
write!(stdout, "{}", bold(fit(heading)))?;
@@ -187,10 +190,10 @@ pub fn languages_all() -> std::io::Result<()> {
187190

188191
let check_binary = |cmd: Option<&str>| match cmd {
189192
Some(cmd) => match helix_stdx::env::which(cmd) {
190-
Ok(_) => color(fit(&format!("✓ {}", cmd)), Color::Green),
191-
Err(_) => color(fit(&format!("✘ {}", cmd)), Color::Red),
193+
Ok(_) => color(fit(&format!("✓ {}", cmd)), ColorSpec::BRIGHT_GREEN),
194+
Err(_) => color(fit(&format!("✘ {}", cmd)), ColorSpec::BRIGHT_RED),
192195
},
193-
None => color(fit("None"), Color::Yellow),
196+
None => color(fit("None"), ColorSpec::BRIGHT_YELLOW),
194197
};
195198

196199
for lang in &syn_loader_conf.language {
@@ -215,8 +218,8 @@ pub fn languages_all() -> std::io::Result<()> {
215218

216219
for ts_feat in TsFeature::all() {
217220
match load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() {
218-
true => write!(stdout, "{}", color(fit("✓"), Color::Green))?,
219-
false => write!(stdout, "{}", color(fit("✘"), Color::Red))?,
221+
true => write!(stdout, "{}", color(fit("✓"), ColorSpec::BRIGHT_GREEN))?,
222+
false => write!(stdout, "{}", color(fit("✘"), ColorSpec::BRIGHT_RED))?,
220223
}
221224
}
222225

helix-term/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use anyhow::{Context, Error, Result};
2-
use crossterm::event::EventStream;
32
use helix_loader::VERSION_AND_GIT_HASH;
43
use helix_term::application::Application;
54
use helix_term::args::Args;
@@ -149,8 +148,9 @@ FLAGS:
149148

150149
// TODO: use the thread local executor to spawn the application task separately from the work pool
151150
let mut app = Application::new(args, config, lang_loader).context("unable to start Helix")?;
151+
let mut events = app.event_stream();
152152

153-
let exit_code = app.run(&mut EventStream::new()).await?;
153+
let exit_code = app.run(&mut events).await?;
154154

155155
Ok(exit_code)
156156
}

helix-term/src/ui/editor.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -538,15 +538,15 @@ impl EditorView {
538538
};
539539
spans.push((selection_scope, range.anchor..selection_end));
540540
// add block cursors
541-
// skip primary cursor if terminal is unfocused - crossterm cursor is used in that case
541+
// skip primary cursor if terminal is unfocused - terminal cursor is used in that case
542542
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
543543
spans.push((cursor_scope, cursor_start..range.head));
544544
}
545545
} else {
546546
// Reverse case.
547547
let cursor_end = next_grapheme_boundary(text, range.head);
548548
// add block cursors
549-
// skip primary cursor if terminal is unfocused - crossterm cursor is used in that case
549+
// skip primary cursor if terminal is unfocused - terminal cursor is used in that case
550550
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
551551
spans.push((cursor_scope, range.head..cursor_end));
552552
}
@@ -1647,7 +1647,7 @@ impl Component for EditorView {
16471647
if self.terminal_focused {
16481648
(pos, CursorKind::Hidden)
16491649
} else {
1650-
// use crossterm cursor when terminal loses focus
1650+
// use terminal cursor when terminal loses focus
16511651
(pos, CursorKind::Underline)
16521652
}
16531653
}

helix-term/tests/test/helpers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ use std::{
66
};
77

88
use anyhow::bail;
9-
use crossterm::event::{Event, KeyEvent};
109
use helix_core::{diagnostic::Severity, test, Selection, Transaction};
1110
use helix_term::{application::Application, args::Args, config::Config, keymap::merge_keys};
1211
use helix_view::{current_ref, doc, editor::LspConfig, input::parse_macro, Editor};
1312
use tempfile::NamedTempFile;
13+
use termina::event::{Event, KeyEvent};
1414
use tokio_stream::wrappers::UnboundedReceiverStream;
1515

1616
/// Specify how to set up the input text with line feeds

helix-tui/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ repository.workspace = true
1212
homepage.workspace = true
1313

1414
[features]
15-
default = ["crossterm"]
15+
default = ["termina"]
1616

1717
[dependencies]
1818
helix-view = { path = "../helix-view", features = ["term"] }
@@ -21,7 +21,7 @@ helix-core = { path = "../helix-core" }
2121
bitflags.workspace = true
2222
cassowary = "0.3"
2323
unicode-segmentation.workspace = true
24-
crossterm = { version = "0.28", optional = true }
24+
termina = { workspace = true, optional = true }
2525
termini = "1.0"
2626
once_cell = "1.21"
2727
log = "~0.4"

0 commit comments

Comments
 (0)