use std::io::{self, Write as _}; use helix_view::{ editor::KittyKeyboardProtocolConfig, graphics::{CursorKind, Rect, UnderlineStyle}, theme::{self, Color, Modifier}, }; use termina::{ escape::{ csi::{self, Csi, SgrAttributes, SgrModifiers}, dcs::{self, Dcs}, }, style::{CursorStyle, RgbColor}, Event, OneBased, PlatformTerminal, Terminal as _, WindowSize, }; 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 { // 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 { std::env::var("VTE_VERSION").ok()?.parse().ok() } #[derive(Debug, Default, Clone, Copy)] struct Capabilities { kitty_keyboard: KittyKeyboardSupport, synchronized_output: bool, true_color: bool, extended_underlines: bool, theme_mode: Option, } #[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 { 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(); capabilities.kitty_keyboard = match config.kitty_keyboard_protocol { KittyKeyboardProtocolConfig::Disabled => KittyKeyboardSupport::None, KittyKeyboardProtocolConfig::Enabled => KittyKeyboardSupport::Full, KittyKeyboardProtocolConfig::Auto => { write!(terminal, "{}", Csi::Keyboard(csi::Keyboard::QueryFlags))?; KittyKeyboardSupport::None } }; // 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, "{}{}{}{}{}{}{}", // Synchronized output Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code( csi::DecPrivateModeCode::SynchronizedOutput ))), // Mode 2031 theme updates. Query the current theme. Csi::Mode(csi::Mode::QueryTheme), // True color and while we're at it, extended underlines: // 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::Csi(Csi::Mode(csi::Mode::ReportTheme(mode))) => { capabilities.theme_mode = Some(mode.into()); } 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 mut reset_cursor_command = Csi::Cursor(csi::Cursor::CursorStyle(CursorStyle::Default)).to_string(); 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. // || matches!(term_program().as_deref(), Some("WezTerm")); if let Some(termini::Value::Utf8String(se_str)) = t.extended_cap("Se") { reset_cursor_command.push_str(se_str); }; reset_cursor_command.push_str( t.utf8_string_cap(termini::StringCapability::CursorNormal) .unwrap_or(""), ); log::debug!( "Cursor reset escape sequence detected from terminfo: {reset_cursor_command:?}" ); } else { log::debug!("terminfo could not be read, using default cursor reset escape sequence: {reset_cursor_command:?}"); } terminal.enter_cooked_mode()?; Ok((capabilities, reset_cursor_command)) } 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; } } } if self.capabilities.theme_mode.is_some() { // Enable mode 2031 theme mode notifications: write!(self.terminal, "{}", decset!(Theme))?; } Ok(()) } fn disable_extensions(&mut self) -> io::Result<()> { if self.capabilities.kitty_keyboard == KittyKeyboardSupport::Full { write!( self.terminal, "{}", Csi::Keyboard(csi::Keyboard::PopFlags(1)) )?; } if self.capabilities.theme_mode.is_some() { // Mode 2031 theme notifications. write!(self.terminal, "{}", decreset!(Theme))?; } Ok(()) } // See . // 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 . 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, { 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 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 { 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 } fn get_theme_mode(&self) -> Option { self.capabilities.theme_mode } } 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 }