Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-tui/src/backend/termina.rs')
-rw-r--r--helix-tui/src/backend/termina.rs645
1 files changed, 645 insertions, 0 deletions
diff --git a/helix-tui/src/backend/termina.rs b/helix-tui/src/backend/termina.rs
new file mode 100644
index 00000000..c818d901
--- /dev/null
+++ b/helix-tui/src/backend/termina.rs
@@ -0,0 +1,645 @@
+use std::io::{self, Write as _};
+
+use helix_view::{
+ graphics::{CursorKind, Rect, UnderlineStyle},
+ theme::{Color, Modifier},
+};
+use termina::{
+ escape::{
+ csi::{self, Csi, SgrAttributes, SgrModifiers},
+ dcs::{self, Dcs},
+ },
+ style::{CursorStyle, RgbColor},
+ Event, OneBased, PlatformTerminal, Terminal as _, WindowSize,
+};
+use termini::TermInfo;
+
+use crate::{buffer::Cell, terminal::Config};
+
+use super::Backend;
+
+// These macros are helpers to set/unset modes like bracketed paste or enter/exit the alternate
+// screen.
+macro_rules! decset {
+ ($mode:ident) => {
+ Csi::Mode(csi::Mode::SetDecPrivateMode(csi::DecPrivateMode::Code(
+ csi::DecPrivateModeCode::$mode,
+ )))
+ };
+}
+macro_rules! decreset {
+ ($mode:ident) => {
+ Csi::Mode(csi::Mode::ResetDecPrivateMode(csi::DecPrivateMode::Code(
+ csi::DecPrivateModeCode::$mode,
+ )))
+ };
+}
+
+fn term_program() -> Option<String> {
+ // Some terminals don't set $TERM_PROGRAM
+ match std::env::var("TERM_PROGRAM") {
+ Err(_) => std::env::var("TERM").ok(),
+ Ok(term_program) => Some(term_program),
+ }
+}
+fn vte_version() -> Option<usize> {
+ std::env::var("VTE_VERSION").ok()?.parse().ok()
+}
+fn reset_cursor_approach(terminfo: TermInfo) -> String {
+ let mut reset_str = Csi::Cursor(csi::Cursor::CursorStyle(CursorStyle::Default)).to_string();
+
+ if let Some(termini::Value::Utf8String(se_str)) = terminfo.extended_cap("Se") {
+ reset_str.push_str(se_str);
+ };
+
+ reset_str.push_str(
+ terminfo
+ .utf8_string_cap(termini::StringCapability::CursorNormal)
+ .unwrap_or(""),
+ );
+
+ reset_str
+}
+
+#[derive(Debug, Default, Clone, Copy)]
+struct Capabilities {
+ kitty_keyboard: KittyKeyboardSupport,
+ synchronized_output: bool,
+ true_color: bool,
+ extended_underlines: bool,
+}
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+enum KittyKeyboardSupport {
+ /// The terminal doesn't support the protocol.
+ #[default]
+ None,
+ /// The terminal supports the protocol but we haven't checked yet whether it has full or
+ /// partial support for the flags we require.
+ Some,
+ /// The terminal only supports some of the flags we require.
+ Partial,
+ /// The terminal supports all flags require.
+ Full,
+}
+
+#[derive(Debug)]
+pub struct TerminaBackend {
+ terminal: PlatformTerminal,
+ config: Config,
+ capabilities: Capabilities,
+ reset_cursor_command: String,
+ is_synchronized_output_set: bool,
+}
+
+impl TerminaBackend {
+ pub fn new(config: Config) -> io::Result<Self> {
+ let mut terminal = PlatformTerminal::new()?;
+ let (capabilities, reset_cursor_command) =
+ Self::detect_capabilities(&mut terminal, &config)?;
+
+ // In the case of a panic, reset the terminal eagerly. If we didn't do this and instead
+ // relied on `Drop`, the backtrace would be lost because it is printed before we would
+ // clear and exit the alternate screen.
+ let hook_reset_cursor_command = reset_cursor_command.clone();
+ terminal.set_panic_hook(move |term| {
+ let _ = write!(
+ term,
+ "{}{}{}{}{}{}{}{}{}{}{}",
+ Csi::Keyboard(csi::Keyboard::PopFlags(1)),
+ decreset!(MouseTracking),
+ decreset!(ButtonEventMouse),
+ decreset!(AnyEventMouse),
+ decreset!(RXVTMouse),
+ decreset!(SGRMouse),
+ &hook_reset_cursor_command,
+ decreset!(BracketedPaste),
+ decreset!(FocusTracking),
+ Csi::Edit(csi::Edit::EraseInDisplay(csi::EraseInDisplay::EraseDisplay)),
+ decreset!(ClearAndEnableAlternateScreen),
+ );
+ });
+
+ Ok(Self {
+ terminal,
+ config,
+ capabilities,
+ reset_cursor_command,
+ is_synchronized_output_set: false,
+ })
+ }
+
+ pub fn terminal(&self) -> &PlatformTerminal {
+ &self.terminal
+ }
+
+ fn detect_capabilities(
+ terminal: &mut PlatformTerminal,
+ config: &Config,
+ ) -> io::Result<(Capabilities, String)> {
+ use std::time::{Duration, Instant};
+
+ // Colibri "midnight"
+ const TEST_COLOR: RgbColor = RgbColor::new(59, 34, 76);
+
+ terminal.enter_raw_mode()?;
+
+ let mut capabilities = Capabilities::default();
+ let start = Instant::now();
+
+ // Many terminal extensions can be detected by querying the terminal for the state of the
+ // extension and then sending a request for the primary device attributes (which is
+ // consistently supported by all terminals). If we receive the status of the feature (for
+ // example the current Kitty keyboard flags) then we know that the feature is supported.
+ // If we only receive the device attributes then we know it is not.
+ write!(
+ terminal,
+ "{}{}{}{}{}{}{}",
+ // Kitty keyboard
+ Csi::Keyboard(csi::Keyboard::QueryFlags),
+ // Synchronized output
+ Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code(
+ csi::DecPrivateModeCode::SynchronizedOutput
+ ))),
+ // True color and while we're at it, extended underlines:
+ // <https://github.com/termstandard/colors?tab=readme-ov-file#querying-the-terminal>
+ Csi::Sgr(csi::Sgr::Background(TEST_COLOR.into())),
+ Csi::Sgr(csi::Sgr::UnderlineColor(TEST_COLOR.into())),
+ Dcs::Request(dcs::DcsRequest::GraphicRendition),
+ Csi::Sgr(csi::Sgr::Reset),
+ // Finally request the primary device attributes
+ Csi::Device(csi::Device::RequestPrimaryDeviceAttributes),
+ )?;
+ terminal.flush()?;
+
+ let device_attributes = |event: &Event| {
+ matches!(
+ event,
+ Event::Csi(Csi::Device(csi::Device::DeviceAttributes(_)))
+ )
+ };
+ // TODO: tune this poll constant? Does it need to be longer when on an SSH connection?
+ let poll_duration = Duration::from_millis(100);
+ if terminal.poll(device_attributes, Some(poll_duration))? {
+ while terminal.poll(Event::is_escape, Some(Duration::ZERO))? {
+ match terminal.read(Event::is_escape)? {
+ Event::Csi(Csi::Keyboard(csi::Keyboard::ReportFlags(_))) => {
+ capabilities.kitty_keyboard = KittyKeyboardSupport::Some;
+ }
+ Event::Csi(Csi::Mode(csi::Mode::ReportDecPrivateMode {
+ mode: csi::DecPrivateMode::Code(csi::DecPrivateModeCode::SynchronizedOutput),
+ setting: csi::DecModeSetting::Set | csi::DecModeSetting::Reset,
+ })) => {
+ capabilities.synchronized_output = true;
+ }
+ Event::Dcs(dcs::Dcs::Response {
+ value: dcs::DcsResponse::GraphicRendition(sgrs),
+ ..
+ }) => {
+ capabilities.true_color =
+ sgrs.contains(&csi::Sgr::Background(TEST_COLOR.into()));
+ capabilities.extended_underlines =
+ sgrs.contains(&csi::Sgr::UnderlineColor(TEST_COLOR.into()));
+ }
+ _ => (),
+ }
+ }
+
+ let end = Instant::now();
+ log::debug!(
+ "Detected terminal capabilities in {:?}: {capabilities:?}",
+ end.duration_since(start)
+ );
+ } else {
+ log::debug!("Failed to detect terminal capabilities within {poll_duration:?}. Using default capabilities only");
+ }
+
+ capabilities.extended_underlines |= config.force_enable_extended_underlines;
+
+ let reset_cursor_approach = if let Ok(t) = termini::TermInfo::from_env() {
+ capabilities.extended_underlines |= t.extended_cap("Smulx").is_some()
+ || t.extended_cap("Su").is_some()
+ || vte_version() >= Some(5102)
+ // HACK: once WezTerm can support DECRQSS/DECRPSS for SGR we can remove this line.
+ // <https://github.com/wezterm/wezterm/pull/6856>
+ || matches!(term_program().as_deref(), Some("WezTerm"));
+
+ reset_cursor_approach(t)
+ } else {
+ Csi::Cursor(csi::Cursor::CursorStyle(CursorStyle::Default)).to_string()
+ };
+
+ terminal.enter_cooked_mode()?;
+
+ Ok((capabilities, reset_cursor_approach))
+ }
+
+ fn enable_mouse_capture(&mut self) -> io::Result<()> {
+ if self.config.enable_mouse_capture {
+ write!(
+ self.terminal,
+ "{}{}{}{}{}",
+ decset!(MouseTracking),
+ decset!(ButtonEventMouse),
+ decset!(AnyEventMouse),
+ decset!(RXVTMouse),
+ decset!(SGRMouse),
+ )?;
+ }
+ Ok(())
+ }
+
+ fn disable_mouse_capture(&mut self) -> io::Result<()> {
+ if self.config.enable_mouse_capture {
+ write!(
+ self.terminal,
+ "{}{}{}{}{}",
+ decreset!(MouseTracking),
+ decreset!(ButtonEventMouse),
+ decreset!(AnyEventMouse),
+ decreset!(RXVTMouse),
+ decreset!(SGRMouse),
+ )?;
+ }
+ Ok(())
+ }
+
+ fn enable_extensions(&mut self) -> io::Result<()> {
+ const KEYBOARD_FLAGS: csi::KittyKeyboardFlags =
+ csi::KittyKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES
+ .union(csi::KittyKeyboardFlags::REPORT_ALTERNATE_KEYS);
+
+ match self.capabilities.kitty_keyboard {
+ KittyKeyboardSupport::None | KittyKeyboardSupport::Partial => (),
+ KittyKeyboardSupport::Full => {
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Keyboard(csi::Keyboard::PushFlags(KEYBOARD_FLAGS))
+ )?;
+ }
+ KittyKeyboardSupport::Some => {
+ write!(
+ self.terminal,
+ "{}{}",
+ // Enable the flags we need.
+ Csi::Keyboard(csi::Keyboard::PushFlags(KEYBOARD_FLAGS)),
+ // Then request the current flags. We need to check if the terminal enabled
+ // all of the flags we require.
+ Csi::Keyboard(csi::Keyboard::QueryFlags),
+ )?;
+ self.terminal.flush()?;
+
+ let event = self.terminal.read(|event| {
+ matches!(
+ event,
+ Event::Csi(Csi::Keyboard(csi::Keyboard::ReportFlags(_)))
+ )
+ })?;
+ let Event::Csi(Csi::Keyboard(csi::Keyboard::ReportFlags(flags))) = event else {
+ unreachable!();
+ };
+ if flags != KEYBOARD_FLAGS {
+ log::info!("Turning off enhanced keyboard support because the terminal enabled different flags. Requested {KEYBOARD_FLAGS:?} but got {flags:?}");
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Keyboard(csi::Keyboard::PopFlags(1))
+ )?;
+ self.terminal.flush()?;
+ self.capabilities.kitty_keyboard = KittyKeyboardSupport::Partial;
+ } else {
+ log::debug!(
+ "The terminal fully supports the requested keyboard enhancement flags"
+ );
+ self.capabilities.kitty_keyboard = KittyKeyboardSupport::Full;
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ fn disable_extensions(&mut self) -> io::Result<()> {
+ if self.capabilities.kitty_keyboard == KittyKeyboardSupport::Full {
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Keyboard(csi::Keyboard::PopFlags(1))
+ )?;
+ }
+
+ Ok(())
+ }
+
+ // See <https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036>.
+ // Synchronized output sequences tell the terminal when we are "starting to render" and
+ // stopping, enabling to make better choices about when it draws a frame. This avoids all
+ // kinds of ugly visual artifacts like tearing and flashing (i.e. the background color
+ // after clearing the terminal).
+
+ fn start_synchronized_render(&mut self) -> io::Result<()> {
+ if self.capabilities.synchronized_output && !self.is_synchronized_output_set {
+ write!(self.terminal, "{}", decset!(SynchronizedOutput))?;
+ self.is_synchronized_output_set = true;
+ }
+ Ok(())
+ }
+
+ fn end_sychronized_render(&mut self) -> io::Result<()> {
+ if self.is_synchronized_output_set {
+ write!(self.terminal, "{}", decreset!(SynchronizedOutput))?;
+ self.is_synchronized_output_set = false;
+ }
+ Ok(())
+ }
+}
+
+impl Backend for TerminaBackend {
+ fn claim(&mut self) -> io::Result<()> {
+ self.terminal.enter_raw_mode()?;
+
+ write!(
+ self.terminal,
+ "{}{}{}{}",
+ // Enter an alternate screen.
+ decset!(ClearAndEnableAlternateScreen),
+ decset!(BracketedPaste),
+ decset!(FocusTracking),
+ // Clear the buffer. `ClearAndEnableAlternateScreen` **should** do this but some
+ // things like mosh are buggy. See <https://github.com/helix-editor/helix/pull/1944>.
+ Csi::Edit(csi::Edit::EraseInDisplay(csi::EraseInDisplay::EraseDisplay)),
+ )?;
+ self.enable_mouse_capture()?;
+ self.enable_extensions()?;
+
+ Ok(())
+ }
+
+ fn reconfigure(&mut self, mut config: Config) -> io::Result<()> {
+ std::mem::swap(&mut self.config, &mut config);
+ if self.config.enable_mouse_capture != config.enable_mouse_capture {
+ if self.config.enable_mouse_capture {
+ self.enable_mouse_capture()?;
+ } else {
+ self.disable_mouse_capture()?;
+ }
+ }
+ self.capabilities.extended_underlines |= self.config.force_enable_extended_underlines;
+ Ok(())
+ }
+
+ fn restore(&mut self) -> io::Result<()> {
+ self.disable_extensions()?;
+ self.disable_mouse_capture()?;
+ write!(
+ self.terminal,
+ "{}{}{}{}",
+ &self.reset_cursor_command,
+ decreset!(BracketedPaste),
+ decreset!(FocusTracking),
+ decreset!(ClearAndEnableAlternateScreen),
+ )?;
+ self.terminal.flush()?;
+ self.terminal.enter_cooked_mode()?;
+ Ok(())
+ }
+
+ fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
+ where
+ I: Iterator<Item = (u16, u16, &'a Cell)>,
+ {
+ self.start_synchronized_render()?;
+
+ let mut fg = Color::Reset;
+ let mut bg = Color::Reset;
+ let mut underline_color = Color::Reset;
+ let mut underline_style = UnderlineStyle::Reset;
+ let mut modifier = Modifier::empty();
+ let mut last_pos: Option<(u16, u16)> = None;
+ for (x, y, cell) in content {
+ // Move the cursor if the previous location was not (x - 1, y)
+ if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Cursor(csi::Cursor::Position {
+ col: OneBased::from_zero_based(x),
+ line: OneBased::from_zero_based(y),
+ })
+ )?;
+ }
+ last_pos = Some((x, y));
+
+ let mut attributes = SgrAttributes::default();
+ if cell.fg != fg {
+ attributes.foreground = Some(cell.fg.into());
+ fg = cell.fg;
+ }
+ if cell.bg != bg {
+ attributes.background = Some(cell.bg.into());
+ bg = cell.bg;
+ }
+ if cell.modifier != modifier {
+ attributes.modifiers = diff_modifiers(modifier, cell.modifier);
+ modifier = cell.modifier;
+ }
+
+ // Set underline style and color separately from SgrAttributes. Some terminals seem
+ // to not like underline colors and styles being intermixed with other SGRs.
+ let mut new_underline_style = cell.underline_style;
+ if self.capabilities.extended_underlines {
+ if cell.underline_color != underline_color {
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Sgr(csi::Sgr::UnderlineColor(cell.underline_color.into()))
+ )?;
+ underline_color = cell.underline_color;
+ }
+ } else {
+ match new_underline_style {
+ UnderlineStyle::Reset | UnderlineStyle::Line => (),
+ _ => new_underline_style = UnderlineStyle::Line,
+ }
+ }
+ if new_underline_style != underline_style {
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Sgr(csi::Sgr::Underline(new_underline_style.into()))
+ )?;
+ underline_style = new_underline_style;
+ }
+
+ // `attributes` will be empty if nothing changed between two cells. Empty
+ // `SgrAttributes` behave the same as a `Sgr::Reset` rather than a 'no-op' though so
+ // we should avoid writing them if they're empty.
+ if !attributes.is_empty() {
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Sgr(csi::Sgr::Attributes(attributes))
+ )?;
+ }
+
+ write!(self.terminal, "{}", &cell.symbol)?;
+ }
+
+ write!(self.terminal, "{}", Csi::Sgr(csi::Sgr::Reset))?;
+
+ self.end_sychronized_render()?;
+
+ Ok(())
+ }
+
+ fn hide_cursor(&mut self) -> io::Result<()> {
+ write!(self.terminal, "{}", decreset!(ShowCursor))?;
+ self.flush()
+ }
+
+ fn show_cursor(&mut self, kind: CursorKind) -> io::Result<()> {
+ let style = match kind {
+ CursorKind::Block => CursorStyle::SteadyBlock,
+ CursorKind::Bar => CursorStyle::SteadyBar,
+ CursorKind::Underline => CursorStyle::SteadyUnderline,
+ CursorKind::Hidden => unreachable!(),
+ };
+ write!(
+ self.terminal,
+ "{}{}",
+ decset!(ShowCursor),
+ Csi::Cursor(csi::Cursor::CursorStyle(style)),
+ )?;
+ self.flush()
+ }
+
+ fn get_cursor(&mut self) -> Result<(u16, u16), io::Error> {
+ write!(
+ self.terminal,
+ "{}",
+ csi::Csi::Cursor(csi::Cursor::RequestActivePositionReport),
+ )?;
+ self.terminal.flush()?;
+ let event = self.terminal.read(|event| {
+ matches!(
+ event,
+ Event::Csi(Csi::Cursor(csi::Cursor::ActivePositionReport { .. }))
+ )
+ })?;
+ let Event::Csi(Csi::Cursor(csi::Cursor::ActivePositionReport { line, col })) = event else {
+ unreachable!();
+ };
+ Ok((line.get_zero_based(), col.get_zero_based()))
+ }
+
+ fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
+ let col = OneBased::from_zero_based(x);
+ let line = OneBased::from_zero_based(y);
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Cursor(csi::Cursor::Position { line, col })
+ )?;
+ self.flush()
+ }
+
+ fn clear(&mut self) -> io::Result<()> {
+ self.start_synchronized_render()?;
+ write!(
+ self.terminal,
+ "{}",
+ Csi::Edit(csi::Edit::EraseInDisplay(csi::EraseInDisplay::EraseDisplay))
+ )?;
+ self.flush()
+ }
+
+ fn size(&self) -> io::Result<Rect> {
+ let WindowSize { rows, cols, .. } = self.terminal.get_dimensions()?;
+ Ok(Rect::new(0, 0, cols, rows))
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ self.terminal.flush()
+ }
+
+ fn supports_true_color(&self) -> bool {
+ self.capabilities.true_color
+ }
+}
+
+impl Drop for TerminaBackend {
+ fn drop(&mut self) {
+ // Avoid resetting the terminal while panicking because we set a panic hook above in
+ // `Self::new`.
+ if !std::thread::panicking() {
+ let _ = self.disable_extensions();
+ let _ = self.disable_mouse_capture();
+ let _ = write!(
+ self.terminal,
+ "{}{}{}{}",
+ &self.reset_cursor_command,
+ decreset!(BracketedPaste),
+ decreset!(FocusTracking),
+ decreset!(ClearAndEnableAlternateScreen),
+ );
+ // NOTE: Drop for Platform terminal resets the mode and flushes the buffer when not
+ // panicking.
+ }
+ }
+}
+
+fn diff_modifiers(from: Modifier, to: Modifier) -> SgrModifiers {
+ let mut modifiers = SgrModifiers::default();
+
+ let removed = from - to;
+ if removed.contains(Modifier::REVERSED) {
+ modifiers |= SgrModifiers::NO_REVERSE;
+ }
+ if removed.contains(Modifier::BOLD) && !to.contains(Modifier::DIM) {
+ modifiers |= SgrModifiers::INTENSITY_NORMAL;
+ }
+ if removed.contains(Modifier::DIM) {
+ modifiers |= SgrModifiers::INTENSITY_NORMAL;
+ }
+ if removed.contains(Modifier::ITALIC) {
+ modifiers |= SgrModifiers::NO_ITALIC;
+ }
+ if removed.contains(Modifier::CROSSED_OUT) {
+ modifiers |= SgrModifiers::NO_STRIKE_THROUGH;
+ }
+ if removed.contains(Modifier::HIDDEN) {
+ modifiers |= SgrModifiers::NO_INVISIBLE;
+ }
+ if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
+ modifiers |= SgrModifiers::BLINK_NONE;
+ }
+
+ let added = to - from;
+ if added.contains(Modifier::REVERSED) {
+ modifiers |= SgrModifiers::REVERSE;
+ }
+ if added.contains(Modifier::BOLD) {
+ modifiers |= SgrModifiers::INTENSITY_BOLD;
+ }
+ if added.contains(Modifier::DIM) {
+ modifiers |= SgrModifiers::INTENSITY_DIM;
+ }
+ if added.contains(Modifier::ITALIC) {
+ modifiers |= SgrModifiers::ITALIC;
+ }
+ if added.contains(Modifier::CROSSED_OUT) {
+ modifiers |= SgrModifiers::STRIKE_THROUGH;
+ }
+ if added.contains(Modifier::HIDDEN) {
+ modifiers |= SgrModifiers::INVISIBLE;
+ }
+ if added.contains(Modifier::SLOW_BLINK) {
+ modifiers |= SgrModifiers::BLINK_SLOW;
+ }
+ if added.contains(Modifier::RAPID_BLINK) {
+ modifiers |= SgrModifiers::BLINK_RAPID;
+ }
+
+ modifiers
+}