From 4873e6e964e19139da5260e1451dc9c23dafeb12 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Wed, 14 Oct 2026 18:26:30 +0200 Subject: [PATCH] feat: cursor blink, dead keys, hyperlinks, rich underlines, mouse tweaks, live config reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cursor: - add blink support (blink, blink_interval config options; defaults: enabled, 500ms) - reset blink phase on keypress so the cursor is always visible right after typing - integrate blink deadline into the poll timeout alongside repeat and sync keyboard: - add XKB compose (dead key) support using the system locale - wire up zwp_text_input_v3 for IME commit-string events mouse: - add copy_on_select option (default: true) — auto-copies selection to clipboard - add right_click_paste option (default: true) — paste on right click - Ctrl+click now also opens OSC 8 hyperlinks in addition to URL-regex matches rendering: - implement distinct underline styles: single (1), double (2), curly (3), dotted (4), dashed (5) - support underline color (SGR 58/59, colon sub-parameter on SGR 4) - auto-underline cells with an active hyperlink when no explicit underline is set term: - OSC 8 hyperlink parsing and per-cell link index tracking - desktop notifications via OSC 9 and OSC 777 (notify-send) - track SGR parameter separators (;/:) to correctly handle colon sub-parameters config: - derive Clone on all config structs - wrap global CONFIG in Mutex to allow live reload - expose config::path() publicly - live config reload on mtime change (cursor blink settings, font size) --- src/config.rs | 47 ++++++++---- src/main.rs | 206 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/term.rs | 128 ++++++++++++++++++++++++++++--- 3 files changed, 351 insertions(+), 30 deletions(-) diff --git a/src/config.rs b/src/config.rs index 81b4b2d..dba80ff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,7 +38,7 @@ pub enum Action { NewWindow, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(default, deny_unknown_fields)] pub struct Config { pub term: String, @@ -51,7 +51,7 @@ pub struct Config { pub shortcuts: Vec, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(default, deny_unknown_fields)] pub struct Window { pub title: String, @@ -62,7 +62,7 @@ pub struct Window { pub alpha: f32, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(default, deny_unknown_fields)] pub struct Font { pub family: String, @@ -70,22 +70,24 @@ pub struct Font { pub zoom_step: f32, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(default, deny_unknown_fields)] pub struct Mouse { pub word_delims: String, pub multi_click_ms: u64, pub scroll_lines: usize, pub url_launcher: String, + pub copy_on_select: bool, + pub right_click_paste: bool, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(default, deny_unknown_fields)] pub struct Scrollback { pub lines: usize, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(default, deny_unknown_fields)] pub struct Colors { #[serde(deserialize_with = "color")] @@ -96,7 +98,7 @@ pub struct Colors { pub palette: [u32; 16], } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(default, deny_unknown_fields)] pub struct Cursor { pub style: CursorStyle, @@ -105,9 +107,11 @@ pub struct Cursor { #[serde(deserialize_with = "color")] pub text: u32, pub thickness: usize, + pub blink: bool, + pub blink_interval: u64, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(try_from = "RawShortcut")] pub struct Shortcut { pub mods: u8, @@ -115,7 +119,7 @@ pub struct Shortcut { pub action: Action, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(deny_unknown_fields)] struct RawShortcut { #[serde(default)] @@ -220,7 +224,7 @@ impl Default for Font { impl Default for Mouse { fn default() -> Mouse { - Mouse { word_delims: " `'\"()[]{}<>|&;,".into(), multi_click_ms: 300, scroll_lines: 3, url_launcher: "xdg-open".into() } + Mouse { word_delims: " `'\"()[]{}<>|&;,".into(), multi_click_ms: 300, scroll_lines: 3, url_launcher: "xdg-open".into(), copy_on_select: true, right_click_paste: true } } } @@ -245,7 +249,7 @@ impl Default for Colors { impl Default for Cursor { fn default() -> Cursor { - Cursor { style: CursorStyle::Block, color: 0x7aa2f7, text: 0x1a1b26, thickness: 2 } + Cursor { style: CursorStyle::Block, color: 0x7aa2f7, text: 0x1a1b26, thickness: 2, blink: true, blink_interval: 500 } } } @@ -292,6 +296,8 @@ word_delims = " `'\"()[]{}<>|&;," # characters that end a word on double-click multi_click_ms = 300 # max gap between clicks for double/triple click scroll_lines = 3 # lines per wheel notch (history, or arrow keys on the alternate screen) url_launcher = "xdg-open" +copy_on_select = true # copy the selection to the clipboard automatically +right_click_paste = true # paste on right mouse button [scrollback] lines = 10000 # history kept per terminal, 0 turns scrollback off @@ -310,6 +316,8 @@ style = "block" # "block", "beam" or "underline" color = "#7aa2f7" text = "#1a1b26" # glyph color under a block cursor thickness = 2 # width of beam / height of underline, in pixels +blink = true # blink the cursor while focused +blink_interval = 500 # cursor blink interval in milliseconds # Shortcuts. Setting any [[shortcuts]] replaces the whole default list. # mods: "shift", "alt", "ctrl". key: an xkb keysym name (C, Insert, Page_Up, F5, ...). @@ -392,15 +400,15 @@ key = "N" action = "new_window" "##; -static CONFIG: OnceLock = OnceLock::new(); +static CONFIG: OnceLock> = OnceLock::new(); /// The active configuration. Falls back to the defaults if `load` has not run. -pub fn get() -> &'static Config { - CONFIG.get_or_init(Config::default) +pub fn get() -> Config { + CONFIG.get_or_init(|| std::sync::Mutex::new(Config::default())).lock().unwrap().clone() } /// `$XDG_CONFIG_HOME/softty/config.toml`, which is `~/.config/softty/config.toml` by default. -fn path() -> Option { +pub fn path() -> Option { let base = match std::env::var_os("XDG_CONFIG_HOME") { Some(dir) if !dir.is_empty() => PathBuf::from(dir), _ => PathBuf::from(std::env::var_os("HOME")?).join(".config"), @@ -417,7 +425,14 @@ pub fn load() -> Option { Some(Ok(config)) => (config, None), None => (Config::default(), None), }; - let _ = CONFIG.set(config); + match CONFIG.get() { + Some(mutex) => { + *mutex.lock().unwrap() = config.clone(); + } + None => { + let _ = CONFIG.set(std::sync::Mutex::new(config)); + } + } error } diff --git a/src/main.rs b/src/main.rs index f68e553..fad8c85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,10 @@ use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHand use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{Shape, WpCursorShapeDeviceV1}; use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1; +use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3; +use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_v3::ZwpTextInputV3; +use wayland_protocols::wp::text_input::zv3::client as zwp_text_input_v3_mod; +use zwp_text_input_v3_mod::zwp_text_input_v3 as zwp_text_input_v3; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_device_v1::{self, ZwpPrimarySelectionDeviceV1}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{self, ZwpPrimarySelectionOfferV1}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_source_v1::{self, ZwpPrimarySelectionSourceV1}; @@ -90,12 +94,14 @@ struct App { cursor_shape: Option, primary_manager: Option, primary_device: Option, + text_input: Option, keyboard: Option, pointer: Option, xkb: xkb::Context, keymap: Option, xkb_state: Option, + compose: Option, repeat: Option, repeat_rate: u64, repeat_delay: u64, @@ -122,6 +128,10 @@ struct App { held: Option, last_cell: Pos, wheel: f64, + cursor_blink: bool, + cursor_blink_on: bool, + next_blink: Option, + cursor_blink_interval: Duration, clip_text: String, clip_source: Option, @@ -136,6 +146,7 @@ struct App { search: Option, sync_deadline: Option, + config_mtime: Option, exit: bool, } @@ -277,7 +288,8 @@ impl App { let (selection, focused) = (self.selection, self.focused); let search = self.search.as_ref().map(|s| (s.query.clone(), s.found)); let (cx, cy) = (term.cur.x, term.cur.y); - let cursor_visible = term.cursor_visible && term.view == 0; + let blink_ok = !self.cursor_blink || !self.focused || self.cursor_blink_on; + let cursor_visible = term.cursor_visible && term.view == 0 && blink_ok; let style = term.cursor_style.unwrap_or(config::get().cursor.style); let block = matches!(style, CursorStyle::Block); let colors = |x: usize, y: usize| { @@ -319,7 +331,46 @@ impl App { fill(px, w, ox, oy + ch / 2, cw, 1, 0xff000000 | fg); } if cell.flags & UNDERLINE != 0 { - fill(px, w, ox, (oy + ascent + 1).min(oy + ch - 1), cw, 1, 0xff000000 | fg); + let ul_color = if cell.ul_color != u32::MAX { cell.ul_color } else { fg }; + let color = 0xff000000 | ul_color; + let base_y = (oy + ascent + 1).min(oy + ch - 1); + match cell.underline { + 2 => { + fill(px, w, ox, base_y, cw, 1, color); + fill(px, w, ox, (base_y + 2).min(oy + ch - 1), cw, 1, color); + } + 3 => { + for xx in 0..cw { + let yy = base_y + if xx % 3 == 1 { 0usize } else { 1usize }; + if oy + yy < oy + ch { + let idx = (oy + yy) * w + (ox + xx); + if idx < px.len() { + px[idx] = color; + } + } + } + } + 4 => { + let mut xx = 0; + while xx < cw { + fill(px, w, ox + xx, base_y, 1, 1, color); + xx += 2; + } + } + 5 => { + let mut xx = 0; + while xx < cw { + let seg = 3.min(cw - xx); + fill(px, w, ox + xx, base_y, seg, 1, color); + xx += 5; + } + } + _ => fill(px, w, ox, base_y, cw, 1, color), + } + } + if cell.link != 0 && cell.flags & UNDERLINE == 0 { + let base_y = (oy + ascent + 1).min(oy + ch - 1); + fill(px, w, ox, base_y, cw, 1, 0xff000000 | fg); } } } @@ -388,6 +439,11 @@ impl App { if let Some(text) = self.term.clipboard.take() { self.set_clipboard(text, qh); } + if let Some((title, body)) = self.term.notification.take() { + let mut cmd = Command::new("notify-send"); + cmd.arg(title).arg(body); + launch(&mut cmd); + } if !self.term.reply.is_empty() { let reply = std::mem::take(&mut self.term.reply); self.pty.send(&reply); @@ -640,6 +696,51 @@ impl App { self.search_key(sym, &text, mods, qh); return; } + + // compose (dead keys) + let compose_result: Option> = if let Some(compose) = &mut self.compose { + let code = xkb::Keycode::new(key + 8); + let Some(state) = &self.xkb_state else { return; }; + let keysym = state.key_get_one_sym(code); + if compose.feed(keysym) == xkb::compose::FeedResult::Accepted { + match compose.status() { + xkb::compose::Status::Composed => { + let out = compose.utf8(); + compose.reset(); + Some(Some(out.unwrap_or_default())) + } + xkb::compose::Status::Composing => { + Some(None) + } + _ => { + compose.reset(); + None + } + } + } else { + None + } + } else { + None + }; + match compose_result { + Some(Some(text)) => { + if !text.is_empty() { + self.snap_to_bottom(); + self.pty.send(text.as_bytes()); + } + self.cursor_blink_on = true; + if self.cursor_blink && self.focused { + self.next_blink = Some(Instant::now() + self.cursor_blink_interval); + } + return; + } + Some(None) => { + return; + } + None => {} + } + if let Some(shortcut) = config::get().shortcuts.iter().find(|s| s.mods == mods && s.sym == sym) { let page = self.term.rows.saturating_sub(1).max(1) as isize; let consumed = match shortcut.action { @@ -685,6 +786,10 @@ impl App { if !bytes.is_empty() { self.snap_to_bottom(); self.pty.send(&bytes); + self.cursor_blink_on = true; + if self.cursor_blink && self.focused { + self.next_blink = Some(Instant::now() + self.cursor_blink_interval); + } } } @@ -891,6 +996,10 @@ impl Dispatch for App { app.serial = serial; app.focused = true; app.dirty = true; + if let Some(ti) = &app.text_input { + ti.enable(); + ti.commit(); + } if app.term.focus_events { app.pty.send(b"\x1b[I"); } @@ -899,6 +1008,10 @@ impl Dispatch for App { app.focused = false; app.repeat = None; app.dirty = true; + if let Some(ti) = &app.text_input { + ti.disable(); + ti.commit(); + } if app.term.focus_events { app.pty.send(b"\x1b[O"); } @@ -971,7 +1084,12 @@ impl Dispatch for App { match (button, state) { (BTN_LEFT, wl_pointer::ButtonState::Pressed) => { let (x, y) = app.cell_at(app.mouse.0, app.mouse.1); - let url = (app.mods() & config::CTRL != 0).then(|| app.term.url_at(x, y)).flatten(); + let ctrl = app.mods() & config::CTRL != 0; + let url = if ctrl { + app.term.link_at(x, y).map(str::to_string).or_else(|| app.term.url_at(x, y)) + } else { + None + }; match url { Some(url) => app.open(&url), None => app.click(), @@ -980,10 +1098,14 @@ impl Dispatch for App { (BTN_LEFT, wl_pointer::ButtonState::Released) => { app.anchor = None; if app.selection.is_some() { + if config::get().mouse.copy_on_select { + app.copy(qh); + } app.publish_primary(qh); } } (BTN_MIDDLE, wl_pointer::ButtonState::Pressed) => app.paste(true), + (BTN_RIGHT, wl_pointer::ButtonState::Pressed) if config::get().mouse.right_click_paste => app.paste(false), _ => {} } } @@ -1080,11 +1202,26 @@ impl Dispatch for App { } } + +fn compose_state(ctx: &xkb::Context) -> Option { + let locale = std::env::var("LC_ALL") + .or_else(|_| std::env::var("LC_CTYPE")) + .or_else(|_| std::env::var("LANG")) + .unwrap_or_else(|_| "C.UTF-8".into()); + if locale.is_empty() { + return None; + } + let table = xkb::compose::Table::new_from_locale(ctx, std::ffi::OsStr::new(&locale), xkb::compose::COMPILE_NO_FLAGS).ok()?; + Some(xkb::compose::State::new(&table, xkb::compose::STATE_NO_FLAGS)) +} + fn main() -> Result<(), Box> { let config_error = config::load(); let args: Vec = std::env::args().skip(1).collect(); let command = args.iter().position(|a| a == "-e").map_or(Vec::new(), |i| args[i + 1..].to_vec()); + let xkb = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); + let compose = compose_state(&xkb); let conn = Connection::connect_to_env()?; let (globals, mut queue) = registry_queue_init::(&conn)?; let qh = queue.handle(); @@ -1098,6 +1235,8 @@ fn main() -> Result<(), Box> { let data_device = data_manager.get_data_device(&seat, &qh, ()); let primary_manager = globals.bind::(&qh, 1..=1, ()).ok(); let primary_device = primary_manager.as_ref().map(|m| m.get_device(&seat, &qh, ())); + let text_input: Option = globals.bind::(&qh, 1..=1, ()).ok() + .as_ref().map(|m| m.get_text_input(&seat, &qh, ())); let surface = compositor.create_surface(&qh, ()); let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); @@ -1125,9 +1264,11 @@ fn main() -> Result<(), Box> { cursor_shape: None, primary_manager, primary_device, + text_input, keyboard: None, pointer: None, - xkb: xkb::Context::new(xkb::CONTEXT_NO_FLAGS), + xkb, + compose, keymap: None, xkb_state: None, repeat: None, @@ -1153,6 +1294,10 @@ fn main() -> Result<(), Box> { held: None, last_cell: (0, 0), wheel: 0.0, + cursor_blink: config::get().cursor.blink, + cursor_blink_on: true, + next_blink: None, + cursor_blink_interval: Duration::from_millis(config::get().cursor.blink_interval.max(1)), clip_text: String::new(), clip_source: None, offer: None, @@ -1165,10 +1310,32 @@ fn main() -> Result<(), Box> { primary_mimes: Vec::new(), search: None, sync_deadline: None, + config_mtime: None, exit: false, }; loop { + // live config reload + let current_mtime = config::path().and_then(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok()); + if app.config_mtime.is_some() && app.config_mtime != current_mtime { + let _ = config::load(); + app.cursor_blink = config::get().cursor.blink; + app.cursor_blink_interval = Duration::from_millis(config::get().cursor.blink_interval.max(1)); + app.fonts.set_size(config::get().font.size); + app.dirty = true; + } + app.config_mtime = current_mtime; + + // blink timer scheduling + if app.focused && app.cursor_blink { + if app.next_blink.is_none() { + app.next_blink = Some(Instant::now() + app.cursor_blink_interval); + } + } else { + app.cursor_blink_on = true; + app.next_blink = None; + } + queue.dispatch_pending(&mut app)?; app.draw(&qh); if app.exit { @@ -1189,7 +1356,7 @@ fn main() -> Result<(), Box> { fds.push(libc::pollfd { fd: file.as_raw_fd(), events: libc::POLLIN, revents: 0 }); } let now = Instant::now(); - let timeout = [app.repeat.map(|r| r.next), app.sync_deadline] + let timeout = [app.repeat.map(|r| r.next), app.sync_deadline, app.next_blink] .into_iter() .flatten() .map(|deadline| deadline.saturating_duration_since(now).as_millis() as i32 + 1) @@ -1226,5 +1393,34 @@ fn main() -> Result<(), Box> { app.on_key(repeat.key, &qh); } } + if app.focused && app.cursor_blink { + if let Some(deadline) = app.next_blink { + if Instant::now() >= deadline { + app.cursor_blink_on = !app.cursor_blink_on; + app.next_blink = Some(Instant::now() + app.cursor_blink_interval); + app.dirty = true; + } + } + } + } +} + +impl Dispatch for App { + fn event(_: &mut Self, _: &ZwpTextInputManagerV3, _: ::Event, _: &(), _: &Connection, _: &QueueHandle) {} +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &ZwpTextInputV3, event: ::Event, _: &(), _: &Connection, _: &QueueHandle) { + match event { + zwp_text_input_v3::Event::CommitString { text, .. } => { + if let Some(t) = text { + app.pty.send(t.as_bytes()); + app.dirty = true; + } + } + zwp_text_input_v3::Event::Enter { surface } => { let _ = surface; } + zwp_text_input_v3::Event::Leave { surface } => { let _ = surface; } + _ => {} + } } } diff --git a/src/term.rs b/src/term.rs index faaf298..0c20508 100644 --- a/src/term.rs +++ b/src/term.rs @@ -30,11 +30,14 @@ pub struct Cell { pub fg: u32, pub bg: u32, pub flags: u8, + pub underline: u8, + pub ul_color: u32, + pub link: u32, } impl Cell { fn blank(bg: u32) -> Cell { - Cell { ch: ' ', fg: default_fg(), bg, flags: 0 } + Cell { ch: ' ', fg: default_fg(), bg, flags: 0, underline: 0, ul_color: u32::MAX, link: 0 } } } @@ -45,6 +48,8 @@ pub struct Cursor { fg: u32, bg: u32, flags: u8, + underline: u8, + underline_color: u32, wrap_next: bool, gfx: bool, origin: bool, @@ -52,7 +57,7 @@ pub struct Cursor { impl Cursor { fn new() -> Cursor { - Cursor { x: 0, y: 0, fg: default_fg(), bg: default_bg(), flags: 0, wrap_next: false, gfx: false, origin: false } + Cursor { x: 0, y: 0, fg: default_fg(), bg: default_bg(), flags: 0, underline: 0, underline_color: u32::MAX, wrap_next: false, gfx: false, origin: false } } } @@ -80,6 +85,9 @@ pub struct Term { pub title: Option, pub reply: Vec, pub sync: bool, + pub hyperlinks: Vec>, + pub current_link: u32, + pub notification: Option<(String, String)>, pub focus_events: bool, pub cwd: Option, pub clipboard: Option, @@ -98,6 +106,7 @@ pub struct Term { last: char, state: State, params: Vec, + param_seps: Vec, private: u8, inter: u8, osc: Vec, @@ -199,6 +208,9 @@ impl Term { title: None, reply: Vec::new(), sync: false, + hyperlinks: vec![None], + current_link: 0, + notification: None, focus_events: false, cwd: None, clipboard: None, @@ -217,6 +229,7 @@ impl Term { last: ' ', state: State::Ground, params: vec![0], + param_seps: vec![b';'], private: 0, inter: 0, osc: Vec::new(), @@ -368,6 +381,18 @@ impl Term { self.history.len() + self.rows } + pub fn link_at(&self, x: usize, y: usize) -> Option<&str> { + if x >= self.cols || y >= self.rows { + return None; + } + let cell = self.cell(x, y); + if cell.link == 0 { + None + } else { + self.hyperlinks.get(cell.link as usize).and_then(|u| u.as_deref()) + } + } + fn line_cells(&self, line: usize) -> &[Cell] { let past = self.history.len(); if line < past { @@ -595,6 +620,35 @@ impl Term { match cmd { "0" | "2" => self.title = Some(rest.to_string()), "7" => self.cwd = parse_cwd(rest), + "8" => { + let (params, uri) = rest.split_once(';').unwrap_or(("", rest)); + let _ = params; + if uri.is_empty() { + self.current_link = 0; + } else { + let idx = match self.hyperlinks.iter().position(|u| u.as_deref() == Some(uri)) { + Some(i) => i, + None => { + self.hyperlinks.push(Some(uri.to_string())); + self.hyperlinks.len() - 1 + } + }; + self.current_link = idx as u32; + } + } + "9" => { + if !rest.starts_with('4') || !rest.contains(';') { + self.notification = Some(("softty".to_string(), rest.to_string())); + } + } + "777" => { + let mut it = rest.splitn(3, ';'); + if it.next() == Some("notify") { + let title = it.next().unwrap_or("softty").to_string(); + let body = it.next().unwrap_or("").to_string(); + self.notification = Some((title, body)); + } + } "52" => { let data = rest.split_once(';').map_or("", |(_, data)| data); if let Some(text) = decode_base64(data).and_then(|bytes| String::from_utf8(bytes).ok()) { @@ -618,6 +672,8 @@ impl Term { b'[' => { self.params.clear(); self.params.push(0); + self.param_seps.clear(); + self.param_seps.push(b';'); self.private = 0; self.inter = 0; self.state = State::Csi; @@ -665,6 +721,7 @@ impl Term { b';' | b':' => { if self.params.len() < 32 { self.params.push(0); + self.param_seps.push(b); } } b'<'..=b'?' => self.private = b, @@ -852,6 +909,7 @@ impl Term { fn sgr(&mut self) { let p = std::mem::take(&mut self.params); + let ps = std::mem::take(&mut self.param_seps); let mut i = 0; while i < p.len() { match p[i] { @@ -859,22 +917,74 @@ impl Term { self.cur.fg = default_fg(); self.cur.bg = default_bg(); self.cur.flags = 0; + self.cur.underline = 0; + self.cur.underline_color = u32::MAX; } 1 => self.cur.flags |= BOLD, 2 => self.cur.flags |= DIM, - 9 => self.cur.flags |= STRIKE, - 4 => self.cur.flags |= UNDERLINE, + 3 => self.cur.flags |= 0, + 4 => { + // colon sub-parameter selects underline style + if i + 1 < p.len() && ps.get(i + 1) == Some(&b':') { + let style = p[i + 1].min(5) as u8; + if style == 0 { + self.cur.flags &= !UNDERLINE; + self.cur.underline = 0; + } else { + self.cur.flags |= UNDERLINE; + self.cur.underline = style.max(1); + } + i += 1; + } else { + self.cur.flags |= UNDERLINE; + if self.cur.underline == 0 { + self.cur.underline = 1; + } + } + } 7 => self.cur.flags |= REVERSE, + 9 => self.cur.flags |= STRIKE, + 21 => { + self.cur.flags |= UNDERLINE; + self.cur.underline = 2; + } 22 => self.cur.flags &= !(BOLD | DIM), - 29 => self.cur.flags &= !STRIKE, - 24 => self.cur.flags &= !UNDERLINE, + 24 => { + self.cur.flags &= !UNDERLINE; + self.cur.underline = 0; + } 27 => self.cur.flags &= !REVERSE, + 29 => self.cur.flags &= !STRIKE, 30..=37 => self.cur.fg = palette(p[i] - 30), 40..=47 => self.cur.bg = palette(p[i] - 40), 90..=97 => self.cur.fg = palette(p[i] - 90 + 8), 100..=107 => self.cur.bg = palette(p[i] - 100 + 8), 39 => self.cur.fg = default_fg(), 49 => self.cur.bg = default_bg(), + 58 => { + // set underline color, mirroring 38 + let (color, used) = match (p.get(i + 1), p.get(i + 2), p.get(i + 3), p.get(i + 4), p.get(i + 5)) { + (Some(5), Some(&n), _, _, _) => (Some(color256(n)), 2), + (Some(2), Some(&0), Some(&r), Some(&g), Some(&b)) if r <= 255 && g <= 255 && b <= 255 => { + (Some((r as u32) << 16 | (g as u32) << 8 | (b as u32)), 5) + } + (Some(2), Some(&r), Some(&g), Some(&b), _) if r <= 255 && g <= 255 && b <= 255 => { + (Some((r as u32) << 16 | (g as u32) << 8 | (b as u32)), 4) + } + _ => (None, p.len()), + }; + if let Some(c) = color { + self.cur.underline_color = c; + self.cur.flags |= UNDERLINE; + if self.cur.underline == 0 { + self.cur.underline = 1; + } + } + i += used; + } + 59 => { + self.cur.underline_color = u32::MAX; + } 38 | 48 => { let (color, used) = match (p.get(i + 1), p.get(i + 2), p.get(i + 3), p.get(i + 4)) { (Some(5), Some(&n), _, _) => (Some(color256(n)), 2), @@ -961,15 +1071,15 @@ impl Term { return; } let i = self.cur.y * self.cols + self.cur.x; - self.grid[i] = Cell { ch: ' ', flags: WRAP, ..Cell::blank(self.cur.bg) }; + self.grid[i] = Cell { ch: ' ', flags: WRAP, underline: 0, ul_color: u32::MAX, link: self.current_link, ..Cell::blank(self.cur.bg) }; self.cur.x = 0; self.linefeed(); } let i = self.cur.y * self.cols + self.cur.x; let Cursor { fg, bg, flags, .. } = self.cur; - self.grid[i] = Cell { ch, fg, bg, flags }; + self.grid[i] = Cell { ch, fg, bg, flags, underline: self.cur.underline, ul_color: self.cur.underline_color, link: self.current_link }; if width == 2 { - self.grid[i + 1] = Cell { ch: ' ', fg, bg, flags: flags | TAIL }; + self.grid[i + 1] = Cell { ch: ' ', fg, bg, flags: flags | TAIL, underline: self.cur.underline, ul_color: self.cur.underline_color, link: self.current_link }; } self.last = ch; if self.cur.x + width < self.cols {