mirror of
https://github.com/socatlolmeow/softty.git
synced 2026-11-03 11:20:21 +00:00
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:
parent
a73c5cef50
commit
4873e6e964
3 changed files with 351 additions and 30 deletions
|
|
@ -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<Shortcut>,
|
||||
}
|
||||
|
||||
#[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<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.
|
||||
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<PathBuf> {
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
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<String> {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
206
src/main.rs
206
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<WpCursorShapeDeviceV1>,
|
||||
primary_manager: Option<ZwpPrimarySelectionDeviceManagerV1>,
|
||||
primary_device: Option<ZwpPrimarySelectionDeviceV1>,
|
||||
text_input: Option<ZwpTextInputV3>,
|
||||
keyboard: Option<WlKeyboard>,
|
||||
pointer: Option<WlPointer>,
|
||||
|
||||
xkb: xkb::Context,
|
||||
keymap: Option<xkb::Keymap>,
|
||||
xkb_state: Option<xkb::State>,
|
||||
compose: Option<xkb::compose::State>,
|
||||
repeat: Option<Repeat>,
|
||||
repeat_rate: u64,
|
||||
repeat_delay: u64,
|
||||
|
|
@ -122,6 +128,10 @@ struct App {
|
|||
held: Option<u8>,
|
||||
last_cell: Pos,
|
||||
wheel: f64,
|
||||
cursor_blink: bool,
|
||||
cursor_blink_on: bool,
|
||||
next_blink: Option<Instant>,
|
||||
cursor_blink_interval: Duration,
|
||||
|
||||
clip_text: String,
|
||||
clip_source: Option<WlDataSource>,
|
||||
|
|
@ -136,6 +146,7 @@ struct App {
|
|||
|
||||
search: Option<Search>,
|
||||
sync_deadline: Option<Instant>,
|
||||
config_mtime: Option<std::time::SystemTime>,
|
||||
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<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) {
|
||||
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<WlKeyboard, ()> 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<WlKeyboard, ()> 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<WlPointer, ()> 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<WlPointer, ()> 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<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>> {
|
||||
let config_error = config::load();
|
||||
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 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::<App>(&conn)?;
|
||||
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 primary_manager = globals.bind::<ZwpPrimarySelectionDeviceManagerV1, _, _>(&qh, 1..=1, ()).ok();
|
||||
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 xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
|
||||
|
|
@ -1125,9 +1264,11 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
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<dyn Error>> {
|
|||
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<dyn Error>> {
|
|||
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<dyn Error>> {
|
|||
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<dyn Error>> {
|
|||
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; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
128
src/term.rs
128
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<String>,
|
||||
pub reply: Vec<u8>,
|
||||
pub sync: bool,
|
||||
pub hyperlinks: Vec<Option<String>>,
|
||||
pub current_link: u32,
|
||||
pub notification: Option<(String, String)>,
|
||||
pub focus_events: bool,
|
||||
pub cwd: Option<String>,
|
||||
pub clipboard: Option<String>,
|
||||
|
|
@ -98,6 +106,7 @@ pub struct Term {
|
|||
last: char,
|
||||
state: State,
|
||||
params: Vec<usize>,
|
||||
param_seps: Vec<u8>,
|
||||
private: u8,
|
||||
inter: u8,
|
||||
osc: Vec<u8>,
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue