Skip to content

Commit 83abbe5

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 9cc912a commit 83abbe5

File tree

21 files changed

+921
-1133
lines changed

21 files changed

+921
-1133
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ futures-executor = "0.3"
5151
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
5252
tokio-stream = "0.1.17"
5353
toml = "0.9"
54+
termina = "0.1.0"
5455

5556
[workspace.package]
5657
version = "25.7.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, features = ["event-stream"] }
5959
signal-hook = "0.3"
6060
tokio-stream = "0.1"
6161
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
@@ -97,9 +97,6 @@ dashmap = "6.0"
9797
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
9898
libc = "0.2.175"
9999

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

helix-term/src/application.rs

Lines changed: 60 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() {
@@ -396,7 +400,11 @@ impl Application {
396400
// the sake of locals highlighting.
397401
let lang_loader = helix_core::config::user_lang_loader()?;
398402
self.editor.syn_loader.store(Arc::new(lang_loader));
399-
Self::load_configured_theme(&mut self.editor, &default_config);
403+
Self::load_configured_theme(
404+
&mut self.editor,
405+
&default_config,
406+
self.terminal.backend().supports_true_color(),
407+
);
400408

401409
// Re-parse any open documents with the new language config.
402410
let lang_loader = self.editor.syn_loader.load();
@@ -429,8 +437,8 @@ impl Application {
429437
}
430438

431439
/// Load the theme set in configuration
432-
fn load_configured_theme(editor: &mut Editor, config: &Config) {
433-
let true_color = config.editor.true_color || crate::true_color();
440+
fn load_configured_theme(editor: &mut Editor, config: &Config, terminal_true_color: bool) {
441+
let true_color = terminal_true_color || config.editor.true_color || crate::true_color();
434442
let theme = config
435443
.theme
436444
.as_ref()
@@ -634,29 +642,29 @@ impl Application {
634642
false
635643
}
636644

637-
pub async fn handle_terminal_events(&mut self, event: std::io::Result<CrosstermEvent>) {
645+
pub async fn handle_terminal_events(&mut self, event: std::io::Result<termina::Event>) {
638646
let mut cx = crate::compositor::Context {
639647
editor: &mut self.editor,
640648
jobs: &mut self.jobs,
641649
scroll: None,
642650
};
643651
// Handle key events
644652
let should_redraw = match event.unwrap() {
645-
CrosstermEvent::Resize(width, height) => {
653+
termina::Event::WindowResized(termina::WindowSize { rows, cols, .. }) => {
646654
self.terminal
647-
.resize(Rect::new(0, 0, width, height))
655+
.resize(Rect::new(0, 0, cols, rows))
648656
.expect("Unable to resize terminal");
649657

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

652660
self.compositor.resize(area);
653661

654662
self.compositor
655-
.handle_event(&Event::Resize(width, height), &mut cx)
663+
.handle_event(&Event::Resize(cols, rows), &mut cx)
656664
}
657665
// Ignore keyboard release events.
658-
CrosstermEvent::Key(crossterm::event::KeyEvent {
659-
kind: crossterm::event::KeyEventKind::Release,
666+
termina::Event::Key(termina::event::KeyEvent {
667+
kind: termina::event::KeyEventKind::Release,
660668
..
661669
}) => false,
662670
event => self.compositor.handle_event(&event.into(), &mut cx),
@@ -1107,22 +1115,40 @@ impl Application {
11071115
self.terminal.restore()
11081116
}
11091117

1118+
#[cfg(not(feature = "integration"))]
1119+
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin {
1120+
use termina::Terminal as _;
1121+
let reader = self.terminal.backend().terminal().event_reader();
1122+
termina::EventStream::new(reader, |event| !event.is_escape())
1123+
}
1124+
1125+
#[cfg(feature = "integration")]
1126+
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin {
1127+
use std::{
1128+
pin::Pin,
1129+
task::{Context, Poll},
1130+
};
1131+
1132+
/// A dummy stream that never polls as ready.
1133+
pub struct DummyEventStream;
1134+
1135+
impl Stream for DummyEventStream {
1136+
type Item = std::io::Result<termina::Event>;
1137+
1138+
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1139+
Poll::Pending
1140+
}
1141+
}
1142+
1143+
DummyEventStream
1144+
}
1145+
11101146
pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error>
11111147
where
1112-
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
1148+
S: Stream<Item = std::io::Result<termina::Event>> + Unpin,
11131149
{
11141150
self.terminal.claim()?;
11151151

1116-
// Exit the alternate screen and disable raw mode before panicking
1117-
let hook = std::panic::take_hook();
1118-
std::panic::set_hook(Box::new(move |info| {
1119-
// We can't handle errors properly inside this closure. And it's
1120-
// probably not a good idea to `unwrap()` inside a panic handler.
1121-
// So we just ignore the `Result`.
1122-
let _ = TerminalBackend::force_restore();
1123-
hook(info);
1124-
}));
1125-
11261152
self.event_loop(input_stream).await;
11271153

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

helix-term/src/health.rs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
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::{collections::HashSet, io::Write};
4+
use std::{
5+
collections::HashSet,
6+
io::{IsTerminal, Write},
7+
};
8+
use termina::{
9+
style::{ColorSpec, StyleExt as _, Stylized},
10+
Terminal as _,
11+
};
912

1013
#[derive(Copy, Clone)]
1114
pub enum TsFeature {
@@ -183,21 +186,24 @@ fn languages(selection: Option<HashSet<String>>) -> std::io::Result<()> {
183186
headings.push(feat.short_title())
184187
}
185188

186-
let terminal_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
189+
let terminal_cols = termina::PlatformTerminal::new()
190+
.and_then(|terminal| terminal.get_dimensions())
191+
.map(|size| size.cols)
192+
.unwrap_or(80);
187193
let column_width = terminal_cols as usize / headings.len();
188-
let is_terminal = std::io::stdout().is_tty();
194+
let is_terminal = std::io::stdout().is_terminal();
189195

190-
let fit = |s: &str| -> StyledContent<String> {
196+
let fit = |s: &str| -> Stylized<'static> {
191197
format!(
192198
"{:column_width$}",
193199
s.get(..column_width - 2)
194200
.map(|s| format!("{}…", s))
195201
.unwrap_or_else(|| s.to_string())
196202
)
197-
.stylize()
203+
.stylized()
198204
};
199-
let color = |s: StyledContent<String>, c: Color| if is_terminal { s.with(c) } else { s };
200-
let bold = |s: StyledContent<String>| if is_terminal { s.bold() } else { s };
205+
let color = |s: Stylized<'static>, c: ColorSpec| if is_terminal { s.foreground(c) } else { s };
206+
let bold = |s: Stylized<'static>| if is_terminal { s.bold() } else { s };
201207

202208
for heading in headings {
203209
write!(stdout, "{}", bold(fit(heading)))?;
@@ -210,10 +216,10 @@ fn languages(selection: Option<HashSet<String>>) -> std::io::Result<()> {
210216

211217
let check_binary_with_name = |cmd: Option<(&str, &str)>| match cmd {
212218
Some((name, cmd)) => match helix_stdx::env::which(cmd) {
213-
Ok(_) => color(fit(&format!("✓ {}", name)), Color::Green),
214-
Err(_) => color(fit(&format!("✘ {}", name)), Color::Red),
219+
Ok(_) => color(fit(&format!("✓ {}", name)), ColorSpec::BRIGHT_GREEN),
220+
Err(_) => color(fit(&format!("✘ {}", name)), ColorSpec::BRIGHT_RED),
215221
},
216-
None => color(fit("None"), Color::Yellow),
222+
None => color(fit("None"), ColorSpec::BRIGHT_YELLOW),
217223
};
218224

219225
let check_binary = |cmd: Option<&str>| check_binary_with_name(cmd.map(|cmd| (cmd, cmd)));
@@ -247,8 +253,8 @@ fn languages(selection: Option<HashSet<String>>) -> std::io::Result<()> {
247253

248254
for ts_feat in TsFeature::all() {
249255
match load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() {
250-
true => write!(stdout, "{}", color(fit("✓"), Color::Green))?,
251-
false => write!(stdout, "{}", color(fit("✘"), Color::Red))?,
256+
true => write!(stdout, "{}", color(fit("✓"), ColorSpec::BRIGHT_GREEN))?,
257+
false => write!(stdout, "{}", color(fit("✘"), ColorSpec::BRIGHT_RED))?,
252258
}
253259
}
254260

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;
@@ -151,8 +150,9 @@ FLAGS:
151150

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

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

157157
Ok(exit_code)
158158
}

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
}
@@ -1631,7 +1631,7 @@ impl Component for EditorView {
16311631
if self.terminal_focused {
16321632
(pos, CursorKind::Hidden)
16331633
} else {
1634-
// use crossterm cursor when terminal loses focus
1634+
// use terminal cursor when terminal loses focus
16351635
(pos, CursorKind::Underline)
16361636
}
16371637
}

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)