feat: cursor blink, dead keys, hyperlinks, rich underlines, mouse tweaks, live config reload

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)
This commit is contained in:
socatlolmeow 2026-10-14 18:26:30 +02:00
commit 4873e6e964
3 changed files with 351 additions and 30 deletions

View file

@ -38,7 +38,7 @@ pub enum Action {
NewWindow, NewWindow,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Config { pub struct Config {
pub term: String, pub term: String,
@ -51,7 +51,7 @@ pub struct Config {
pub shortcuts: Vec<Shortcut>, pub shortcuts: Vec<Shortcut>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Window { pub struct Window {
pub title: String, pub title: String,
@ -62,7 +62,7 @@ pub struct Window {
pub alpha: f32, pub alpha: f32,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Font { pub struct Font {
pub family: String, pub family: String,
@ -70,22 +70,24 @@ pub struct Font {
pub zoom_step: f32, pub zoom_step: f32,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Mouse { pub struct Mouse {
pub word_delims: String, pub word_delims: String,
pub multi_click_ms: u64, pub multi_click_ms: u64,
pub scroll_lines: usize, pub scroll_lines: usize,
pub url_launcher: String, pub url_launcher: String,
pub copy_on_select: bool,
pub right_click_paste: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Scrollback { pub struct Scrollback {
pub lines: usize, pub lines: usize,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Colors { pub struct Colors {
#[serde(deserialize_with = "color")] #[serde(deserialize_with = "color")]
@ -96,7 +98,7 @@ pub struct Colors {
pub palette: [u32; 16], pub palette: [u32; 16],
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Cursor { pub struct Cursor {
pub style: CursorStyle, pub style: CursorStyle,
@ -105,9 +107,11 @@ pub struct Cursor {
#[serde(deserialize_with = "color")] #[serde(deserialize_with = "color")]
pub text: u32, pub text: u32,
pub thickness: usize, pub thickness: usize,
pub blink: bool,
pub blink_interval: u64,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(try_from = "RawShortcut")] #[serde(try_from = "RawShortcut")]
pub struct Shortcut { pub struct Shortcut {
pub mods: u8, pub mods: u8,
@ -115,7 +119,7 @@ pub struct Shortcut {
pub action: Action, pub action: Action,
} }
#[derive(Deserialize)] #[derive(Deserialize, Clone)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RawShortcut { struct RawShortcut {
#[serde(default)] #[serde(default)]
@ -220,7 +224,7 @@ impl Default for Font {
impl Default for Mouse { impl Default for Mouse {
fn default() -> 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 { impl Default for Cursor {
fn default() -> 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 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) scroll_lines = 3 # lines per wheel notch (history, or arrow keys on the alternate screen)
url_launcher = "xdg-open" 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] [scrollback]
lines = 10000 # history kept per terminal, 0 turns scrollback off lines = 10000 # history kept per terminal, 0 turns scrollback off
@ -310,6 +316,8 @@ style = "block" # "block", "beam" or "underline"
color = "#7aa2f7" color = "#7aa2f7"
text = "#1a1b26" # glyph color under a block cursor text = "#1a1b26" # glyph color under a block cursor
thickness = 2 # width of beam / height of underline, in pixels 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. # Shortcuts. Setting any [[shortcuts]] replaces the whole default list.
# mods: "shift", "alt", "ctrl". key: an xkb keysym name (C, Insert, Page_Up, F5, ...). # mods: "shift", "alt", "ctrl". key: an xkb keysym name (C, Insert, Page_Up, F5, ...).
@ -392,15 +400,15 @@ key = "N"
action = "new_window" action = "new_window"
"##; "##;
static CONFIG: OnceLock<Config> = OnceLock::new(); static CONFIG: OnceLock<std::sync::Mutex<Config>> = OnceLock::new();
/// The active configuration. Falls back to the defaults if `load` has not run. /// The active configuration. Falls back to the defaults if `load` has not run.
pub fn get() -> &'static Config { pub fn get() -> Config {
CONFIG.get_or_init(Config::default) 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. /// `$XDG_CONFIG_HOME/softty/config.toml`, which is `~/.config/softty/config.toml` by default.
fn path() -> Option<PathBuf> { pub fn path() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") { let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(dir) if !dir.is_empty() => PathBuf::from(dir), Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"), _ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
@ -417,7 +425,14 @@ pub fn load() -> Option<String> {
Some(Ok(config)) => (config, None), Some(Ok(config)) => (config, None),
None => (Config::default(), 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 error
} }

View file

@ -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_device_v1::{Shape, WpCursorShapeDeviceV1};
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1; 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::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_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_offer_v1::{self, ZwpPrimarySelectionOfferV1};
use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_source_v1::{self, ZwpPrimarySelectionSourceV1}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_source_v1::{self, ZwpPrimarySelectionSourceV1};
@ -90,12 +94,14 @@ struct App {
cursor_shape: Option<WpCursorShapeDeviceV1>, cursor_shape: Option<WpCursorShapeDeviceV1>,
primary_manager: Option<ZwpPrimarySelectionDeviceManagerV1>, primary_manager: Option<ZwpPrimarySelectionDeviceManagerV1>,
primary_device: Option<ZwpPrimarySelectionDeviceV1>, primary_device: Option<ZwpPrimarySelectionDeviceV1>,
text_input: Option<ZwpTextInputV3>,
keyboard: Option<WlKeyboard>, keyboard: Option<WlKeyboard>,
pointer: Option<WlPointer>, pointer: Option<WlPointer>,
xkb: xkb::Context, xkb: xkb::Context,
keymap: Option<xkb::Keymap>, keymap: Option<xkb::Keymap>,
xkb_state: Option<xkb::State>, xkb_state: Option<xkb::State>,
compose: Option<xkb::compose::State>,
repeat: Option<Repeat>, repeat: Option<Repeat>,
repeat_rate: u64, repeat_rate: u64,
repeat_delay: u64, repeat_delay: u64,
@ -122,6 +128,10 @@ struct App {
held: Option<u8>, held: Option<u8>,
last_cell: Pos, last_cell: Pos,
wheel: f64, wheel: f64,
cursor_blink: bool,
cursor_blink_on: bool,
next_blink: Option<Instant>,
cursor_blink_interval: Duration,
clip_text: String, clip_text: String,
clip_source: Option<WlDataSource>, clip_source: Option<WlDataSource>,
@ -136,6 +146,7 @@ struct App {
search: Option<Search>, search: Option<Search>,
sync_deadline: Option<Instant>, sync_deadline: Option<Instant>,
config_mtime: Option<std::time::SystemTime>,
exit: bool, exit: bool,
} }
@ -277,7 +288,8 @@ impl App {
let (selection, focused) = (self.selection, self.focused); let (selection, focused) = (self.selection, self.focused);
let search = self.search.as_ref().map(|s| (s.query.clone(), s.found)); let search = self.search.as_ref().map(|s| (s.query.clone(), s.found));
let (cx, cy) = (term.cur.x, term.cur.y); 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 style = term.cursor_style.unwrap_or(config::get().cursor.style);
let block = matches!(style, CursorStyle::Block); let block = matches!(style, CursorStyle::Block);
let colors = |x: usize, y: usize| { let colors = |x: usize, y: usize| {
@ -319,7 +331,46 @@ impl App {
fill(px, w, ox, oy + ch / 2, cw, 1, 0xff000000 | fg); fill(px, w, ox, oy + ch / 2, cw, 1, 0xff000000 | fg);
} }
if cell.flags & UNDERLINE != 0 { 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() { if let Some(text) = self.term.clipboard.take() {
self.set_clipboard(text, qh); 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() { if !self.term.reply.is_empty() {
let reply = std::mem::take(&mut self.term.reply); let reply = std::mem::take(&mut self.term.reply);
self.pty.send(&reply); self.pty.send(&reply);
@ -640,6 +696,51 @@ impl App {
self.search_key(sym, &text, mods, qh); self.search_key(sym, &text, mods, qh);
return; return;
} }
// compose (dead keys)
let compose_result: Option<Option<String>> = 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) { 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 page = self.term.rows.saturating_sub(1).max(1) as isize;
let consumed = match shortcut.action { let consumed = match shortcut.action {
@ -685,6 +786,10 @@ impl App {
if !bytes.is_empty() { if !bytes.is_empty() {
self.snap_to_bottom(); self.snap_to_bottom();
self.pty.send(&bytes); 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<WlKeyboard, ()> for App {
app.serial = serial; app.serial = serial;
app.focused = true; app.focused = true;
app.dirty = true; app.dirty = true;
if let Some(ti) = &app.text_input {
ti.enable();
ti.commit();
}
if app.term.focus_events { if app.term.focus_events {
app.pty.send(b"\x1b[I"); app.pty.send(b"\x1b[I");
} }
@ -899,6 +1008,10 @@ impl Dispatch<WlKeyboard, ()> for App {
app.focused = false; app.focused = false;
app.repeat = None; app.repeat = None;
app.dirty = true; app.dirty = true;
if let Some(ti) = &app.text_input {
ti.disable();
ti.commit();
}
if app.term.focus_events { if app.term.focus_events {
app.pty.send(b"\x1b[O"); app.pty.send(b"\x1b[O");
} }
@ -971,7 +1084,12 @@ impl Dispatch<WlPointer, ()> for App {
match (button, state) { match (button, state) {
(BTN_LEFT, wl_pointer::ButtonState::Pressed) => { (BTN_LEFT, wl_pointer::ButtonState::Pressed) => {
let (x, y) = app.cell_at(app.mouse.0, app.mouse.1); 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 { match url {
Some(url) => app.open(&url), Some(url) => app.open(&url),
None => app.click(), None => app.click(),
@ -980,10 +1098,14 @@ impl Dispatch<WlPointer, ()> for App {
(BTN_LEFT, wl_pointer::ButtonState::Released) => { (BTN_LEFT, wl_pointer::ButtonState::Released) => {
app.anchor = None; app.anchor = None;
if app.selection.is_some() { if app.selection.is_some() {
if config::get().mouse.copy_on_select {
app.copy(qh);
}
app.publish_primary(qh); app.publish_primary(qh);
} }
} }
(BTN_MIDDLE, wl_pointer::ButtonState::Pressed) => app.paste(true), (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<ZwpPrimarySelectionSourceV1, ()> for App {
} }
} }
fn compose_state(ctx: &xkb::Context) -> Option<xkb::compose::State> {
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<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let config_error = config::load(); let config_error = config::load();
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = 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 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 conn = Connection::connect_to_env()?;
let (globals, mut queue) = registry_queue_init::<App>(&conn)?; let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
let qh = queue.handle(); let qh = queue.handle();
@ -1098,6 +1235,8 @@ fn main() -> Result<(), Box<dyn Error>> {
let data_device = data_manager.get_data_device(&seat, &qh, ()); let data_device = data_manager.get_data_device(&seat, &qh, ());
let primary_manager = globals.bind::<ZwpPrimarySelectionDeviceManagerV1, _, _>(&qh, 1..=1, ()).ok(); let primary_manager = globals.bind::<ZwpPrimarySelectionDeviceManagerV1, _, _>(&qh, 1..=1, ()).ok();
let primary_device = primary_manager.as_ref().map(|m| m.get_device(&seat, &qh, ())); let primary_device = primary_manager.as_ref().map(|m| m.get_device(&seat, &qh, ()));
let text_input: Option<ZwpTextInputV3> = globals.bind::<ZwpTextInputManagerV3, _, _>(&qh, 1..=1, ()).ok()
.as_ref().map(|m| m.get_text_input(&seat, &qh, ()));
let surface = compositor.create_surface(&qh, ()); let surface = compositor.create_surface(&qh, ());
let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
@ -1125,9 +1264,11 @@ fn main() -> Result<(), Box<dyn Error>> {
cursor_shape: None, cursor_shape: None,
primary_manager, primary_manager,
primary_device, primary_device,
text_input,
keyboard: None, keyboard: None,
pointer: None, pointer: None,
xkb: xkb::Context::new(xkb::CONTEXT_NO_FLAGS), xkb,
compose,
keymap: None, keymap: None,
xkb_state: None, xkb_state: None,
repeat: None, repeat: None,
@ -1153,6 +1294,10 @@ fn main() -> Result<(), Box<dyn Error>> {
held: None, held: None,
last_cell: (0, 0), last_cell: (0, 0),
wheel: 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_text: String::new(),
clip_source: None, clip_source: None,
offer: None, offer: None,
@ -1165,10 +1310,32 @@ fn main() -> Result<(), Box<dyn Error>> {
primary_mimes: Vec::new(), primary_mimes: Vec::new(),
search: None, search: None,
sync_deadline: None, sync_deadline: None,
config_mtime: None,
exit: false, exit: false,
}; };
loop { 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)?; queue.dispatch_pending(&mut app)?;
app.draw(&qh); app.draw(&qh);
if app.exit { if app.exit {
@ -1189,7 +1356,7 @@ fn main() -> Result<(), Box<dyn Error>> {
fds.push(libc::pollfd { fd: file.as_raw_fd(), events: libc::POLLIN, revents: 0 }); fds.push(libc::pollfd { fd: file.as_raw_fd(), events: libc::POLLIN, revents: 0 });
} }
let now = Instant::now(); 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() .into_iter()
.flatten() .flatten()
.map(|deadline| deadline.saturating_duration_since(now).as_millis() as i32 + 1) .map(|deadline| deadline.saturating_duration_since(now).as_millis() as i32 + 1)
@ -1226,5 +1393,34 @@ fn main() -> Result<(), Box<dyn Error>> {
app.on_key(repeat.key, &qh); 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<ZwpTextInputManagerV3, ()> for App {
fn event(_: &mut Self, _: &ZwpTextInputManagerV3, _: <ZwpTextInputManagerV3 as Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ZwpTextInputV3, ()> for App {
fn event(app: &mut Self, _: &ZwpTextInputV3, event: <ZwpTextInputV3 as Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
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; }
_ => {}
}
} }
} }

View file

@ -30,11 +30,14 @@ pub struct Cell {
pub fg: u32, pub fg: u32,
pub bg: u32, pub bg: u32,
pub flags: u8, pub flags: u8,
pub underline: u8,
pub ul_color: u32,
pub link: u32,
} }
impl Cell { impl Cell {
fn blank(bg: u32) -> 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, fg: u32,
bg: u32, bg: u32,
flags: u8, flags: u8,
underline: u8,
underline_color: u32,
wrap_next: bool, wrap_next: bool,
gfx: bool, gfx: bool,
origin: bool, origin: bool,
@ -52,7 +57,7 @@ pub struct Cursor {
impl Cursor { impl Cursor {
fn new() -> 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<String>, pub title: Option<String>,
pub reply: Vec<u8>, pub reply: Vec<u8>,
pub sync: bool, pub sync: bool,
pub hyperlinks: Vec<Option<String>>,
pub current_link: u32,
pub notification: Option<(String, String)>,
pub focus_events: bool, pub focus_events: bool,
pub cwd: Option<String>, pub cwd: Option<String>,
pub clipboard: Option<String>, pub clipboard: Option<String>,
@ -98,6 +106,7 @@ pub struct Term {
last: char, last: char,
state: State, state: State,
params: Vec<usize>, params: Vec<usize>,
param_seps: Vec<u8>,
private: u8, private: u8,
inter: u8, inter: u8,
osc: Vec<u8>, osc: Vec<u8>,
@ -199,6 +208,9 @@ impl Term {
title: None, title: None,
reply: Vec::new(), reply: Vec::new(),
sync: false, sync: false,
hyperlinks: vec![None],
current_link: 0,
notification: None,
focus_events: false, focus_events: false,
cwd: None, cwd: None,
clipboard: None, clipboard: None,
@ -217,6 +229,7 @@ impl Term {
last: ' ', last: ' ',
state: State::Ground, state: State::Ground,
params: vec![0], params: vec![0],
param_seps: vec![b';'],
private: 0, private: 0,
inter: 0, inter: 0,
osc: Vec::new(), osc: Vec::new(),
@ -368,6 +381,18 @@ impl Term {
self.history.len() + self.rows 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] { fn line_cells(&self, line: usize) -> &[Cell] {
let past = self.history.len(); let past = self.history.len();
if line < past { if line < past {
@ -595,6 +620,35 @@ impl Term {
match cmd { match cmd {
"0" | "2" => self.title = Some(rest.to_string()), "0" | "2" => self.title = Some(rest.to_string()),
"7" => self.cwd = parse_cwd(rest), "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" => { "52" => {
let data = rest.split_once(';').map_or("", |(_, data)| data); let data = rest.split_once(';').map_or("", |(_, data)| data);
if let Some(text) = decode_base64(data).and_then(|bytes| String::from_utf8(bytes).ok()) { if let Some(text) = decode_base64(data).and_then(|bytes| String::from_utf8(bytes).ok()) {
@ -618,6 +672,8 @@ impl Term {
b'[' => { b'[' => {
self.params.clear(); self.params.clear();
self.params.push(0); self.params.push(0);
self.param_seps.clear();
self.param_seps.push(b';');
self.private = 0; self.private = 0;
self.inter = 0; self.inter = 0;
self.state = State::Csi; self.state = State::Csi;
@ -665,6 +721,7 @@ impl Term {
b';' | b':' => { b';' | b':' => {
if self.params.len() < 32 { if self.params.len() < 32 {
self.params.push(0); self.params.push(0);
self.param_seps.push(b);
} }
} }
b'<'..=b'?' => self.private = b, b'<'..=b'?' => self.private = b,
@ -852,6 +909,7 @@ impl Term {
fn sgr(&mut self) { fn sgr(&mut self) {
let p = std::mem::take(&mut self.params); let p = std::mem::take(&mut self.params);
let ps = std::mem::take(&mut self.param_seps);
let mut i = 0; let mut i = 0;
while i < p.len() { while i < p.len() {
match p[i] { match p[i] {
@ -859,22 +917,74 @@ impl Term {
self.cur.fg = default_fg(); self.cur.fg = default_fg();
self.cur.bg = default_bg(); self.cur.bg = default_bg();
self.cur.flags = 0; self.cur.flags = 0;
self.cur.underline = 0;
self.cur.underline_color = u32::MAX;
} }
1 => self.cur.flags |= BOLD, 1 => self.cur.flags |= BOLD,
2 => self.cur.flags |= DIM, 2 => self.cur.flags |= DIM,
9 => self.cur.flags |= STRIKE, 3 => self.cur.flags |= 0,
4 => self.cur.flags |= UNDERLINE, 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, 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), 22 => self.cur.flags &= !(BOLD | DIM),
29 => self.cur.flags &= !STRIKE, 24 => {
24 => self.cur.flags &= !UNDERLINE, self.cur.flags &= !UNDERLINE;
self.cur.underline = 0;
}
27 => self.cur.flags &= !REVERSE, 27 => self.cur.flags &= !REVERSE,
29 => self.cur.flags &= !STRIKE,
30..=37 => self.cur.fg = palette(p[i] - 30), 30..=37 => self.cur.fg = palette(p[i] - 30),
40..=47 => self.cur.bg = palette(p[i] - 40), 40..=47 => self.cur.bg = palette(p[i] - 40),
90..=97 => self.cur.fg = palette(p[i] - 90 + 8), 90..=97 => self.cur.fg = palette(p[i] - 90 + 8),
100..=107 => self.cur.bg = palette(p[i] - 100 + 8), 100..=107 => self.cur.bg = palette(p[i] - 100 + 8),
39 => self.cur.fg = default_fg(), 39 => self.cur.fg = default_fg(),
49 => self.cur.bg = default_bg(), 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 => { 38 | 48 => {
let (color, used) = match (p.get(i + 1), p.get(i + 2), p.get(i + 3), p.get(i + 4)) { 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), (Some(5), Some(&n), _, _) => (Some(color256(n)), 2),
@ -961,15 +1071,15 @@ impl Term {
return; return;
} }
let i = self.cur.y * self.cols + self.cur.x; 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.cur.x = 0;
self.linefeed(); self.linefeed();
} }
let i = self.cur.y * self.cols + self.cur.x; let i = self.cur.y * self.cols + self.cur.x;
let Cursor { fg, bg, flags, .. } = self.cur; 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 { 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; self.last = ch;
if self.cur.x + width < self.cols { if self.cur.x + width < self.cols {