use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::symbols::Marker;
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Axis, Block, BorderType, Chart, Clear, Dataset, GraphType, Paragraph, Tabs, Wrap,
};
use ratatui::{DefaultTerminal, Frame};
use crate::config::{self, ConfigFile, Settings, REFERENCE_DPI};
use crate::ipc::{Request, Response, TelemetrySample, SOCKET_PATH};
use crate::remap::parse_key;
const ACCENT: Color = Color::Cyan;
const CURVE: Color = Color::LightGreen;
const MARKER: Color = Color::LightRed;
const BAR_FILL: Color = Color::Cyan;
const BAR_EMPTY: Color = Color::DarkGray;
const KEY: Color = Color::Yellow;
struct Client {
writer: UnixStream,
reader: BufReader<UnixStream>,
}
impl Client {
fn connect() -> Result<Self, String> {
let stream = UnixStream::connect(SOCKET_PATH).map_err(|e| e.to_string())?;
let reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
Ok(Client {
writer: stream,
reader,
})
}
fn call(&mut self, req: &Request) -> Result<Response, String> {
let mut line = serde_json::to_string(req).map_err(|e| e.to_string())?;
line.push('\n');
self.writer
.write_all(line.as_bytes())
.map_err(|e| e.to_string())?;
let mut resp = String::new();
let n = self
.reader
.read_line(&mut resp)
.map_err(|e| e.to_string())?;
if n == 0 {
return Err("daemon closed the connection".into());
}
serde_json::from_str(&resp).map_err(|e| e.to_string())
}
fn get_config(&mut self) -> Result<ConfigFile, String> {
match self.call(&Request::GetConfig)? {
Response::Config { config } => Ok(*config),
Response::Error { message } => Err(message),
_ => Err("unexpected response".into()),
}
}
fn set_config(&mut self, cfg: &ConfigFile) -> Result<(), String> {
match self.call(&Request::SetConfig {
config: Box::new(cfg.clone()),
})? {
Response::Ok => Ok(()),
Response::Error { message } => Err(message),
_ => Err("unexpected response".into()),
}
}
fn save(&mut self) -> Result<(), String> {
match self.call(&Request::Save)? {
Response::Ok => Ok(()),
Response::Error { message } => Err(message),
_ => Err("unexpected response".into()),
}
}
fn telemetry(&mut self) -> Result<TelemetrySample, String> {
match self.call(&Request::GetTelemetry)? {
Response::Telemetry(t) => Ok(t),
Response::Error { message } => Err(message),
_ => Err("unexpected response".into()),
}
}
fn arm_capture(&mut self) -> Result<(), String> {
match self.call(&Request::ArmCapture)? {
Response::Ok => Ok(()),
Response::Error { message } => Err(message),
_ => Err("unexpected response".into()),
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Field {
PtrEnabled,
Precision,
MaxGain,
Midpoint,
Width,
PtrSmoothing,
WheelEnabled,
StartSpeed,
Strength,
Curve,
MinMult,
MaxMult,
StepStart,
SmoothUp,
SmoothDown,
ResetMs,
Dpi,
}
const PTR_FIELDS: [Field; 6] = [
Field::PtrEnabled,
Field::Precision,
Field::MaxGain,
Field::Midpoint,
Field::Width,
Field::PtrSmoothing,
];
const WHEEL_FIELDS: [Field; 10] = [
Field::WheelEnabled,
Field::StartSpeed,
Field::Strength,
Field::Curve,
Field::MinMult,
Field::MaxMult,
Field::StepStart,
Field::SmoothUp,
Field::SmoothDown,
Field::ResetMs,
];
impl Field {
fn label(self) -> &'static str {
match self {
Field::PtrEnabled => "Pointer accel",
Field::Precision => "Precision (slow)",
Field::MaxGain => "Reach (fast)",
Field::Midpoint => "Knee speed",
Field::Width => "Transition width",
Field::PtrSmoothing => "Smoothing",
Field::WheelEnabled => "Wheel accel",
Field::StartSpeed => "Start speed",
Field::Strength => "Strength",
Field::Curve => "Curve",
Field::MinMult => "Min multiplier",
Field::MaxMult => "Max multiplier",
Field::StepStart => "Steps above",
Field::SmoothUp => "Speed-up smooth",
Field::SmoothDown => "Slow-down smooth",
Field::ResetMs => "Reset pause",
Field::Dpi => "Mouse DPI",
}
}
fn is_bool(self) -> bool {
matches!(self, Field::PtrEnabled | Field::WheelEnabled)
}
fn bounds(self) -> (f64, f64) {
match self {
Field::Precision => (0.1, 3.0),
Field::MaxGain => (0.5, 8.0),
Field::Midpoint => (200.0, 12000.0),
Field::Width => (100.0, 8000.0),
Field::PtrSmoothing => (1.0, 60.0),
Field::StartSpeed => (0.0, 40.0),
Field::Strength => (0.0, 1.0),
Field::Curve => (0.5, 2.5),
Field::MinMult => (0.2, 2.0),
Field::MaxMult => (1.0, 20.0),
Field::StepStart => (1.0, 10.0),
Field::SmoothUp | Field::SmoothDown => (0.05, 1.0),
Field::ResetMs => (40.0, 600.0),
Field::Dpi => (200.0, 6400.0),
Field::PtrEnabled | Field::WheelEnabled => (0.0, 1.0),
}
}
fn step(self) -> f64 {
match self {
Field::Precision => 0.05,
Field::MaxGain => 0.1,
Field::Midpoint => 250.0,
Field::Width => 250.0,
Field::PtrSmoothing => 1.0,
Field::StartSpeed => 1.0,
Field::Strength => 0.01,
Field::Curve => 0.05,
Field::MinMult => 0.05,
Field::MaxMult => 0.5,
Field::StepStart => 0.5,
Field::SmoothUp | Field::SmoothDown => 0.05,
Field::ResetMs => 10.0,
Field::Dpi => 50.0,
Field::PtrEnabled | Field::WheelEnabled => 1.0,
}
}
fn decimals(self) -> usize {
match self {
Field::Precision
| Field::MaxGain
| Field::Strength
| Field::Curve
| Field::MinMult
| Field::SmoothUp
| Field::SmoothDown => 2,
Field::MaxMult | Field::StepStart => 1,
_ => 0,
}
}
fn unit(self) -> &'static str {
match self {
Field::Midpoint | Field::Width => " cnt/s",
Field::StartSpeed => " det/s",
Field::PtrSmoothing | Field::ResetMs => " ms",
Field::MaxGain => "×",
Field::MaxMult | Field::MinMult | Field::StepStart => "×",
_ => "",
}
}
}
fn field_value(cfg: &ConfigFile, f: Field) -> f64 {
let s = cfg.resolve_unscaled();
match f {
Field::PtrEnabled => s.pointer_accel as i32 as f64,
Field::Precision => s.ptr_base,
Field::MaxGain => s.ptr_max,
Field::Midpoint => s.ptr_mid,
Field::Width => s.ptr_width,
Field::PtrSmoothing => s.ptr_tau * 1000.0,
Field::WheelEnabled => s.wheel_enabled as i32 as f64,
Field::StartSpeed => s.threshold_dps,
Field::Strength => s.accel,
Field::Curve => s.exponent,
Field::MinMult => s.min_mult,
Field::MaxMult => s.max_mult,
Field::StepStart => s.step_start,
Field::SmoothUp => s.attack,
Field::SmoothDown => s.release,
Field::ResetMs => s.reset_gap.as_secs_f64() * 1000.0,
Field::Dpi => s.dpi,
}
}
fn field_set(cfg: &mut ConfigFile, f: Field, v: f64) {
match f {
Field::Precision => cfg.pointer.precision_gain = Some(v),
Field::MaxGain => cfg.pointer.max_gain = Some(v),
Field::Midpoint => cfg.pointer.midpoint_speed = Some(v),
Field::Width => cfg.pointer.transition_width = Some(v),
Field::PtrSmoothing => cfg.pointer.smoothing_ms = Some(v),
Field::StartSpeed => cfg.wheel.start_speed = Some(v),
Field::Strength => cfg.wheel.strength = Some(v),
Field::Curve => cfg.wheel.curve = Some(v),
Field::MinMult => cfg.wheel.min_multiplier = Some(v),
Field::MaxMult => cfg.wheel.max_multiplier = Some(v),
Field::StepStart => cfg.wheel.steps_above = Some(v),
Field::SmoothUp => cfg.wheel.smoothing_up = Some(v),
Field::SmoothDown => cfg.wheel.smoothing_down = Some(v),
Field::ResetMs => cfg.wheel.reset_after_ms = Some(v),
Field::Dpi => cfg.dpi = Some(v),
Field::PtrEnabled | Field::WheelEnabled => {}
}
}
fn field_adjust(cfg: &mut ConfigFile, f: Field, dir: f64) {
if f.is_bool() {
let on = field_value(cfg, f) > 0.5;
match f {
Field::PtrEnabled => cfg.pointer.enabled = Some(!on),
Field::WheelEnabled => cfg.wheel.enabled = Some(!on),
_ => {}
}
return;
}
let (lo, hi) = f.bounds();
let step = f.step();
let raw = field_value(cfg, f) + dir * step;
let snapped = (raw / step).round() * step;
field_set(cfg, f, snapped.clamp(lo, hi));
}
#[cfg(test)]
fn cycle_preset(cfg: &mut ConfigFile, dir: i32) {
let names = config::PRESET_NAMES;
let cur = names
.iter()
.position(|n| n.eq_ignore_ascii_case(&cfg.preset))
.unwrap_or(0) as i32;
let n = names.len() as i32;
let next = (((cur + dir) % n) + n) % n;
cfg.preset = names[next as usize].to_string();
}
fn pointer_gain(s: &Settings, v: f64) -> f64 {
let sig = 1.0 / (1.0 + (-(v - s.ptr_mid) / s.ptr_width).exp());
s.ptr_base + (s.ptr_max - s.ptr_base) * sig
}
fn wheel_mult(s: &Settings, dps: f64) -> f64 {
let over = dps - s.threshold_dps;
let grown = if over <= 0.0 {
s.min_mult
} else {
s.min_mult + s.accel * over.powf(s.exponent)
};
grown.min(s.max_mult)
}
#[derive(Clone, Copy, PartialEq)]
enum Tab {
Pointer,
Wheel,
Buttons,
General,
}
impl Tab {
const ALL: [Tab; 4] = [Tab::Pointer, Tab::Wheel, Tab::Buttons, Tab::General];
fn index(self) -> usize {
Tab::ALL.iter().position(|t| *t == self).unwrap_or(0)
}
fn title(self) -> &'static str {
match self {
Tab::Pointer => "Pointer",
Tab::Wheel => "Wheel",
Tab::Buttons => "Buttons",
Tab::General => "General",
}
}
}
enum Row {
Knob(Field),
Preset,
}
enum Modal {
None,
CaptureButton,
EnterKeys {
button: String,
input: String,
},
}
struct App {
client: Client,
cfg: ConfigFile,
saved: ConfigFile,
tab: Tab,
sel: usize,
tel: TelemetrySample,
pending_preset: Option<String>,
modal: Modal,
status: String,
confirm_quit: bool,
quit: bool,
}
impl App {
fn new(client: Client, cfg: ConfigFile) -> Self {
App {
saved: cfg.clone(),
client,
cfg,
tab: Tab::Pointer,
sel: 0,
tel: TelemetrySample::default(),
pending_preset: None,
modal: Modal::None,
status: "move + scroll the mouse to see the marker ride the curve".into(),
confirm_quit: false,
quit: false,
}
}
fn dirty(&self) -> bool {
self.cfg != self.saved
}
fn rows(&self) -> Vec<Row> {
match self.tab {
Tab::Pointer => PTR_FIELDS.iter().map(|f| Row::Knob(*f)).collect(),
Tab::Wheel => WHEEL_FIELDS.iter().map(|f| Row::Knob(*f)).collect(),
Tab::General => vec![Row::Preset, Row::Knob(Field::Dpi)],
Tab::Buttons => Vec::new(),
}
}
fn row_count(&self) -> usize {
match self.tab {
Tab::Buttons => self.cfg.button.len(),
_ => self.rows().len(),
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
while !self.quit {
match self.client.telemetry() {
Ok(t) => self.tel = t,
Err(e) => self.status = format!("disconnected: {e}"),
}
if matches!(self.modal, Modal::CaptureButton) && self.tel.last_button != 0 {
let button = format!("{:?}", evdev::KeyCode(self.tel.last_button as u16));
self.modal = Modal::EnterKeys {
button,
input: String::new(),
};
self.status = "type the shortcut, e.g. Super+Page_Up".into();
}
terminal.draw(|f| ui(f, self))?;
if event::poll(Duration::from_millis(33))? {
if let Event::Key(k) = event::read()? {
if k.kind != KeyEventKind::Release {
self.on_key(k.code, k.modifiers);
}
}
}
}
Ok(())
}
fn on_key(&mut self, code: KeyCode, mods: KeyModifiers) {
match self.modal {
Modal::EnterKeys { .. } => return self.key_enter_keys(code),
Modal::CaptureButton => {
if matches!(code, KeyCode::Esc) {
self.modal = Modal::None;
self.status = "capture cancelled".into();
}
return;
}
Modal::None => {}
}
if self.pending_preset.is_some() {
match code {
KeyCode::Enter => return self.apply_pending_preset(),
KeyCode::Esc => return self.cancel_pending(),
KeyCode::Char('p') | KeyCode::Right | KeyCode::Char('l') => {
return self.preview_preset(1)
}
KeyCode::Left | KeyCode::Char('h') => return self.preview_preset(-1),
_ => self.cancel_pending(), }
}
let big = mods.contains(KeyModifiers::SHIFT);
if !matches!(code, KeyCode::Char('q') | KeyCode::Esc) {
self.confirm_quit = false;
}
match code {
KeyCode::Char('q') | KeyCode::Esc => {
if self.dirty() && !self.confirm_quit {
self.confirm_quit = true;
self.status =
"unsaved changes — press s to save, or q/Esc again to quit".into();
} else {
self.quit = true;
}
}
KeyCode::Tab => self.switch_tab(1),
KeyCode::BackTab => self.switch_tab(-1),
KeyCode::Char(c @ '1'..='4') => {
self.tab = Tab::ALL[c as usize - '1' as usize];
self.sel = 0;
}
KeyCode::Up | KeyCode::Char('k') => self.move_sel(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_sel(1),
KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('-') => self.adjust(-1.0, big),
KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('+') | KeyCode::Char('=') => {
self.adjust(1.0, big)
}
KeyCode::Char(' ') | KeyCode::Enter => self.toggle(),
KeyCode::Char('p') => self.preview_preset(1),
KeyCode::Char('a') if self.tab == Tab::Buttons => self.start_add_button(),
KeyCode::Char('s') => self.do_save(),
KeyCode::Char('r') => self.do_reset(),
KeyCode::Char('d') if self.tab == Tab::Buttons => self.delete_button(),
_ => {}
}
}
fn switch_tab(&mut self, dir: i32) {
let i = self.tab.index() as i32 + dir;
let n = Tab::ALL.len() as i32;
self.tab = Tab::ALL[(((i % n) + n) % n) as usize];
self.sel = 0;
}
fn move_sel(&mut self, dir: i32) {
let count = self.row_count();
if count == 0 {
return;
}
let i = self.sel as i32 + dir;
self.sel = i.clamp(0, count as i32 - 1) as usize;
}
fn adjust(&mut self, dir: f64, big: bool) {
if self.tab == Tab::Buttons {
return;
}
let rows = self.rows();
let Some(row) = rows.get(self.sel) else {
return;
};
match row {
Row::Preset => self.preview_preset(dir as i32),
Row::Knob(f) => {
let f = *f;
let mult = if f.is_bool() || !big { 1.0 } else { 5.0 };
field_adjust(&mut self.cfg, f, dir * mult);
self.touch("");
}
}
}
fn toggle(&mut self) {
if self.tab == Tab::Buttons {
return;
}
let rows = self.rows();
let Some(row) = rows.get(self.sel) else {
return;
};
match row {
Row::Preset => self.preview_preset(1),
Row::Knob(f) if f.is_bool() => {
let f = *f;
field_adjust(&mut self.cfg, f, 1.0);
self.touch("");
}
Row::Knob(_) => {}
}
}
fn preview_preset(&mut self, dir: i32) {
let base = self
.pending_preset
.clone()
.unwrap_or_else(|| self.cfg.preset.clone());
let names = config::PRESET_NAMES;
let cur = names
.iter()
.position(|n| n.eq_ignore_ascii_case(&base))
.unwrap_or(0) as i32;
let n = names.len() as i32;
let next = names[(((cur + dir) % n + n) % n) as usize].to_string();
self.status =
format!("preset → {next} (Enter to load — resets your tweaks · Esc to cancel)");
self.pending_preset = Some(next);
}
fn apply_pending_preset(&mut self) {
if let Some(p) = self.pending_preset.take() {
self.cfg.preset = p.clone();
self.cfg.pointer = Default::default();
self.cfg.wheel = Default::default();
self.touch(&format!("loaded '{p}' preset (tweaks reset)"));
}
}
fn cancel_pending(&mut self) {
if self.pending_preset.take().is_some() {
self.status = "preset change cancelled".into();
}
}
fn start_add_button(&mut self) {
match self.client.arm_capture() {
Ok(()) => {
self.modal = Modal::CaptureButton;
self.status = "press the mouse button you want to map (Esc to cancel)".into();
}
Err(e) => self.status = format!("capture unavailable: {e}"),
}
}
fn key_enter_keys(&mut self, code: KeyCode) {
match code {
KeyCode::Char(c) => {
if let Modal::EnterKeys { input, .. } = &mut self.modal {
input.push(c);
}
}
KeyCode::Backspace => {
if let Modal::EnterKeys { input, .. } = &mut self.modal {
input.pop();
}
}
KeyCode::Esc => {
self.modal = Modal::None;
self.status = "cancelled".into();
}
KeyCode::Enter => {
let (button, input) = match &self.modal {
Modal::EnterKeys { button, input } => (button.clone(), input.clone()),
_ => return,
};
let keys: Vec<String> = input
.split('+')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if keys.is_empty() {
self.status = "type a key, e.g. Super+Page_Up".into();
return;
}
if let Some(bad) = keys.iter().find(|t| parse_key(t).is_none()) {
self.status = format!("unknown key: {bad}");
return;
}
self.cfg.button.push(crate::config::ButtonRule {
match_: button,
keys,
mode: None,
});
self.modal = Modal::None;
self.sel = self.cfg.button.len().saturating_sub(1);
self.touch("binding added — live now; press s to save it to disk");
}
_ => {}
}
}
fn do_reset(&mut self) {
match self.tab {
Tab::Pointer => self.cfg.pointer = Default::default(),
Tab::Wheel => self.cfg.wheel = Default::default(),
Tab::General => self.cfg.dpi = None,
Tab::Buttons => return,
}
self.touch("reset this tab to the preset");
}
fn delete_button(&mut self) {
if self.sel < self.cfg.button.len() {
let removed = self.cfg.button.remove(self.sel);
self.sel = self.sel.min(self.cfg.button.len().saturating_sub(1));
self.touch(&format!("removed {} (live)", removed.match_));
}
}
fn touch(&mut self, msg: &str) {
if let Err(e) = self.client.set_config(&self.cfg) {
self.status = format!("live-apply failed: {e}");
} else if !msg.is_empty() {
self.status = msg.to_string();
}
}
fn do_save(&mut self) {
match self.client.save() {
Ok(()) => {
self.saved = self.cfg.clone();
self.confirm_quit = false;
self.status = "saved to /etc/wayland-mouse/config.toml ✓".into();
}
Err(e) => self.status = format!("save failed: {e}"),
}
}
}
pub fn run() -> i32 {
let mut client = match Client::connect() {
Ok(c) => c,
Err(e) => {
eprintln!("wayland-mouse tune: can't reach the daemon at {SOCKET_PATH}: {e}");
eprintln!("Is the service running, and are you root?");
eprintln!(" sudo systemctl start wayland-mouse && sudo wayland-mouse tune");
return 1;
}
};
let cfg = match client.get_config() {
Ok(c) => c,
Err(e) => {
eprintln!("wayland-mouse tune: {e}");
return 1;
}
};
let mut terminal = ratatui::init();
let mut app = App::new(client, cfg);
let res = app.run(&mut terminal);
ratatui::restore();
match res {
Ok(()) => 0,
Err(e) => {
eprintln!("wayland-mouse tune: {e}");
1
}
}
}
fn ui(f: &mut Frame, app: &App) {
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(2),
])
.split(f.area());
render_tabs(f, app, chunks[0]);
match app.tab {
Tab::Pointer => render_curve_tab(f, app, chunks[1], true),
Tab::Wheel => render_curve_tab(f, app, chunks[1], false),
Tab::Buttons => render_buttons(f, app, chunks[1]),
Tab::General => render_general(f, app, chunks[1]),
}
render_footer(f, app, chunks[2]);
if !matches!(app.modal, Modal::None) {
render_modal(f, app);
}
}
fn centered_rect(px: u16, py: u16, area: Rect) -> Rect {
let v = Layout::vertical([
Constraint::Percentage((100 - py) / 2),
Constraint::Percentage(py),
Constraint::Percentage((100 - py) / 2),
])
.split(area);
Layout::horizontal([
Constraint::Percentage((100 - px) / 2),
Constraint::Percentage(px),
Constraint::Percentage((100 - px) / 2),
])
.split(v[1])[1]
}
fn render_modal(f: &mut Frame, app: &App) {
let area = centered_rect(60, 40, f.area());
let lines = match &app.modal {
Modal::CaptureButton => vec![
Line::raw(""),
Line::from("Press the mouse button you want to map…".bold()),
Line::raw(""),
Line::from("(side / extra / middle — whichever you like)".fg(Color::Gray)),
Line::raw(""),
Line::from("Esc to cancel".fg(Color::Gray)),
],
Modal::EnterKeys { button, input } => {
let valid = !input.is_empty()
&& input
.split('+')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.all(|t| parse_key(t).is_some());
let input_color = if input.is_empty() {
Color::Gray
} else if valid {
Color::Green
} else {
Color::Red
};
vec![
Line::from(vec![
Span::raw("Button: "),
Span::styled(button_label(button), Style::new().fg(Color::Magenta).bold()),
]),
Line::raw(""),
Line::from("Type the shortcut (e.g. Super+Page_Up):".fg(Color::Gray)),
Line::from(vec![
Span::raw(" > "),
Span::styled(format!("{input}▌"), Style::new().fg(input_color).bold()),
]),
Line::raw(""),
Line::from("Enter to add · Esc to cancel".fg(Color::Gray)),
]
}
Modal::None => return,
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(ACCENT))
.title(Span::from(" Add button binding ").fg(ACCENT).bold());
f.render_widget(Clear, area);
f.render_widget(
Paragraph::new(lines).block(block).wrap(Wrap { trim: true }),
area,
);
}
fn render_tabs(f: &mut Frame, app: &App, area: Rect) {
let titles: Vec<Line> = Tab::ALL
.iter()
.enumerate()
.map(|(i, t)| Line::from(format!(" {} {} ", i + 1, t.title())))
.collect();
let tabs = Tabs::new(titles)
.select(app.tab.index())
.highlight_style(
Style::new()
.fg(Color::Black)
.bg(ACCENT)
.add_modifier(Modifier::BOLD),
)
.block(
Block::bordered().border_type(BorderType::Rounded).title(
Span::from(" wayland-mouse · live tuning ")
.fg(ACCENT)
.bold(),
),
);
f.render_widget(tabs, area);
}
fn render_curve_tab(f: &mut Frame, app: &App, area: Rect, pointer: bool) {
let cols =
Layout::horizontal([Constraint::Percentage(44), Constraint::Percentage(56)]).split(area);
render_knobs(f, app, cols[0]);
let right = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(cols[1]);
render_chart(f, app, right[0], pointer);
render_readout(f, app, right[1], pointer);
}
fn render_knobs(f: &mut Frame, app: &App, area: Rect) {
let rows = app.rows();
let bar_w = (area.width as usize).saturating_sub(34).clamp(6, 28);
let mut lines = Vec::new();
for (i, row) in rows.iter().enumerate() {
lines.push(knob_line(app, row, i == app.sel, bar_w));
}
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(Span::from(" Knobs ").fg(ACCENT).bold());
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn knob_line<'a>(app: &App, row: &Row, selected: bool, bar_w: usize) -> Line<'a> {
let marker = if selected { "▶ " } else { " " };
let base = if selected {
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::new()
};
match row {
Row::Preset => Line::from(vec![
Span::styled(format!("{marker}{:<18}", "Preset"), base),
Span::styled(
format!("‹ {} ›", app.cfg.preset),
Style::new().fg(Color::Magenta).bold(),
),
]),
Row::Knob(field) => {
let f = *field;
let label = Span::styled(format!("{marker}{:<18}", f.label()), base);
if f.is_bool() {
let on = field_value(&app.cfg, f) > 0.5;
let (txt, col) = if on {
("ON ", Color::Green)
} else {
("OFF", BAR_EMPTY)
};
Line::from(vec![label, Span::styled(txt, Style::new().fg(col).bold())])
} else {
let v = field_value(&app.cfg, f);
let (lo, hi) = f.bounds();
let frac = ((v - lo) / (hi - lo)).clamp(0.0, 1.0);
let fill = (frac * bar_w as f64).round() as usize;
let bar: String = "█".repeat(fill) + &"░".repeat(bar_w.saturating_sub(fill));
let value = format!("{:>7.*}{}", f.decimals(), v, f.unit());
Line::from(vec![
label,
Span::styled(format!("{value:>11} "), Style::new().fg(Color::White)),
Span::styled(bar, Style::new().fg(BAR_FILL)),
])
}
}
}
}
fn render_chart(f: &mut Frame, app: &App, area: Rect, pointer: bool) {
let s = app.cfg.resolve_unscaled();
let (curve, marker, xmax, ymax, xlabel, ylabel, enabled) = if pointer {
let enabled = s.pointer_accel;
let k = (s.dpi / REFERENCE_DPI).max(0.0001);
let mx = app.tel.pointer_speed / k;
let my = app.tel.pointer_gain * k;
let xmax = (s.ptr_mid * 2.0).max(2000.0);
let ymax = s.ptr_max.max(1.0) * 1.15;
let curve = if enabled {
sample(xmax, |x| pointer_gain(&s, x))
} else {
sample(xmax, |_| 1.0)
};
(
curve,
(mx, my),
xmax,
ymax,
"mouse speed →",
"gain ×",
enabled,
)
} else {
let enabled = s.wheel_enabled;
let mx = app.tel.wheel_dps;
let my = app.tel.wheel_mult;
let xmax = (s.threshold_dps * 2.0).max(50.0);
let ymax = (s.max_mult * 1.08).max(1.5);
let curve = if enabled {
sample(xmax, |x| wheel_mult(&s, x))
} else {
sample(xmax, |_| 1.0)
};
(
curve,
(mx, my),
xmax,
ymax,
"scroll speed →",
"× mult",
enabled,
)
};
let marker_pt = [(marker.0.clamp(0.0, xmax), marker.1.clamp(0.0, ymax))];
let (curve_color, title) = if enabled {
(CURVE, Span::from(" curve ").fg(ACCENT).bold())
} else {
(
BAR_EMPTY,
Span::from(" curve — ACCEL OFF (1:1, press Space to enable) ")
.fg(MARKER)
.bold(),
)
};
let datasets = vec![
Dataset::default()
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::new().fg(curve_color))
.data(&curve),
Dataset::default()
.marker(Marker::Braille)
.graph_type(GraphType::Scatter)
.style(Style::new().fg(MARKER).add_modifier(Modifier::BOLD))
.data(&marker_pt),
];
let chart = Chart::new(datasets)
.block(
Block::bordered()
.border_type(BorderType::Rounded)
.title(title),
)
.x_axis(
Axis::default()
.title(xlabel)
.style(Style::new().fg(Color::Gray))
.bounds([0.0, xmax])
.labels(vec![Span::raw("0"), Span::raw(format!("{xmax:.0}"))]),
)
.y_axis(
Axis::default()
.title(ylabel)
.style(Style::new().fg(Color::Gray))
.bounds([0.0, ymax])
.labels(vec![Span::raw("0"), Span::raw(format!("{ymax:.1}"))]),
);
f.render_widget(chart, area);
}
fn sample(xmax: f64, g: impl Fn(f64) -> f64) -> Vec<(f64, f64)> {
const N: usize = 80;
(0..=N)
.map(|i| {
let x = xmax * i as f64 / N as f64;
(x, g(x))
})
.collect()
}
fn render_readout(f: &mut Frame, app: &App, area: Rect, pointer: bool) {
let line = if pointer {
Line::from(vec![
Span::raw("live "),
Span::styled("speed ", Style::new().fg(Color::Gray)),
Span::styled(
format!("{:>6.0}", app.tel.pointer_speed),
Style::new().fg(MARKER).bold(),
),
Span::styled(" gain ", Style::new().fg(Color::Gray)),
Span::styled(
format!("{:>4.2}×", app.tel.pointer_gain),
Style::new().fg(MARKER).bold(),
),
])
} else {
Line::from(vec![
Span::raw("live "),
Span::styled("scroll ", Style::new().fg(Color::Gray)),
Span::styled(
format!("{:>5.1}", app.tel.wheel_dps),
Style::new().fg(MARKER).bold(),
),
Span::styled(" det/s mult ", Style::new().fg(Color::Gray)),
Span::styled(
format!("{:>4.2}×", app.tel.wheel_mult),
Style::new().fg(MARKER).bold(),
),
])
};
let block = Block::bordered().border_type(BorderType::Rounded);
f.render_widget(Paragraph::new(line).block(block), area);
}
fn button_label(name: &str) -> String {
let n = match name {
"BTN_LEFT" => Some(1),
"BTN_RIGHT" => Some(2),
"BTN_MIDDLE" => Some(3),
"BTN_SIDE" => Some(4),
"BTN_EXTRA" => Some(5),
"BTN_FORWARD" => Some(6),
"BTN_BACK" => Some(7),
"BTN_TASK" => Some(8),
_ => None,
};
match n {
Some(n) => format!("{name} (button {n})"),
None => name.to_string(),
}
}
fn render_buttons(f: &mut Frame, app: &App, area: Rect) {
let mut lines = Vec::new();
if app.cfg.button.is_empty() {
lines.push(Line::from("No button mappings yet.".bold()));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::raw("Press "),
Span::styled("a", Style::new().fg(KEY).bold()),
Span::raw(" to add one — then just press the mouse button and type a shortcut."),
]));
} else {
for (i, b) in app.cfg.button.iter().enumerate() {
let sel = i == app.sel;
let marker = if sel { "▶ " } else { " " };
let mode = b.mode.as_deref().unwrap_or("tap");
let style = if sel {
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::new()
};
lines.push(Line::from(vec![
Span::styled(format!("{marker}{:<22}", button_label(&b.match_)), style),
Span::styled("→ ", Style::new().fg(Color::Gray)),
Span::styled(b.keys.join(" + "), Style::new().fg(Color::Magenta)),
Span::styled(format!(" ({mode})"), Style::new().fg(Color::Gray)),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(
"a add · d delete · applied live; press s to save to disk".fg(Color::Gray),
));
}
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(Span::from(" Button mappings ").fg(ACCENT).bold());
f.render_widget(
Paragraph::new(lines).block(block).wrap(Wrap { trim: true }),
area,
);
}
fn render_general(f: &mut Frame, app: &App, area: Rect) {
let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).split(area);
let bar_w = (rows[0].width as usize).saturating_sub(34).clamp(6, 28);
let knob_rows = app.rows();
let mut lines = Vec::new();
for (i, row) in knob_rows.iter().enumerate() {
lines.push(knob_line(app, row, i == app.sel, bar_w));
}
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(Span::from(" General ").fg(ACCENT).bold());
f.render_widget(Paragraph::new(lines).block(block), rows[0]);
let help = vec![
Line::from("Presets set a starting point; tune any knob to override it.".fg(Color::Gray)),
Line::raw(""),
Line::from(vec![
Span::styled("DPI ", Style::new().fg(ACCENT).bold()),
Span::raw("only matters for keeping presets consistent across mice. "),
]),
Line::from(
"If you're tuning by feel on the Pointer/Wheel tabs, you can ignore it."
.fg(Color::Gray),
),
Line::raw(""),
Line::from(
"Press s to save your tuning to /etc/wayland-mouse/config.toml.".fg(Color::Gray),
),
];
f.render_widget(
Paragraph::new(help)
.block(Block::bordered().border_type(BorderType::Rounded))
.wrap(Wrap { trim: true }),
rows[1],
);
}
fn render_footer(f: &mut Frame, app: &App, area: Rect) {
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
let key = |k: &'static str| Span::styled(k, Style::new().fg(KEY).bold());
let keys = Line::from(vec![
key("Tab"),
Span::raw(" tabs "),
key("↑↓"),
Span::raw(" move "),
key("←→"),
Span::raw(" adjust "),
key("⎵"),
Span::raw(" toggle "),
key("p"),
Span::raw(" preset "),
key("s"),
Span::raw(" save "),
key("r"),
Span::raw(" reset "),
key("q"),
Span::raw(" quit"),
]);
f.render_widget(Paragraph::new(keys), rows[0]);
let dirty = if app.dirty() {
Span::styled("●unsaved", Style::new().fg(MARKER).bold())
} else {
Span::styled("✓saved", Style::new().fg(Color::Green))
};
let status = Line::from(vec![
Span::styled(
format!("preset {} ", app.cfg.preset),
Style::new().fg(Color::Magenta),
),
dirty,
Span::raw(" "),
Span::styled(app.status.clone(), Style::new().fg(Color::Gray)),
]);
f.render_widget(Paragraph::new(status).alignment(Alignment::Left), rows[1]);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_value_reads_preset_defaults() {
let cfg = ConfigFile::default();
assert!((field_value(&cfg, Field::MaxGain) - 2.5).abs() < 1e-9);
assert!((field_value(&cfg, Field::StartSpeed) - 5.0).abs() < 1e-9);
assert!(field_value(&cfg, Field::PtrEnabled) > 0.5);
}
#[test]
fn adjust_writes_override_and_snaps() {
let mut cfg = ConfigFile::default();
field_adjust(&mut cfg, Field::MaxGain, 1.0); assert_eq!(cfg.pointer.max_gain, Some(2.6));
field_adjust(&mut cfg, Field::MaxGain, -1.0); assert!((cfg.pointer.max_gain.unwrap() - 2.5).abs() < 1e-9);
}
#[test]
fn adjust_clamps_to_bounds() {
let mut cfg = ConfigFile::default();
for _ in 0..1000 {
field_adjust(&mut cfg, Field::MaxGain, 1.0);
}
assert_eq!(cfg.pointer.max_gain, Some(8.0)); }
#[test]
fn toggle_bool_field() {
let mut cfg = ConfigFile::default();
assert!(field_value(&cfg, Field::WheelEnabled) > 0.5);
field_adjust(&mut cfg, Field::WheelEnabled, 1.0);
assert_eq!(cfg.wheel.enabled, Some(false));
}
#[test]
fn preset_cycles_and_wraps() {
let mut cfg = ConfigFile::default(); cycle_preset(&mut cfg, 1);
assert_eq!(cfg.preset, "subtle");
cycle_preset(&mut cfg, 1);
assert_eq!(cfg.preset, "off");
cycle_preset(&mut cfg, 1);
assert_eq!(cfg.preset, "mac-like"); cycle_preset(&mut cfg, -1);
assert_eq!(cfg.preset, "off");
}
#[test]
fn curves_are_monotonic_ish() {
let s = ConfigFile::default().resolve_unscaled();
assert!(pointer_gain(&s, 0.0) < pointer_gain(&s, 10000.0));
assert!(wheel_mult(&s, 0.0) <= wheel_mult(&s, 50.0));
}
fn dummy_client() -> Client {
let (a, _b) = UnixStream::pair().unwrap();
let reader = BufReader::new(a.try_clone().unwrap());
Client { writer: a, reader }
}
#[test]
fn renders_every_tab_and_modal_without_panicking() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = App::new(dummy_client(), ConfigFile::default());
app.cfg.button.push(crate::config::ButtonRule {
match_: "BTN_SIDE".into(),
keys: vec!["Super".into(), "Page_Up".into()],
mode: None,
});
app.tel = TelemetrySample {
pointer_speed: 1500.0,
pointer_gain: 1.8,
wheel_dps: 10.0,
wheel_mult: 2.5,
last_button: 0,
};
let mut term = Terminal::new(TestBackend::new(140, 36)).unwrap();
for _ in 0..Tab::ALL.len() {
term.draw(|f| ui(f, &app)).unwrap();
app.switch_tab(1);
}
app.tab = Tab::Buttons;
app.modal = Modal::CaptureButton;
term.draw(|f| ui(f, &app)).unwrap();
app.modal = Modal::EnterKeys {
button: "BTN_SIDE".into(),
input: "Super+Pag".into(),
};
term.draw(|f| ui(f, &app)).unwrap();
app.modal = Modal::None;
app.pending_preset = Some("off".into());
term.draw(|f| ui(f, &app)).unwrap();
app.pending_preset = None;
app.cfg.wheel.enabled = Some(false);
app.cfg.pointer.enabled = Some(false);
app.tab = Tab::Wheel;
term.draw(|f| ui(f, &app)).unwrap();
app.tab = Tab::Pointer;
term.draw(|f| ui(f, &app)).unwrap();
let mut tiny = Terminal::new(TestBackend::new(20, 8)).unwrap();
tiny.draw(|f| ui(f, &app)).unwrap();
}
#[test]
fn dirty_clears_when_edit_is_reverted() {
let mut app = App::new(dummy_client(), ConfigFile::default());
assert!(!app.dirty());
app.cfg.pointer.max_gain = Some(3.0);
assert!(app.dirty());
app.cfg.pointer.max_gain = None; assert!(!app.dirty());
}
#[test]
fn preset_change_is_staged_not_instant() {
let mut app = App::new(dummy_client(), ConfigFile::default());
app.preview_preset(1);
assert_eq!(app.pending_preset.as_deref(), Some("subtle"));
assert_eq!(app.cfg.preset, "mac-like"); app.cancel_pending();
assert!(app.pending_preset.is_none());
assert_eq!(app.cfg.preset, "mac-like");
}
}