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.rs | 645 |
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 +} |