Improved mouse handling (#5697)

* configurable mouse bindings

* feat(bars): reorder tabs with mouse drag, clickable status-bar shortcuts with hover effects

* fix tests

* add pr
This commit is contained in:
Aram Drevekenin
2026-10-09 11:59:23 +02:00
committed by GitHub
parent 9dacbc1982
commit b1033be2a3
102 changed files with 33477 additions and 2463 deletions
+1
View File
@@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* fix: occasional resurrection layout serialization corruption (https://github.com/zellij-org/zellij/pull/5690)
* fix: support horizontal scroll for programs that request it (https://github.com/zellij-org/zellij/pull/4860)
* feat: allow config to dangerously disable https certificate enforcement on non-localhost (https://github.com/zellij-org/zellij/pull/5694)
* feat: reorder tabs with mouse, configurable mouse bindings (https://github.com/zellij-org/zellij/pull/5697)
## [0.45.1] - 2026-08-28
* fix: nested-session detection over SSH (https://github.com/zellij-org/zellij/pull/5522)
+219
View File
@@ -0,0 +1,219 @@
use std::collections::{BTreeMap, VecDeque};
use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
pub const CLICK_SEQUENCE_CONTEXT_KEY: &str = "bars_click_sequence";
#[derive(Debug, Clone, PartialEq)]
pub struct ClickRegion {
pub start: usize,
pub end: usize,
pub actions: Vec<Action>,
}
impl ClickRegion {
pub fn new(start: usize, end: usize, actions: Vec<Action>) -> Self {
ClickRegion {
start,
end,
actions,
}
}
pub fn contains(&self, col: usize) -> bool {
col >= self.start && col < self.end
}
pub fn shifted(&self, by: usize) -> Self {
ClickRegion {
start: self.start + by,
end: self.end + by,
actions: self.actions.clone(),
}
}
}
pub fn region_at(regions: &[ClickRegion], col: usize) -> Option<&ClickRegion> {
regions.iter().find(|region| region.contains(col))
}
pub fn actions_for_key(
keybinds: &[(KeyWithModifier, Vec<Action>)],
key: &KeyWithModifier,
stripped_modifiers: &[KeyModifier],
) -> Option<Vec<Action>> {
let mut full_key = key.clone();
for modifier in stripped_modifiers {
full_key.key_modifiers.insert(*modifier);
}
keybinds
.iter()
.find(|(bound_key, _)| *bound_key == full_key)
.map(|(_, actions)| actions.clone())
.filter(|actions| !actions.is_empty())
}
#[derive(Debug, Default)]
pub struct ActionRunner {
queues: BTreeMap<ClientId, (String, u64, VecDeque<Action>)>,
next_id: u64,
}
impl ActionRunner {
fn tag(owner: &str, id: u64) -> String {
format!("{}:{}", owner, id)
}
fn context(owner: &str, id: u64) -> BTreeMap<String, String> {
let mut context = BTreeMap::new();
context.insert(CLICK_SEQUENCE_CONTEXT_KEY.to_owned(), Self::tag(owner, id));
context
}
pub fn run(&mut self, owner: &str, client_id: ClientId, actions: Vec<Action>) {
let mut queue: VecDeque<Action> = actions.into();
let Some(first) = queue.pop_front() else {
return;
};
self.next_id += 1;
let id = self.next_id;
if queue.is_empty() {
self.queues.remove(&client_id);
} else {
self.queues.insert(client_id, (owner.to_owned(), id, queue));
}
run_action(first, Self::context(owner, id));
}
pub fn action_completed(&mut self, owner: &str, client_id: ClientId, event: &Event) -> bool {
let Event::ActionComplete(_, _, context) = event else {
return false;
};
let Some(tag) = context.get(CLICK_SEQUENCE_CONTEXT_KEY) else {
return false;
};
let Some((queue_owner, id, queue)) = self.queues.get_mut(&client_id) else {
return false;
};
if queue_owner != owner || *tag != Self::tag(owner, *id) {
return false;
}
let id = *id;
match queue.pop_front() {
Some(next) => {
if queue.is_empty() {
self.queues.remove(&client_id);
}
run_action(next, Self::context(owner, id));
},
None => {
self.queues.remove(&client_id);
},
}
true
}
#[cfg(test)]
pub fn pending_for(&self, client_id: ClientId) -> usize {
self.queues
.get(&client_id)
.map(|(_, _, queue)| queue.len())
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn completion(owner: &str, id: u64) -> Event {
Event::ActionComplete(Action::NoOp, None, ActionRunner::context(owner, id))
}
#[test]
fn actions_run_one_after_another() {
let mut runner = ActionRunner::default();
runner.run(
"status",
1,
vec![Action::ScrollUp, Action::ScrollDown, Action::Detach],
);
assert_eq!(runner.pending_for(1), 2);
assert!(!runner.action_completed("compact", 1, &completion("compact", 1)));
assert!(!runner.action_completed("status", 2, &completion("status", 1)));
assert_eq!(runner.pending_for(1), 2);
assert!(runner.action_completed("status", 1, &completion("status", 1)));
assert_eq!(runner.pending_for(1), 1);
assert!(runner.action_completed("status", 1, &completion("status", 1)));
assert_eq!(runner.pending_for(1), 0);
assert!(!runner.action_completed("status", 1, &completion("status", 1)));
}
#[test]
fn a_new_click_replaces_the_rest_of_an_older_sequence() {
let mut runner = ActionRunner::default();
runner.run("status", 1, vec![Action::ScrollUp, Action::ScrollDown]);
runner.run("status", 1, vec![Action::Detach, Action::Quit]);
assert!(!runner.action_completed("status", 1, &completion("status", 1)));
assert!(runner.action_completed("status", 1, &completion("status", 2)));
assert_eq!(runner.pending_for(1), 0);
}
#[test]
fn single_actions_leave_nothing_pending() {
let mut runner = ActionRunner::default();
runner.run("status", 1, vec![Action::Detach]);
assert_eq!(runner.pending_for(1), 0);
runner.run("status", 1, vec![]);
assert_eq!(runner.pending_for(1), 0);
}
#[test]
fn keys_are_found_with_their_stripped_modifiers_restored() {
let ctrl_p = KeyWithModifier::new(BareKey::Char('p')).with_ctrl_modifier();
let keybinds = vec![
(
ctrl_p.clone(),
vec![Action::SwitchToMode {
input_mode: InputMode::Pane,
}],
),
(KeyWithModifier::new(BareKey::Char('x')), vec![]),
];
assert_eq!(
actions_for_key(&keybinds, &ctrl_p, &[]),
Some(vec![Action::SwitchToMode {
input_mode: InputMode::Pane
}])
);
assert_eq!(
actions_for_key(
&keybinds,
&KeyWithModifier::new(BareKey::Char('p')),
&[KeyModifier::Ctrl]
),
Some(vec![Action::SwitchToMode {
input_mode: InputMode::Pane
}])
);
assert_eq!(
actions_for_key(&keybinds, &KeyWithModifier::new(BareKey::Char('p')), &[]),
None
);
assert_eq!(
actions_for_key(&keybinds, &KeyWithModifier::new(BareKey::Char('x')), &[]),
None
);
}
#[test]
fn regions_are_found_by_column_and_can_be_shifted() {
let regions = vec![
ClickRegion::new(2, 4, vec![Action::Detach]),
ClickRegion::new(4, 5, vec![Action::Quit]),
];
assert_eq!(region_at(&regions, 1), None);
assert_eq!(region_at(&regions, 3).map(|r| r.start), Some(2));
assert_eq!(region_at(&regions, 4).map(|r| r.start), Some(4));
assert_eq!(regions[0].shifted(10).start, 12);
}
}
@@ -3,6 +3,60 @@ use std::collections::HashSet;
use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
#[derive(Debug, Clone, PartialEq)]
pub struct TooltipKeyChunk {
pub text: String,
pub actions: Option<Vec<Action>>,
}
impl TooltipKeyChunk {
fn plain(text: &str) -> Self {
TooltipKeyChunk {
text: text.to_owned(),
actions: None,
}
}
fn key(text: String, actions: Vec<Action>) -> Self {
TooltipKeyChunk {
text,
actions: Some(actions),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TooltipEntry {
pub chunks: Vec<TooltipKeyChunk>,
pub description: String,
}
impl TooltipEntry {
pub fn key_text(&self) -> String {
self.chunks.iter().map(|c| c.text.as_str()).collect()
}
pub fn description_actions(&self) -> Option<Vec<Action>> {
let mut actions = self.chunks.iter().filter_map(|c| c.actions.as_ref());
let first = actions.next()?;
if actions.all(|other| other == first) {
Some(first.clone())
} else {
None
}
}
pub fn chunk_ranges(&self) -> Vec<(usize, usize, &TooltipKeyChunk)> {
let mut ranges = vec![];
let mut start = 0;
for chunk in &self.chunks {
let end = start + chunk.text.chars().count();
ranges.push((start, end, chunk));
start = end;
}
ranges
}
}
pub struct KeybindProcessor;
impl KeybindProcessor {
@@ -10,7 +64,7 @@ impl KeybindProcessor {
mode_info: &ModeInfo,
mode: InputMode,
predicates: Vec<F>,
) -> Vec<(String, String)>
) -> Vec<TooltipEntry>
where
F: Fn(&Action) -> bool,
{
@@ -19,14 +73,12 @@ impl KeybindProcessor {
let mut processed_action_types = HashSet::new();
for predicate in predicates {
let mut found_match = false;
for (_key, actions) in &keybinds {
if let Some(first_action) = actions.first() {
if predicate(first_action) {
let action_type = ActionType::from_action(first_action);
if processed_action_types.contains(&action_type) {
found_match = true;
break;
}
@@ -35,7 +87,8 @@ impl KeybindProcessor {
for (inner_key, inner_actions) in &keybinds {
if let Some(inner_first_action) = inner_actions.first() {
if ActionType::from_action(inner_first_action) == action_type {
matching_keys.push(format!("{}", inner_key));
matching_keys
.push((format!("{}", inner_key), inner_actions.clone()));
}
}
}
@@ -51,42 +104,40 @@ impl KeybindProcessor {
}
);
let grouped_keys = Self::group_key_sets(
let chunks = Self::group_key_chunks(
&matching_keys,
should_add_brackets_to_keys,
is_switching_to_locked,
);
result.push((grouped_keys, description));
result.push(TooltipEntry {
chunks,
description,
});
processed_action_types.insert(action_type);
}
found_match = true;
break;
}
}
}
if found_match {
continue;
}
}
result
}
fn group_key_sets(
keys: &[String],
pub fn group_key_chunks(
keys: &[(String, Vec<Action>)],
should_add_brackets_to_keys: bool,
is_switching_to_locked: bool,
) -> String {
) -> Vec<TooltipKeyChunk> {
if keys.is_empty() {
return String::new();
return Vec::new();
}
let filtered_keys: Vec<String> = if is_switching_to_locked {
let non_esc_enter_keys: Vec<String> = keys
let filtered_keys: Vec<(String, Vec<Action>)> = if is_switching_to_locked {
let non_esc_enter_keys: Vec<(String, Vec<Action>)> = keys
.iter()
.filter(|k| k.as_str() != "ESC" && k.as_str() != "ENTER")
.filter(|(k, _)| k.as_str() != "ESC" && k.as_str() != "ENTER")
.cloned()
.collect();
@@ -99,194 +150,122 @@ impl KeybindProcessor {
keys.to_vec()
};
if filtered_keys.len() == 1 {
return if should_add_brackets_to_keys {
format!("<{}>", filtered_keys[0])
let bracketed = |text: &str| -> String {
if should_add_brackets_to_keys {
format!("<{}>", text)
} else {
filtered_keys[0].clone()
};
text.to_owned()
}
};
if filtered_keys.len() == 1 {
let (key, actions) = &filtered_keys[0];
return vec![TooltipKeyChunk::key(bracketed(key), actions.clone())];
}
let mut arrow_keys = Vec::new();
let mut hjkl_lower = Vec::new();
let mut hjkl_upper = Vec::new();
let mut square_bracket_keys = Vec::new();
let mut plus_minus_keys = Vec::new();
let mut pgup_pgdown = Vec::new();
let mut other_keys = Vec::new();
let mut arrow_keys: Vec<(&'static str, Vec<Action>)> = Vec::new();
let mut hjkl_lower: Vec<(&'static str, Vec<Action>)> = Vec::new();
let mut hjkl_upper: Vec<(&'static str, Vec<Action>)> = Vec::new();
let mut square_bracket_keys: Vec<(&'static str, Vec<Action>)> = Vec::new();
let mut plus_minus_keys: Vec<(&'static str, Vec<Action>)> = Vec::new();
let mut pgup_pgdown: Vec<(&'static str, Vec<Action>)> = Vec::new();
let mut other_keys: Vec<TooltipKeyChunk> = Vec::new();
for key in &filtered_keys {
for (key, actions) in &filtered_keys {
let actions = actions.clone();
match key.as_str() {
"Left" | "←" => arrow_keys.push("←"),
"Down" | "↓" => arrow_keys.push("↓"),
"Up" | "↑" => arrow_keys.push("↑"),
"Right" | "→" => arrow_keys.push("→"),
"h" => hjkl_lower.push("h"),
"j" => hjkl_lower.push("j"),
"k" => hjkl_lower.push("k"),
"l" => hjkl_lower.push("l"),
"H" => hjkl_upper.push("H"),
"J" => hjkl_upper.push("J"),
"K" => hjkl_upper.push("K"),
"L" => hjkl_upper.push("L"),
"[" => square_bracket_keys.push("["),
"]" => square_bracket_keys.push("]"),
"+" => plus_minus_keys.push("+"),
"-" => plus_minus_keys.push("-"),
"=" => plus_minus_keys.push("="),
"PgUp" => pgup_pgdown.push("PgUp"),
"PgDn" => pgup_pgdown.push("PgDn"),
_ => {
if should_add_brackets_to_keys {
other_keys.push(format!("<{}>", key));
} else {
other_keys.push(key.clone());
}
},
"Left" | "←" => arrow_keys.push(("←", actions)),
"Down" | "↓" => arrow_keys.push(("↓", actions)),
"Up" | "↑" => arrow_keys.push(("↑", actions)),
"Right" | "→" => arrow_keys.push(("→", actions)),
"h" => hjkl_lower.push(("h", actions)),
"j" => hjkl_lower.push(("j", actions)),
"k" => hjkl_lower.push(("k", actions)),
"l" => hjkl_lower.push(("l", actions)),
"H" => hjkl_upper.push(("H", actions)),
"J" => hjkl_upper.push(("J", actions)),
"K" => hjkl_upper.push(("K", actions)),
"L" => hjkl_upper.push(("L", actions)),
"[" => square_bracket_keys.push(("[", actions)),
"]" => square_bracket_keys.push(("]", actions)),
"+" => plus_minus_keys.push(("+", actions)),
"-" => plus_minus_keys.push(("-", actions)),
"=" => plus_minus_keys.push(("=", actions)),
"PgUp" => pgup_pgdown.push(("PgUp", actions)),
"PgDn" => pgup_pgdown.push(("PgDn", actions)),
_ => other_keys.push(TooltipKeyChunk::key(bracketed(key), actions)),
}
}
let mut groups = Vec::new();
let mut groups: Vec<Vec<TooltipKeyChunk>> = Vec::new();
let mut add_group =
|mut keys: Vec<(&'static str, Vec<Action>)>, order: &[&str], separator: &str| {
if keys.is_empty() {
return;
}
Self::sort_by_order(&mut keys, order);
if order.contains(&"+")
&& keys.iter().any(|(k, _)| *k == "+")
&& keys.iter().any(|(k, _)| *k == "=")
{
keys.retain(|(k, _)| *k != "=");
}
let mut chunks = Vec::new();
if should_add_brackets_to_keys {
chunks.push(TooltipKeyChunk::plain("<"));
}
for (index, (key, actions)) in keys.into_iter().enumerate() {
if index > 0 && !separator.is_empty() {
chunks.push(TooltipKeyChunk::plain(separator));
}
chunks.push(TooltipKeyChunk::key(key.to_owned(), actions));
}
if should_add_brackets_to_keys {
chunks.push(TooltipKeyChunk::plain(">"));
}
groups.push(chunks);
};
if !hjkl_lower.is_empty() {
Self::sort_hjkl(&mut hjkl_lower);
groups.push(Self::format_key_group(
&hjkl_lower,
should_add_brackets_to_keys,
false,
));
}
if !hjkl_upper.is_empty() {
Self::sort_hjkl_upper(&mut hjkl_upper);
groups.push(Self::format_key_group(
&hjkl_upper,
should_add_brackets_to_keys,
false,
));
}
if !arrow_keys.is_empty() {
Self::sort_arrows(&mut arrow_keys);
groups.push(Self::format_key_group(
&arrow_keys,
should_add_brackets_to_keys,
false,
));
}
if !square_bracket_keys.is_empty() {
Self::sort_square_brackets(&mut square_bracket_keys);
groups.push(Self::format_key_group(
&square_bracket_keys,
should_add_brackets_to_keys,
false,
));
}
if !plus_minus_keys.is_empty() {
Self::sort_plus_minus(&mut plus_minus_keys);
groups.push(Self::format_key_group(
&plus_minus_keys,
should_add_brackets_to_keys,
false,
));
}
if !pgup_pgdown.is_empty() {
Self::sort_pgup_pgdown(&mut pgup_pgdown);
groups.push(Self::format_key_group(
&pgup_pgdown,
should_add_brackets_to_keys,
true,
));
}
add_group(hjkl_lower, &["h", "j", "k", "l"], "");
add_group(hjkl_upper, &["H", "J", "K", "L"], "");
add_group(arrow_keys, &["←", "↓", "↑", "→"], "");
add_group(square_bracket_keys, &["[", "]"], "");
add_group(plus_minus_keys, &["+", "-"], "");
add_group(pgup_pgdown, &["PgUp", "PgDn"], "|");
if !other_keys.is_empty() {
groups.push(other_keys.join("/"));
let mut chunks = Vec::new();
for (index, chunk) in other_keys.into_iter().enumerate() {
if index > 0 {
chunks.push(TooltipKeyChunk::plain("/"));
}
chunks.push(chunk);
}
groups.push(chunks);
}
groups.join("/")
}
fn sort_hjkl(keys: &mut Vec<&str>) {
keys.sort_by(|a, b| {
let order = ["h", "j", "k", "l"];
let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX);
let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX);
pos_a.cmp(&pos_b)
});
}
fn sort_hjkl_upper(keys: &mut Vec<&str>) {
keys.sort_by(|a, b| {
let order = ["H", "J", "K", "L"];
let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX);
let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX);
pos_a.cmp(&pos_b)
});
}
fn sort_arrows(keys: &mut Vec<&str>) {
keys.sort();
keys.dedup();
keys.sort_by(|a, b| {
let order = ["←", "↓", "↑", "→"];
let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX);
let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX);
pos_a.cmp(&pos_b)
});
}
fn sort_square_brackets(keys: &mut Vec<&str>) {
keys.sort_by(|a, b| {
let order = ["[", "]"];
let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX);
let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX);
pos_a.cmp(&pos_b)
});
}
fn sort_plus_minus(keys: &mut Vec<&str>) {
keys.sort_by(|a, b| {
let order = ["+", "-"];
let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX);
let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX);
pos_a.cmp(&pos_b)
});
if keys.contains(&"+") && keys.contains(&"=") {
keys.retain(|k| k != &"=");
let mut result = Vec::new();
for (index, group) in groups.into_iter().enumerate() {
if index > 0 {
result.push(TooltipKeyChunk::plain("/"));
}
result.extend(group);
}
result
}
fn sort_pgup_pgdown(keys: &mut Vec<&str>) {
keys.sort_by(|a, b| {
let order = ["PgUp", "PgDn"];
let pos_a = order.iter().position(|&x| &x == a).unwrap_or(usize::MAX);
let pos_b = order.iter().position(|&x| &x == b).unwrap_or(usize::MAX);
pos_a.cmp(&pos_b)
fn sort_by_order(keys: &mut Vec<(&'static str, Vec<Action>)>, order: &[&str]) {
let mut seen = HashSet::new();
keys.retain(|(key, _)| seen.insert(*key));
keys.sort_by_key(|(key, _)| {
order
.iter()
.position(|candidate| candidate == key)
.unwrap_or(usize::MAX)
});
}
fn format_key_group(
keys: &[&str],
should_add_brackets: bool,
use_pipe_separator: bool,
) -> String {
let separator = if use_pipe_separator { "|" } else { "" };
let joined = keys.join(separator);
if should_add_brackets {
format!("<{}>", joined)
} else {
joined
}
}
pub fn get_predetermined_actions(
mode_info: &ModeInfo,
mode: InputMode,
) -> Vec<(String, String)> {
pub fn get_tooltip_entries(mode_info: &ModeInfo, mode: InputMode) -> Vec<TooltipEntry> {
match mode {
InputMode::Locked => {
let ordered_predicates = vec![|action: &Action| {
@@ -719,3 +698,97 @@ impl KeybindProcessor {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn focus(direction: Direction) -> Vec<Action> {
vec![Action::MoveFocus { direction }]
}
fn texts(chunks: &[TooltipKeyChunk]) -> Vec<&str> {
chunks.iter().map(|c| c.text.as_str()).collect()
}
#[test]
fn arrows_are_grouped_but_each_keeps_its_own_action() {
let keys = vec![
("Right".to_owned(), focus(Direction::Right)),
("Left".to_owned(), focus(Direction::Left)),
("Up".to_owned(), focus(Direction::Up)),
("Down".to_owned(), focus(Direction::Down)),
("h".to_owned(), focus(Direction::Left)),
];
let chunks = KeybindProcessor::group_key_chunks(&keys, true, false);
assert_eq!(
texts(&chunks),
vec!["<", "h", ">", "/", "<", "←", "↓", "↑", "→", ">"]
);
assert_eq!(chunks[5].actions, Some(focus(Direction::Left)));
assert_eq!(chunks[8].actions, Some(focus(Direction::Right)));
assert_eq!(chunks[0].actions, None);
let entry = TooltipEntry {
chunks,
description: "Move focus".to_owned(),
};
assert_eq!(entry.key_text(), "<h>/<←↓↑→>");
assert_eq!(entry.description_actions(), None);
}
#[test]
fn plus_minus_drop_equals_and_keep_separate_actions() {
let increase = vec![Action::Resize {
resize: Resize::Increase,
direction: None,
}];
let decrease = vec![Action::Resize {
resize: Resize::Decrease,
direction: None,
}];
let keys = vec![
("=".to_owned(), increase.clone()),
("-".to_owned(), decrease.clone()),
("+".to_owned(), increase.clone()),
];
let chunks = KeybindProcessor::group_key_chunks(&keys, true, false);
assert_eq!(texts(&chunks), vec!["<", "+", "-", ">"]);
assert_eq!(chunks[1].actions, Some(increase));
assert_eq!(chunks[2].actions, Some(decrease));
}
#[test]
fn a_single_key_is_one_clickable_chunk_and_its_description_is_clickable_too() {
let lock = vec![Action::SwitchToMode {
input_mode: InputMode::Locked,
}];
let keys = vec![
("Ctrl g".to_owned(), lock.clone()),
("ESC".to_owned(), lock.clone()),
];
let chunks = KeybindProcessor::group_key_chunks(&keys, false, true);
assert_eq!(texts(&chunks), vec!["Ctrl g"]);
let entry = TooltipEntry {
chunks,
description: "Lock".to_owned(),
};
assert_eq!(entry.description_actions(), Some(lock));
assert_eq!(entry.chunk_ranges()[0].0, 0);
assert_eq!(entry.chunk_ranges()[0].1, 6);
}
#[test]
fn page_keys_and_other_keys_use_their_separators() {
let keys = vec![
("PgDn".to_owned(), vec![Action::PageScrollDown]),
("PgUp".to_owned(), vec![Action::PageScrollUp]),
("x".to_owned(), vec![Action::PageScrollDown]),
];
let chunks = KeybindProcessor::group_key_chunks(&keys, true, false);
let entry = TooltipEntry {
chunks,
description: "Scroll page".to_owned(),
};
assert_eq!(entry.key_text(), "<PgUp|PgDn>/<x>");
}
}
+32 -1
View File
@@ -8,6 +8,12 @@ use zellij_tile_utils::style;
pub struct TabLineOutput {
pub parts: Vec<LinePart>,
pub breadcrumb_range: Option<(usize, usize)>,
pub mode_range: Option<(usize, usize)>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct CompactHover {
pub breadcrumb: bool,
}
pub fn tab_line(
@@ -16,6 +22,7 @@ pub fn tab_line(
cols: usize,
toggle_tooltip_key: Option<String>,
tooltip_is_active: bool,
hover: CompactHover,
) -> TabLineOutput {
let dimmed = mode_info.session_ascended == Some(true) || mode_info.session_dimmed == Some(true);
let breadcrumb_ancestry = if mode_info.host_fullscreen == Some(true) {
@@ -35,6 +42,7 @@ pub fn tab_line(
dimmed,
breadcrumb_ancestry,
nested_hint,
hover,
};
let builder = TabLineBuilder::new(config, mode_info.style.colors, mode_info.capabilities, cols);
@@ -76,6 +84,7 @@ pub struct TabLineConfig {
pub dimmed: bool,
pub breadcrumb_ancestry: Vec<String>,
pub nested_hint: NestedSessionHint,
pub hover: CompactHover,
}
fn calculate_total_length(parts: &[LinePart]) -> usize {
@@ -327,20 +336,29 @@ struct TabLinePrefixBuilder<'a> {
cols: usize,
dimmed: bool,
breadcrumb_ancestry: &'a [String],
hover: CompactHover,
}
struct TabLinePrefix {
parts: Vec<LinePart>,
breadcrumb_range: Option<(usize, usize)>,
mode_range: Option<(usize, usize)>,
}
impl<'a> TabLinePrefixBuilder<'a> {
fn new(palette: Styling, cols: usize, dimmed: bool, breadcrumb_ancestry: &'a [String]) -> Self {
fn new(
palette: Styling,
cols: usize,
dimmed: bool,
breadcrumb_ancestry: &'a [String],
hover: CompactHover,
) -> Self {
Self {
palette,
cols,
dimmed,
breadcrumb_ancestry,
hover,
}
}
@@ -364,13 +382,16 @@ impl<'a> TabLinePrefixBuilder<'a> {
}
}
let mut mode_range = None;
if let Some(mode_part) = self.create_mode_part(mode, used_len) {
mode_range = Some((used_len, used_len + mode_part.len));
parts.push(mode_part);
}
TabLinePrefix {
parts,
breadcrumb_range,
mode_range,
}
}
@@ -400,11 +421,17 @@ impl<'a> TabLinePrefixBuilder<'a> {
};
let name_style = if self.dimmed {
dim_style(bg_color)
} else if self.hover.breadcrumb {
style!(self.palette.text_unselected.emphasis_0, bg_color)
.bold()
.italic()
} else {
style!(self.palette.text_unselected.emphasis_0, bg_color).bold()
};
let closing_style = if self.dimmed {
dim_style(bg_color)
} else if self.hover.breadcrumb {
style!(text_color, bg_color).bold().italic()
} else {
style!(text_color, bg_color).bold()
};
@@ -766,17 +793,20 @@ impl TabLineBuilder {
self.cols,
self.config.dimmed,
breadcrumb_ancestry,
self.config.hover,
);
let prefix_result = prefix_builder.build(session_name, self.config.mode);
let mut prefix = prefix_result.parts;
let breadcrumb_range = prefix_result.breadcrumb_range;
let mode_range = prefix_result.mode_range;
let prefix_len = calculate_total_length(&prefix);
if prefix_len + active_tab.len > self.cols {
return TabLineOutput {
parts: prefix,
breadcrumb_range,
mode_range,
};
}
@@ -798,6 +828,7 @@ impl TabLineBuilder {
TabLineOutput {
parts: prefix,
breadcrumb_range,
mode_range,
}
}
+540 -33
View File
@@ -7,17 +7,25 @@ mod tooltip;
use std::cmp::{max, min};
use std::collections::{BTreeMap, BTreeSet};
use std::convert::TryInto;
use tab::{get_clicked_line_part, get_tab_to_focus};
use tab::get_clicked_line_part;
use zellij_tile::prelude::*;
use crate::click_actions::ActionRunner;
use crate::double_click::{
apply_double_click_outcome, classify_click, double_click_outcome, ClickTarget,
DoubleClickConfig,
};
use crate::keybinds::KeybindStore;
use crate::tab_drag::{
apply_release, is_tab_move_completion, request_tab_move, segments_of, switch_outcome,
tab_parts_in_line, TabDrag, TabPart, TabSegment,
};
use crate::ClientSeed;
use clipboard_utils::{system_clipboard_error, text_copied_hint};
use line::tab_line;
use line::{tab_line, CompactHover};
use tab::tab_style;
use tooltip::TooltipRenderer;
use tooltip::{tooltip_region_at, TooltipRegion, TooltipRenderer};
static ARROW_SEPARATOR: &str = "";
@@ -26,6 +34,7 @@ const CONFIG_TOGGLE_TOOLTIP_KEY: &str = "tooltip";
const MSG_TOGGLE_TOOLTIP: &str = "toggle_tooltip";
const MSG_TOGGLE_PERSISTED_TOOLTIP: &str = "toggle_persisted_tooltip";
const MSG_LAUNCH_TOOLTIP: &str = "launch_tooltip_if_not_launched";
const RUNNER_OWNER: &str = "compact-bar";
#[derive(Debug, Default)]
pub struct LinePart {
@@ -41,6 +50,73 @@ struct TabRenderData {
is_swap_layout_dirty: bool,
}
fn click_target(slot_client: &SlotClientState, col: usize) -> ClickTarget {
let in_reserved_range =
matches!(slot_client.breadcrumb_range, Some((start, end)) if col >= start && col < end);
let clicked_part = get_clicked_line_part(&slot_client.tab_line, col)
.map(|line_part| (line_part.tab_index, line_part.part.as_str()));
classify_click(clicked_part, in_reserved_range)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompactRegion {
Tab(usize),
Marker { left: bool, position: usize },
Breadcrumb,
Mode,
}
fn region_at(regions: &[(usize, usize, CompactRegion)], col: usize) -> Option<CompactRegion> {
regions
.iter()
.find(|(start, end, _)| col >= *start && col < *end)
.map(|(_, _, region)| *region)
}
fn mode_click_target(mode_info: &ModeInfo) -> Option<InputMode> {
let base_mode = mode_info.base_mode.unwrap_or(InputMode::Normal);
if mode_info.mode != base_mode {
Some(base_mode)
} else {
None
}
}
fn compact_regions(
parts: &[LinePart],
rendered_tabs: &[String],
active_tab_position: usize,
breadcrumb_range: Option<(usize, usize)>,
mode_range: Option<(usize, usize)>,
) -> (Vec<(usize, usize, CompactRegion)>, Vec<TabSegment>) {
let mut regions = vec![];
if let Some((start, end)) = breadcrumb_range {
regions.push((start, end, CompactRegion::Breadcrumb));
}
if let Some((start, end)) = mode_range {
regions.push((start, end, CompactRegion::Mode));
}
let tab_parts = tab_parts_in_line(
parts
.iter()
.map(|part| (part.len, part.tab_index, part.part.as_str())),
rendered_tabs,
active_tab_position,
);
let segments = segments_of(&tab_parts);
for (start, end, kind) in tab_parts {
regions.push((
start,
end,
match kind {
TabPart::Tab(position) => CompactRegion::Tab(position),
TabPart::Marker { left, position } => CompactRegion::Marker { left, position },
},
));
}
(regions, segments)
}
#[derive(Default)]
struct SlotConfig {
config: BTreeMap<String, String>,
@@ -48,6 +124,7 @@ struct SlotConfig {
toggle_tooltip_key: Option<String>,
persist: bool,
is_first_run: bool,
double_click: DoubleClickConfig,
}
#[derive(Default)]
@@ -59,12 +136,34 @@ struct ClientState {
display_area_cols: usize,
text_copy_destination: Option<CopyDestination>,
display_system_clipboard_failure: bool,
last_left_click: Option<(SlotId, ClickTarget)>,
hovered: Option<CompactRegion>,
hover_at: Option<(SlotId, usize)>,
drag: Option<TabDrag>,
switch_when_settled: Option<usize>,
tooltip_hovered: Option<Vec<zellij_tile::prelude::actions::Action>>,
tooltip_hover_at: Option<(SlotId, usize, usize)>,
}
fn hoverable(client: &ClientState, region: &CompactRegion) -> bool {
match region {
CompactRegion::Mode => mode_click_target(&client.mode_info).is_some(),
CompactRegion::Tab(position) => client
.tabs
.iter()
.any(|t| t.position == *position && !t.active),
_ => true,
}
}
#[derive(Default)]
struct SlotClientState {
tab_line: Vec<LinePart>,
breadcrumb_range: Option<(usize, usize)>,
regions: Vec<(usize, usize, CompactRegion)>,
segments: Vec<TabSegment>,
tooltip_regions: Vec<TooltipRegion>,
cols: usize,
}
#[derive(Default)]
@@ -75,6 +174,7 @@ pub struct CompactBar {
slot_clients: BTreeMap<(SlotId, ClientId), SlotClientState>,
tooltip_is_active: bool,
configured_toggle_keys: BTreeSet<(String, ClientId)>,
runner: ActionRunner,
}
impl CompactBar {
@@ -98,6 +198,8 @@ impl CompactBar {
EventType::ModeUpdate,
EventType::TabUpdate,
EventType::InitialKeybinds,
EventType::Mouse,
EventType::ActionComplete,
]
} else {
vec![
@@ -109,9 +211,11 @@ impl CompactBar {
EventType::InputReceived,
EventType::SystemClipboardFailure,
EventType::InitialKeybinds,
EventType::ActionComplete,
]
};
subscribe(&events);
let double_click = DoubleClickConfig::from_configuration(&config);
self.slots.insert(
slot.id,
SlotConfig {
@@ -120,6 +224,7 @@ impl CompactBar {
toggle_tooltip_key,
persist: false,
is_first_run: is_tooltip,
double_click,
},
);
}
@@ -299,7 +404,9 @@ impl CompactBar {
},
Event::Mouse(mouse_event) => {
if let (Some(slot_id), Some(client_id)) = (context.slot_id, context.client_id) {
self.handle_mouse_event(slot_id, client_id, mouse_event);
if self.handle_mouse_event(slot_id, client_id, mouse_event) {
return RenderResponse::Client(client_id);
}
}
RenderResponse::Nothing
},
@@ -345,6 +452,23 @@ impl CompactBar {
},
Event::SystemClipboardFailure => self.handle_clipboard_failure(client_id),
Event::InputReceived => self.handle_input_received(client_id),
Event::ActionComplete(..) if !is_tab_move_completion(event) => {
self.runner.action_completed(RUNNER_OWNER, client_id, event);
false
},
event if is_tab_move_completion(event) => {
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
let mut should_render = false;
if let Some(drag) = client.drag.as_mut() {
should_render = drag.move_completed();
}
if let Some(tab_id) = client.switch_when_settled.take() {
apply_release(switch_outcome(&client.tabs, tab_id));
}
should_render
},
_ => false,
}
}
@@ -416,6 +540,12 @@ impl CompactBar {
client.active_tab_idx = active_tab_idx;
client.tabs = tabs.to_vec();
if let Some(drag) = client.drag.as_mut() {
drag.tabs_updated(tabs);
}
if let Some(tab_id) = client.switch_when_settled.take() {
apply_release(switch_outcome(tabs, tab_id));
}
if previous_active_tab_idx != 0 && previous_active_tab_idx != active_tab_idx {
let tooltips = self.tooltip_slots_in_tab(Some(previous_active_tab_idx - 1));
@@ -447,17 +577,21 @@ impl CompactBar {
}
}
fn handle_mouse_event(&mut self, slot_id: SlotId, client_id: ClientId, mouse_event: &Mouse) {
if self
.slots
.get(&slot_id)
.map(|s| s.is_tooltip)
.unwrap_or(true)
{
return;
fn handle_mouse_event(
&mut self,
slot_id: SlotId,
client_id: ClientId,
mouse_event: &Mouse,
) -> bool {
let Some(slot) = self.slots.get(&slot_id) else {
return false;
};
if slot.is_tooltip {
return self.handle_tooltip_mouse_event(slot_id, client_id, mouse_event);
}
let Some(client) = self.clients.get(&client_id) else {
return;
let double_click_config = slot.double_click;
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
let empty_slot_client = SlotClientState::default();
let slot_client = self
@@ -465,17 +599,90 @@ impl CompactBar {
.get(&(slot_id, client_id))
.unwrap_or(&empty_slot_client);
let mut should_render = false;
match mouse_event {
Mouse::LeftClick(_, col) => {
let col = *col;
if matches!(slot_client.breadcrumb_range, Some((start, end)) if col >= start && col < end)
{
focus_host_session();
} else if let Some(tab_idx) =
get_tab_to_focus(&slot_client.tab_line, client.active_tab_idx, col)
{
switch_tab_to(tab_idx.try_into().unwrap());
client.hover_at = Some((slot_id, col));
client.last_left_click = Some((slot_id, click_target(slot_client, col)));
if client.drag.take().is_some() {
should_render = true;
}
match region_at(&slot_client.regions, col) {
Some(CompactRegion::Breadcrumb) => {
focus_host_session();
},
Some(CompactRegion::Mode) => {
if let Some(base_mode) = mode_click_target(&client.mode_info) {
switch_to_input_mode(&base_mode);
}
},
Some(CompactRegion::Marker { position, .. }) => {
switch_tab_to(position as u32 + 1);
},
Some(CompactRegion::Tab(position)) => {
if let Some(tab) = client.tabs.iter().find(|t| t.position == position) {
client.drag = Some(TabDrag::new(Some(slot_id), tab.tab_id));
should_render = true;
}
},
None => {},
}
},
Mouse::Hold(_, col) => {
client.hover_at = Some((slot_id, *col));
if let Some(drag) = client
.drag
.as_mut()
.filter(|drag| drag.slot_id == Some(slot_id))
{
if let Some((tab_id, target)) =
drag.hold(&client.tabs, &slot_client.segments, *col)
{
request_tab_move(tab_id, target);
}
}
},
Mouse::Release(_, col) => {
client.hover_at = Some((slot_id, *col));
if let Some(drag) = client.drag.take() {
client.switch_when_settled = apply_release(drag.release(&client.tabs));
should_render = true;
}
},
Mouse::Hover(_, col) => {
let col = *col;
let on_screen = col < slot_client.cols;
let owns_hover = client
.hover_at
.map(|(hover_slot, _)| hover_slot == slot_id)
.unwrap_or(true);
if on_screen || owns_hover {
client.hover_at = if on_screen {
Some((slot_id, col))
} else {
None
};
let hovered = region_at(&slot_client.regions, col)
.filter(|region| on_screen && hoverable(client, region));
if client.hovered != hovered {
client.hovered = hovered;
should_render = true;
}
}
},
Mouse::DoubleClick(_, col) => {
if client.drag.take().is_some() {
should_render = true;
}
let target = click_target(slot_client, *col);
let last_left_click = client
.last_left_click
.take()
.filter(|(last_slot_id, _)| *last_slot_id == slot_id)
.map(|(_, last_target)| last_target);
let outcome = double_click_outcome(&double_click_config, last_left_click, target);
apply_double_click_outcome(outcome, &client.tabs, client.active_tab_idx);
},
Mouse::RightClick(line, col) => {
let target = match get_clicked_line_part(&slot_client.tab_line, *col)
@@ -496,6 +703,54 @@ impl CompactBar {
},
_ => {},
}
should_render
}
fn handle_tooltip_mouse_event(
&mut self,
slot_id: SlotId,
client_id: ClientId,
mouse_event: &Mouse,
) -> bool {
let Some(client) = self.clients.get_mut(&client_id) else {
return false;
};
let empty_slot_client = SlotClientState::default();
let slot_client = self
.slot_clients
.get(&(slot_id, client_id))
.unwrap_or(&empty_slot_client);
let regions = slot_client.tooltip_regions.as_slice();
match mouse_event {
Mouse::LeftClick(line, col) => {
if let Some(region) = tooltip_region_at(regions, (*line).max(0) as usize, *col) {
self.runner
.run(RUNNER_OWNER, client_id, region.actions.clone());
}
false
},
Mouse::Hover(line, col) => {
let on_screen = *line >= 0 && *col < slot_client.cols;
client.tooltip_hover_at = if on_screen {
Some((slot_id, *line as usize, *col))
} else {
None
};
let hovered = if on_screen {
tooltip_region_at(regions, *line as usize, *col)
.map(|region| region.actions.clone())
} else {
None
};
if client.tooltip_hovered != hovered {
client.tooltip_hovered = hovered;
true
} else {
false
}
},
_ => false,
}
}
fn handle_clipboard_copy(
@@ -721,7 +976,19 @@ impl CompactBar {
};
if slot.is_tooltip {
let tooltip_renderer = TooltipRenderer::new(&client.mode_info);
tooltip_renderer.render(rows, cols);
let regions = tooltip_renderer.regions(rows, cols);
let hovered = client
.tooltip_hover_at
.filter(|(hover_slot, _, _)| *hover_slot == slot_id)
.and_then(|(_, row, col)| tooltip_region_at(&regions, row, col))
.map(|region| region.actions.clone());
tooltip_renderer.render(rows, cols, hovered.as_ref());
if let Some(client) = self.clients.get_mut(&client_id) {
client.tooltip_hovered = hovered;
}
let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default();
slot_client.tooltip_regions = regions;
slot_client.cols = cols;
return;
}
if let Some(copy_destination) = client.text_copy_destination {
@@ -735,24 +1002,67 @@ impl CompactBar {
return;
}
let tab_data = prepare_tab_data(client);
let tab_line_output = tab_line(
&client.mode_info,
tab_data,
cols,
slot.toggle_tooltip_key.clone(),
self.tooltip_is_active,
);
let compose = |hovered: Option<CompactRegion>| {
let tab_data = prepare_tab_data(client, hovered);
let rendered_tabs: Vec<String> =
tab_data.tabs.iter().map(|t| t.part.clone()).collect();
let active_tab_position = tab_data.active_tab_index;
let hover = CompactHover {
breadcrumb: hovered == Some(CompactRegion::Breadcrumb),
};
let tab_line_output = tab_line(
&client.mode_info,
tab_data,
cols,
slot.toggle_tooltip_key.clone(),
self.tooltip_is_active,
hover,
);
let (regions, segments) = compact_regions(
&tab_line_output.parts,
&rendered_tabs,
active_tab_position,
tab_line_output.breadcrumb_range,
tab_line_output.mode_range,
);
(tab_line_output, regions, segments)
};
let hover_col = client
.hover_at
.filter(|(hover_slot, _)| *hover_slot == slot_id)
.map(|(_, col)| col);
let mut hovered = hover_col.and(client.hovered);
let (mut tab_line_output, mut regions, mut segments) = compose(hovered);
if let Some(col) = hover_col {
let under_mouse =
region_at(&regions, col).filter(|region| hoverable(client, region));
if under_mouse != hovered {
hovered = under_mouse;
(tab_line_output, regions, segments) = compose(hovered);
}
}
let mode_info = client.mode_info.clone();
if let Some(client) = self.clients.get_mut(&client_id) {
if hover_col.is_some() {
client.hovered = hovered;
}
if let Some(drag) = client.drag.as_mut() {
drag.laid_out();
}
}
let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default();
slot_client.cols = cols;
slot_client.tab_line = tab_line_output.parts;
slot_client.breadcrumb_range = tab_line_output.breadcrumb_range;
slot_client.regions = regions;
slot_client.segments = segments;
let output = slot_client
.tab_line
.iter()
.fold(String::new(), |acc, part| acc + &part.part);
render_background_with_text(&client.mode_info, &output);
render_background_with_text(&mode_info, &output);
}
}
}
@@ -797,7 +1107,7 @@ fn render_background_with_text(mode_info: &ModeInfo, text: &str) {
}
}
fn prepare_tab_data(client: &ClientState) -> TabRenderData {
fn prepare_tab_data(client: &ClientState, hovered: Option<CompactRegion>) -> TabRenderData {
let mut all_tabs = Vec::new();
let mut active_tab_index = 0;
let mut active_swap_layout_name = None;
@@ -817,10 +1127,18 @@ fn prepare_tab_data(client: &ClientState) -> TabRenderData {
}
}
let is_hovered = hovered == Some(CompactRegion::Tab(tab.position));
let is_dragged = client
.drag
.as_ref()
.map(|drag| drag.tab_id == tab.tab_id)
.unwrap_or(false);
let styled_tab = tab_style(
tab_name,
tab,
is_alternate_tab,
is_hovered,
is_dragged,
client.mode_info.style.colors,
client.mode_info.capabilities,
dimmed,
@@ -864,3 +1182,192 @@ fn bind_toggle_key_config(toggle_key: &str, client_id: ClientId) -> String {
toggle_key, toggle_key, client_id
)
}
#[cfg(test)]
mod tests {
use super::*;
fn part(text: &str, tab_index: Option<usize>) -> LinePart {
LinePart {
part: text.to_owned(),
len: text.chars().count(),
tab_index,
}
}
fn client_with_tabs(ids: &[usize], active: usize, mode: InputMode) -> ClientState {
ClientState {
tabs: ids
.iter()
.enumerate()
.map(|(position, id)| TabInfo {
position,
tab_id: *id,
active: position == active,
..Default::default()
})
.collect(),
active_tab_idx: active + 1,
mode_info: ModeInfo {
mode,
base_mode: Some(InputMode::Normal),
..Default::default()
},
..Default::default()
}
}
fn bar(client: ClientState) -> CompactBar {
let mut bar = CompactBar::default();
bar.slots.insert(0, SlotConfig::default());
bar.clients.insert(1, client);
let line = vec![
part(" Zellij ", None),
part(" PANE ", None),
part("<1", Some(0)),
part("T1", Some(1)),
part("T2", Some(2)),
];
let rendered = vec!["T0".to_owned(), "T1".to_owned(), "T2".to_owned()];
let (regions, segments) = compact_regions(&line, &rendered, 1, None, Some((8, 14)));
bar.slot_clients.insert(
(0, 1),
SlotClientState {
tab_line: line,
regions,
segments,
cols: 100,
..Default::default()
},
);
bar
}
#[test]
fn compact_regions_cover_mode_markers_and_tabs() {
let bar = bar(client_with_tabs(&[10, 11, 12], 1, InputMode::Pane));
let regions = &bar.slot_clients[&(0, 1)].regions;
assert_eq!(region_at(regions, 9), Some(CompactRegion::Mode));
assert_eq!(
region_at(regions, 14),
Some(CompactRegion::Marker {
left: true,
position: 0
})
);
assert_eq!(region_at(regions, 16), Some(CompactRegion::Tab(1)));
assert_eq!(region_at(regions, 18), Some(CompactRegion::Tab(2)));
assert_eq!(bar.slot_clients[&(0, 1)].segments.len(), 2);
}
#[test]
fn the_mode_label_returns_to_the_base_mode_only_outside_it() {
let mut mode_info = ModeInfo {
mode: InputMode::Pane,
base_mode: Some(InputMode::Normal),
..Default::default()
};
assert_eq!(mode_click_target(&mode_info), Some(InputMode::Normal));
mode_info.mode = InputMode::Normal;
assert_eq!(mode_click_target(&mode_info), None);
mode_info.base_mode = Some(InputMode::Locked);
assert_eq!(mode_click_target(&mode_info), Some(InputMode::Locked));
}
#[test]
fn hover_ignores_the_active_tab_and_the_mode_label_in_the_base_mode() {
let mut bar = bar(client_with_tabs(&[10, 11, 12], 1, InputMode::Normal));
assert!(!bar.handle_mouse_event(0, 1, &Mouse::Hover(0, 9)));
assert!(!bar.handle_mouse_event(0, 1, &Mouse::Hover(0, 16)));
assert!(bar.handle_mouse_event(0, 1, &Mouse::Hover(0, 18)));
assert_eq!(bar.clients[&1].hovered, Some(CompactRegion::Tab(2)));
assert!(bar.handle_mouse_event(0, 1, &Mouse::Hover(0, 300)));
assert_eq!(bar.clients[&1].hovered, None);
bar.clients.get_mut(&1).unwrap().mode_info.mode = InputMode::Pane;
assert!(bar.handle_mouse_event(0, 1, &Mouse::Hover(0, 9)));
assert_eq!(bar.clients[&1].hovered, Some(CompactRegion::Mode));
}
#[test]
fn dragging_a_compact_tab_moves_it_once_per_layout() {
let mut bar = bar(client_with_tabs(&[10, 11, 12], 1, InputMode::Normal));
assert!(bar.handle_mouse_event(0, 1, &Mouse::LeftClick(0, 16)));
assert_eq!(bar.clients[&1].drag.as_ref().map(|d| d.tab_id), Some(11));
bar.handle_mouse_event(0, 1, &Mouse::Hold(0, 19));
assert_eq!(
bar.clients[&1].drag.as_ref().and_then(|d| d.pending_target),
Some(2)
);
let moved = client_with_tabs(&[10, 12, 11], 2, InputMode::Normal).tabs;
bar.handle_tab_update(1, &moved);
assert_eq!(
bar.clients[&1].drag.as_ref().and_then(|d| d.pending_target),
None
);
assert!(bar.handle_mouse_event(0, 1, &Mouse::Release(0, 19)));
assert!(bar.clients[&1].drag.is_none());
}
#[test]
fn a_resting_mouse_over_the_tooltip_keeps_its_hover_after_a_redraw() {
let mut bar = bar(client_with_tabs(&[10], 0, InputMode::Resize));
bar.slots.insert(
5,
SlotConfig {
is_tooltip: true,
..Default::default()
},
);
let increase = vec![actions::Action::Resize {
resize: Resize::Increase,
direction: None,
}];
bar.clients.get_mut(&1).unwrap().mode_info.keybinds = vec![(
InputMode::Resize,
vec![(KeyWithModifier::new(BareKey::Char('+')), increase.clone())],
)];
bar.render_client(10, 40, 5, 1);
let region = bar.slot_clients[&(5, 1)].tooltip_regions[0].clone();
bar.handle_mouse_event(
5,
1,
&Mouse::Hover(region.row as isize, region.region.start),
);
bar.handle_mouse_event(
5,
1,
&Mouse::LeftClick(region.row as isize, region.region.start),
);
bar.clients.get_mut(&1).unwrap().tooltip_hovered = None;
bar.render_client(10, 40, 5, 1);
assert_eq!(bar.clients[&1].tooltip_hovered, Some(increase));
}
#[test]
fn tooltip_clicks_and_hover_use_the_stored_tooltip_regions() {
let mut bar = bar(client_with_tabs(&[10], 0, InputMode::Resize));
bar.slots.insert(
5,
SlotConfig {
is_tooltip: true,
..Default::default()
},
);
let actions = vec![actions::Action::Resize {
resize: Resize::Increase,
direction: None,
}];
bar.slot_clients.entry((5, 1)).or_default().cols = 100;
bar.slot_clients.entry((5, 1)).or_default().tooltip_regions = vec![TooltipRegion {
row: 2,
region: crate::click_actions::ClickRegion::new(4, 5, actions.clone()),
}];
assert!(bar.handle_mouse_event(5, 1, &Mouse::Hover(2, 4)));
assert_eq!(bar.clients[&1].tooltip_hovered, Some(actions.clone()));
assert!(!bar.handle_mouse_event(5, 1, &Mouse::Hover(2, 4)));
assert!(bar.handle_mouse_event(5, 1, &Mouse::Hover(1, 4)));
assert_eq!(bar.clients[&1].tooltip_hovered, None);
bar.handle_mouse_event(5, 1, &Mouse::LeftClick(2, 4));
assert_eq!(bar.runner.pending_for(1), 0);
}
}
+21 -15
View File
@@ -24,6 +24,8 @@ pub fn render_tab(
text: String,
tab: &TabInfo,
is_alternate_tab: bool,
is_hovered: bool,
is_dragged: bool,
palette: Styling,
separator: &str,
dimmed: bool,
@@ -37,6 +39,8 @@ pub fn render_tab(
};
let background_color = if tab.active {
palette.ribbon_selected.background
} else if is_hovered {
palette.ribbon_unselected.emphasis_1
} else if is_alternate_tab {
alternate_tab_color
} else {
@@ -64,6 +68,11 @@ pub fn render_tab(
} else {
style!(foreground_color, background_color).bold()
};
let text_style = if is_dragged {
text_style.italic()
} else {
text_style
};
let left_separator = style!(separator_fill_color, background_color).paint(separator);
let mut tab_text_len = text.width() + (separator_width * 2) + 2;
@@ -100,6 +109,8 @@ pub fn tab_style(
mut tabname: String,
tab: &TabInfo,
mut is_alternate_tab: bool,
is_hovered: bool,
is_dragged: bool,
palette: Styling,
capabilities: PluginCapabilities,
dimmed: bool,
@@ -118,21 +129,16 @@ pub fn tab_style(
is_alternate_tab = false;
}
render_tab(tabname, tab, is_alternate_tab, palette, separator, dimmed)
}
pub(crate) fn get_tab_to_focus(
tab_line: &[LinePart],
active_tab_idx: usize,
mouse_click_col: usize,
) -> Option<usize> {
let clicked_line_part = get_clicked_line_part(tab_line, mouse_click_col)?;
let clicked_tab_idx = clicked_line_part.tab_index?;
let clicked_tab_idx = clicked_tab_idx + 1;
if clicked_tab_idx != active_tab_idx {
return Some(clicked_tab_idx);
}
None
render_tab(
tabname,
tab,
is_alternate_tab,
is_hovered,
is_dragged,
palette,
separator,
dimmed,
)
}
pub(crate) fn get_clicked_line_part(
+381 -68
View File
@@ -1,6 +1,83 @@
use crate::compact_bar::keybind_utils::KeybindProcessor;
use crate::click_actions::ClickRegion;
use crate::compact_bar::keybind_utils::{KeybindProcessor, TooltipEntry};
use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
#[derive(Debug, Clone, PartialEq)]
pub struct TooltipRegion {
pub row: usize,
pub region: ClickRegion,
}
pub fn tooltip_region_at(
regions: &[TooltipRegion],
row: usize,
col: usize,
) -> Option<&ClickRegion> {
regions
.iter()
.find(|r| r.row == row && r.region.contains(col))
.map(|r| &r.region)
}
struct NormalComponent {
entry: TooltipEntry,
x: usize,
is_first: bool,
}
impl NormalComponent {
fn key_text(&self) -> String {
if self.is_first {
format!("{} ", self.entry.key_text())
} else {
format!(" {} ", self.entry.key_text())
}
}
fn key_offset(&self) -> usize {
if self.is_first {
0
} else {
1
}
}
}
fn is_hovered(hovered: Option<&Vec<Action>>, actions: &Option<Vec<Action>>) -> bool {
match (hovered, actions) {
(Some(hovered), Some(actions)) => hovered == actions,
_ => false,
}
}
fn styled_key_text(
text: String,
entry: &TooltipEntry,
offset: usize,
hovered: Option<&Vec<Action>>,
) -> (Text, bool) {
let mut hovered_ranges = vec![];
let mut plain_ranges = vec![];
for (start, end, chunk) in entry.chunk_ranges() {
if is_hovered(hovered, &chunk.actions) {
hovered_ranges.push((start + offset, end + offset));
} else {
plain_ranges.push((start + offset, end + offset));
}
}
if hovered_ranges.is_empty() {
return (Text::from(text).color_all(3), false);
}
let mut styled = Text::from(text);
for (start, end) in plain_ranges {
styled = styled.color_range(3, start..end);
}
for (start, end) in hovered_ranges {
styled = styled.color_range(0, start..end);
}
(styled.selected(), true)
}
pub struct TooltipRenderer<'a> {
mode_info: &'a ModeInfo,
}
@@ -10,20 +87,135 @@ impl<'a> TooltipRenderer<'a> {
Self { mode_info }
}
pub fn render(&self, rows: usize, cols: usize) {
fn entries(&self, mode: InputMode) -> Vec<TooltipEntry> {
KeybindProcessor::get_tooltip_entries(self.mode_info, mode)
}
fn normal_components(&self, mode: InputMode) -> (Vec<NormalComponent>, usize) {
let mut running_x = 0;
let mut components = Vec::new();
let mut max_columns = 0;
for (index, entry) in self.entries(mode).into_iter().enumerate() {
let is_first = index == 0;
let key_len = entry.key_text().chars().count();
let description_len = entry.description.chars().count();
let line_length = if is_first {
key_len + description_len
} else {
key_len + 1 + description_len
};
components.push(NormalComponent {
entry,
x: running_x,
is_first,
});
running_x += line_length + 5;
max_columns = max_columns.max(running_x);
}
(components, max_columns)
}
fn table_widths(entries: &[TooltipEntry]) -> (usize, usize) {
let mut key_width = 0;
let mut action_width = 0;
for entry in entries {
key_width = key_width.max(entry.key_text().chars().count());
action_width = action_width.max(format!("- {}", entry.description).chars().count());
}
(key_width, action_width)
}
pub fn regions(&self, rows: usize, cols: usize) -> Vec<TooltipRegion> {
let current_mode = self.mode_info.mode;
let mut regions = vec![];
if current_mode == InputMode::Normal {
let (components, tooltip_columns) = self.normal_components(current_mode);
let base_x = cols.saturating_sub(tooltip_columns) / 2;
let base_y = rows.saturating_sub(1) / 2;
for component in components {
let text_width = component.key_text().chars().count();
let ribbon_total_width = component.entry.description.chars().count() + 4;
let start = base_x + component.x;
if start + text_width + ribbon_total_width + 1 > cols {
break;
}
let key_start = start + component.key_offset();
for (chunk_start, chunk_end, chunk) in component.entry.chunk_ranges() {
if let Some(actions) = &chunk.actions {
regions.push(TooltipRegion {
row: base_y,
region: ClickRegion::new(
key_start + chunk_start,
key_start + chunk_end,
actions.clone(),
),
});
}
}
if let Some(actions) = component.entry.description_actions() {
regions.push(TooltipRegion {
row: base_y,
region: ClickRegion::new(
start + text_width,
start + text_width + ribbon_total_width,
actions,
),
});
}
}
} else {
let entries = self.entries(current_mode);
if entries.is_empty() {
return regions;
}
let (key_width, action_width) = Self::table_widths(&entries);
let total_width = key_width + action_width + 1;
let tooltip_rows = entries.len() + 1;
let base_x = cols.saturating_sub(total_width) / 2;
let base_y = rows.saturating_sub(tooltip_rows) / 2;
let key_column_width = key_width.max(1);
for (index, entry) in entries.iter().enumerate() {
let row = base_y + 1 + index;
for (chunk_start, chunk_end, chunk) in entry.chunk_ranges() {
if let Some(actions) = &chunk.actions {
regions.push(TooltipRegion {
row,
region: ClickRegion::new(
base_x + chunk_start,
base_x + chunk_end,
actions.clone(),
),
});
}
}
if let Some(actions) = entry.description_actions() {
let start = base_x + key_column_width + 1;
let end = start + format!("- {}", entry.description).chars().count();
regions.push(TooltipRegion {
row,
region: ClickRegion::new(start, end, actions),
});
}
}
}
regions
}
pub fn render(&self, rows: usize, cols: usize, hovered: Option<&Vec<Action>>) {
let current_mode = self.mode_info.mode;
if current_mode == InputMode::Normal {
let (text_components, tooltip_rows, tooltip_columns) =
self.normal_mode_tooltip(current_mode);
let (components, tooltip_columns) = self.normal_components(current_mode);
let base_x = cols.saturating_sub(tooltip_columns) / 2;
let base_y = rows.saturating_sub(tooltip_rows) / 2;
let base_y = rows.saturating_sub(1) / 2;
for (text, ribbon, x, y) in text_components {
let text_width = text.content().chars().count();
let ribbon_content_width = ribbon.content().chars().count();
for component in components {
let key_text = component.key_text();
let text_width = key_text.chars().count();
let ribbon_content_width = component.entry.description.chars().count();
let ribbon_total_width = ribbon_content_width + 4;
let total_element_width = text_width + ribbon_total_width + 1;
let x = component.x;
if base_x + x + total_element_width > cols {
let remaining_space = cols.saturating_sub(base_x + x);
@@ -31,24 +223,27 @@ impl<'a> TooltipRenderer<'a> {
print_text_with_coordinates(
ellipsis,
base_x + x,
base_y + y,
base_y,
Some(remaining_space),
None,
);
break;
}
print_text_with_coordinates(text, base_x + x, base_y + y, None, None);
print_ribbon_with_coordinates(
ribbon,
base_x + x + text_width,
base_y + y,
None,
None,
);
let (text, _) =
styled_key_text(key_text, &component.entry, component.key_offset(), hovered);
let ribbon = Text::from(component.entry.description.clone());
let ribbon = if is_hovered(hovered, &component.entry.description_actions()) {
ribbon.selected()
} else {
ribbon
};
print_text_with_coordinates(text.opaque(), base_x + x, base_y, None, None);
print_ribbon_with_coordinates(ribbon, base_x + x + text_width, base_y, None, None);
}
} else {
let (table, tooltip_rows, tooltip_columns) = self.other_mode_tooltip(current_mode);
let (table, tooltip_rows, tooltip_columns) =
self.other_mode_tooltip(current_mode, hovered);
let base_x = cols.saturating_sub(tooltip_columns) / 2;
let base_y = rows.saturating_sub(tooltip_rows) / 2;
print_table_with_coordinates(table, base_x, base_y, None, None);
@@ -58,59 +253,27 @@ impl<'a> TooltipRenderer<'a> {
pub fn calculate_dimensions(&self, current_mode: InputMode) -> (usize, usize) {
match current_mode {
InputMode::Normal => {
let (_, tooltip_rows, tooltip_cols) = self.normal_mode_tooltip(current_mode);
(tooltip_rows, tooltip_cols)
let (_, tooltip_cols) = self.normal_components(current_mode);
(1, tooltip_cols)
},
_ => {
let (_, tooltip_rows, tooltip_cols) = self.other_mode_tooltip(current_mode);
let (_, tooltip_rows, tooltip_cols) = self.other_mode_tooltip(current_mode, None);
(tooltip_rows + 1, tooltip_cols)
},
}
}
fn normal_mode_tooltip(
fn other_mode_tooltip(
&self,
current_mode: InputMode,
) -> (Vec<(Text, Text, usize, usize)>, usize, usize) {
let actions = KeybindProcessor::get_predetermined_actions(self.mode_info, current_mode);
let y = 0;
let mut running_x = 0;
let mut components = Vec::new();
let mut max_columns = 0;
let mut is_first = true;
for (key, description) in actions {
let text = if is_first {
Text::from(format!("{} ", key)).color_all(3).opaque()
} else {
Text::from(format!(" {} ", key)).color_all(3).opaque()
};
let ribbon = Text::from(description.clone());
let line_length = if is_first {
key.chars().count() + description.chars().count()
} else {
key.chars().count() + 1 + description.chars().count()
};
components.push((text, ribbon, running_x, y));
running_x += line_length + 5;
max_columns = max_columns.max(running_x);
is_first = false;
}
let total_rows = 1;
(components, total_rows, max_columns)
}
fn other_mode_tooltip(&self, current_mode: InputMode) -> (Table, usize, usize) {
let actions = KeybindProcessor::get_predetermined_actions(self.mode_info, current_mode);
let actions_vec: Vec<_> = actions.into_iter().collect();
hovered: Option<&Vec<Action>>,
) -> (Table, usize, usize) {
let entries = self.entries(current_mode);
let mut table = Table::new().add_row(vec![" ".to_owned(); 2]);
let mut row_count = 1;
if actions_vec.is_empty() {
if entries.is_empty() {
let tooltip_text = match self.mode_info.mode {
InputMode::EnterSearch => "Entering search term...".to_owned(),
InputMode::RenameTab => "Renaming tab...".to_owned(),
@@ -124,16 +287,16 @@ impl<'a> TooltipRenderer<'a> {
row_count += 1;
(table, row_count, total_width)
} else {
let mut key_width = 0;
let mut action_width = 0;
for (key, description) in actions_vec.into_iter() {
let description_formatted = format!("- {}", description);
key_width = key_width.max(key.chars().count());
action_width = action_width.max(description_formatted.chars().count());
table = table.add_styled_row(vec![
Text::from(key).color_all(3),
Text::from(description_formatted),
]);
let (key_width, action_width) = Self::table_widths(&entries);
for entry in entries.iter() {
let description = Text::from(format!("- {}", entry.description));
let description = if is_hovered(hovered, &entry.description_actions()) {
description.selected()
} else {
description
};
let (key, _) = styled_key_text(entry.key_text(), entry, 0, hovered);
table = table.add_styled_row(vec![key, description]);
row_count += 1;
}
@@ -142,3 +305,153 @@ impl<'a> TooltipRenderer<'a> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(bare_key: BareKey) -> KeyWithModifier {
KeyWithModifier::new(bare_key)
}
fn resize(resize: Resize, direction: Option<Direction>) -> Vec<Action> {
vec![Action::Resize { resize, direction }]
}
fn resize_mode_info() -> ModeInfo {
ModeInfo {
mode: InputMode::Resize,
base_mode: Some(InputMode::Normal),
keybinds: vec![(
InputMode::Resize,
vec![
(key(BareKey::Char('+')), resize(Resize::Increase, None)),
(key(BareKey::Char('-')), resize(Resize::Decrease, None)),
(
key(BareKey::Left),
resize(Resize::Increase, Some(Direction::Left)),
),
(
key(BareKey::Right),
resize(Resize::Increase, Some(Direction::Right)),
),
],
)],
..Default::default()
}
}
fn normal_mode_info() -> ModeInfo {
ModeInfo {
mode: InputMode::Normal,
base_mode: Some(InputMode::Normal),
keybinds: vec![(
InputMode::Normal,
vec![
(
KeyWithModifier::new(BareKey::Char('g')).with_ctrl_modifier(),
vec![Action::SwitchToMode {
input_mode: InputMode::Locked,
}],
),
(
KeyWithModifier::new(BareKey::Char('p')).with_ctrl_modifier(),
vec![Action::SwitchToMode {
input_mode: InputMode::Pane,
}],
),
],
)],
..Default::default()
}
}
#[test]
fn each_arrow_and_plus_minus_in_the_tooltip_has_its_own_region() {
let mode_info = resize_mode_info();
let renderer = TooltipRenderer::new(&mode_info);
let regions = renderer.regions(10, 60);
let find = |actions: &Vec<Action>| {
regions
.iter()
.find(|r| &r.region.actions == actions)
.cloned()
.unwrap_or_else(|| panic!("{:?} not in {:?}", actions, regions))
};
let increase = find(&resize(Resize::Increase, None));
let decrease = find(&resize(Resize::Decrease, None));
assert_eq!(increase.row, decrease.row);
assert_eq!(increase.region.end - increase.region.start, 1);
assert_eq!(decrease.region.start, increase.region.end);
let left = find(&resize(Resize::Increase, Some(Direction::Left)));
let right = find(&resize(Resize::Increase, Some(Direction::Right)));
assert_eq!(left.row, right.row);
assert_ne!(left.row, increase.row);
assert_eq!(right.region.start, left.region.end);
assert!(regions
.iter()
.all(|r| r.region.actions != resize(Resize::Increase, None)
|| r.region.end - r.region.start == 1));
assert_eq!(
tooltip_region_at(&regions, left.row, left.region.start).map(|r| r.actions.clone()),
Some(resize(Resize::Increase, Some(Direction::Left)))
);
assert_eq!(tooltip_region_at(&regions, left.row, 0), None);
}
#[test]
fn table_regions_match_the_table_layout() {
let mode_info = resize_mode_info();
let renderer = TooltipRenderer::new(&mode_info);
let (rows, cols) = renderer.calculate_dimensions(InputMode::Resize);
let regions = renderer.regions(rows, cols + 10);
let first_row = regions.iter().map(|r| r.row).min().unwrap();
assert_eq!(first_row, (rows - 3) / 2 + 1);
let increase = regions
.iter()
.find(|r| r.region.actions == resize(Resize::Increase, None))
.unwrap();
assert_eq!(increase.region.start, 5 + 1);
}
#[test]
fn normal_mode_tooltip_entries_are_clickable_on_key_and_description() {
let mode_info = normal_mode_info();
let renderer = TooltipRenderer::new(&mode_info);
let regions = renderer.regions(1, 80);
let lock = vec![Action::SwitchToMode {
input_mode: InputMode::Locked,
}];
let lock_regions: Vec<&TooltipRegion> = regions
.iter()
.filter(|r| r.region.actions == lock)
.collect();
assert_eq!(lock_regions.len(), 2);
assert_eq!(lock_regions[0].region.end, lock_regions[1].region.start - 1);
assert!(regions.iter().any(|r| r.region.actions
== vec![Action::SwitchToMode {
input_mode: InputMode::Pane
}]));
}
#[test]
fn hovering_marks_the_hovered_chunk_and_leaves_others_alone() {
let mode_info = resize_mode_info();
let entries = KeybindProcessor::get_tooltip_entries(&mode_info, InputMode::Resize);
let plus_minus = entries
.iter()
.find(|e| e.key_text() == "<+->")
.unwrap_or_else(|| panic!("{:?}", entries));
let hovered = resize(Resize::Decrease, None);
let (_, is_hovered) = styled_key_text(plus_minus.key_text(), plus_minus, 0, Some(&hovered));
assert!(is_hovered);
let (_, not_hovered) = styled_key_text(
plus_minus.key_text(),
plus_minus,
0,
Some(&vec![Action::Detach]),
);
assert!(!not_hovered);
assert_eq!(plus_minus.description_actions(), None);
}
}
+261
View File
@@ -0,0 +1,261 @@
use std::collections::BTreeMap;
use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
const CONFIG_DOUBLE_CLICK_TAB: &str = "double_click_tab";
const CONFIG_DOUBLE_CLICK_EMPTY: &str = "double_click_empty";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DoubleClickTabAction {
#[default]
Rename,
Nothing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DoubleClickEmptyAction {
#[default]
NewTab,
Nothing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DoubleClickConfig {
pub tab: DoubleClickTabAction,
pub empty: DoubleClickEmptyAction,
}
impl DoubleClickConfig {
pub fn from_configuration(configuration: &BTreeMap<String, String>) -> Self {
let tab = match configuration.get(CONFIG_DOUBLE_CLICK_TAB).map(|s| s.trim()) {
Some("none") => DoubleClickTabAction::Nothing,
_ => DoubleClickTabAction::Rename,
};
let empty = match configuration
.get(CONFIG_DOUBLE_CLICK_EMPTY)
.map(|s| s.trim())
{
Some("none") => DoubleClickEmptyAction::Nothing,
_ => DoubleClickEmptyAction::NewTab,
};
DoubleClickConfig { tab, empty }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClickTarget {
Tab(usize),
Empty,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DoubleClickOutcome {
RenameTab(usize),
NewTab,
Nothing,
}
pub fn classify_click(
clicked_part: Option<(Option<usize>, &str)>,
in_reserved_range: bool,
) -> ClickTarget {
if in_reserved_range {
return ClickTarget::Other;
}
match clicked_part {
None => ClickTarget::Empty,
Some((Some(tab_index), _)) => ClickTarget::Tab(tab_index),
Some((None, text)) if is_blank(text) => ClickTarget::Empty,
Some((None, _)) => ClickTarget::Other,
}
}
pub fn double_click_outcome(
config: &DoubleClickConfig,
last_left_click: Option<ClickTarget>,
target: ClickTarget,
) -> DoubleClickOutcome {
if last_left_click != Some(target) {
return DoubleClickOutcome::Nothing;
}
match target {
ClickTarget::Tab(tab_index) if config.tab == DoubleClickTabAction::Rename => {
DoubleClickOutcome::RenameTab(tab_index)
},
ClickTarget::Empty if config.empty == DoubleClickEmptyAction::NewTab => {
DoubleClickOutcome::NewTab
},
_ => DoubleClickOutcome::Nothing,
}
}
pub fn apply_double_click_outcome(
outcome: DoubleClickOutcome,
tabs: &[TabInfo],
active_tab_idx: usize,
) {
match outcome {
DoubleClickOutcome::RenameTab(tab_index) => {
let Some(tab) = tabs.iter().find(|tab| tab.position == tab_index) else {
return;
};
let tab_position = tab_index + 1;
if tab_position != active_tab_idx {
switch_tab_to(tab_position as u32);
}
run_action(
Action::StartRenameTabByTabId {
id: tab.tab_id as u64,
},
BTreeMap::new(),
);
},
DoubleClickOutcome::NewTab => {
new_tab::<&str>(None, None);
},
DoubleClickOutcome::Nothing => {},
}
}
fn is_blank(text: &str) -> bool {
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
while let Some(next) = chars.next() {
if ('\u{40}'..='\u{7e}').contains(&next) {
break;
}
}
}
} else if !c.is_whitespace() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn config(tab: &str, empty: &str) -> DoubleClickConfig {
let mut configuration = BTreeMap::new();
configuration.insert(CONFIG_DOUBLE_CLICK_TAB.to_owned(), tab.to_owned());
configuration.insert(CONFIG_DOUBLE_CLICK_EMPTY.to_owned(), empty.to_owned());
DoubleClickConfig::from_configuration(&configuration)
}
#[test]
fn defaults_are_rename_and_new_tab() {
let config = DoubleClickConfig::from_configuration(&BTreeMap::new());
assert_eq!(config.tab, DoubleClickTabAction::Rename);
assert_eq!(config.empty, DoubleClickEmptyAction::NewTab);
}
#[test]
fn none_values_disable_both_actions() {
let config = config("none", "none");
assert_eq!(config.tab, DoubleClickTabAction::Nothing);
assert_eq!(config.empty, DoubleClickEmptyAction::Nothing);
}
#[test]
fn explicit_values_enable_both_actions() {
let config = config("rename", "new_tab");
assert_eq!(config.tab, DoubleClickTabAction::Rename);
assert_eq!(config.empty, DoubleClickEmptyAction::NewTab);
}
#[test]
fn double_click_on_tab_renames_it() {
let outcome = double_click_outcome(
&DoubleClickConfig::default(),
Some(ClickTarget::Tab(2)),
ClickTarget::Tab(2),
);
assert_eq!(outcome, DoubleClickOutcome::RenameTab(2));
}
#[test]
fn double_click_on_tab_does_nothing_when_disabled() {
let outcome = double_click_outcome(
&config("none", "new_tab"),
Some(ClickTarget::Tab(2)),
ClickTarget::Tab(2),
);
assert_eq!(outcome, DoubleClickOutcome::Nothing);
}
#[test]
fn double_click_on_empty_space_opens_new_tab() {
let outcome = double_click_outcome(
&DoubleClickConfig::default(),
Some(ClickTarget::Empty),
ClickTarget::Empty,
);
assert_eq!(outcome, DoubleClickOutcome::NewTab);
}
#[test]
fn double_click_on_empty_space_does_nothing_when_disabled() {
let outcome = double_click_outcome(
&config("rename", "none"),
Some(ClickTarget::Empty),
ClickTarget::Empty,
);
assert_eq!(outcome, DoubleClickOutcome::Nothing);
}
#[test]
fn double_click_on_different_target_than_last_click_is_ignored() {
let config = DoubleClickConfig::default();
assert_eq!(
double_click_outcome(&config, Some(ClickTarget::Tab(1)), ClickTarget::Tab(2)),
DoubleClickOutcome::Nothing
);
assert_eq!(
double_click_outcome(&config, Some(ClickTarget::Tab(1)), ClickTarget::Empty),
DoubleClickOutcome::Nothing
);
assert_eq!(
double_click_outcome(&config, None, ClickTarget::Tab(1)),
DoubleClickOutcome::Nothing
);
}
#[test]
fn double_click_on_other_elements_does_nothing() {
let outcome = double_click_outcome(
&DoubleClickConfig::default(),
Some(ClickTarget::Other),
ClickTarget::Other,
);
assert_eq!(outcome, DoubleClickOutcome::Nothing);
}
#[test]
fn classify_click_targets() {
assert_eq!(classify_click(None, false), ClickTarget::Empty);
assert_eq!(
classify_click(Some((Some(3), "tab")), false),
ClickTarget::Tab(3)
);
assert_eq!(
classify_click(Some((None, "\u{1b}[48;5;0m \u{1b}[0m")), false),
ClickTarget::Empty
);
assert_eq!(
classify_click(Some((None, "Zellij")), false),
ClickTarget::Other
);
assert_eq!(
classify_click(Some((Some(3), "tab")), true),
ClickTarget::Other
);
assert_eq!(classify_click(None, true), ClickTarget::Other);
}
}
+3
View File
@@ -1,8 +1,11 @@
mod click_actions;
mod compact_bar;
mod double_click;
mod keybinds;
mod link;
mod status_bar;
mod tab_bar;
mod tab_drag;
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
@@ -165,6 +165,7 @@ fn long_mode_shortcut(
+ key_hint.chars().count()
+ 1
+ separator.chars().count(),
..Default::default()
}
}
@@ -217,6 +218,7 @@ fn shortened_modifier_shortcut(
+ key_hint.chars().count()
+ 1
+ separator.chars().count(),
..Default::default()
}
}
@@ -255,6 +257,7 @@ fn short_mode_shortcut(
+ key_binding.chars().count()
+ 1
+ separator.chars().count(),
..Default::default()
}
}
@@ -317,7 +320,11 @@ fn swap_layout_keycode(mode_info: &ModeInfo) -> LinePart {
let keycode = ANSIStrings(&prev_next_keys_indicator);
let len = unstyled_len(&keycode);
let part = keycode.to_string();
LinePart { part, len }
LinePart {
part,
len,
..Default::default()
}
}
fn swap_layout_status(
@@ -383,11 +390,13 @@ fn swap_layout_status(
Some(LinePart {
part,
len: full_len,
..Default::default()
})
} else if short_len <= max_len && mode_info.mode != InputMode::Locked {
Some(LinePart {
part: swap_layout_indicator,
len: short_len,
..Default::default()
})
} else {
None
@@ -478,6 +487,7 @@ pub fn superkey(
LinePart {
part: ANSIStrings(&[prefix, suffix_separator]).to_string(),
len: prefix_text.chars().count() + separator.chars().count(),
..Default::default()
},
)
}
@@ -0,0 +1,18 @@
use std::cell::RefCell;
use zellij_tile::prelude::actions::Action;
thread_local! {
static HOVERED: RefCell<Option<Vec<Action>>> = RefCell::new(None);
}
pub fn with_hovered<T>(hovered: Option<Vec<Action>>, render: impl FnOnce() -> T) -> T {
HOVERED.with(|h| *h.borrow_mut() = hovered);
let result = render();
HOVERED.with(|h| *h.borrow_mut() = None);
result
}
pub fn is_hovered(actions: &[Action]) -> bool {
HOVERED.with(|h| h.borrow().as_deref() == Some(actions))
}
+175 -41
View File
@@ -1,4 +1,5 @@
mod first_line;
mod hover;
mod one_line_ui;
mod second_line;
mod tip;
@@ -24,8 +25,12 @@ use second_line::{
};
use tip::utils::get_cached_tip_name;
use crate::click_actions::{region_at, ActionRunner, ClickRegion};
use crate::keybinds::KeybindStore;
use crate::ClientSeed;
use hover::with_hovered;
const RUNNER_OWNER: &str = "status-bar";
static ARROW_SEPARATOR: &str = "";
static MORE_MSG: &str = " ... ";
@@ -46,14 +51,14 @@ struct ClientState {
text_copy_destination: Option<CopyDestination>,
display_system_clipboard_failure: bool,
base_mode_is_locked: bool,
new_pane_ribbon_hovered: bool,
floating_ribbon_hovered: bool,
hovered: Option<Vec<Action>>,
hover_at: Option<(SlotId, usize)>,
}
#[derive(Default)]
struct SlotClientState {
new_pane_ribbon_range: Option<(usize, usize)>,
floating_ribbon_range: Option<(usize, usize)>,
clicks: Vec<ClickRegion>,
cols: usize,
}
#[derive(Default)]
@@ -61,19 +66,31 @@ pub struct StatusBar {
slots: BTreeMap<SlotId, SlotConfig>,
clients: BTreeMap<ClientId, ClientState>,
slot_clients: BTreeMap<(SlotId, ClientId), SlotClientState>,
runner: ActionRunner,
}
#[derive(Default)]
pub struct LinePart {
part: String,
len: usize,
clicks: Vec<ClickRegion>,
}
impl LinePart {
pub fn append(&mut self, to_append: &LinePart) {
let offset = self.len;
self.clicks
.extend(to_append.clicks.iter().map(|click| click.shifted(offset)));
self.part.push_str(&to_append.part);
self.len += to_append.len;
}
pub fn prepend_padding(&mut self, padding: &str, padding_len: usize) {
self.part = format!("{}{}", padding, self.part);
self.len += padding_len;
for click in self.clicks.iter_mut() {
*click = click.shifted(padding_len);
}
}
}
impl Display for LinePart {
@@ -230,13 +247,6 @@ fn color_elements(
}
}
fn col_in_range(range: Option<(usize, usize)>, col: usize) -> bool {
match range {
Some((start, end)) => col >= start && col < end,
None => false,
}
}
impl StatusBar {
pub fn has_slots(&self) -> bool {
!self.slots.is_empty()
@@ -265,6 +275,7 @@ impl StatusBar {
EventType::SystemClipboardFailure,
EventType::InitialKeybinds,
EventType::Mouse,
EventType::ActionComplete,
]);
}
@@ -391,29 +402,41 @@ impl StatusBar {
client.text_copy_destination = None;
client.display_system_clipboard_failure = false;
},
Event::ActionComplete(..) => {
self.runner.action_completed(RUNNER_OWNER, client_id, event);
},
Event::Mouse(mouse_event) => match mouse_event {
Mouse::RightClick(line, col) => {
open_context_menu(ContextMenuTarget::Bar, (*line).max(0) as usize, *col);
},
Mouse::LeftClick(_, col) => {
let col = *col;
if col_in_range(slot_client.new_pane_ribbon_range, col) {
new_pane();
} else if col_in_range(slot_client.floating_ribbon_range, col) {
toggle_floating_panes(None);
if let Some(region) = region_at(&slot_client.clicks, *col) {
self.runner
.run(RUNNER_OWNER, client_id, region.actions.clone());
}
},
Mouse::Hover(_, col) => {
let col = *col;
let new_pane_hovered = col_in_range(slot_client.new_pane_ribbon_range, col);
let floating_hovered =
!new_pane_hovered && col_in_range(slot_client.floating_ribbon_range, col);
if client.new_pane_ribbon_hovered != new_pane_hovered
|| client.floating_ribbon_hovered != floating_hovered
{
client.new_pane_ribbon_hovered = new_pane_hovered;
client.floating_ribbon_hovered = floating_hovered;
should_render = true;
if let Some(slot_id) = slot_id {
let col = *col;
let on_screen = col < slot_client.cols;
let owns_hover = client
.hover_at
.map(|(hover_slot, _)| hover_slot == slot_id)
.unwrap_or(true);
if on_screen || owns_hover {
client.hover_at = if on_screen {
Some((slot_id, col))
} else {
None
};
let hovered = region_at(&slot_client.clicks, col)
.filter(|_| on_screen)
.map(|region| region.actions.clone());
if client.hovered != hovered {
client.hovered = hovered;
should_render = true;
}
}
}
},
_ => {},
@@ -467,26 +490,46 @@ impl StatusBar {
PaletteColor::EightBit(color) => format!("\u{1b}[48;5;{}m\u{1b}[0K", color),
};
let active_tab = client.tabs.iter().find(|t| t.active);
let (line, new_pane_ribbon_range, floating_ribbon_range) = one_line_ui(
&client.mode_info,
active_tab,
cols,
separator,
client.base_mode_is_locked,
client.text_copy_destination,
client.display_system_clipboard_failure,
client.new_pane_ribbon_hovered,
client.floating_ribbon_hovered,
);
let render_line = |hovered: Option<Vec<Action>>| {
with_hovered(hovered, || {
one_line_ui(
&client.mode_info,
active_tab,
cols,
separator,
client.base_mode_is_locked,
client.text_copy_destination,
client.display_system_clipboard_failure,
)
})
};
let hover_col = client
.hover_at
.filter(|(hover_slot, _)| *hover_slot == slot_id)
.map(|(_, col)| col);
let mut hovered = hover_col.and(client.hovered.clone());
let mut line = render_line(hovered.clone());
if let Some(col) = hover_col {
let under_mouse = region_at(&line.clicks, col).map(|region| region.actions.clone());
if under_mouse != hovered {
hovered = under_mouse;
line = render_line(hovered.clone());
}
}
if hover_col.is_some() {
if let Some(client) = self.clients.get_mut(&client_id) {
client.hovered = hovered;
}
}
let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default();
slot_client.new_pane_ribbon_range = new_pane_ribbon_range;
slot_client.floating_ribbon_range = floating_ribbon_range;
slot_client.clicks = std::mem::take(&mut line.clicks);
slot_client.cols = cols;
print!("{}{}", line, fill_bg);
return;
}
let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default();
slot_client.new_pane_ribbon_range = None;
slot_client.floating_ribbon_range = None;
slot_client.clicks.clear();
slot_client.cols = cols;
let active_tab = client.tabs.iter().find(|t| t.active);
let first_line = first_line(&client.mode_info, active_tab, cols, separator);
@@ -1080,3 +1123,94 @@ pub mod tests {
assert_eq!(ret, "Alt + <ENTER|SPACE|TAB>".to_string())
}
}
#[cfg(test)]
mod hover_tests {
use super::*;
fn ctrl(c: char) -> KeyWithModifier {
KeyWithModifier::new(BareKey::Char(c)).with_ctrl_modifier()
}
fn to_mode(input_mode: InputMode) -> Vec<Action> {
vec![Action::SwitchToMode { input_mode }]
}
fn mode_info(mode: InputMode) -> ModeInfo {
ModeInfo {
mode,
base_mode: Some(InputMode::Normal),
keybinds: vec![
(
InputMode::Normal,
vec![
(ctrl('g'), to_mode(InputMode::Locked)),
(ctrl('p'), to_mode(InputMode::Pane)),
(ctrl('q'), vec![Action::Quit]),
],
),
(
InputMode::Pane,
vec![(ctrl('p'), to_mode(InputMode::Normal))],
),
(
InputMode::Locked,
vec![(ctrl('g'), to_mode(InputMode::Normal))],
),
],
..Default::default()
}
}
fn status_bar(mode: InputMode) -> StatusBar {
let mut bar = StatusBar::default();
bar.slots.insert(0, SlotConfig::default());
bar.clients.insert(
1,
ClientState {
mode_info: mode_info(mode),
..Default::default()
},
);
bar
}
fn region_start(bar: &StatusBar, actions: &[Action]) -> usize {
bar.slot_clients[&(0, 1)]
.clicks
.iter()
.find(|region| region.actions.as_slice() == actions)
.map(|region| region.start)
.unwrap_or_else(|| panic!("{:?} is not clickable", actions))
}
#[test]
fn a_resting_mouse_keeps_its_hover_when_the_bar_changes_under_it() {
let mut bar = status_bar(InputMode::Normal);
bar.render_client(1, 200, 0, 1);
let lock_col = region_start(&bar, &to_mode(InputMode::Locked));
assert!(bar.update_client(&Event::Mouse(Mouse::Hover(0, lock_col)), 1, Some(0)));
assert_eq!(bar.clients[&1].hovered, Some(to_mode(InputMode::Locked)));
bar.update_client(&Event::Mouse(Mouse::LeftClick(0, lock_col)), 1, Some(0));
bar.update_client(&Event::ModeUpdate(mode_info(InputMode::Locked)), 1, Some(0));
bar.render_client(1, 200, 0, 1);
assert_eq!(region_start(&bar, &to_mode(InputMode::Normal)), lock_col);
assert_eq!(bar.clients[&1].hovered, Some(to_mode(InputMode::Normal)));
}
#[test]
fn leaving_another_slot_does_not_clear_the_hover() {
let mut bar = status_bar(InputMode::Normal);
bar.slots.insert(7, SlotConfig::default());
bar.render_client(1, 200, 0, 1);
bar.render_client(1, 200, 7, 1);
let pane_col = region_start(&bar, &to_mode(InputMode::Pane));
bar.update_client(&Event::Mouse(Mouse::Hover(0, pane_col)), 1, Some(0));
assert!(!bar.update_client(&Event::Mouse(Mouse::Hover(0, 65535)), 1, Some(7)));
assert_eq!(bar.clients[&1].hovered, Some(to_mode(InputMode::Pane)));
assert!(bar.update_client(&Event::Mouse(Mouse::Hover(0, 65535)), 1, Some(0)));
assert_eq!(bar.clients[&1].hovered, None);
assert_eq!(bar.clients[&1].hover_at, None);
}
}
File diff suppressed because it is too large Load Diff
@@ -39,6 +39,7 @@ fn full_length_shortcut(
LinePart {
part: part.to_string(),
len: unstyled_len(&part),
..Default::default()
}
}
@@ -50,6 +51,7 @@ fn locked_interface_indication(palette: Styling) -> LinePart {
LinePart {
part: locked_styled_text.to_string(),
len: locked_text_len,
..Default::default()
}
}
@@ -371,6 +373,7 @@ fn nested_session_status_hint(
return LinePart {
part: part.to_string(),
len,
..Default::default()
};
}
let part = ANSIStrings(&key_bits);
@@ -379,6 +382,7 @@ fn nested_session_status_hint(
LinePart {
part: part.to_string(),
len,
..Default::default()
}
} else {
LinePart::default()
@@ -397,6 +401,7 @@ pub fn text_copied_hint(copy_destination: CopyDestination) -> LinePart {
LinePart {
part: serialize_text(&Text::from(hint).color_range(2, ..).opaque()),
len: hint.len(),
..Default::default()
}
}
@@ -406,6 +411,7 @@ pub fn system_clipboard_error(palette: &Styling) -> LinePart {
LinePart {
part: Style::new().fg(red_color).bold().paint(hint).to_string(),
len: hint.len(),
..Default::default()
}
}
@@ -435,6 +441,7 @@ pub fn fullscreen_panes_to_hide(palette: &Styling, panes_to_hide: usize) -> Line
Style::new().fg(text_color).bold().paint(hide)
),
len,
..Default::default()
}
}
@@ -497,6 +504,7 @@ pub fn floating_panes_are_visible(mode_info: &ModeInfo) -> LinePart {
Style::new().fg(white_color).bold().paint(to_hide),
),
len,
..Default::default()
}
}
@@ -529,6 +537,7 @@ pub fn locked_fullscreen_panes_to_hide(palette: &Styling, panes_to_hide: usize)
Style::new().fg(text_color).bold().paint(hide)
),
len,
..Default::default()
}
}
@@ -550,6 +559,7 @@ pub fn locked_floating_panes_are_visible(palette: &Styling) -> LinePart {
shortcut_right_separator,
),
len,
..Default::default()
}
}
@@ -18,6 +18,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -17,6 +17,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -12,6 +12,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -12,6 +12,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -18,6 +18,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -12,6 +12,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -17,6 +17,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -12,6 +12,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -17,6 +17,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
@@ -17,6 +17,7 @@ macro_rules! strings {
LinePart {
part: format!("{}", ansi_strings),
len: unstyled_len(&ansi_strings),
..Default::default()
}
}};
}
+35 -12
View File
@@ -7,6 +7,12 @@ use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
use zellij_tile_utils::style;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TabLineHover {
pub new_tab_button: bool,
pub breadcrumb: bool,
}
fn get_current_title_len(current_title: &[LinePart]) -> usize {
current_title.iter().map(|p| p.len).sum()
}
@@ -189,6 +195,7 @@ fn tab_line_prefix(
cols: usize,
dimmed: bool,
breadcrumb_ancestry: &[String],
breadcrumb_hovered: bool,
) -> (Vec<LinePart>, Option<(usize, usize)>) {
let prefix_text = " Zellij ".to_string();
@@ -222,11 +229,17 @@ fn tab_line_prefix(
};
let name_style = if dimmed {
dim_style(bg_color)
} else if breadcrumb_hovered {
style!(palette.text_unselected.emphasis_0, bg_color)
.bold()
.italic()
} else {
style!(palette.text_unselected.emphasis_0, bg_color).bold()
};
let closing_style = if dimmed {
dim_style(bg_color)
} else if breadcrumb_hovered {
style!(text_color, bg_color).bold().italic()
} else {
style!(text_color, bg_color).bold()
};
@@ -291,7 +304,7 @@ pub fn tab_line(
active_pane_scroll: Option<(usize, usize)>,
hint: Option<&BTreeMap<usize, StyledText>>,
is_alternate_tab: bool,
new_tab_button_is_hovered: bool,
hover: TabLineHover,
dimmed: bool,
breadcrumb_ancestry: &[String],
) -> (
@@ -307,8 +320,15 @@ pub fn tab_line(
tabs_before_active.pop().unwrap()
};
let (mut prefix, breadcrumb_range) = match hide_session_name {
true => tab_line_prefix(None, palette, cols, dimmed, &[]),
false => tab_line_prefix(session_name, palette, cols, dimmed, breadcrumb_ancestry),
true => tab_line_prefix(None, palette, cols, dimmed, &[], false),
false => tab_line_prefix(
session_name,
palette,
cols,
dimmed,
breadcrumb_ancestry,
hover.breadcrumb,
),
};
let mut swap_layout_indicator = if hide_swap_layout_indicator {
@@ -363,12 +383,10 @@ pub fn tab_line(
}]);
let supports_arrow_fonts = !capabilities.arrow_fonts;
let new_tab_button_background = if supports_arrow_fonts {
if new_tab_button_is_hovered {
palette.ribbon_unselected.emphasis_1
} else {
palette.ribbon_unselected.background
}
let new_tab_button_background = if hover.new_tab_button {
palette.ribbon_unselected.emphasis_1
} else if supports_arrow_fonts {
palette.ribbon_unselected.background
} else if is_alternate_tab {
palette.ribbon_unselected.emphasis_1
} else {
@@ -390,7 +408,7 @@ pub fn tab_line(
None
};
if let Some(mut swap_layout_indicator) = swap_layout_indicator.take() {
if let Some(swap_layout_indicator) = swap_layout_indicator.take() {
let remaining_space = cols
.saturating_sub(prefix.iter().fold(0, |len, part| len + part.len))
.saturating_sub(swap_layout_indicator.len);
@@ -407,8 +425,13 @@ pub fn tab_line(
);
padding_len += 1;
}
swap_layout_indicator.part = format!("{}{}", padding, swap_layout_indicator.part);
swap_layout_indicator.len += padding_len;
if padding_len > 0 {
prefix.push(LinePart {
part: padding,
len: padding_len,
tab_index: None,
});
}
prefix.push(swap_layout_indicator);
}
+447 -48
View File
@@ -3,14 +3,21 @@ mod tab;
use std::cmp::{max, min};
use std::collections::{BTreeMap, VecDeque};
use std::convert::TryInto;
use tab::{get_clicked_line_part, get_tab_to_focus};
use tab::get_clicked_line_part;
use zellij_tile::prelude::*;
use crate::double_click::{
apply_double_click_outcome, classify_click, double_click_outcome, ClickTarget,
DoubleClickConfig,
};
use crate::keybinds::KeybindStore;
use crate::tab_drag::{
apply_release, is_tab_move_completion, request_tab_move, segments_of, switch_outcome,
tab_parts_in_line, TabDrag, TabPart, TabSegment,
};
use crate::ClientSeed;
use line::tab_line;
use line::{tab_line, TabLineHover};
use tab::tab_style;
#[derive(Debug, Default)]
@@ -29,19 +36,84 @@ impl LinePart {
static ARROW_SEPARATOR: &str = "";
fn in_range(range: Option<(usize, usize)>, col: usize) -> bool {
matches!(range, Some((start, end)) if col >= start && col < end)
}
fn click_target(slot_client: &SlotClientState, col: usize) -> ClickTarget {
let in_reserved_range = in_range(slot_client.breadcrumb_range, col)
|| in_range(slot_client.new_tab_button_range, col);
let clicked_part = get_clicked_line_part(&slot_client.tab_line, col)
.map(|line_part| (line_part.tab_index, line_part.part.as_str()));
classify_click(clicked_part, in_reserved_range)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TabBarRegion {
Tab(usize),
Marker { left: bool, position: usize },
NewTabButton,
Breadcrumb,
}
fn region_at(regions: &[(usize, usize, TabBarRegion)], col: usize) -> Option<TabBarRegion> {
regions
.iter()
.find(|(start, end, _)| col >= *start && col < *end)
.map(|(_, _, region)| *region)
}
pub(crate) fn line_regions(
line: &[LinePart],
tab_parts: &[String],
active_tab_position: usize,
new_tab_button_range: Option<(usize, usize)>,
breadcrumb_range: Option<(usize, usize)>,
) -> (Vec<(usize, usize, TabBarRegion)>, Vec<TabSegment>) {
let mut regions = vec![];
if let Some((start, end)) = breadcrumb_range {
regions.push((start, end, TabBarRegion::Breadcrumb));
}
let parts = tab_parts_in_line(
line.iter()
.map(|part| (part.len, part.tab_index, part.part.as_str())),
tab_parts,
active_tab_position,
);
let segments = segments_of(&parts);
for (start, end, kind) in parts {
regions.push((
start,
end,
match kind {
TabPart::Tab(position) => TabBarRegion::Tab(position),
TabPart::Marker { left, position } => TabBarRegion::Marker { left, position },
},
));
}
if let Some((start, end)) = new_tab_button_range {
regions.push((start, end, TabBarRegion::NewTabButton));
}
(regions, segments)
}
#[derive(Debug, Default)]
struct SlotConfig {
hide_swap_layout_indication: bool,
double_click: DoubleClickConfig,
}
#[derive(Debug, Default)]
struct ClientState {
last_left_click: Option<(Option<SlotId>, ClickTarget)>,
tabs: Vec<TabInfo>,
active_tab_idx: usize,
mode_info: ModeInfo,
active_pane_scroll: Option<(usize, usize)>,
hovered_tab_idx: Option<usize>,
hovered_new_tab_button: bool,
hovered: Option<TabBarRegion>,
hover_at: Option<(SlotId, usize)>,
drag: Option<TabDrag>,
switch_when_settled: Option<usize>,
hint_text: Option<BTreeMap<usize, StyledText>>,
outstanding_hint_timeouts: usize,
}
@@ -51,6 +123,18 @@ struct SlotClientState {
tab_line: Vec<LinePart>,
new_tab_button_range: Option<(usize, usize)>,
breadcrumb_range: Option<(usize, usize)>,
regions: Vec<(usize, usize, TabBarRegion)>,
segments: Vec<TabSegment>,
cols: usize,
}
struct ComposedLine {
line: Vec<LinePart>,
new_tab_button_range: Option<(usize, usize)>,
breadcrumb_range: Option<(usize, usize)>,
regions: Vec<(usize, usize, TabBarRegion)>,
segments: Vec<TabSegment>,
background: PaletteColor,
}
#[derive(Debug, Default)]
@@ -76,6 +160,7 @@ impl TabBar {
slot.id,
SlotConfig {
hide_swap_layout_indication,
double_click: DoubleClickConfig::from_configuration(&slot.configuration),
},
);
subscribe(&[
@@ -87,6 +172,7 @@ impl TabBar {
EventType::HintText,
EventType::Timer,
EventType::InputReceived,
EventType::ActionComplete,
]);
}
@@ -194,6 +280,10 @@ impl TabBar {
client_id: ClientId,
slot_id: Option<SlotId>,
) -> bool {
let double_click_config = slot_id
.and_then(|slot_id| self.slots.get(&slot_id))
.map(|slot| slot.double_click)
.unwrap_or_default();
let client = self.clients.entry(client_id).or_default();
let empty_slot_client = SlotClientState::default();
let slot_client = slot_id
@@ -219,6 +309,12 @@ impl TabBar {
}
client.active_tab_idx = active_tab_idx;
client.tabs = tabs.clone();
if let Some(drag) = client.drag.as_mut() {
drag.tabs_updated(tabs);
}
if let Some(tab_id) = client.switch_when_settled.take() {
apply_release(switch_outcome(tabs, tab_id));
}
} else {
eprintln!("Could not find active tab.");
}
@@ -249,51 +345,102 @@ impl TabBar {
should_render = true;
}
},
event if is_tab_move_completion(event) => {
if let Some(drag) = client.drag.as_mut() {
if drag.move_completed() {
should_render = true;
}
}
if let Some(tab_id) = client.switch_when_settled.take() {
apply_release(switch_outcome(&client.tabs, tab_id));
}
},
Event::Mouse(me) => match me {
Mouse::LeftClick(_, col) => {
let col = *col;
if let Some((start, end)) = slot_client.breadcrumb_range {
if col >= start && col < end {
if let Some(slot_id) = slot_id {
client.hover_at = Some((slot_id, col));
}
client.last_left_click = Some((slot_id, click_target(slot_client, col)));
client.drag = None;
match region_at(&slot_client.regions, col) {
Some(TabBarRegion::Breadcrumb) => {
focus_host_session();
return should_render;
}
}
if let Some((start, end)) = slot_client.new_tab_button_range {
if col >= start && col < end {
},
Some(TabBarRegion::NewTabButton) => {
new_tab::<&str>(None, None);
return should_render;
}
}
let tab_to_focus =
get_tab_to_focus(&slot_client.tab_line, client.active_tab_idx, col);
if let Some(idx) = tab_to_focus {
switch_tab_to(idx.try_into().unwrap());
},
Some(TabBarRegion::Marker { position, .. }) => {
switch_tab_to(position as u32 + 1);
},
Some(TabBarRegion::Tab(position)) => {
if let Some(tab) = client.tabs.iter().find(|t| t.position == position) {
client.drag = Some(TabDrag::new(slot_id, tab.tab_id));
should_render = true;
}
},
None => {},
}
},
Mouse::Hover(_, col) => {
let col = *col;
let simplified_ui = client.mode_info.capabilities.arrow_fonts;
let mut new_hovered_new_tab_button = false;
let mut new_hovered_tab_idx = None;
if !simplified_ui {
if let Some((start, end)) = slot_client.new_tab_button_range {
if col >= start && col < end {
new_hovered_new_tab_button = true;
}
}
if !new_hovered_new_tab_button {
new_hovered_tab_idx =
get_tab_to_focus(&slot_client.tab_line, client.active_tab_idx, col);
Mouse::Hold(_, col) => {
if let Some(slot_id) = slot_id {
client.hover_at = Some((slot_id, *col));
}
if let Some(drag) = client.drag.as_mut().filter(|drag| drag.slot_id == slot_id)
{
if let Some((tab_id, target)) =
drag.hold(&client.tabs, &slot_client.segments, *col)
{
request_tab_move(tab_id, target);
}
}
if client.hovered_new_tab_button != new_hovered_new_tab_button
|| client.hovered_tab_idx != new_hovered_tab_idx
{
client.hovered_new_tab_button = new_hovered_new_tab_button;
client.hovered_tab_idx = new_hovered_tab_idx;
},
Mouse::Release(_, col) => {
if let Some(slot_id) = slot_id {
client.hover_at = Some((slot_id, *col));
}
if let Some(drag) = client.drag.take() {
client.switch_when_settled = apply_release(drag.release(&client.tabs));
should_render = true;
}
},
Mouse::DoubleClick(_, col) => {
if client.drag.take().is_some() {
should_render = true;
}
let target = click_target(slot_client, *col);
let last_left_click = client
.last_left_click
.take()
.filter(|(last_slot_id, _)| *last_slot_id == slot_id)
.map(|(_, last_target)| last_target);
let outcome =
double_click_outcome(&double_click_config, last_left_click, target);
apply_double_click_outcome(outcome, &client.tabs, client.active_tab_idx);
},
Mouse::Hover(_, col) => {
if let Some(slot_id) = slot_id {
let col = *col;
let on_screen = col < slot_client.cols;
let owns_hover = client
.hover_at
.map(|(hover_slot, _)| hover_slot == slot_id)
.unwrap_or(true);
if on_screen || owns_hover {
client.hover_at = if on_screen {
Some((slot_id, col))
} else {
None
};
let hovered =
region_at(&slot_client.regions, col).filter(|_| on_screen);
if client.hovered != hovered {
client.hovered = hovered;
should_render = true;
}
}
}
},
Mouse::RightClick(line, col) => {
let target = match get_clicked_line_part(&slot_client.tab_line, *col)
.and_then(|line_part| line_part.tab_index)
@@ -341,12 +488,16 @@ impl TabBar {
}
}
fn render_client(&mut self, _rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) {
let Some(client) = self.clients.get(&client_id) else {
return;
};
fn compose_line(
&self,
cols: usize,
slot_id: SlotId,
client_id: ClientId,
hovered: Option<TabBarRegion>,
) -> Option<ComposedLine> {
let client = self.clients.get(&client_id)?;
if client.tabs.is_empty() {
return;
return None;
}
let hide_swap_layout_indication = self
.slots
@@ -368,12 +519,18 @@ impl TabBar {
} else if t.active {
active_tab_index = t.position;
}
let is_hovered = client.hovered_tab_idx == Some(t.position + 1);
let is_hovered = !t.active && hovered == Some(TabBarRegion::Tab(t.position));
let is_dragged = client
.drag
.as_ref()
.map(|drag| drag.tab_id == t.tab_id)
.unwrap_or(false);
let tab = tab_style(
tabname,
t,
is_alternate_tab,
is_hovered,
is_dragged,
client.mode_info.style.colors,
client.mode_info.capabilities,
dimmed,
@@ -382,6 +539,7 @@ impl TabBar {
all_tabs.push(tab);
}
let tab_parts: Vec<String> = all_tabs.iter().map(|t| t.part.clone()).collect();
let background = client.mode_info.style.colors.text_unselected.background;
let full_pane_frames = client.mode_info.pane_frame_style == Some(PaneFrameStyle::Full);
@@ -410,14 +568,67 @@ impl TabBar {
client.active_pane_scroll,
hint_text,
is_alternate_tab,
client.hovered_new_tab_button,
TabLineHover {
new_tab_button: hovered == Some(TabBarRegion::NewTabButton),
breadcrumb: hovered == Some(TabBarRegion::Breadcrumb),
},
dimmed,
&breadcrumb_ancestry,
);
let (regions, segments) = line_regions(
&line,
&tab_parts,
active_tab_index,
new_tab_button_range,
breadcrumb_range,
);
Some(ComposedLine {
line,
new_tab_button_range,
breadcrumb_range,
regions,
segments,
background,
})
}
fn render_client(&mut self, _rows: usize, cols: usize, slot_id: SlotId, client_id: ClientId) {
let Some(client) = self.clients.get(&client_id) else {
return;
};
let hover_col = client
.hover_at
.filter(|(hover_slot, _)| *hover_slot == slot_id)
.map(|(_, col)| col);
let mut hovered = hover_col.and(client.hovered);
let Some(mut composed) = self.compose_line(cols, slot_id, client_id, hovered) else {
return;
};
if let Some(col) = hover_col {
let under_mouse = region_at(&composed.regions, col);
if under_mouse != hovered {
hovered = under_mouse;
if let Some(recomposed) = self.compose_line(cols, slot_id, client_id, hovered) {
composed = recomposed;
}
}
}
if let Some(client) = self.clients.get_mut(&client_id) {
if hover_col.is_some() {
client.hovered = hovered;
}
if let Some(drag) = client.drag.as_mut() {
drag.laid_out();
}
}
let background = composed.background;
let slot_client = self.slot_clients.entry((slot_id, client_id)).or_default();
slot_client.tab_line = line;
slot_client.new_tab_button_range = new_tab_button_range;
slot_client.breadcrumb_range = breadcrumb_range;
slot_client.tab_line = composed.line;
slot_client.new_tab_button_range = composed.new_tab_button_range;
slot_client.breadcrumb_range = composed.breadcrumb_range;
slot_client.regions = composed.regions;
slot_client.segments = composed.segments;
slot_client.cols = cols;
let output = slot_client
.tab_line
@@ -434,3 +645,191 @@ impl TabBar {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn part(text: &str, tab_index: Option<usize>) -> LinePart {
LinePart {
part: text.to_owned(),
len: text.chars().count(),
tab_index,
}
}
#[test]
fn regions_tell_tabs_apart_from_the_hidden_tab_markers() {
let tab_parts = vec![
"TAB0".to_owned(),
"TAB1".to_owned(),
"TAB2".to_owned(),
"TAB3".to_owned(),
"TAB4".to_owned(),
];
let line = vec![
part(" Zellij ", None),
part("<+1", Some(0)),
part("TAB1", Some(1)),
part("TAB2", Some(2)),
part("+2>", Some(3)),
part(" + ", None),
];
let (regions, segments) = line_regions(&line, &tab_parts, 2, Some((22, 25)), Some((1, 4)));
assert_eq!(
regions,
vec![
(1, 4, TabBarRegion::Breadcrumb),
(
8,
11,
TabBarRegion::Marker {
left: true,
position: 0
}
),
(11, 15, TabBarRegion::Tab(1)),
(15, 19, TabBarRegion::Tab(2)),
(
19,
22,
TabBarRegion::Marker {
left: false,
position: 3
}
),
(22, 25, TabBarRegion::NewTabButton),
]
);
assert_eq!(
segments,
vec![
TabSegment {
start: 11,
end: 15,
position: 1
},
TabSegment {
start: 15,
end: 19,
position: 2
},
]
);
assert_eq!(region_at(&regions, 2), Some(TabBarRegion::Breadcrumb));
assert_eq!(region_at(&regions, 12), Some(TabBarRegion::Tab(1)));
assert_eq!(region_at(&regions, 40), None);
}
fn client_with_tabs(ids: &[usize], active: usize) -> ClientState {
let tabs: Vec<TabInfo> = ids
.iter()
.enumerate()
.map(|(position, id)| TabInfo {
position,
tab_id: *id,
active: position == active,
name: format!("t{}", id),
..Default::default()
})
.collect();
ClientState {
active_tab_idx: active + 1,
tabs,
..Default::default()
}
}
fn bar_with_line(client: ClientState) -> TabBar {
let mut bar = TabBar::default();
bar.clients.insert(1, client);
let line = vec![
part("AAAA", Some(0)),
part("BBBB", Some(1)),
part("CCCC", Some(2)),
];
let tab_parts: Vec<String> = line.iter().map(|p| p.part.clone()).collect();
let (regions, segments) = line_regions(&line, &tab_parts, 0, None, None);
bar.slot_clients.insert(
(0, 1),
SlotClientState {
tab_line: line,
regions,
segments,
cols: 100,
..Default::default()
},
);
bar
}
#[test]
fn hovering_highlights_the_region_under_the_mouse_and_leaving_clears_it() {
let mut bar = bar_with_line(client_with_tabs(&[10, 11, 12], 0));
assert!(bar.update_client(&Event::Mouse(Mouse::Hover(0, 5)), 1, Some(0)));
assert_eq!(bar.clients[&1].hovered, Some(TabBarRegion::Tab(1)));
assert!(!bar.update_client(&Event::Mouse(Mouse::Hover(0, 6)), 1, Some(0)));
assert!(bar.update_client(&Event::Mouse(Mouse::Hover(0, 500)), 1, Some(0)));
assert_eq!(bar.clients[&1].hovered, None);
}
#[test]
fn pressing_a_tab_starts_a_drag_and_a_double_click_cancels_it() {
let mut bar = bar_with_line(client_with_tabs(&[10, 11, 12], 0));
bar.update_client(&Event::Mouse(Mouse::LeftClick(0, 5)), 1, Some(0));
assert_eq!(bar.clients[&1].drag.as_ref().map(|d| d.tab_id), Some(11));
bar.slots.insert(0, SlotConfig::default());
bar.slots.get_mut(&0).unwrap().double_click.tab =
crate::double_click::DoubleClickTabAction::Nothing;
bar.update_client(&Event::Mouse(Mouse::DoubleClick(0, 5)), 1, Some(0));
assert!(bar.clients[&1].drag.is_none());
}
#[test]
fn a_drag_only_moves_once_per_confirmed_layout() {
let mut bar = bar_with_line(client_with_tabs(&[10, 11, 12], 0));
bar.update_client(&Event::Mouse(Mouse::LeftClick(0, 1)), 1, Some(0));
bar.update_client(&Event::Mouse(Mouse::Hold(0, 5)), 1, Some(0));
assert_eq!(
bar.clients[&1].drag.as_ref().and_then(|d| d.pending_target),
Some(1)
);
bar.update_client(&Event::Mouse(Mouse::Hold(0, 9)), 1, Some(0));
assert_eq!(
bar.clients[&1].drag.as_ref().and_then(|d| d.pending_target),
Some(1)
);
let moved = client_with_tabs(&[11, 10, 12], 1).tabs;
bar.update_client(&Event::TabUpdate(moved), 1, Some(0));
let drag = bar.clients[&1].drag.clone().unwrap();
assert_eq!(drag.pending_target, None);
assert!(drag.awaiting_layout);
}
#[test]
fn a_resting_mouse_is_found_again_after_the_tabs_change() {
let mut bar = bar_with_line(client_with_tabs(&[10, 11, 12], 0));
bar.slots.insert(0, SlotConfig::default());
bar.render_client(1, 100, 0, 1);
let second_tab = bar.slot_clients[&(0, 1)]
.regions
.iter()
.find(|(_, _, region)| *region == TabBarRegion::Tab(1))
.map(|(start, _, _)| *start)
.unwrap();
bar.update_client(&Event::Mouse(Mouse::LeftClick(0, second_tab)), 1, Some(0));
bar.update_client(&Event::Mouse(Mouse::Release(0, second_tab)), 1, Some(0));
bar.clients.get_mut(&1).unwrap().hovered = None;
bar.render_client(1, 100, 0, 1);
assert_eq!(bar.clients[&1].hovered, Some(TabBarRegion::Tab(1)));
}
#[test]
fn releasing_ends_the_drag() {
let mut bar = bar_with_line(client_with_tabs(&[10, 11, 12], 0));
bar.update_client(&Event::Mouse(Mouse::LeftClick(0, 1)), 1, Some(0));
assert!(bar.update_client(&Event::Mouse(Mouse::Release(0, 1)), 1, Some(0)));
assert!(bar.clients[&1].drag.is_none());
assert!(bar.clients[&1].switch_when_settled.is_none());
}
}
+8 -14
View File
@@ -24,6 +24,7 @@ pub fn render_tab(
tab: &TabInfo,
is_alternate_tab: bool,
is_hovered: bool,
is_dragged: bool,
palette: Styling,
separator: &str,
dimmed: bool,
@@ -68,6 +69,11 @@ pub fn render_tab(
} else {
style!(foreground_color, background_color).bold()
};
let text_style = if is_dragged {
text_style.italic()
} else {
text_style
};
let left_separator = style!(separator_fill_color, background_color).paint(separator);
let right_separator_style = style!(background_color, separator_fill_color);
let mut tab_text_len = text.width() + (separator_width * 2) + 2;
@@ -105,6 +111,7 @@ pub fn tab_style(
tab: &TabInfo,
mut is_alternate_tab: bool,
is_hovered: bool,
is_dragged: bool,
palette: Styling,
capabilities: PluginCapabilities,
dimmed: bool,
@@ -128,26 +135,13 @@ pub fn tab_style(
tab,
is_alternate_tab,
is_hovered,
is_dragged,
palette,
separator,
dimmed,
)
}
pub(crate) fn get_tab_to_focus(
tab_line: &[LinePart],
active_tab_idx: usize,
mouse_click_col: usize,
) -> Option<usize> {
let clicked_line_part = get_clicked_line_part(tab_line, mouse_click_col)?;
let clicked_tab_idx = clicked_line_part.tab_index?;
let clicked_tab_idx = clicked_tab_idx + 1;
if clicked_tab_idx != active_tab_idx {
return Some(clicked_tab_idx);
}
None
}
pub(crate) fn get_clicked_line_part(
tab_line: &[LinePart],
mouse_click_col: usize,
+421
View File
@@ -0,0 +1,421 @@
use std::collections::BTreeMap;
use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
pub const TAB_DRAG_CONTEXT_KEY: &str = "bars_tab_drag";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabSegment {
pub start: usize,
pub end: usize,
pub position: usize,
}
impl TabSegment {
fn len(&self) -> usize {
self.end.saturating_sub(self.start)
}
fn contains(&self, col: usize) -> bool {
col >= self.start && col < self.end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabPart {
Tab(usize),
Marker { left: bool, position: usize },
}
pub fn tab_parts_in_line<'a>(
parts: impl IntoIterator<Item = (usize, Option<usize>, &'a str)>,
rendered_tabs: &[String],
active_tab_position: usize,
) -> Vec<(usize, usize, TabPart)> {
let mut found = vec![];
let mut start = 0;
for (len, tab_index, text) in parts {
let end = start + len;
if let Some(position) = tab_index {
if len > 0 {
let kind = if rendered_tabs.get(position).map(|t| t.as_str()) == Some(text) {
TabPart::Tab(position)
} else {
TabPart::Marker {
left: position < active_tab_position,
position,
}
};
found.push((start, end, kind));
}
}
start = end;
}
found
}
pub fn segments_of(parts: &[(usize, usize, TabPart)]) -> Vec<TabSegment> {
parts
.iter()
.filter_map(|(start, end, kind)| match kind {
TabPart::Tab(position) => Some(TabSegment {
start: *start,
end: *end,
position: *position,
}),
TabPart::Marker { .. } => None,
})
.collect()
}
pub fn segment_at(segments: &[TabSegment], col: usize) -> Option<TabSegment> {
segments.iter().copied().find(|s| s.contains(col))
}
pub fn drop_target(segments: &[TabSegment], dragged_position: usize, col: usize) -> Option<usize> {
let dragged = segments.iter().find(|s| s.position == dragged_position)?;
let hovered = segment_at(segments, col)?;
let dragged_len = dragged.len();
if hovered.position > dragged_position {
if col >= hovered.end.saturating_sub(dragged_len) {
Some(hovered.position)
} else {
None
}
} else if hovered.position < dragged_position {
if col < hovered.start + dragged_len {
Some(hovered.position)
} else {
None
}
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReleaseOutcome {
Nothing,
SwitchTo(u32),
SwitchWhenSettled(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TabDrag {
pub slot_id: Option<SlotId>,
pub tab_id: usize,
pub pending_target: Option<usize>,
pub awaiting_layout: bool,
}
impl TabDrag {
pub fn new(slot_id: Option<SlotId>, tab_id: usize) -> Self {
TabDrag {
slot_id,
tab_id,
pending_target: None,
awaiting_layout: false,
}
}
pub fn hold(
&mut self,
tabs: &[TabInfo],
segments: &[TabSegment],
col: usize,
) -> Option<(u64, usize)> {
if self.pending_target.is_some() || self.awaiting_layout {
return None;
}
let dragged_position = tabs.iter().find(|t| t.tab_id == self.tab_id)?.position;
let target = drop_target(segments, dragged_position, col)?;
self.pending_target = Some(target);
Some((self.tab_id as u64, target))
}
pub fn tabs_updated(&mut self, tabs: &[TabInfo]) {
let Some(target) = self.pending_target else {
return;
};
let reached = tabs
.iter()
.find(|t| t.tab_id == self.tab_id)
.map(|t| t.position == target)
.unwrap_or(true);
if reached {
self.pending_target = None;
self.awaiting_layout = true;
}
}
pub fn move_completed(&mut self) -> bool {
if self.pending_target.take().is_some() {
self.awaiting_layout = true;
true
} else {
false
}
}
pub fn laid_out(&mut self) {
if self.pending_target.is_none() {
self.awaiting_layout = false;
}
}
pub fn release(&self, tabs: &[TabInfo]) -> ReleaseOutcome {
if self.pending_target.is_some() {
return ReleaseOutcome::SwitchWhenSettled(self.tab_id);
}
switch_outcome(tabs, self.tab_id)
}
}
pub fn switch_outcome(tabs: &[TabInfo], tab_id: usize) -> ReleaseOutcome {
match tabs.iter().find(|t| t.tab_id == tab_id) {
Some(tab) if !tab.active => ReleaseOutcome::SwitchTo(tab.position as u32 + 1),
_ => ReleaseOutcome::Nothing,
}
}
pub fn request_tab_move(tab_id: u64, position: usize) {
let mut context = BTreeMap::new();
context.insert(TAB_DRAG_CONTEXT_KEY.to_owned(), tab_id.to_string());
run_action(
Action::MoveTabToPosition {
id: tab_id,
position: position as u64,
},
context,
);
}
pub fn is_tab_move_completion(event: &Event) -> bool {
matches!(
event,
Event::ActionComplete(Action::MoveTabToPosition { .. }, _, context)
if context.contains_key(TAB_DRAG_CONTEXT_KEY)
)
}
pub fn apply_release(outcome: ReleaseOutcome) -> Option<usize> {
match outcome {
ReleaseOutcome::Nothing => None,
ReleaseOutcome::SwitchTo(position) => {
switch_tab_to(position);
None
},
ReleaseOutcome::SwitchWhenSettled(tab_id) => Some(tab_id),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn segments(widths: &[usize]) -> Vec<TabSegment> {
let mut start = 0;
widths
.iter()
.enumerate()
.map(|(position, width)| {
let segment = TabSegment {
start,
end: start + width,
position,
};
start += width;
segment
})
.collect()
}
fn tabs(ids: &[usize], active_id: usize) -> Vec<TabInfo> {
ids.iter()
.enumerate()
.map(|(position, id)| TabInfo {
position,
tab_id: *id,
active: *id == active_id,
..Default::default()
})
.collect()
}
#[test]
fn a_narrow_tab_only_moves_past_a_wide_one_once_it_would_stay_under_the_mouse() {
let segments = segments(&[4, 12]);
assert_eq!(drop_target(&segments, 0, 2), None);
assert_eq!(drop_target(&segments, 0, 5), None);
assert_eq!(drop_target(&segments, 0, 11), None);
assert_eq!(drop_target(&segments, 0, 12), Some(1));
assert_eq!(drop_target(&segments, 0, 15), Some(1));
}
#[test]
fn moving_left_requires_the_mouse_to_be_where_the_dragged_tab_would_land() {
let segments = segments(&[12, 4]);
assert_eq!(drop_target(&segments, 1, 13), None);
assert_eq!(drop_target(&segments, 1, 4), None);
assert_eq!(drop_target(&segments, 1, 3), Some(0));
assert_eq!(drop_target(&segments, 1, 0), Some(0));
}
#[test]
fn a_wide_tab_moves_as_soon_as_it_reaches_a_narrow_one() {
let segments = segments(&[12, 4]);
assert_eq!(drop_target(&segments, 0, 12), Some(1));
let segments = super::tests::segments(&[4, 12]);
assert_eq!(drop_target(&segments, 1, 3), Some(0));
}
#[test]
fn after_a_move_the_mouse_is_over_the_dragged_tab_so_it_does_not_flip_back() {
let before = segments(&[4, 12]);
let col = 12;
assert_eq!(drop_target(&before, 0, col), Some(1));
let after = vec![
TabSegment {
start: 0,
end: 12,
position: 0,
},
TabSegment {
start: 12,
end: 16,
position: 1,
},
];
assert_eq!(drop_target(&after, 1, col), None);
}
#[test]
fn dragging_across_several_tabs_targets_the_tab_under_the_mouse() {
let segments = segments(&[5, 5, 5, 5]);
assert_eq!(drop_target(&segments, 0, 17), Some(3));
assert_eq!(drop_target(&segments, 3, 1), Some(0));
}
#[test]
fn nothing_happens_outside_tabs_or_for_a_hidden_dragged_tab() {
let segments = segments(&[5, 5]);
assert_eq!(drop_target(&segments, 0, 30), None);
assert_eq!(drop_target(&segments, 4, 7), None);
}
#[test]
fn a_drag_waits_for_its_move_and_a_new_layout_before_moving_again() {
let segments = segments(&[5, 5, 5]);
let mut tabs = tabs(&[10, 11, 12], 10);
let mut drag = TabDrag::new(None, 10);
assert_eq!(drag.hold(&tabs, &segments, 7), Some((10, 1)));
assert_eq!(drag.hold(&tabs, &segments, 12), None);
drag.tabs_updated(&tabs);
assert_eq!(drag.pending_target, Some(1));
tabs[0].position = 1;
tabs[1].position = 0;
drag.tabs_updated(&tabs);
assert_eq!(drag.pending_target, None);
assert_eq!(drag.hold(&tabs, &segments, 12), None);
drag.laid_out();
assert_eq!(drag.hold(&tabs, &segments, 12), Some((10, 2)));
}
#[test]
fn a_finished_move_without_a_tab_update_unblocks_the_drag() {
let segments = segments(&[5, 5]);
let tabs = tabs(&[10, 11], 10);
let mut drag = TabDrag::new(None, 10);
assert!(!drag.move_completed());
drag.hold(&tabs, &segments, 7);
assert!(drag.move_completed());
drag.laid_out();
assert_eq!(drag.hold(&tabs, &segments, 7), Some((10, 1)));
}
#[test]
fn releasing_switches_to_the_dragged_tab_unless_it_is_active() {
let tabs = tabs(&[10, 11, 12], 10);
assert_eq!(
TabDrag::new(None, 10).release(&tabs),
ReleaseOutcome::Nothing
);
assert_eq!(
TabDrag::new(None, 12).release(&tabs),
ReleaseOutcome::SwitchTo(3)
);
assert_eq!(
TabDrag::new(None, 99).release(&tabs),
ReleaseOutcome::Nothing
);
}
#[test]
fn releasing_during_a_move_switches_once_the_move_settles() {
let segments = segments(&[5, 5]);
let tabs = tabs(&[10, 11], 10);
let mut drag = TabDrag::new(None, 11);
drag.hold(&tabs, &segments, 2);
assert_eq!(drag.release(&tabs), ReleaseOutcome::SwitchWhenSettled(11));
}
#[test]
fn tab_parts_are_told_apart_from_hidden_tab_markers() {
let rendered = vec!["T0".to_owned(), "T1".to_owned(), "T2".to_owned()];
let line = vec![
(3, None, "abc"),
(2, Some(0), "<1"),
(2, Some(1), "T1"),
(0, Some(9), ""),
(2, Some(2), ">1"),
];
let parts = tab_parts_in_line(line, &rendered, 1);
assert_eq!(
parts,
vec![
(
3,
5,
TabPart::Marker {
left: true,
position: 0
}
),
(5, 7, TabPart::Tab(1)),
(
7,
9,
TabPart::Marker {
left: false,
position: 2
}
),
]
);
assert_eq!(
segments_of(&parts),
vec![TabSegment {
start: 5,
end: 7,
position: 1
}]
);
}
#[test]
fn only_tab_moves_requested_by_a_drag_count_as_completions() {
let mut context = BTreeMap::new();
context.insert(TAB_DRAG_CONTEXT_KEY.to_owned(), "3".to_owned());
let action = Action::MoveTabToPosition { id: 3, position: 0 };
assert!(is_tab_move_completion(&Event::ActionComplete(
action.clone(),
None,
context
)));
assert!(!is_tab_move_completion(&Event::ActionComplete(
action,
None,
BTreeMap::new()
)));
}
}
@@ -40,6 +40,7 @@ pub struct ActionSpec {
pub description: &'static str,
pub args: &'static [ArgSpec],
pub menu_only: bool,
pub mouse_only: bool,
}
const fn positional(label: &'static str, kind: ArgKind, required: bool) -> ArgSpec {
@@ -78,6 +79,7 @@ const fn plain(name: &'static str, description: &'static str) -> ActionSpec {
description,
args: &[],
menu_only: false,
mouse_only: false,
}
}
@@ -91,6 +93,7 @@ const fn with_args(
description,
args,
menu_only: false,
mouse_only: false,
}
}
@@ -104,6 +107,21 @@ const fn menu_only(
description,
args,
menu_only: true,
mouse_only: false,
}
}
const fn mouse_only(
name: &'static str,
description: &'static str,
args: &'static [ArgSpec],
) -> ActionSpec {
ActionSpec {
name,
description,
args,
menu_only: false,
mouse_only: true,
}
}
@@ -126,6 +144,7 @@ const DIRECTIONS: &[&str] = &["left", "right", "up", "down"];
const OPTIONAL_DIRECTIONS: &[&str] = &["", "left", "right", "up", "down"];
const NEW_PANE_PLACES: &[&str] = &["", "right", "down", "stacked"];
const LEFT_RIGHT: &[&str] = &["left", "right"];
const START_END: &[&str] = &["start", "end"];
const RESIZES: &[&str] = &[
"Increase",
"Decrease",
@@ -141,6 +160,8 @@ const RESIZES: &[&str] = &[
const SEARCH_DIRECTIONS: &[&str] = &["down", "up"];
const SEARCH_OPTIONS: &[&str] = &["CaseSensitivity", "WholeWord", "Wrap"];
const FRAME_STYLES: &[&str] = &["full", "titles", "none"];
const PROMPT_DIRECTIONS: &[&str] = &["previous", "next"];
const RESIZE_DIRECTIONS: &[&str] = &["increase", "decrease"];
pub const RAW_KDL: ActionSpec = ActionSpec {
name: "KDL",
@@ -153,6 +174,7 @@ pub const RAW_KDL: ActionSpec = ActionSpec {
required: true,
}],
menu_only: false,
mouse_only: false,
};
pub const ACTION_SPECS: &[ActionSpec] = &[
@@ -436,12 +458,64 @@ pub const ACTION_SPECS: &[ActionSpec] = &[
"Move the clicked tab",
&[positional("Direction", ArgKind::Choice(LEFT_RIGHT), true)],
),
menu_only(
"MoveTabToPosition",
"Move the clicked tab to the start or the end",
&[positional("Position", ArgKind::Choice(START_END), true)],
),
mouse_only("Click", "Send the click to the pane and focus it", &[]),
mouse_only("Select", "Select text by dragging", &[]),
mouse_only("FocusPane", "Focus the clicked pane", &[]),
mouse_only("MovePane", "Drag the clicked pane to move it", &[]),
mouse_only("ResizePane", "Drag a pane border to resize", &[]),
mouse_only(
"ToggleFullscreen",
"Toggle fullscreen for the clicked pane",
&[],
),
mouse_only(
"GroupToggle",
"Add or remove the clicked pane from the group",
&[],
),
mouse_only("Ungroup", "Clear the pane group", &[]),
mouse_only("ContextMenu", "Open the right-click menu", &[]),
mouse_only(
"Scroll",
"Scroll the pane under the mouse (wheel only)",
&[positional("Lines", ArgKind::Number, false)],
),
mouse_only(
"ScrollColumns",
"Scroll the pane sideways (wheel only)",
&[positional("Columns", ArgKind::Number, false)],
),
mouse_only(
"ScrollToPrompt",
"Scroll to a shell prompt",
&[positional(
"Direction",
ArgKind::Choice(PROMPT_DIRECTIONS),
true,
)],
),
mouse_only(
"ResizeScroll",
"Resize the pane under the mouse",
&[positional(
"Direction",
ArgKind::Choice(RESIZE_DIRECTIONS),
true,
)],
),
mouse_only("PassToApp", "Send the event to the app in the pane", &[]),
mouse_only("Ignore", "Do nothing", &[]),
];
pub fn specs_for(for_menu: bool) -> Vec<ActionSpec> {
pub fn specs_for(for_menu: bool, for_mouse: bool) -> Vec<ActionSpec> {
let mut specs: Vec<ActionSpec> = ACTION_SPECS
.iter()
.filter(|spec| for_menu || !spec.menu_only)
.filter(|spec| (for_menu || !spec.menu_only) && (for_mouse || !spec.mouse_only))
.copied()
.collect();
specs.push(RAW_KDL);
@@ -570,12 +644,26 @@ pub fn size_button(button: &mut Button, width: usize) {
}
pub fn parse_action(text: &str) -> (ActionSpec, Vec<String>) {
parse_action_for(text, false)
}
pub fn parse_action_for(text: &str, for_mouse: bool) -> (ActionSpec, Vec<String>) {
let Ok(document) = text.parse::<KdlDocument>() else {
return raw_values(text);
};
let Some(node) = document.nodes().first() else {
return raw_values(text);
};
if for_mouse {
let mouse_spec = ACTION_SPECS
.iter()
.find(|spec| spec.mouse_only && spec.name == node.name().value());
if let Some(spec) = mouse_spec {
if let Some(values) = values_for(spec, node) {
return (*spec, values);
}
}
}
let Some(spec) = spec_named(node.name().value()) else {
return raw_values(text);
};
@@ -662,9 +750,9 @@ fn values_for(spec: &ActionSpec, node: &KdlNode) -> Option<Vec<String>> {
Some(values)
}
pub fn matching_specs(for_menu: bool, query: &str) -> Vec<ActionSpec> {
pub fn matching_specs(for_menu: bool, for_mouse: bool, query: &str) -> Vec<ActionSpec> {
let query = query.trim().to_lowercase();
specs_for(for_menu)
specs_for(for_menu, for_mouse)
.into_iter()
.filter(|spec| {
query.is_empty()
@@ -732,6 +820,7 @@ pub struct ActionPicker {
pub actions: Vec<String>,
base_mode: Option<InputMode>,
for_menu: bool,
for_mouse: bool,
selected: usize,
stage: Stage,
title: String,
@@ -765,6 +854,7 @@ impl ActionPicker {
actions,
base_mode,
for_menu,
for_mouse: false,
selected: 0,
stage: Stage::List,
title: title.into(),
@@ -784,6 +874,10 @@ impl ActionPicker {
}
picker
}
pub fn with_mouse_behaviours(mut self) -> Self {
self.for_mouse = true;
self
}
pub fn set_label_width(&mut self, label_width: usize) {
self.label_width = Some(label_width);
}
@@ -1022,7 +1116,7 @@ impl ActionPicker {
}
fn activate_row(&mut self, row: usize) -> PickerResponse {
if row < self.actions.len() {
let (spec, values) = parse_action(&self.actions[row]);
let (spec, values) = parse_action_for(&self.actions[row], self.for_mouse);
self.open_args(spec, values, Some(row));
} else if row == self.actions.len() {
self.open_chooser();
@@ -1038,6 +1132,7 @@ impl ActionPicker {
}
pub fn handle_key(&mut self, key: &KeyWithModifier) -> PickerResponse {
let for_menu = self.for_menu;
let for_mouse = self.for_mouse;
match &mut self.stage {
Stage::List => self.handle_list_key(key),
Stage::Choose {
@@ -1045,7 +1140,7 @@ impl ActionPicker {
highlighted,
} => {
let query = search.get_text().to_owned();
let matches = matching_specs(for_menu, &query);
let matches = matching_specs(for_menu, for_mouse, &query);
if is_plain(key, BareKey::Esc) {
return self.back_from_stage();
} else if is_plain(key, BareKey::Down) {
@@ -1151,6 +1246,7 @@ impl ActionPicker {
}
pub fn handle_mouse(&mut self, mouse: Mouse) -> PickerResponse {
let for_menu = self.for_menu;
let for_mouse = self.for_mouse;
if matches!(self.stage, Stage::Args { .. }) {
if let Stage::Args { group, .. } = &mut self.stage {
let event = group.handle_mouse(mouse);
@@ -1218,7 +1314,7 @@ impl ActionPicker {
let Some((line, column)) = is_click(&mouse) else {
return PickerResponse::Pending;
};
let matches = matching_specs(for_menu, search.get_text());
let matches = matching_specs(for_menu, for_mouse, search.get_text());
if let Some(row) = self.scroll.row_at(line, column) {
*highlighted = row;
if let Some(spec) = matches.get(row).copied() {
@@ -1298,6 +1394,7 @@ impl ActionPicker {
}
let body_y = y + 1;
let for_menu = self.for_menu;
let for_mouse = self.for_mouse;
let actions = self.actions.clone();
let selected = if self.active {
self.selected
@@ -1424,7 +1521,7 @@ impl ActionPicker {
search.set_show_cursor(true);
search.render(list_x, y, natural_button_width.min(list_width));
let query = search.get_text().to_owned();
let matches = matching_specs(for_menu, &query);
let matches = matching_specs(for_menu, for_mouse, &query);
let list_height = height.saturating_sub(2);
let visible = self.scroll.layout(
list_x,
@@ -1577,7 +1674,7 @@ mod tests {
#[test]
fn every_action_form_writes_kdl_that_parses_back_to_the_same_action() {
for spec in specs_for(true) {
for spec in specs_for(true, false) {
let values = sample_values(&spec);
let text = build_action(&spec, &values)
.unwrap_or_else(|e| panic!("{} did not build: {}", spec.name, e));
@@ -1667,6 +1764,64 @@ mod tests {
);
}
#[test]
fn mouse_behaviours_are_only_offered_for_mouse_bindings() {
let names = |specs: Vec<ActionSpec>| -> Vec<&'static str> {
specs.iter().map(|spec| spec.name).collect()
};
let keys = names(specs_for(false, false));
let menu = names(specs_for(true, false));
let mouse = names(specs_for(false, true));
for behaviour in [
"Click",
"Select",
"PassToApp",
"Ignore",
"Scroll",
"ResizeScroll",
] {
assert!(
!keys.contains(&behaviour),
"{} is offered for keys",
behaviour
);
assert!(
!menu.contains(&behaviour),
"{} is offered for the menu",
behaviour
);
assert!(
mouse.contains(&behaviour),
"{} is missing for the mouse",
behaviour
);
}
assert!(mouse.contains(&"NewPane"));
assert!(!mouse.contains(&"CloseFocusByPaneId"));
assert_eq!(keys.iter().filter(|name| **name == "MovePane").count(), 1);
assert!(matching_specs(false, false, "PassToApp").is_empty());
assert!(!matching_specs(false, true, "PassToApp").is_empty());
}
#[test]
fn mouse_behaviours_are_written_and_read_back() {
let scroll = ACTION_SPECS
.iter()
.find(|spec| spec.mouse_only && spec.name == "Scroll")
.unwrap();
assert_eq!(build_action(scroll, &[String::new()]).unwrap(), "Scroll");
assert_eq!(build_action(scroll, &["5".to_owned()]).unwrap(), "Scroll 5");
let (spec, values) = parse_action_for("ScrollToPrompt \"next\"", true);
assert!(spec.mouse_only);
assert_eq!(values, vec!["next".to_owned()]);
let (spec, _) = parse_action_for("MovePane", true);
assert!(spec.mouse_only);
let (spec, values) = parse_action_for("MovePane \"left\"", true);
assert!(!spec.mouse_only);
assert_eq!(values, vec!["left".to_owned()]);
assert!(!parse_action("MovePane").0.mouse_only);
}
#[test]
fn actions_are_numbered_only_when_there_are_several() {
let mut picker = ActionPicker::new("Actions", vec!["NewTab".to_owned()], None, false);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -478,6 +478,7 @@ impl KeysScreen {
}
fn switch_from_custom(&mut self, preset: &str, saved: Option<&str>) {
unset_config_setting(SettingKey::Keybinds);
unset_config_setting(SettingKey::Mousebinds);
if self.configured_default_mode.is_some() {
unset_config_setting(SettingKey::DefaultMode);
}
@@ -573,7 +574,11 @@ impl KeysScreen {
}
}
fn open_switch_dialog(&mut self, preset: String) {
let mut message = format!("Your keybinds block has clear-defaults=true, so it defines every key itself. Switching removes that block and uses the {} preset instead. Save your keybindings as a preset first to be able to go back to them by choosing it here.", preset);
let mut message = if self.selection.has_own_mousebindings {
format!("Your keybinds block has clear-defaults=true, so it defines every key itself. Switching removes that block and your own mouse bindings, and uses the {} preset instead. Save your keybindings and mouse bindings as a preset first to be able to go back to them by choosing it here.", preset)
} else {
format!("Your keybinds block has clear-defaults=true, so it defines every key itself. Switching removes that block and uses the {} preset instead. Save your keybindings as a preset first to be able to go back to them by choosing it here.", preset)
};
if let Some(default_mode) = &self.configured_default_mode {
message.push_str(&format!(
" The default mode (\"{}\") is removed as well, so the preset's own starting mode applies.",
@@ -1,9 +1,11 @@
mod action_picker;
mod bindings_list;
mod blocks_screen;
mod contrast;
mod keybindings_screen;
mod keys_screen;
mod list_editor;
mod mousebindings_screen;
mod page;
mod settings;
mod settings_screen;
File diff suppressed because it is too large Load Diff
@@ -104,6 +104,7 @@ pub enum Effect {
ReplaceBlocks(String),
Revert(SettingKey),
ResetKeys(Vec<(InputMode, KeyWithModifier)>),
ResetMousebinds(Vec<(InputMode, MouseTrigger)>),
SwitchMode(InputMode),
Refresh,
}
@@ -128,6 +129,10 @@ pub fn run_effects(effects: Vec<Effect>) -> bool {
reset_keys(keys, false);
changed_config = true;
},
Effect::ResetMousebinds(triggers) => {
reset_mousebinds(triggers, false);
changed_config = true;
},
Effect::SwitchMode(mode) => switch_to_input_mode(&mode),
Effect::Refresh => changed_config = true,
}
+23 -3
View File
@@ -8,6 +8,7 @@ pub enum Category {
Keys,
ContextMenu,
MouseAndClipboard,
MouseBindings,
PanesAndLayouts,
ScrollbackAndEditor,
Sessions,
@@ -17,11 +18,12 @@ pub enum Category {
FoldersAndFiles,
}
pub const CATEGORIES: [Category; 13] = [
pub const CATEGORIES: [Category; 14] = [
Category::Appearance,
Category::PaneFrames,
Category::Keys,
Category::MouseAndClipboard,
Category::MouseBindings,
Category::ContextMenu,
Category::Themes,
Category::PanesAndLayouts,
@@ -42,6 +44,7 @@ impl Category {
Category::Keys => "Keys",
Category::ContextMenu => "Right-click menu",
Category::MouseAndClipboard => "Mouse and clipboard",
Category::MouseBindings => "Mouse bindings",
Category::PanesAndLayouts => "Panes and layouts",
Category::ScrollbackAndEditor => "Scrollback and editor",
Category::Sessions => "Sessions",
@@ -57,7 +60,10 @@ impl Category {
pub fn is_page(&self) -> bool {
matches!(
self,
Category::Themes | Category::ContextMenu | Category::PluginsAndEnvironment
Category::Themes
| Category::ContextMenu
| Category::MouseBindings
| Category::PluginsAndEnvironment
)
}
pub fn has_rows(&self) -> bool {
@@ -88,6 +94,7 @@ pub enum SettingKind {
Text(TextCheck),
Number { min: i64, max: i64, step: i64 },
Keybindings,
Mousebindings,
Block,
}
@@ -359,6 +366,14 @@ pub fn describe(key: SettingKey) -> SettingInfo {
"default preset",
OnlyYou,
),
SettingKey::Mousebinds => info(
"Mouse bindings",
MouseBindings,
"What clicks, the wheel and drags do in each mode",
SettingKind::Mousebindings,
"the preset's mouse bindings",
OnlyYou,
),
SettingKey::PluginAliases => info(
"Plugin aliases",
PluginsAndEnvironment,
@@ -974,6 +989,7 @@ pub fn section(key: SettingKey) -> &'static str {
| SupportKittyGraphicsProtocol
| HostNotificationProtocol
| Keybinds
| Mousebinds
| PluginAliases
| LoadPlugins
| Env
@@ -1001,7 +1017,10 @@ pub fn sort_for_display(keys: &mut Vec<SettingKey>) {
}
pub fn is_row_kind(kind: SettingKind) -> bool {
!matches!(kind, SettingKind::Keybindings | SettingKind::Block)
!matches!(
kind,
SettingKind::Keybindings | SettingKind::Mousebindings | SettingKind::Block
)
}
pub fn settings_in(category: Category) -> Vec<SettingKey> {
@@ -1080,6 +1099,7 @@ mod tests {
assert!(!choices.is_empty(), "{} has no choices", key)
},
SettingKind::Keybindings => assert_eq!(key, SettingKey::Keybinds),
SettingKind::Mousebindings => assert_eq!(key, SettingKey::Mousebinds),
SettingKind::Block => assert!(key.is_block(), "{} is not a block", key),
_ => {},
}
@@ -5,6 +5,7 @@ use zellij_tile::prelude::*;
use crate::blocks_screen::BlockPage;
use crate::keybindings_screen::KeybindingsScreen;
use crate::keys_screen::KeysScreen;
use crate::mousebindings_screen::MousebindingsScreen;
use crate::page::{changed_by, note_group, outside_overlays, run_effects, Page, PageResponse};
use crate::settings::{
check_text, describe, is_row_kind, kdl_for, mode_choice_label, option_value, section,
@@ -33,9 +34,10 @@ const DEFAULT_THEME: &str = "default";
const FILE_PREFIX: &str = "File: ";
const WINDOW_TITLE: &str = "Configuration";
const KEYBINDINGS_MIN_ROWS: usize = 8;
const PAGE_CATEGORIES: [Category; 3] = [
const PAGE_CATEGORIES: [Category; 4] = [
Category::Themes,
Category::ContextMenu,
Category::MouseBindings,
Category::PluginsAndEnvironment,
];
const THEME_NOTE: &str = " A dark or light terminal theme is set and is used instead of this one";
@@ -116,6 +118,7 @@ pub struct SettingsScreen {
bindings_hint_y: Option<usize>,
blocks_screen: BlockPage,
menu_screen: BlockPage,
mousebindings_screen: MousebindingsScreen,
themes_screen: ThemesScreen,
latest_mode_info: Option<ModeInfo>,
follow_focus: bool,
@@ -158,6 +161,7 @@ impl Default for SettingsScreen {
bindings_hint_y: None,
blocks_screen: BlockPage::plugins_and_environment(),
menu_screen: BlockPage::right_click_menu(),
mousebindings_screen: MousebindingsScreen::default(),
themes_screen: ThemesScreen::default(),
latest_mode_info: None,
follow_focus: false,
@@ -246,6 +250,7 @@ impl SettingsScreen {
match category {
Category::Themes => Some(&mut self.themes_screen),
Category::ContextMenu => Some(&mut self.menu_screen),
Category::MouseBindings => Some(&mut self.mousebindings_screen),
Category::PluginsAndEnvironment => Some(&mut self.blocks_screen),
_ => None,
}
@@ -254,6 +259,7 @@ impl SettingsScreen {
match category {
Category::Themes => Some(&self.themes_screen),
Category::ContextMenu => Some(&self.menu_screen),
Category::MouseBindings => Some(&self.mousebindings_screen),
Category::PluginsAndEnvironment => Some(&self.blocks_screen),
_ => None,
}
@@ -349,6 +355,7 @@ impl SettingsScreen {
self.keybindings_screen.set_snapshot(&self.snapshot);
self.blocks_screen.set_snapshot(&self.snapshot);
self.menu_screen.set_snapshot(&self.snapshot);
self.mousebindings_screen.set_snapshot(&self.snapshot);
self.themes_screen.set_snapshot(&self.snapshot);
if self.theme_note_needed() != self.theme_note_shown
&& !self.elements.has_open_overlay()
@@ -601,7 +608,9 @@ impl SettingsScreen {
.label_width(label_width)
.into()
},
SettingKind::Keybindings | SettingKind::Block => return None,
SettingKind::Keybindings | SettingKind::Mousebindings | SettingKind::Block => {
return None
},
};
Some(element)
}
@@ -663,7 +672,7 @@ impl SettingsScreen {
}
}
},
SettingKind::Keybindings | SettingKind::Block => {},
SettingKind::Keybindings | SettingKind::Mousebindings | SettingKind::Block => {},
}
}
}
+2
View File
@@ -313,6 +313,8 @@ load_plugins {
// item "Rename tab" { StartRenameTabByTabId; }
// item "Move tab left" { MoveTabByTabId "left"; }
// item "Move tab right" { MoveTabByTabId "right"; }
// item "Move tab to start" { MoveTabToPosition "start"; }
// item "Move tab to end" { MoveTabToPosition "end"; }
// separator
// item "Close tab" { CloseTabById; }
// }
+482
View File
@@ -0,0 +1,482 @@
#![cfg(unix)]
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, col, keys, split_right_and_wait_for_prompt,
FakePtyHandle, GridSnapshot, LayoutInfo, Size, TestRunner, TestSession, PROMPT, TERMINAL_SIZE,
};
const WIDE_TERMINAL_SIZE: Size = Size {
cols: 190,
rows: 24,
};
const COMPACT_LAYOUT_WITH_TOOLTIP: &str = r#"
layout {
pane
pane size=1 borderless=true {
plugin location="compact-bar" {
tooltip "F1"
}
}
}
"#;
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true")
.start()
}
fn start_wide_zellij() -> TestSession {
TestRunner::new(WIDE_TERMINAL_SIZE)
.with_config("mouse_mode true")
.start()
}
fn start_zellij_with_compact_bar() -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true")
.with_layout(LayoutInfo::Stringified(
COMPACT_LAYOUT_WITH_TOOLTIP.to_owned(),
))
.start()
}
fn sgr(button: u8, column: usize, line: usize, final_byte: char) -> Vec<u8> {
format!("\u{1b}[<{};{};{}{}", button, column, line, final_byte).into_bytes()
}
fn press(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(&sgr(0, column, line, 'M'));
}
fn drag_to(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(&sgr(32, column, line, 'M'));
}
fn release(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(&sgr(0, column, line, 'm'));
}
fn left_click(zellij: &TestSession, column: usize, line: usize) {
press(zellij, column, line);
release(zellij, column, line);
}
fn right_click(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(&sgr(2, column, line, 'M'));
zellij.send_stdin(&sgr(2, column, line, 'm'));
}
fn column_of(grid_snapshot: &GridSnapshot, row: usize, needle: &str) -> usize {
let line = grid_snapshot
.lines()
.get(row)
.cloned()
.unwrap_or_else(|| panic!("row {} is not on screen", row));
let byte_index = line
.find(needle)
.unwrap_or_else(|| panic!("{:?} is not on row {}: {:?}", needle, row, line));
line[..byte_index].chars().count()
}
fn last_row(grid_snapshot: &GridSnapshot) -> usize {
grid_snapshot.lines().len().saturating_sub(1)
}
fn row_order(line: &str, labels: &[&str]) -> bool {
let mut search_from = 0;
for label in labels {
match line[search_from..].find(label) {
Some(offset) => search_from += offset + label.len(),
None => return false,
}
}
true
}
fn first_row_order(grid_snapshot: &GridSnapshot, labels: &[&str]) -> bool {
grid_snapshot
.lines()
.first()
.map(|line| row_order(line, labels))
.unwrap_or(false)
}
fn open_marked_tab(zellij: &TestSession, expected_tab: &str, marker: &str) -> FakePtyHandle {
zellij.send_stdin(&keys::ctrl('t'));
zellij.send_stdin(&keys::key('n'));
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
terminal.output(marker.as_bytes());
let expected_tab = expected_tab.to_owned();
let marker = marker.to_owned();
zellij.wait_until("new marked tab opened", move |grid_snapshot| {
grid_snapshot.contains(&expected_tab) && grid_snapshot.contains(&marker)
});
terminal
}
fn three_marked_tabs(zellij: &TestSession) -> GridSnapshot {
let first_terminal = claim_first_terminal_and_wait_for_prompt(zellij);
first_terminal.output(b"FIRST-TAB");
zellij.wait_until("first tab marked", |grid_snapshot| {
grid_snapshot.contains("FIRST-TAB")
});
open_marked_tab(zellij, "Tab #2", "SECOND-TAB");
open_marked_tab(zellij, "Tab #3", "THIRD-TAB");
zellij.wait_until("three tabs in order", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& first_row_order(grid_snapshot, &["Tab #1", "Tab #2", "Tab #3"])
})
}
#[test]
fn dragging_a_tab_moves_it_live_and_releasing_focuses_it() {
let mut zellij = start_zellij();
let grid_snapshot = three_marked_tabs(&zellij);
let first_tab = column_of(&grid_snapshot, 0, "Tab #1");
let second_tab = column_of(&grid_snapshot, 0, "Tab #2");
press(&zellij, first_tab + 2, 1);
drag_to(&zellij, second_tab + 5, 1);
let grid_snapshot = zellij.wait_until("tab one moved past tab two", |grid_snapshot| {
first_row_order(grid_snapshot, &["Tab #2", "Tab #1", "Tab #3"])
});
assert!(grid_snapshot.contains("THIRD-TAB"));
let third_tab = column_of(&grid_snapshot, 0, "Tab #3");
drag_to(&zellij, third_tab + 5, 1);
zellij.wait_until("tab one moved past tab three", |grid_snapshot| {
first_row_order(grid_snapshot, &["Tab #2", "Tab #3", "Tab #1"])
});
release(&zellij, third_tab + 5, 1);
zellij.wait_until("the dragged tab is focused", |grid_snapshot| {
grid_snapshot.contains("FIRST-TAB")
&& !grid_snapshot.contains("THIRD-TAB")
&& first_row_order(grid_snapshot, &["Tab #2", "Tab #3", "Tab #1"])
});
zellij.quit();
}
#[test]
fn dragging_within_the_same_tab_does_not_move_it() {
let mut zellij = start_zellij();
let grid_snapshot = three_marked_tabs(&zellij);
let third_tab = column_of(&grid_snapshot, 0, "Tab #3");
press(&zellij, third_tab + 1, 1);
drag_to(&zellij, third_tab + 4, 1);
release(&zellij, third_tab + 4, 1);
open_marked_tab(&zellij, "Tab #4", "FOURTH-TAB");
zellij.wait_until("order is unchanged", |grid_snapshot| {
first_row_order(grid_snapshot, &["Tab #1", "Tab #2", "Tab #3", "Tab #4"])
});
zellij.quit();
}
#[test]
fn clicking_a_tab_focuses_it() {
let mut zellij = start_zellij();
let grid_snapshot = three_marked_tabs(&zellij);
let first_tab = column_of(&grid_snapshot, 0, "Tab #1");
left_click(&zellij, first_tab + 2, 1);
zellij.wait_until("first tab focused", |grid_snapshot| {
grid_snapshot.contains("FIRST-TAB")
&& first_row_order(grid_snapshot, &["Tab #1", "Tab #2", "Tab #3"])
});
zellij.quit();
}
fn click_menu_item(zellij: &TestSession, grid_snapshot: &GridSnapshot, label: &str) {
let row = grid_snapshot
.row_of_line(label)
.unwrap_or_else(|| panic!("menu item {} is not on screen", label));
let column = column_of(grid_snapshot, row, label);
left_click(zellij, column + 2, row + 1);
}
fn open_tab_menu(zellij: &TestSession, tab: &str) -> GridSnapshot {
let grid_snapshot = zellij.snapshot();
let tab_column = column_of(&grid_snapshot, 0, tab);
right_click(zellij, tab_column + 2, 1);
zellij.wait_until("tab menu opened", |grid_snapshot| {
grid_snapshot.contains("Move tab to start") && grid_snapshot.contains("Move tab to end")
})
}
#[test]
fn the_tab_menu_moves_a_tab_to_the_start_and_to_the_end() {
let mut zellij = start_zellij();
three_marked_tabs(&zellij);
let grid_snapshot = open_tab_menu(&zellij, "Tab #3");
click_menu_item(&zellij, &grid_snapshot, "Move tab to start");
zellij.wait_until("tab three moved to the start", |grid_snapshot| {
!grid_snapshot.contains("Move tab to end")
&& first_row_order(grid_snapshot, &["Tab #3", "Tab #1", "Tab #2"])
});
let grid_snapshot = open_tab_menu(&zellij, "Tab #3");
click_menu_item(&zellij, &grid_snapshot, "Move tab to end");
zellij.wait_until("tab three moved back to the end", |grid_snapshot| {
!grid_snapshot.contains("Move tab to start")
&& first_row_order(grid_snapshot, &["Tab #1", "Tab #2", "Tab #3"])
});
zellij.quit();
}
#[test]
fn clicking_a_mode_in_the_status_bar_enters_it_and_clicking_it_again_leaves_it() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let grid_snapshot = zellij.snapshot();
let status_row = last_row(&grid_snapshot);
let pane_column = column_of(&grid_snapshot, status_row, "PANE");
left_click(&zellij, pane_column + 2, status_row + 1);
let grid_snapshot = zellij.wait_until("pane mode entered", |grid_snapshot| {
!grid_snapshot.status_bar_appears() && grid_snapshot.contains("PANE")
});
let pane_column = column_of(&grid_snapshot, status_row, "PANE");
left_click(&zellij, pane_column + 2, status_row + 1);
zellij.wait_until("back in normal mode", |grid_snapshot| {
grid_snapshot.status_bar_appears()
});
zellij.quit();
}
#[test]
fn clicking_an_arrow_in_the_status_bar_moves_focus() {
let mut zellij = start_wide_zellij();
let left_terminal = zellij.expect_pty_spawn();
left_terminal.output(PROMPT);
zellij.wait_until("first terminal loaded", |grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(2).row(1))
});
zellij.send_stdin(&keys::ctrl('p'));
zellij.send_stdin(&keys::key('r'));
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
let grid_snapshot = zellij.wait_until("focus hint shown", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& !grid_snapshot.cursor_is_at(col(2).row(2))
&& grid_snapshot
.lines()
.last()
.map(|line| line.contains('←') && line.contains("Change Focus"))
.unwrap_or(false)
});
let status_row = last_row(&grid_snapshot);
let left_arrow = column_of(&grid_snapshot, status_row, "←");
left_click(&zellij, left_arrow + 1, status_row + 1);
zellij.wait_until("focus moved to the left pane", |grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(2).row(2))
});
zellij.quit();
}
#[test]
fn clicking_plus_in_resize_mode_grows_the_focused_pane() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
let (initial_cols, _) =
right_terminal.wait_for_size("right pane has a size", |cols, _| cols > 0);
zellij.send_stdin(&keys::ctrl('n'));
let grid_snapshot = zellij.wait_until(
"resize mode shows the plus and minus keys",
|grid_snapshot| {
grid_snapshot
.lines()
.last()
.map(|line| line.contains("+|-"))
.unwrap_or(false)
},
);
let status_row = last_row(&grid_snapshot);
let plus = column_of(&grid_snapshot, status_row, "+|-");
left_click(&zellij, plus + 1, status_row + 1);
right_terminal.wait_for_size("right pane grew", |cols, _| cols > initial_cols);
zellij.quit();
}
fn wait_for_compact_bar(zellij: &TestSession) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("compact bar loaded", |grid_snapshot| {
grid_snapshot.contains("Tab #1") && grid_snapshot.contains("NORMAL")
});
terminal
}
fn open_compact_tab(zellij: &TestSession, expected_tab: &str, marker: &str) {
zellij.send_stdin(&keys::ctrl('t'));
zellij.send_stdin(&keys::key('n'));
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
terminal.output(marker.as_bytes());
let expected_tab = expected_tab.to_owned();
let marker = marker.to_owned();
zellij.wait_until("compact tab opened", move |grid_snapshot| {
grid_snapshot.contains(&expected_tab)
&& grid_snapshot.contains(&marker)
&& grid_snapshot.contains("NORMAL")
});
}
fn last_row_order(grid_snapshot: &GridSnapshot, labels: &[&str]) -> bool {
grid_snapshot
.lines()
.last()
.map(|line| row_order(line, labels))
.unwrap_or(false)
}
#[test]
fn dragging_a_tab_in_the_compact_bar_moves_it() {
let mut zellij = start_zellij_with_compact_bar();
let first_terminal = wait_for_compact_bar(&zellij);
first_terminal.output(b"FIRST-TAB");
open_compact_tab(&zellij, "Tab #2", "SECOND-TAB");
let grid_snapshot = zellij.wait_until("two compact tabs", |grid_snapshot| {
last_row_order(grid_snapshot, &["Tab #1", "Tab #2"])
});
let bar_row = last_row(&grid_snapshot);
let first_tab = column_of(&grid_snapshot, bar_row, "Tab #1");
let second_tab = column_of(&grid_snapshot, bar_row, "Tab #2");
press(&zellij, first_tab + 2, bar_row + 1);
drag_to(&zellij, second_tab + 5, bar_row + 1);
zellij.wait_until("compact tabs swapped", |grid_snapshot| {
last_row_order(grid_snapshot, &["Tab #2", "Tab #1"])
});
release(&zellij, second_tab + 5, bar_row + 1);
zellij.wait_until("the dragged compact tab is focused", |grid_snapshot| {
grid_snapshot.contains("FIRST-TAB") && !grid_snapshot.contains("SECOND-TAB")
});
zellij.quit();
}
#[test]
fn clicking_the_compact_mode_label_returns_to_normal_mode() {
let mut zellij = start_zellij_with_compact_bar();
wait_for_compact_bar(&zellij);
zellij.send_stdin(&keys::ctrl('t'));
let grid_snapshot = zellij.wait_until("tab mode shown", |grid_snapshot| {
grid_snapshot.contains(" TAB ") && !grid_snapshot.contains("NORMAL")
});
let bar_row = last_row(&grid_snapshot);
let mode_column = column_of(&grid_snapshot, bar_row, " TAB ");
left_click(&zellij, mode_column + 2, bar_row + 1);
zellij.wait_until("normal mode shown", |grid_snapshot| {
grid_snapshot.contains("NORMAL")
});
zellij.quit();
}
#[test]
fn clicking_plus_in_the_compact_tooltip_grows_the_focused_pane() {
let mut zellij = start_zellij_with_compact_bar();
wait_for_compact_bar(&zellij);
zellij.send_stdin(&keys::ctrl('p'));
zellij.send_stdin(&keys::key('r'));
let right_terminal = zellij.expect_pty_spawn();
right_terminal.output(PROMPT);
zellij.wait_until("right pane opened", |grid_snapshot| {
grid_snapshot.contains("NORMAL") && grid_snapshot.contains("Pane #2")
});
let (initial_cols, _) =
right_terminal.wait_for_size("right pane has a size", |cols, _| cols > 0);
zellij.send_stdin(&keys::ctrl('n'));
let grid_snapshot = zellij.wait_until("resize tooltip shown", |grid_snapshot| {
grid_snapshot.contains("<+->") && grid_snapshot.contains("Increase or decrease size")
});
let row = grid_snapshot
.row_of_line("<+->")
.expect("tooltip row with plus and minus");
let plus = column_of(&grid_snapshot, row, "<+->") + 1;
left_click(&zellij, plus + 1, row + 1);
right_terminal.wait_for_size("right pane grew", |cols, _| cols > initial_cols);
zellij.quit();
}
fn hover(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(&sgr(35, column, line, 'M'));
}
#[test]
fn the_hover_stays_on_a_status_bar_button_after_clicking_it() {
let mut zellij = start_wide_zellij();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
let grid_snapshot =
zellij.wait_until("status bar shows the floating button", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot
.lines()
.last()
.map(|line| line.contains("Floating"))
.unwrap_or(false)
});
let status_row = last_row(&grid_snapshot);
let floating = column_of(&grid_snapshot, status_row, "Floating") + 2;
let plain_style = grid_snapshot.cell_style(floating, status_row);
hover(&zellij, floating + 1, status_row + 1);
let grid_snapshot = zellij.wait_until("floating button highlighted", |grid_snapshot| {
grid_snapshot.cell_style(floating, status_row) != plain_style
});
let hovered_style = grid_snapshot.cell_style(floating, status_row);
left_click(&zellij, floating + 1, status_row + 1);
let floating_terminal = zellij.expect_pty_spawn();
floating_terminal.output(b"FLOATING-MARKER");
let grid_snapshot = zellij.wait_until("floating pane opened", |grid_snapshot| {
grid_snapshot.contains("FLOATING-MARKER") && grid_snapshot.status_bar_appears()
});
assert_eq!(
grid_snapshot.cell_style(floating, status_row),
hovered_style
);
zellij.quit();
}
#[test]
fn the_hover_follows_a_mode_label_that_changes_under_a_resting_mouse() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let grid_snapshot = zellij.snapshot();
let status_row = last_row(&grid_snapshot);
let lock_column = column_of(&grid_snapshot, status_row, "LOCK") + 1;
let plain_style = grid_snapshot.cell_style(lock_column, status_row);
hover(&zellij, lock_column + 1, status_row + 1);
zellij.wait_until("lock label highlighted", |grid_snapshot| {
grid_snapshot.cell_style(lock_column, status_row) != plain_style
});
left_click(&zellij, lock_column + 1, status_row + 1);
let grid_snapshot = zellij.wait_until("locked mode entered", |grid_snapshot| {
!grid_snapshot.contains("PANE") && grid_snapshot.contains("LOCK")
});
assert_eq!(
column_of(&grid_snapshot, status_row, "LOCK") + 1,
lock_column
);
let hovered_locked_style = grid_snapshot.cell_style(lock_column, status_row);
hover(&zellij, 30, 10);
zellij.wait_until("hover left the locked label", |grid_snapshot| {
grid_snapshot.cell_style(lock_column, status_row) != hovered_locked_style
});
zellij.quit();
}
@@ -8,15 +8,17 @@ use zellij_integration_tests::{
const ARROW_DOWN: &[u8] = b"\x1b[B";
const ARROW_UP: &[u8] = b"\x1b[A";
const ARROW_LEFT: &[u8] = b"\x1b[D";
const ARROW_RIGHT: &[u8] = b"\x1b[C";
const SHIFT_DOWN: &[u8] = b"\x1b[1;2B";
const DELETE: &[u8] = b"\x1b[3~";
const END: &[u8] = b"\x1b[F";
const BACKSPACE: &[u8] = &[0x7f];
const KEYS_PAGE: usize = 2;
const MENU_PAGE: usize = 4;
const THEMES_PAGE: usize = 5;
const PLUGINS_PAGE: usize = 11;
const MOUSE_BINDINGS_PAGE: usize = 4;
const MENU_PAGE: usize = 5;
const THEMES_PAGE: usize = 6;
const PLUGINS_PAGE: usize = 12;
fn open_settings(zellij: &TestSession) {
zellij.send_stdin(&keys::ctrl('o'));
@@ -771,3 +773,285 @@ fn menu_items_move_with_shift_arrows_and_the_menu_search_finds_them() {
});
zellij.quit();
}
fn wait_for_mouse_bindings_page(zellij: &TestSession) {
zellij.wait_until("mouse bindings page shown", |grid_snapshot| {
grid_snapshot.contains("From preset:") && grid_snapshot.contains("Mouse bindings")
});
}
fn enter_mouse_binding_search(zellij: &TestSession) {
zellij.send_stdin(&keys::TAB);
zellij.send_stdin(&keys::key('/'));
zellij.wait_until("the mouse binding search is focused", |grid_snapshot| {
grid_snapshot.contains("- search mouse bindings and actions")
});
}
fn open_mouse_binding_search(zellij: &TestSession) {
open_settings(zellij);
for _ in 0..MOUSE_BINDINGS_PAGE {
zellij.send_stdin(ARROW_DOWN);
}
wait_for_mouse_bindings_page(zellij);
enter_mouse_binding_search(zellij);
}
fn open_mouse_bindings(zellij: &TestSession) {
open_mouse_binding_search(zellij);
for _ in 0..3 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("mouse binding list focused", |grid_snapshot| {
grid_snapshot.contains("<Del> - delete")
});
}
fn start_add_mouse_binding(zellij: &TestSession, button_steps: usize) {
zellij.send_stdin(&keys::key('a'));
zellij.wait_until("mouse binding form shown", |grid_snapshot| {
grid_snapshot.contains("Add Mouse Binding") && grid_snapshot.contains("Button")
});
for _ in 0..button_steps {
zellij.send_stdin(ARROW_RIGHT);
}
for _ in 0..7 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.send_stdin(&keys::ENTER);
}
fn press_mouse_form_done(zellij: &TestSession) {
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
}
fn middle_click(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(format!("\u{1b}[<1;{};{}M", column, line).as_bytes());
zellij.send_stdin(format!("\u{1b}[<1;{};{}m", column, line).as_bytes());
}
#[test]
fn a_mouse_binding_added_in_normal_mode_works_live_and_saves_only_its_bind_line() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true")
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
let config_file_path = zellij.config_file_path().unwrap();
let original = std::fs::read_to_string(&config_file_path).unwrap();
open_mouse_bindings(&zellij);
start_add_mouse_binding(&zellij, 2);
choose_action(&zellij, "newtab", "NewTab");
press_mouse_form_done(&zellij);
zellij.wait_until(
"the new mouse binding is listed as unsaved",
|grid_snapshot| {
!grid_snapshot.contains("Add Mouse Binding")
&& grid_snapshot.contains("1 unsaved change ")
},
);
close_settings(&zellij);
middle_click(&zellij, 30, 10);
zellij.expect_pty_spawn();
zellij.wait_until("the middle click opened a tab", |grid_snapshot| {
grid_snapshot.contains("Tab #2")
});
open_settings(&zellij);
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
let added = "mousebinds {\n shared {\n bind \"Middle\" { NewTab; }\n }\n}\n";
assert!(saved.contains(added), "{}", saved);
assert_eq!(saved.replacen(added, "", 1), original);
zellij.quit();
}
#[test]
fn a_mouse_binding_added_for_one_mode_saves_a_block_for_that_mode_only() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let config_file_path = zellij.config_file_path().unwrap();
let original = std::fs::read_to_string(&config_file_path).unwrap();
open_mouse_binding_search(&zellij);
zellij.send_stdin(ARROW_DOWN);
zellij.wait_until("the mode dropdown is focused", |grid_snapshot| {
grid_snapshot.contains("choose mode") && grid_snapshot.contains("All modes")
});
zellij.send_stdin(ARROW_RIGHT);
zellij.wait_until("normal mode is chosen", |grid_snapshot| {
grid_snapshot.contains("Normal") && !grid_snapshot.contains("All modes")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(ARROW_DOWN);
zellij.wait_until("mouse binding list focused", |grid_snapshot| {
grid_snapshot.contains("<Del> - delete")
});
start_add_mouse_binding(&zellij, 2);
choose_action(&zellij, "newtab", "NewTab");
press_mouse_form_done(&zellij);
zellij.wait_until(
"the new mouse binding is listed as unsaved",
|grid_snapshot| {
!grid_snapshot.contains("Add Mouse Binding")
&& grid_snapshot.contains("1 unsaved change ")
&& grid_snapshot.contains("Bound Middle in Normal mode")
},
);
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
let added = "mousebinds {\n normal {\n bind \"Middle\" { NewTab; }\n }\n}\n";
assert!(saved.contains(added), "{}", saved);
assert_eq!(saved.replacen(added, "", 1), original);
zellij.quit();
}
#[test]
fn a_removed_preset_mouse_binding_is_written_as_unbind_and_reset_back() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let config_file_path = zellij.config_file_path().unwrap();
let original = std::fs::read_to_string(&config_file_path).unwrap();
open_mouse_binding_search(&zellij);
type_text(&zellij, "alt right");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the preset mouse binding is found", |grid_snapshot| {
grid_snapshot.contains("Alt Right") && grid_snapshot.contains("<Del> - delete")
});
zellij.send_stdin(DELETE);
zellij.wait_until("removal is confirmed first", |grid_snapshot| {
grid_snapshot.contains("Delete Alt Right from all modes?")
&& grid_snapshot.contains("Don't ask again")
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the mouse binding is unbound", |grid_snapshot| {
grid_snapshot.contains("(unboun") && grid_snapshot.contains("1 unsaved change ")
});
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
let removed = "mousebinds {\n unbind \"Alt Right\"\n}\n";
assert!(saved.contains(removed), "{}", saved);
assert_eq!(saved.replacen(removed, "", 1), original);
zellij.send_stdin(&keys::key('r'));
zellij.wait_until("the mouse binding is back to the preset", |grid_snapshot| {
grid_snapshot.contains("back to the preset") && grid_snapshot.contains("1 unsaved change ")
});
save_and_wait(&zellij);
let reset = std::fs::read_to_string(&config_file_path).unwrap();
assert!(!reset.contains("Alt Right"), "{}", reset);
assert!(!reset.contains("mousebinds"), "{}", reset);
assert!(reset.starts_with(original.trim_end()), "{}", reset);
zellij.quit();
}
#[test]
fn adding_a_mouse_binding_for_an_already_bound_trigger_warns_first() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_mouse_bindings(&zellij);
start_add_mouse_binding(&zellij, 1);
choose_action(&zellij, "newtab", "NewTab");
press_mouse_form_done(&zellij);
zellij.wait_until("the conflict is reported", |grid_snapshot| {
grid_snapshot.contains("Trigger already bound")
&& grid_snapshot.contains("Right is already bound in all modes")
&& grid_snapshot.contains("Replace")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("the warning closed and the form stays", |grid_snapshot| {
!grid_snapshot.contains("Trigger already bound")
&& grid_snapshot.contains("Add Mouse Binding")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("the form is left with nothing changed", |grid_snapshot| {
!grid_snapshot.contains("Add Mouse Binding") && grid_snapshot.contains("0 unsaved changes")
});
zellij.quit();
}
#[test]
fn switching_the_preset_updates_the_leader_keys_of_the_mouse_bindings() {
let keybinds_dir = tempfile::tempdir().unwrap();
let default_preset = include_str!("../../zellij-utils/assets/keybinds/default.kdl");
let swapped_preset = default_preset
.replace(
"primary \"Ctrl\"\n secondary \"Alt\"",
"primary \"Alt\"\n secondary \"Ctrl\"",
)
.replace("name \"Default\"", "name \"Swapped\"");
assert!(swapped_preset.contains("primary \"Alt\""));
std::fs::write(keybinds_dir.path().join("swapped.kdl"), swapped_preset).unwrap();
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(&format!(
"keybinds_dir \"{}\"",
keybinds_dir.path().display()
))
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_mouse_binding_search(&zellij);
type_text(&zellij, "resizepane");
zellij.wait_until(
"the frame resize binding uses the default primary key",
|grid_snapshot| {
grid_snapshot.contains("Ctrl Left · frame")
&& !grid_snapshot.contains("Alt Left · frame")
},
);
zellij.send_stdin(ARROW_DOWN);
zellij.wait_until("search results focused", |grid_snapshot| {
grid_snapshot.contains("<Del> - delete")
});
zellij.send_stdin(ARROW_LEFT);
zellij.wait_until("the category menu is focused", |grid_snapshot| {
grid_snapshot.contains("<↓↑> - category")
});
zellij.send_stdin(ARROW_UP);
zellij.send_stdin(ARROW_UP);
zellij.wait_until("keys page shown", |grid_snapshot| {
grid_snapshot.contains("Save as a preset")
});
zellij.send_stdin(&keys::TAB);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("preset list opened", |grid_snapshot| {
grid_snapshot.contains("unlock-first") && grid_snapshot.contains("swapped")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the swapped preset is applied", |grid_snapshot| {
grid_snapshot.contains("Alt +") && grid_snapshot.contains("[ swapped")
});
zellij.send_stdin(&keys::key('/'));
zellij.wait_until("the keybinding search is focused", |grid_snapshot| {
grid_snapshot.contains("search keys and actions")
});
for _ in 0..3 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("keybinding list focused", |grid_snapshot| {
grid_snapshot.contains("<Del> - delete")
});
zellij.send_stdin(ARROW_LEFT);
zellij.wait_until("the category menu is focused", |grid_snapshot| {
grid_snapshot.contains("<↓↑> - category")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(ARROW_DOWN);
wait_for_mouse_bindings_page(&zellij);
enter_mouse_binding_search(&zellij);
zellij.wait_until(
"the frame resize binding follows the new primary key",
|grid_snapshot| {
grid_snapshot.contains("Alt Left · frame")
&& !grid_snapshot.contains("Ctrl Left · frame")
},
);
zellij.kill_session();
}
@@ -514,7 +514,7 @@ fn the_preset_folder_set_on_the_files_page_lists_its_presets_on_the_keys_page()
zellij.wait_until("configuration plugin opened", |grid_snapshot| {
grid_snapshot.contains("Configuration")
});
for _ in 0..12 {
for _ in 0..13 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("files and folders shown", |grid_snapshot| {
@@ -539,7 +539,7 @@ fn the_preset_folder_set_on_the_files_page_lists_its_presets_on_the_keys_page()
grid_snapshot.contains("1 unsaved change") && grid_snapshot.contains("● unsaved")
});
zellij.send_stdin(b"\x1b[D");
for _ in 0..10 {
for _ in 0..11 {
zellij.send_stdin(ARROW_UP);
}
zellij.wait_until("keys page shown", |grid_snapshot| {
@@ -0,0 +1,268 @@
#![cfg(unix)]
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, keys, FakePtyHandle, GridSnapshot, TestRunner,
TestSession, PROMPT, TERMINAL_SIZE,
};
const FULL_FRAMES_CONFIG: &str = "mouse_mode true\npane_frame_style \"full\"";
const LOCKED_PASS_THROUGH_CONFIG: &str =
"mouse_mode true\nmousebinds {\n locked clear-defaults=true {\n }\n}";
const COMMON_MENU_MARKER: &str = "Session manager";
const ENABLE_MOUSE_REPORTING: &[u8] = b"\x1b[?1000h\x1b[?1006h";
fn sgr_press(column: usize, line: usize, button: u8) -> Vec<u8> {
format!("\u{1b}[<{};{};{}M", button, column, line).into_bytes()
}
fn sgr_release(column: usize, line: usize, button: u8) -> Vec<u8> {
format!("\u{1b}[<{};{};{}m", button, column, line).into_bytes()
}
fn click(zellij: &TestSession, column: usize, line: usize, button: u8) {
zellij.send_stdin(&sgr_press(column, line, button));
zellij.send_stdin(&sgr_release(column, line, button));
}
fn double_click(zellij: &TestSession, column: usize, line: usize) {
click(zellij, column, line, 0);
click(zellij, column, line, 0);
}
fn column_of(grid_snapshot: &GridSnapshot, row: usize, needle: &str) -> Option<usize> {
let line = grid_snapshot.lines().get(row)?.clone();
let byte_index = line.find(needle)?;
Some(line[..byte_index].chars().count())
}
fn start_with_full_frames() -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_config(FULL_FRAMES_CONFIG)
.start()
}
fn claim_framed_terminal(zellij: &TestSession, marker: &'static str) -> FakePtyHandle {
let terminal = zellij.expect_pty_spawn();
terminal.output(marker.as_bytes());
zellij.wait_until("framed terminal rendered", move |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.status_bar_appears()
&& grid_snapshot.contains(marker)
});
terminal
}
fn split_right_framed(zellij: &TestSession, marker: &'static str) -> FakePtyHandle {
zellij.send_stdin(&keys::ctrl('p'));
zellij.send_stdin(&keys::key('r'));
let terminal = zellij.expect_pty_spawn();
terminal.output(marker.as_bytes());
zellij.wait_until("right terminal rendered", move |grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.contains(marker)
});
terminal
}
fn open_floating_framed(zellij: &TestSession, marker: &'static str) -> FakePtyHandle {
zellij.send_stdin(&keys::ctrl('p'));
zellij.send_stdin(&keys::key('w'));
let terminal = zellij.expect_pty_spawn();
terminal.output(marker.as_bytes());
zellij.wait_until("floating terminal rendered", move |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains(marker)
&& grid_snapshot.contains("PIN [")
});
terminal
}
fn double_click_frame_title(zellij: &TestSession, title: &str, offset: usize) {
let grid_snapshot = zellij.snapshot();
let row = grid_snapshot
.row_of_line(title)
.unwrap_or_else(|| panic!("{} is not on screen:\n{}", title, grid_snapshot.text));
let column = column_of(&grid_snapshot, row, title).unwrap();
double_click(zellij, column + 1 + offset, row + 1);
}
fn is_wide(cols: u16) -> bool {
cols > 100
}
#[test]
fn double_clicking_a_tiled_pane_frame_toggles_fullscreen() {
let mut zellij = start_with_full_frames();
let left_terminal = claim_framed_terminal(&zellij, "LEFT-MARKER");
split_right_framed(&zellij, "RIGHT-MARKER");
let (split_cols, _) =
left_terminal.wait_for_size("left pane is split", |cols, _| !is_wide(cols));
assert!(!is_wide(split_cols));
double_click_frame_title(&zellij, "Pane #1", 2);
left_terminal.wait_for_size("left pane went fullscreen", |cols, _| is_wide(cols));
zellij.wait_until("only the fullscreen pane is shown", |grid_snapshot| {
grid_snapshot.contains("LEFT-MARKER") && !grid_snapshot.contains("RIGHT-MARKER")
});
double_click_frame_title(&zellij, "Pane #1", 5);
left_terminal.wait_for_size("left pane left fullscreen", |cols, _| !is_wide(cols));
zellij.wait_until("both panes are shown again", |grid_snapshot| {
grid_snapshot.contains("LEFT-MARKER") && grid_snapshot.contains("RIGHT-MARKER")
});
zellij.quit();
}
#[test]
fn double_clicking_a_floating_pane_frame_toggles_fullscreen() {
let mut zellij = start_with_full_frames();
claim_framed_terminal(&zellij, "TILED-MARKER");
let floating_terminal = open_floating_framed(&zellij, "FLOAT-MARKER");
floating_terminal.wait_for_size("floating pane has its own size", |cols, _| !is_wide(cols));
double_click_frame_title(&zellij, "Pane #2", 2);
floating_terminal.wait_for_size("floating pane went fullscreen", |cols, _| is_wide(cols));
zellij.wait_until("the floating pane covers the screen", |grid_snapshot| {
grid_snapshot.contains("FLOAT-MARKER") && !grid_snapshot.contains("TILED-MARKER")
});
double_click_frame_title(&zellij, "Pane #2", 5);
floating_terminal.wait_for_size("floating pane left fullscreen", |cols, _| !is_wide(cols));
zellij.wait_until("the tiled pane shows again", |grid_snapshot| {
grid_snapshot.contains("FLOAT-MARKER") && grid_snapshot.contains("TILED-MARKER")
});
zellij.quit();
}
fn lock_interface(zellij: &TestSession) {
zellij.send_stdin(&keys::ctrl('g'));
zellij.wait_until("interface locked", |grid_snapshot| {
grid_snapshot.contains("LOCK") && !grid_snapshot.contains("PANE")
});
}
fn unlock_interface(zellij: &TestSession) {
zellij.send_stdin(&keys::ctrl('g'));
zellij.wait_until("interface unlocked", |grid_snapshot| {
grid_snapshot.status_bar_appears()
});
}
#[test]
fn cleared_locked_mouse_bindings_pass_clicks_and_scrolls_to_the_program() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(LOCKED_PASS_THROUGH_CONFIG)
.start();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
terminal.output(ENABLE_MOUSE_REPORTING);
terminal.output(b"MOUSE-APP");
zellij.wait_until("program output rendered", |grid_snapshot| {
grid_snapshot.contains("MOUSE-APP")
});
lock_interface(&zellij);
click(&zellij, 30, 10, 0);
terminal.wait_for_stdin("left click reported to the program", |stdin| {
String::from_utf8_lossy(stdin).contains("\u{1b}[<0;")
});
zellij.send_stdin(&sgr_press(30, 10, 64));
terminal.wait_for_stdin("scroll up reported to the program", |stdin| {
String::from_utf8_lossy(stdin).contains("\u{1b}[<64;")
});
click(&zellij, 30, 10, 2);
terminal.wait_for_stdin("right click reported to the program", |stdin| {
String::from_utf8_lossy(stdin).contains("\u{1b}[<2;")
});
let grid_snapshot = zellij.snapshot();
assert!(
!grid_snapshot.contains(COMMON_MENU_MARKER),
"{}",
grid_snapshot.text
);
assert!(!grid_snapshot.contains("SCROLL:"), "{}", grid_snapshot.text);
zellij.kill_session();
}
#[test]
fn cleared_locked_mouse_bindings_stop_right_click_from_opening_the_menu() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(LOCKED_PASS_THROUGH_CONFIG)
.start();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
lock_interface(&zellij);
click(&zellij, 30, 10, 2);
terminal.output(b"AFTER-LOCKED-CLICK");
zellij.wait_until("output after the click rendered", |grid_snapshot| {
grid_snapshot.contains("AFTER-LOCKED-CLICK")
});
unlock_interface(&zellij);
let grid_snapshot = zellij.snapshot();
assert!(
!grid_snapshot.contains(COMMON_MENU_MARKER),
"{}",
grid_snapshot.text
);
click(&zellij, 30, 10, 2);
zellij.wait_until("normal mode right click opens the menu", |grid_snapshot| {
grid_snapshot.contains(COMMON_MENU_MARKER)
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("menu closed", |grid_snapshot| {
!grid_snapshot.contains(COMMON_MENU_MARKER) && grid_snapshot.status_bar_appears()
});
zellij.quit();
}
fn start_with_mouse() -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true")
.start()
}
#[test]
fn double_clicking_a_tab_name_starts_renaming_the_tab() {
let mut zellij = start_with_mouse();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
let grid_snapshot = zellij.snapshot();
let tab_column = column_of(&grid_snapshot, 0, "Tab #1").expect("tab bar shows Tab #1");
double_click(&zellij, tab_column + 2, 1);
zellij.wait_until("rename tab mode entered", |grid_snapshot| {
grid_snapshot.contains("RENAMING TAB")
});
zellij.send_stdin(b"Renamed");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the tab shows its new name", |grid_snapshot| {
grid_snapshot
.lines()
.first()
.is_some_and(|tab_bar| tab_bar.contains("Renamed") && !tab_bar.contains("Tab #1"))
&& grid_snapshot.status_bar_appears()
});
assert!(!String::from_utf8_lossy(&terminal.stdin_bytes()).contains("Renamed"));
zellij.quit();
}
#[test]
fn double_clicking_empty_tab_bar_space_opens_a_new_tab() {
let mut zellij = start_with_mouse();
claim_first_terminal_and_wait_for_prompt(&zellij);
let grid_snapshot = zellij.snapshot();
let tab_bar = grid_snapshot.lines()[0].clone();
assert!(
tab_bar.chars().nth(80).map_or(true, |c| c == ' '),
"{}",
tab_bar
);
double_click(&zellij, 81, 1);
let new_tab_terminal = zellij.expect_pty_spawn();
new_tab_terminal.output(PROMPT);
zellij.wait_until("a second tab opened", |grid_snapshot| {
grid_snapshot.contains("Tab #1")
&& grid_snapshot.contains("Tab #2")
&& grid_snapshot.status_bar_appears()
});
zellij.quit();
}
+62 -2
View File
@@ -65,7 +65,8 @@ use zellij_utils::{
},
data::{
ConfigSnapshot, ConnectToSession, Direction, InputMode, KeyWithModifier,
KeybindPresetSource, LayoutInfo, LayoutWithError, SettingKey, Style, WebSharing,
KeybindPresetSource, LayoutInfo, LayoutWithError, MouseTrigger, SettingKey, Style,
WebSharing,
},
errors::{prelude::*, ContextType, ErrorInstruction, FatalError, ServerContext},
home::{default_layout_dir, get_default_data_dir},
@@ -205,6 +206,11 @@ pub enum ServerInstruction {
keys: Vec<(InputMode, KeyWithModifier)>,
write_config_to_disk: bool,
},
ResetMousebinds {
client_id: ClientId,
triggers: Vec<(InputMode, MouseTrigger)>,
write_config_to_disk: bool,
},
StartWebServer(ClientId),
ShareCurrentSession(ClientId),
StopSharingCurrentSession(ClientId),
@@ -257,6 +263,7 @@ impl From<&ServerInstruction> for ServerContext {
ServerInstruction::RebindKeys { .. } => ServerContext::RebindKeys,
ServerInstruction::ReplaceConfigBlocks { .. } => ServerContext::ReplaceConfigBlocks,
ServerInstruction::ResetKeys { .. } => ServerContext::ResetKeys,
ServerInstruction::ResetMousebinds { .. } => ServerContext::ResetMousebinds,
ServerInstruction::ReadConfig { .. } => ServerContext::ReadConfig,
ServerInstruction::RevertConfig { .. } => ServerContext::RevertConfig,
ServerInstruction::UnsetConfigSetting { .. } => ServerContext::UnsetConfigSetting,
@@ -315,7 +322,7 @@ struct LayoutSettings {
fn settings_without_keybinds(first: &Config, second: &Config) -> BTreeSet<SettingKey> {
config_settings::differing_settings(first, second)
.into_iter()
.filter(|key| *key != SettingKey::Keybinds)
.filter(|key| *key != SettingKey::Keybinds && *key != SettingKey::Mousebinds)
.collect()
}
@@ -454,6 +461,7 @@ impl SessionConfiguration {
save_keybinds_as_preset(
file_contents.as_deref(),
&saved.keybinds_layers.user,
&saved.keybinds_layers.user_mouse,
saved.keybinds_layers.config_default_mode,
new_name,
&keybinds_dir,
@@ -675,6 +683,7 @@ impl SessionConfiguration {
file_contents.as_deref(),
),
keybinds: current.keybinds_selection_snapshot(),
mousebindings: config_blocks::mousebinding_entries(saved, &current),
}
}
fn replace_runtime_config_if_changed(
@@ -938,6 +947,29 @@ impl SessionConfiguration {
None => (None, false),
}
}
pub fn reset_mousebinds(
&mut self,
client_id: &ClientId,
triggers: Vec<(InputMode, MouseTrigger)>,
) -> (Option<Config>, bool) {
let current = self.get_client_configuration(client_id);
let mut reset = current.clone();
for (mode, trigger) in triggers {
let user_mouse = &mut reset.keybinds_layers.user_mouse;
if let Some(mode_changes) = user_mouse.modes.get_mut(&mode) {
mode_changes.bind.remove(&trigger);
mode_changes.unbind.remove(&trigger);
if mode_changes.is_empty() {
user_mouse.modes.remove(&mode);
}
}
}
reset.resolve_keybinds();
match self.replace_runtime_config_if_changed(client_id, current, reset) {
Some(new_config) => (Some(new_config), true),
None => (None, false),
}
}
}
fn chooses_keybind_preset_or_leaders(stringified_config: &str) -> bool {
@@ -1156,6 +1188,12 @@ impl SessionMetaData {
new_config.context_menu.clone(),
))
.unwrap();
self.senders
.send_to_screen(ScreenInstruction::UpdateMousebinds(
client_id,
new_config.mousebinds.clone(),
))
.unwrap();
self.senders
.send_to_screen(ScreenInstruction::Reconfigure {
client_id,
@@ -4070,6 +4108,28 @@ pub fn start_server_impl(
client_id,
);
},
ServerInstruction::ResetMousebinds {
client_id,
triggers,
write_config_to_disk,
} => {
let config_before = client_configuration(&session_data, client_id);
let (new_config, runtime_config_changed) = session_data
.write()
.unwrap()
.as_mut()
.unwrap()
.session_configuration
.reset_mousebinds(&client_id, triggers);
apply_runtime_change(
config_before,
new_config,
write_config_to_disk,
runtime_config_changed,
&session_data,
client_id,
);
},
ServerInstruction::ResetKeys {
client_id,
keys,
+17 -2
View File
@@ -1718,6 +1718,21 @@ impl FloatingPanes {
true
}
pub fn move_pane_with_mouse(&mut self, position: Position, search_selectable: bool) -> bool {
self.move_pane_with_mouse_grabbed(position, search_selectable, true)
}
pub fn move_pane_with_mouse_from_anywhere(
&mut self,
position: Position,
search_selectable: bool,
) -> bool {
self.move_pane_with_mouse_grabbed(position, search_selectable, false)
}
fn move_pane_with_mouse_grabbed(
&mut self,
position: Position,
search_selectable: bool,
require_frame: bool,
) -> bool {
// true => handled, false => not handled (eg. no pane at this position)
if *self.fullscreen_covers_ui.borrow() || self.fullscreen_pane_id.is_some() {
return false;
@@ -1730,8 +1745,8 @@ impl FloatingPanes {
return true;
}
} else if let Some(pane) = self.get_pane_at_mut(&position, search_selectable) {
let clicked_on_frame = pane.position_is_on_frame(&position);
if (show_panes || pane.position_and_size().is_pinned) && clicked_on_frame {
let grabbed = !require_frame || pane.position_is_on_frame(&position);
if (show_panes || pane.position_and_size().is_pinned) && grabbed {
let pid = pane.pid();
if self.pane_being_moved_with_mouse.is_none() {
self.set_pane_being_moved_with_mouse(pid, position);
+12
View File
@@ -669,6 +669,18 @@ impl Pane for PluginPane {
.unwrap();
}
}
fn mouse_double_click(&mut self, position: &Position, client_id: ClientId) {
if self.supports_mouse_selection {
return;
}
self.send_plugin_instructions
.send(PluginInstruction::Update(vec![(
Some(self.pid),
Some(client_id),
Event::Mouse(Mouse::DoubleClick(position.line.0, position.column.0)),
)]))
.unwrap();
}
fn update_selection(&mut self, position: &Position, client_id: ClientId) {
if self.supports_mouse_selection {
if let Some(grid) = self.grids.get_mut(&client_id) {
+28 -6
View File
@@ -29,11 +29,12 @@ use zellij_utils::data::{
DeleteLayoutResponse, EditLayoutResponse, Event, FloatingPaneCoordinates,
FocusOrCreateTabResponse, GetFocusedPaneInfoResponse, GetPaneCwdResponse, GetPanePidResponse,
GetPaneRunningCommandResponse, HttpVerb, KeyWithModifier, KillSessionsResponse, LayoutInfo,
LayoutMetadata, LayoutParsingError, MessageToPlugin, NewPanePlacement, NewTabResponse,
NewTabUnfocusedResponse, NewTiledPaneInTabResponse, OpenCommandPaneBackgroundResponse,
OpenCommandPaneFloatingNearPluginResponse, OpenCommandPaneFloatingResponse,
OpenCommandPaneInPlaceOfPaneIdResponse, OpenCommandPaneInPlaceOfPluginResponse,
OpenCommandPaneInPlaceResponse, OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
LayoutMetadata, LayoutParsingError, MessageToPlugin, MouseTrigger, NewPanePlacement,
NewTabResponse, NewTabUnfocusedResponse, NewTiledPaneInTabResponse,
OpenCommandPaneBackgroundResponse, OpenCommandPaneFloatingNearPluginResponse,
OpenCommandPaneFloatingResponse, OpenCommandPaneInPlaceOfPaneIdResponse,
OpenCommandPaneInPlaceOfPluginResponse, OpenCommandPaneInPlaceResponse,
OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
OpenEditPaneInPlaceOfPaneIdResponse, OpenFileFloatingNearPluginResponse,
OpenFileFloatingResponse, OpenFileInPlaceOfPluginResponse, OpenFileInPlaceResponse,
OpenFileNearPluginResponse, OpenFileResponse, OpenPaneInNewTabResponse,
@@ -512,6 +513,10 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
keys,
write_config_to_disk,
} => reset_keys(env, keys, write_config_to_disk),
PluginCommand::ResetMousebinds {
triggers,
write_config_to_disk,
} => reset_mousebinds(env, triggers, write_config_to_disk),
PluginCommand::WriteThemeFile {
name,
copy_from,
@@ -3389,6 +3394,22 @@ fn reset_keys(
.non_fatal();
}
fn reset_mousebinds(
env: &PluginEnv,
triggers: Vec<(InputMode, MouseTrigger)>,
write_config_to_disk: bool,
) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::ResetMousebinds {
client_id,
triggers,
write_config_to_disk,
})
.with_context(|| "Failed to reset mouse bindings")
.non_fatal();
}
fn rebind_keys(
env: &PluginEnv,
keys_to_rebind: Vec<(InputMode, KeyWithModifier, Vec<Action>)>,
@@ -6431,7 +6452,8 @@ fn required_permission(command: &PluginCommand) -> Option<PermissionType> {
| PluginCommand::OverwriteConfigFile
| PluginCommand::ReloadConfigFile
| PluginCommand::ReplaceConfigBlocks(..)
| PluginCommand::ResetKeys { .. } => PermissionType::Reconfigure,
| PluginCommand::ResetKeys { .. }
| PluginCommand::ResetMousebinds { .. } => PermissionType::Reconfigure,
PluginCommand::ChangeHostFolder(..) | PluginCommand::ListWindowsVolumes => {
PermissionType::FullHdAccess
},
+9
View File
@@ -2314,6 +2314,15 @@ pub(crate) fn route_action(
))
.with_context(err_context)?;
},
Action::MoveTabToPosition { id, position } => {
senders
.send_to_screen(ScreenInstruction::MoveTabToPosition(
id as usize,
usize::try_from(position).unwrap_or(usize::MAX),
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
},
Action::TogglePaneInGroupByPaneId { pane_id } => {
senders
.send_to_screen(ScreenInstruction::TogglePaneIdInGroup(
+98 -1
View File
@@ -60,6 +60,7 @@ use zellij_utils::input::config::Config;
use zellij_utils::input::context_menu::ContextMenuConfig;
use zellij_utils::input::keybinds::{shortcut_for_action, Keybinds};
use zellij_utils::input::mouse::{MouseEvent, MouseEventType};
use zellij_utils::input::mousebinds::Mousebinds;
use zellij_utils::input::options::{
Clipboard, HostNotificationProtocol, NestedSessionHandling, PaneFrameStyle,
DEFAULT_WORD_SEPARATORS,
@@ -989,6 +990,7 @@ pub enum ScreenInstruction {
height: usize,
},
UpdateContextMenuConfig(ClientId, ContextMenuConfig),
UpdateMousebinds(ClientId, Arc<Mousebinds>),
GetContextMenuItemActions {
plugin_id: u32,
client_id: ClientId,
@@ -1055,6 +1057,7 @@ pub enum ScreenInstruction {
ApplyTiledSwapLayoutWithTabId(usize, String, Option<NotificationEnd>),
ApplyFloatingSwapLayoutWithTabId(usize, String, Option<NotificationEnd>),
MoveTabWithTabId(usize, Direction, Option<NotificationEnd>),
MoveTabToPosition(usize, usize, Option<NotificationEnd>),
SetSoftKeyboard {
client_id: ClientId,
on: bool,
@@ -1404,6 +1407,7 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::UpdateContextMenuConfig(..) => {
ScreenContext::UpdateContextMenuConfig
},
ScreenInstruction::UpdateMousebinds(..) => ScreenContext::UpdateMousebinds,
ScreenInstruction::GetContextMenuItemActions { .. } => {
ScreenContext::GetContextMenuItemActions
},
@@ -1499,6 +1503,7 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenContext::ApplyFloatingSwapLayoutWithTabId
},
ScreenInstruction::MoveTabWithTabId(..) => ScreenContext::MoveTabWithTabId,
ScreenInstruction::MoveTabToPosition(..) => ScreenContext::MoveTabToPosition,
ScreenInstruction::SetSoftKeyboard { .. } => ScreenContext::SetSoftKeyboard,
ScreenInstruction::FocusHostSession(..) => ScreenContext::FocusHostSession,
ScreenInstruction::FocusGuestSession(..) => ScreenContext::FocusGuestSession,
@@ -1713,6 +1718,8 @@ pub(crate) struct Screen {
client_keybinds: BTreeMap<ClientId, SharedKeybinds>,
default_context_menu_config: ContextMenuConfig,
context_menu_configs: HashMap<ClientId, ContextMenuConfig>,
default_mousebinds: Arc<Mousebinds>,
client_mousebinds: HashMap<ClientId, Arc<Mousebinds>>,
open_context_menus: HashMap<ClientId, (ContextMenuContext, Vec<ContextMenuEntry>)>,
last_client_input: HashMap<ClientId, std::time::Instant>,
last_mouse_positions: HashMap<ClientId, Position>,
@@ -1955,6 +1962,10 @@ impl Screen {
client_keybinds: BTreeMap::new(),
default_context_menu_config: ContextMenuConfig::default(),
context_menu_configs: HashMap::new(),
default_mousebinds: zellij_utils::input::config_settings::default_config()
.mousebinds
.clone(),
client_mousebinds: HashMap::new(),
open_context_menus: HashMap::new(),
last_client_input: HashMap::new(),
last_mouse_positions: HashMap::new(),
@@ -5835,6 +5846,7 @@ impl Screen {
self.rename_pane_targets.remove(&client_id);
self.rename_tab_targets.remove(&client_id);
self.context_menu_configs.remove(&client_id);
self.client_mousebinds.remove(&client_id);
self.last_client_input.remove(&client_id);
self.last_mouse_positions.remove(&client_id);
let passthrough_panes: Vec<PaneId> = self
@@ -6914,6 +6926,34 @@ impl Screen {
Ok(true)
}
pub fn move_tab_to_position(&mut self, tab_id: usize, position: usize) -> bool {
let Some(current_position) = self.tabs.get(&tab_id).map(|t| t.position) else {
log::error!("Tab with id {} not found", tab_id);
return false;
};
let target = position.min(self.tabs.len().saturating_sub(1));
if target == current_position {
return true;
}
for tab in self.tabs.values_mut() {
if tab.id == tab_id {
tab.position = target;
} else if current_position < target
&& tab.position > current_position
&& tab.position <= target
{
tab.position -= 1;
} else if target < current_position
&& tab.position >= target
&& tab.position < current_position
{
tab.position += 1;
}
}
self.log_and_report_session_state().non_fatal();
true
}
pub fn change_mode(
&mut self,
new_mode: InputMode,
@@ -8539,11 +8579,24 @@ impl Screen {
let active_pane_was_scrolled = self.active_pane_is_scrolled(client_id);
let passthrough_pane_id = active_pane_id_before
.filter(|pane_id| self.should_route_keys_to_pane(client_id, *pane_id));
let mousebinds = self
.client_mousebinds
.get(&client_id)
.unwrap_or(&self.default_mousebinds)
.clone();
match self.get_active_tab_mut(client_id).and_then(|tab| {
tab.handle_mouse_event_with_passthrough(&event, client_id, passthrough_pane_id)
tab.handle_mouse_event_with_passthrough(
&event,
client_id,
passthrough_pane_id,
mousebinds,
)
}) {
Ok(mouse_effect) => {
let mut should_render = false;
if let Some(actions) = mouse_effect.run_actions.clone() {
self.run_mouse_bound_actions(actions, client_id);
}
if let Some(pane_id) = mouse_effect.group_toggle {
if self.advanced_mouse_actions {
self.toggle_pane_id_in_group(pane_id, &client_id);
@@ -8614,6 +8667,28 @@ impl Screen {
},
}
}
fn run_mouse_bound_actions(&self, actions: Vec<Action>, client_id: ClientId) {
let senders = self.bus.senders.clone();
let default_mode = self.base_input_mode();
std::thread::spawn(move || {
for action in actions {
if let Err(e) = crate::route::route_action(
action,
client_id,
None,
None,
senders.clone(),
None,
None,
default_mode,
None,
false,
) {
log::error!("Failed to run a mouse-bound action: {:?}", e);
}
}
});
}
pub fn toggle_pane_in_group(&mut self, client_id: ClientId) -> Result<()> {
let err_context = "Can't add pane to group";
let active_tab = self
@@ -9474,6 +9549,7 @@ pub(crate) fn screen_thread_main(
);
screen.default_keybinds = default_keybinds;
screen.default_context_menu_config = config.context_menu.clone();
screen.default_mousebinds = config.mousebinds.clone();
screen.update_context_menu_enabled(config_options.context_menu_enabled.unwrap_or(true));
screen.host_theme_dark_styling = host_theme_dark_styling;
screen.host_theme_light_styling = host_theme_light_styling;
@@ -13894,6 +13970,9 @@ pub(crate) fn screen_thread_main(
ScreenInstruction::UpdateContextMenuConfig(client_id, context_menu_config) => {
screen.update_context_menu_config(client_id, context_menu_config);
},
ScreenInstruction::UpdateMousebinds(client_id, mousebinds) => {
screen.client_mousebinds.insert(client_id, mousebinds);
},
ScreenInstruction::GetContextMenuItemActions {
plugin_id,
client_id,
@@ -14587,6 +14666,24 @@ pub(crate) fn screen_thread_main(
));
}
},
ScreenInstruction::MoveTabToPosition(tab_id, position, mut _completion_tx) => {
if pending_tab_ids.is_empty() {
let tab_found = screen.move_tab_to_position(tab_id, position);
if !tab_found {
mark_action_as_failed(
&mut _completion_tx,
&format!("Tab with id {} not found", tab_id),
);
}
screen.render(None)?;
} else {
pending_events_waiting_for_tab.push(ScreenInstruction::MoveTabToPosition(
tab_id,
position,
_completion_tx,
));
}
},
ScreenInstruction::SetSoftKeyboard { client_id, on } => {
if let Some(os_input) = &screen.bus.os_input {
let _ = os_input
+6
View File
@@ -910,6 +910,12 @@ mod tests {
Direction::Left,
))],
),
ContextMenuEntry::item(
"Move tab to end",
vec![ContextMenuAction::ClickedTab(
ClickedTabAction::MoveToPosition(zellij_utils::data::TAB_POSITION_END),
)],
),
ContextMenuEntry::Separator,
ContextMenuEntry::item(
"Close pane 3",
+15 -4
View File
@@ -26,6 +26,7 @@ use zellij_utils::data::{
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::RunCommand;
use zellij_utils::input::mouse::MouseEvent;
use zellij_utils::input::mousebinds::Mousebinds;
use zellij_utils::input::options::DEFAULT_WORD_SEPARATORS;
use zellij_utils::position::Position;
use zellij_utils::position::{Column, Line};
@@ -68,6 +69,7 @@ use std::time::Instant;
use std::{
collections::{BTreeMap, HashMap, HashSet},
str,
sync::Arc,
};
use zellij_utils::{
data::{Event, FloatingPaneCoordinates, InputMode, ModeInfo, Palette, PaletteColor, Styling},
@@ -318,6 +320,8 @@ pub(crate) struct Tab {
web_clients_allowed: bool,
web_sharing: WebSharing,
mouse_hover_pane_id: HashMap<ClientId, PaneId>,
mouse_press_owner: HashMap<ClientId, mouse_handler::MousePressOwner>,
mouse_click_tracker: HashMap<ClientId, mouse_handler::MouseClickRecord>,
dimmed_clients: HashSet<ClientId>,
plugin_hover_pane_id: HashMap<ClientId, PaneId>,
mouse_last_pane_id: HashMap<ClientId, PaneId>,
@@ -462,6 +466,7 @@ pub trait Pane {
}
}
fn start_selection(&mut self, _start: &Position, _client_id: ClientId) {}
fn mouse_double_click(&mut self, _position: &Position, _client_id: ClientId) {}
fn set_mouse_modifiers(&mut self, _modifiers: std::collections::BTreeSet<KeyModifier>) {}
fn update_selection(&mut self, _position: &Position, _client_id: ClientId) {}
fn end_selection(&mut self, _end: &Position, _client_id: ClientId) {}
@@ -1108,6 +1113,8 @@ impl Tab {
web_clients_allowed,
web_sharing,
mouse_hover_pane_id: HashMap::new(),
mouse_press_owner: HashMap::new(),
mouse_click_tracker: HashMap::new(),
plugin_hover_pane_id: HashMap::new(),
mouse_last_pane_id: HashMap::new(),
mouse_help_text_visible: HashMap::new(),
@@ -6978,7 +6985,7 @@ impl Tab {
lines: usize,
client_id: ClientId,
) -> Result<MouseEffect> {
MouseHandler::handle_scrollwheel_up(self, point, lines, client_id)
MouseHandler::handle_scrollwheel_up(self, point, lines, true, client_id)
}
pub fn handle_scrollwheel_down(
@@ -6987,7 +6994,7 @@ impl Tab {
lines: usize,
client_id: ClientId,
) -> Result<MouseEffect> {
MouseHandler::handle_scrollwheel_down(self, point, lines, client_id)
MouseHandler::handle_scrollwheel_down(self, point, lines, true, client_id)
}
fn get_pane_id_at(
@@ -7070,7 +7077,10 @@ impl Tab {
event: &MouseEvent,
client_id: ClientId,
) -> Result<MouseEffect> {
MouseHandler::handle_mouse_event(self, event, client_id, None)
let mousebinds = zellij_utils::input::config_settings::default_config()
.mousebinds
.clone();
MouseHandler::handle_mouse_event(self, event, client_id, None, mousebinds)
}
pub fn handle_mouse_event_with_passthrough(
@@ -7078,8 +7088,9 @@ impl Tab {
event: &MouseEvent,
client_id: ClientId,
passthrough_pane_id: Option<PaneId>,
mousebinds: Arc<Mousebinds>,
) -> Result<MouseEffect> {
MouseHandler::handle_mouse_event(self, event, client_id, passthrough_pane_id)
MouseHandler::handle_mouse_event(self, event, client_id, passthrough_pane_id, mousebinds)
}
pub fn copy_selection(&self, client_id: ClientId) -> Result<()> {
File diff suppressed because it is too large Load Diff
@@ -12434,6 +12434,9 @@ fn alt_click_is_forwarded_to_a_pane_the_client_is_descended_into() {
&MouseEvent::new_left_press_with_alt_event(Position::new(5, 71)),
client_id,
Some(PaneId::Terminal(1)),
zellij_utils::input::config_settings::default_config()
.mousebinds
.clone(),
)
.unwrap();
@@ -12482,6 +12485,9 @@ fn alt_click_still_groups_a_mouse_tracking_pane_the_client_is_not_descended_into
&MouseEvent::new_left_press_with_alt_event(Position::new(5, 71)),
client_id,
None,
zellij_utils::input::config_settings::default_config()
.mousebinds
.clone(),
)
.unwrap();
@@ -12521,6 +12527,9 @@ fn alt_wheel_up_is_forwarded_to_a_pane_the_client_is_descended_into() {
&MouseEvent::new_alt_scroll_up_event(Position::new(5, 71)),
client_id,
Some(PaneId::Terminal(1)),
zellij_utils::input::config_settings::default_config()
.mousebinds
.clone(),
)
.unwrap();
@@ -12564,6 +12573,9 @@ fn alt_wheel_over_a_mouse_tracking_pane_is_forwarded_instead_of_jumping_prompts(
&MouseEvent::new_alt_scroll_up_event(Position::new(5, 71)),
client_id,
None,
zellij_utils::input::config_settings::default_config()
.mousebinds
.clone(),
)
.unwrap();
@@ -17870,3 +17882,654 @@ fn a_collapsed_bar_gives_its_row_to_the_viewport_across_a_swap_layout() {
"the viewport should still cover the collapsed bar's row after a swap layout"
);
}
fn mousebinds_from(text: &str) -> Arc<zellij_utils::input::mousebinds::Mousebinds> {
let config = zellij_utils::input::config::Config::from_kdl(
text,
Some(zellij_utils::input::config::Config::from_default_assets().unwrap()),
)
.unwrap();
config.mousebinds.clone()
}
fn click_with(
tab: &mut Tab,
event: MouseEvent,
client_id: ClientId,
mousebinds: &Arc<zellij_utils::input::mousebinds::Mousebinds>,
) -> super::MouseEffect {
tab.handle_mouse_event_with_passthrough(&event, client_id, None, mousebinds.clone())
.unwrap()
}
fn tab_with_two_tiled_panes(size: Size, client_id: ClientId) -> Tab {
let mut tab = create_new_tab(size, ModeInfo::default());
tab.vertical_split(PaneId::Terminal(2), None, client_id, None, None)
.unwrap();
let mut output = Output::default();
tab.render(&mut output, None).unwrap();
tab
}
#[test]
fn double_click_on_a_tiled_pane_frame_toggles_fullscreen() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = tab_with_two_tiled_panes(size, client_id);
let frame_position = Position::new(5, 60);
for _ in 0..2 {
tab.handle_mouse_event(&MouseEvent::new_left_press_event(frame_position), client_id)
.unwrap();
tab.handle_mouse_event(
&MouseEvent::new_left_release_event(frame_position),
client_id,
)
.unwrap();
}
assert!(tab.is_fullscreen_active());
assert_eq!(tab.fullscreen_pane_id(), Some(PaneId::Terminal(1)));
}
#[test]
fn double_click_on_a_floating_pane_frame_toggles_its_fullscreen() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = tab_with_floating_pane(size, client_id);
let geom_before = tab
.get_pane_with_id(PaneId::Terminal(2))
.unwrap()
.position_and_size();
let frame_position = Position::new(geom_before.y as i32, (geom_before.x + 3) as u16);
let double_click = |tab: &mut Tab| {
for _ in 0..2 {
tab.handle_mouse_event(&MouseEvent::new_left_press_event(frame_position), client_id)
.unwrap();
tab.handle_mouse_event(
&MouseEvent::new_left_release_event(frame_position),
client_id,
)
.unwrap();
}
};
double_click(&mut tab);
assert!(tab.floating_panes.fullscreen_is_active());
assert_eq!(
tab.floating_panes.fullscreen_pane_id(),
Some(PaneId::Terminal(2))
);
}
#[test]
fn clicks_far_apart_are_not_counted_as_a_double_click() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = tab_with_two_tiled_panes(size, client_id);
for position in [Position::new(5, 60), Position::new(6, 60)] {
tab.handle_mouse_event(&MouseEvent::new_left_press_event(position), client_id)
.unwrap();
tab.handle_mouse_event(&MouseEvent::new_left_release_event(position), client_id)
.unwrap();
}
assert!(!tab.is_fullscreen_active());
}
#[test]
fn a_rebound_ctrl_click_runs_its_new_behaviour() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = tab_with_two_tiled_panes(size, client_id);
let mousebinds = mousebinds_from(
"mousebinds {\n shared {\n bind \"Ctrl Left\" on=\"content\" { ToggleFullscreen; }\n }\n}\n",
);
click_with(
&mut tab,
MouseEvent::new_left_press_with_ctrl_event(Position::new(5, 70)),
client_id,
&mousebinds,
);
assert_eq!(tab.fullscreen_pane_id(), Some(PaneId::Terminal(2)));
}
#[test]
fn an_ignored_scroll_leaves_the_pane_where_it_was() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = create_new_tab(size, ModeInfo::default());
for i in 0..60 {
tab.handle_pty_bytes(1, format!("line {}\n\r", i).into_bytes())
.unwrap();
}
let mousebinds = mousebinds_from(
"mousebinds {\n shared {\n bind \"ScrollUp\" { Ignore; }\n }\n}\n",
);
click_with(
&mut tab,
MouseEvent::new_scroll_up_event(Position::new(5, 10)),
client_id,
&mousebinds,
);
assert!(!tab.get_active_pane(client_id).unwrap().is_scrolled());
tab.handle_mouse_event(
&MouseEvent::new_scroll_up_event(Position::new(5, 10)),
client_id,
)
.unwrap();
assert!(tab.get_active_pane(client_id).unwrap().is_scrolled());
}
#[test]
fn an_action_binding_runs_once_per_press() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = create_new_tab(size, ModeInfo::default());
let mousebinds = mousebinds_from(
"mousebinds {\n shared {\n bind \"Middle\" { GoToNextTab; }\n }\n}\n",
);
let position = Position::new(5, 10);
let press = click_with(
&mut tab,
MouseEvent::new_middle_press_event(position),
client_id,
&mousebinds,
);
assert_eq!(
press.run_actions,
Some(vec![zellij_utils::input::actions::Action::GoToNextTab])
);
let motion = click_with(
&mut tab,
MouseEvent::new_middle_motion_event(position),
client_id,
&mousebinds,
);
assert_eq!(motion.run_actions, None);
let release = click_with(
&mut tab,
MouseEvent::new_middle_release_event(position),
client_id,
&mousebinds,
);
assert_eq!(release.run_actions, None);
}
#[test]
fn cleared_mouse_bindings_pass_everything_to_the_app() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut pty_instruction_bus = MockPtyInstructionBus::new();
let mut tab = create_new_tab_with_mock_pty_writer(
size,
ModeInfo::default(),
pty_instruction_bus.pty_write_sender(),
);
pty_instruction_bus.start();
tab.handle_pty_bytes(1, Vec::from("\u{1b}[?1002h\u{1b}[?1006h".as_bytes()))
.unwrap();
let mousebinds = mousebinds_from("mousebinds clear-defaults=true {\n}\n");
let position = Position::new(5, 10);
click_with(
&mut tab,
MouseEvent::new_left_press_with_alt_event(position),
client_id,
&mousebinds,
);
click_with(
&mut tab,
MouseEvent::new_left_release_event(position),
client_id,
&mousebinds,
);
click_with(
&mut tab,
MouseEvent::new_scroll_up_event(position),
client_id,
&mousebinds,
);
pty_instruction_bus.exit();
assert_eq!(
pty_instruction_bus.clone_output(),
vec![
"\u{1b}[<8;10;5M".to_string(),
"\u{1b}[<0;10;5m".to_string(),
"\u{1b}[<64;10;5M".to_string(),
]
);
}
#[test]
fn passed_wheel_events_become_arrow_keys_in_a_full_screen_app_without_mouse_reporting() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut pty_instruction_bus = MockPtyInstructionBus::new();
let mut tab = create_new_tab_with_mock_pty_writer(
size,
ModeInfo::default(),
pty_instruction_bus.pty_write_sender(),
);
pty_instruction_bus.start();
tab.handle_pty_bytes(1, Vec::from("\u{1b}[?1049h".as_bytes()))
.unwrap();
let mousebinds = mousebinds_from(
"mousebinds {\n shared {\n bind \"ScrollDown\" { PassToApp; }\n }\n}\n",
);
click_with(
&mut tab,
MouseEvent::new_scroll_down_event(Position::new(5, 10)),
client_id,
&mousebinds,
);
pty_instruction_bus.exit();
assert_eq!(
pty_instruction_bus.clone_output(),
vec!["\u{1b}[B".to_string(); 3]
);
}
#[test]
fn a_press_passed_to_the_app_keeps_its_drag_and_release_with_the_app() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut pty_instruction_bus = MockPtyInstructionBus::new();
let mut tab = create_new_tab_with_mock_pty_writer(
size,
ModeInfo::default(),
pty_instruction_bus.pty_write_sender(),
);
pty_instruction_bus.start();
tab.handle_pty_bytes(1, Vec::from("\u{1b}[?1002h\u{1b}[?1006h".as_bytes()))
.unwrap();
let mousebinds = mousebinds_from(
"mousebinds {\n shared {\n bind \"Ctrl Left\" on=\"content\" { PassToApp; }\n }\n}\n",
);
click_with(
&mut tab,
MouseEvent::new_left_press_with_ctrl_event(Position::new(5, 10)),
client_id,
&mousebinds,
);
click_with(
&mut tab,
MouseEvent::new_left_motion_with_ctrl_event(Position::new(6, 12)),
client_id,
&mousebinds,
);
click_with(
&mut tab,
MouseEvent::new_left_release_with_ctrl_event(Position::new(6, 12)),
client_id,
&mousebinds,
);
pty_instruction_bus.exit();
assert_eq!(pty_instruction_bus.clone_output().len(), 3);
}
#[test]
fn a_program_with_mouse_reporting_gets_both_raw_clicks_of_a_double_click() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut pty_instruction_bus = MockPtyInstructionBus::new();
let mut tab = create_new_tab_with_mock_pty_writer(
size,
ModeInfo::default(),
pty_instruction_bus.pty_write_sender(),
);
pty_instruction_bus.start();
tab.handle_pty_bytes(1, Vec::from("\u{1b}[?1000h\u{1b}[?1006h".as_bytes()))
.unwrap();
let position = Position::new(5, 10);
for _ in 0..2 {
tab.handle_mouse_event(&MouseEvent::new_left_press_event(position), client_id)
.unwrap();
tab.handle_mouse_event(&MouseEvent::new_left_release_event(position), client_id)
.unwrap();
}
pty_instruction_bus.exit();
assert_eq!(
pty_instruction_bus.clone_output(),
vec![
"\u{1b}[<0;10;5M".to_string(),
"\u{1b}[<0;10;5m".to_string(),
"\u{1b}[<0;10;5M".to_string(),
"\u{1b}[<0;10;5m".to_string(),
]
);
assert!(!tab.is_fullscreen_active());
}
#[test]
fn double_and_triple_clicks_select_a_word_and_a_line_through_the_default_bindings() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = create_new_tab(size, ModeInfo::default());
tab.handle_pty_bytes(1, Vec::from("first second third".as_bytes()))
.unwrap();
let position = Position::new(1, 9);
let click = |tab: &mut Tab| {
tab.handle_mouse_event(&MouseEvent::new_left_press_event(position), client_id)
.unwrap();
tab.handle_mouse_event(&MouseEvent::new_left_release_event(position), client_id)
.unwrap();
};
let selected = |tab: &Tab| {
tab.get_active_pane(client_id)
.unwrap()
.get_selected_text(client_id)
};
click(&mut tab);
click(&mut tab);
assert_eq!(selected(&tab), Some("second".to_owned()));
click(&mut tab);
assert_eq!(selected(&tab), Some("first second third".to_owned()));
}
#[test]
fn scrolling_with_app_first_off_scrolls_zellij_even_when_the_program_wants_the_mouse() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut pty_instruction_bus = MockPtyInstructionBus::new();
let mut tab = create_new_tab_with_mock_pty_writer(
size,
ModeInfo::default(),
pty_instruction_bus.pty_write_sender(),
);
pty_instruction_bus.start();
for i in 0..60 {
tab.handle_pty_bytes(1, format!("line {}\n\r", i).into_bytes())
.unwrap();
}
tab.handle_pty_bytes(1, Vec::from("\u{1b}[?1000h\u{1b}[?1006h".as_bytes()))
.unwrap();
let mousebinds = mousebinds_from(
"mousebinds {\n shared {\n bind \"ScrollUp\" app_first=false { Scroll 3; }\n }\n}\n",
);
click_with(
&mut tab,
MouseEvent::new_scroll_up_event(Position::new(5, 10)),
client_id,
&mousebinds,
);
pty_instruction_bus.exit();
assert!(pty_instruction_bus.clone_output().is_empty());
assert!(tab.get_active_pane(client_id).unwrap().is_scrolled());
}
#[test]
fn dragging_inside_a_floating_pane_with_a_move_pane_binding_moves_it() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let mut tab = tab_with_floating_pane(size, client_id);
let geom_before = tab
.get_pane_with_id(PaneId::Terminal(2))
.unwrap()
.position_and_size();
let inside = Position::new((geom_before.y + 2) as i32, (geom_before.x + 3) as u16);
let moved_to = Position::new((geom_before.y + 4) as i32, (geom_before.x + 8) as u16);
let mousebinds = mousebinds_from(
"mousebinds {\n shared {\n bind \"Alt Left\" { MovePane; }\n }\n}\n",
);
click_with(
&mut tab,
MouseEvent::new_left_press_with_alt_event(inside),
client_id,
&mousebinds,
);
click_with(
&mut tab,
MouseEvent::new_left_motion_with_alt_event(moved_to),
client_id,
&mousebinds,
);
let mut release = MouseEvent::new_left_release_event(moved_to);
release.alt = true;
click_with(&mut tab, release, client_id, &mousebinds);
let geom_after = tab
.get_pane_with_id(PaneId::Terminal(2))
.unwrap()
.position_and_size();
assert_eq!(geom_after.x, geom_before.x + 5);
assert_eq!(geom_after.y, geom_before.y + 2);
}
fn mouse_events_sent_to_plugin(
plugin_receiver: &Receiver<(PluginInstruction, ErrorContext)>,
plugin_id: u32,
) -> Vec<zellij_utils::data::Mouse> {
let mut events = vec![];
while let Ok((instruction, _)) = plugin_receiver.try_recv() {
if let PluginInstruction::Update(updates) = instruction {
for (pid, _, event) in updates {
if pid != Some(plugin_id) {
continue;
}
match event {
zellij_utils::data::Event::Mouse(mouse)
| zellij_utils::data::Event::MouseWithModifiers(mouse, _) => events.push(mouse),
_ => {},
}
}
}
}
events
}
fn tab_with_unselectable_plugin_pane(
size: Size,
client_id: ClientId,
) -> (Tab, Receiver<(PluginInstruction, ErrorContext)>) {
let (mut tab, plugin_receiver) = create_new_tab_with_plugin_receiver(size, ModeInfo::default());
tab.new_pane(
PaneId::Plugin(7),
None,
None,
false,
true,
NewPanePlacement::default(),
Some(client_id),
None,
)
.unwrap();
tab.set_pane_selectable(PaneId::Plugin(7), false);
while plugin_receiver.try_recv().is_ok() {}
(tab, plugin_receiver)
}
#[test]
fn dragging_in_an_unselectable_plugin_pane_sends_hold_and_release_to_the_plugin() {
use zellij_utils::data::Mouse;
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let (mut tab, plugin_receiver) = tab_with_unselectable_plugin_pane(size, client_id);
assert_eq!(tab.get_active_pane_id(client_id), Some(PaneId::Terminal(1)));
let plugin_point = center_of_pane(&tab, PaneId::Plugin(7));
let moved_point = Position::new(
plugin_point.line() as i32,
(plugin_point.column() + 3) as u16,
);
tab.handle_mouse_event(&MouseEvent::new_left_press_event(plugin_point), client_id)
.unwrap();
assert_eq!(tab.selecting_with_mouse_in_pane, Some(PaneId::Plugin(7)));
assert_eq!(tab.get_active_pane_id(client_id), Some(PaneId::Terminal(1)));
tab.handle_mouse_event(&MouseEvent::new_left_motion_event(moved_point), client_id)
.unwrap();
tab.handle_mouse_event(&MouseEvent::new_left_release_event(moved_point), client_id)
.unwrap();
assert!(tab.selecting_with_mouse_in_pane.is_none());
let events = mouse_events_sent_to_plugin(&plugin_receiver, 7);
let kinds: Vec<&'static str> = events
.iter()
.filter_map(|event| match event {
Mouse::LeftClick(..) => Some("click"),
Mouse::Hold(..) => Some("hold"),
Mouse::Release(..) => Some("release"),
_ => None,
})
.collect();
assert_eq!(kinds, vec!["click", "hold", "release"], "{:?}", events);
let click_column = events.iter().find_map(|event| match event {
Mouse::LeftClick(_, column) => Some(*column),
_ => None,
});
let hold_column = events.iter().find_map(|event| match event {
Mouse::Hold(_, column) => Some(*column),
_ => None,
});
assert_eq!(hold_column, click_column.map(|column| column + 3));
}
#[test]
fn a_middle_click_on_an_unselectable_plugin_pane_does_not_start_a_drag() {
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let (mut tab, _plugin_receiver) = tab_with_unselectable_plugin_pane(size, client_id);
let plugin_point = center_of_pane(&tab, PaneId::Plugin(7));
tab.handle_mouse_event(&MouseEvent::new_middle_press_event(plugin_point), client_id)
.unwrap();
assert!(tab.selecting_with_mouse_in_pane.is_none());
let terminal_point = center_of_pane(&tab, PaneId::Terminal(1));
tab.handle_mouse_event(&MouseEvent::new_left_press_event(terminal_point), client_id)
.unwrap();
assert_eq!(tab.selecting_with_mouse_in_pane, Some(PaneId::Terminal(1)));
}
#[test]
fn clicking_a_pinned_unselectable_plugin_pane_sends_it_the_click_without_moving_focus() {
use zellij_utils::data::Mouse;
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let (mut tab, plugin_receiver) = create_new_tab_with_plugin_receiver(size, ModeInfo::default());
let coordinates = FloatingPaneCoordinates {
x: Some(PercentOrFixed::Fixed(30)),
y: Some(PercentOrFixed::Fixed(5)),
width: Some(PercentOrFixed::Fixed(40)),
height: Some(PercentOrFixed::Fixed(8)),
pinned: Some(true),
borderless: Some(false),
border_style: None,
};
tab.new_floating_pane(
PaneId::Plugin(8),
None,
None,
false,
false,
Some(coordinates),
None,
)
.unwrap();
tab.set_pane_selectable(PaneId::Plugin(8), false);
assert!(!tab.floating_panes.panes_are_visible());
while plugin_receiver.try_recv().is_ok() {}
let active_before = tab.get_active_pane_id(client_id);
let plugin_point = center_of_pane(&tab, PaneId::Plugin(8));
tab.handle_mouse_event(&MouseEvent::new_left_press_event(plugin_point), client_id)
.unwrap();
tab.handle_mouse_event(&MouseEvent::new_left_release_event(plugin_point), client_id)
.unwrap();
assert_eq!(tab.get_active_pane_id(client_id), active_before);
assert!(!tab.floating_panes.panes_are_visible());
let events = mouse_events_sent_to_plugin(&plugin_receiver, 8);
assert!(
events.iter().any(|e| matches!(e, Mouse::LeftClick(..))),
"{:?}",
events
);
assert!(
events.iter().any(|e| matches!(e, Mouse::Release(..))),
"{:?}",
events
);
}
#[test]
fn clicking_a_hovered_unselectable_plugin_pane_keeps_its_hover() {
use zellij_utils::data::Mouse;
let size = Size {
cols: 121,
rows: 20,
};
let client_id = 1;
let (mut tab, plugin_receiver) = tab_with_unselectable_plugin_pane(size, client_id);
let plugin_point = center_of_pane(&tab, PaneId::Plugin(7));
tab.handle_mouse_event(&MouseEvent::new_buttonless_motion(plugin_point), client_id)
.unwrap();
let events = mouse_events_sent_to_plugin(&plugin_receiver, 7);
assert!(
events.iter().any(|e| matches!(e, Mouse::Hover(..))),
"{:?}",
events
);
tab.handle_mouse_event(&MouseEvent::new_left_press_event(plugin_point), client_id)
.unwrap();
tab.handle_mouse_event(&MouseEvent::new_left_release_event(plugin_point), client_id)
.unwrap();
let events = mouse_events_sent_to_plugin(&plugin_receiver, 7);
assert!(
!events
.iter()
.any(|e| matches!(e, Mouse::Hover(_, column) if *column >= size.cols)),
"{:?}",
events
);
let terminal_point = center_of_pane(&tab, PaneId::Terminal(1));
tab.handle_mouse_event(
&MouseEvent::new_buttonless_motion(terminal_point),
client_id,
)
.unwrap();
let events = mouse_events_sent_to_plugin(&plugin_receiver, 7);
assert!(
events
.iter()
.any(|e| matches!(e, Mouse::Hover(_, column) if *column >= size.cols)),
"{:?}",
events
);
}
+58
View File
@@ -831,6 +831,9 @@ impl MockScreen {
let os_input = FakeInputOutput::default();
let config_options = Options::default();
let mut config = Config::default();
config.mousebinds = zellij_utils::input::config_settings::default_config()
.mousebinds
.clone();
config.options.pane_frame_style = Some(PaneFrameStyle::Full);
config.options.stacked_pane_list = Some(false);
let main_client_id = 1;
@@ -15628,6 +15631,61 @@ pub fn moving_an_unknown_tab_reports_failure() {
);
}
fn tab_ids_in_position_order(screen: &Screen) -> Vec<usize> {
let mut tabs: Vec<(usize, usize)> = screen.tabs.values().map(|t| (t.position, t.id)).collect();
tabs.sort();
tabs.into_iter().map(|(_, id)| id).collect()
}
#[test]
pub fn move_tab_to_position_shifts_the_tabs_in_between() {
let size = Size {
cols: 121,
rows: 20,
};
let mut screen = create_new_screen(size, true, true);
for (pid, index) in [(1, 0), (2, 1), (3, 2), (4, 3)] {
new_tab(&mut screen, pid, index);
}
assert_eq!(tab_ids_in_position_order(&screen), vec![0, 1, 2, 3]);
assert!(screen.move_tab_to_position(0, 2));
assert_eq!(tab_ids_in_position_order(&screen), vec![1, 2, 0, 3]);
assert!(screen.move_tab_to_position(3, 0));
assert_eq!(tab_ids_in_position_order(&screen), vec![3, 1, 2, 0]);
assert!(screen.move_tab_to_position(2, 2));
assert_eq!(tab_ids_in_position_order(&screen), vec![3, 1, 2, 0]);
assert!(screen.move_tab_to_position(3, usize::MAX));
assert_eq!(tab_ids_in_position_order(&screen), vec![1, 2, 0, 3]);
assert!(!screen.move_tab_to_position(99, 0));
assert_eq!(tab_ids_in_position_order(&screen), vec![1, 2, 0, 3]);
}
#[test]
pub fn move_tab_to_position_instruction_moves_the_tab_and_reports_unknown_tabs() {
let size = Size {
cols: 121,
rows: 20,
};
let result = send_instructions_and_collect(
size,
true,
|_| {},
|completion| ScreenInstruction::MoveTabToPosition(99, 0, Some(completion)),
);
assert_eq!(result.completion.exit_status, Some(1));
assert_eq!(
result.completion.error_message.as_deref(),
Some("Tab with id 99 not found")
);
let result = send_instructions_and_collect(
size,
true,
|_| {},
|completion| ScreenInstruction::MoveTabToPosition(0, 0, Some(completion)),
);
assert_ne!(result.completion.exit_status, Some(1));
}
#[test]
pub fn overriding_with_an_empty_layout_keeps_all_tabs_and_reports_failure() {
let size = Size {
+10
View File
@@ -2034,6 +2034,16 @@ pub fn reset_keys(keys: Vec<(InputMode, KeyWithModifier)>, write_config_to_disk:
unsafe { host_run_plugin_command() };
}
pub fn reset_mousebinds(triggers: Vec<(InputMode, MouseTrigger)>, write_config_to_disk: bool) {
let plugin_command = PluginCommand::ResetMousebinds {
triggers,
write_config_to_disk,
};
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
pub fn save_keybinds_as_preset(new_name: &str) -> Result<String, String> {
use zellij_utils::plugin_api::plugin_command::copy_keybind_preset_response::Result as CopyResult;
let plugin_command = PluginCommand::SaveKeybindsAsPreset {
+2
View File
@@ -83,6 +83,8 @@ context_menu {
item "Rename tab" { StartRenameTabByTabId; }
item "Move tab left" { MoveTabByTabId "left"; }
item "Move tab right" { MoveTabByTabId "right"; }
item "Move tab to start" { MoveTabToPosition "start"; }
item "Move tab to end" { MoveTabToPosition "end"; }
separator
item "Close tab" { CloseTabById; }
}
+25
View File
@@ -246,3 +246,28 @@ keybinds {
bind "{primary} b" { SwitchToMode "Tmux"; }
}
}
mousebinds {
shared {
bind "Left" { Click; }
bind "Double Left" on="content" { Select; }
bind "Triple Left" on="content" { Select; }
bind "Double Left" on="frame" { ToggleFullscreen; }
bind "{primary} Left" on="frame" { ResizePane; }
bind "{secondary} Left" { GroupToggle; }
bind "{secondary} Right" { Ungroup; }
bind "{secondary} Middle" { Ignore; }
bind "Right" { ContextMenu; }
bind "{primary} Right" { ContextMenu; }
bind "ScrollUp" { Scroll 3; }
bind "ScrollDown" { Scroll 3; }
bind "ScrollLeft" { ScrollColumns 4; }
bind "ScrollRight" { ScrollColumns 4; }
bind "{primary} ScrollUp" { ResizeScroll "increase"; }
bind "{primary} ScrollDown" { ResizeScroll "decrease"; }
bind "{primary} ScrollLeft" { ScrollColumns 4; }
bind "{primary} ScrollRight" { ScrollColumns 4; }
bind "{secondary} ScrollUp" { ScrollToPrompt "previous"; }
bind "{secondary} ScrollDown" { ScrollToPrompt "next"; }
bind "{secondary} ScrollLeft" "{secondary} ScrollRight" { Ignore; }
}
}
@@ -229,3 +229,28 @@ keybinds {
bind "m" { SwitchToMode "Move"; }
}
}
mousebinds {
shared {
bind "Left" { Click; }
bind "Double Left" on="content" { Select; }
bind "Triple Left" on="content" { Select; }
bind "Double Left" on="frame" { ToggleFullscreen; }
bind "{primary} Left" on="frame" { ResizePane; }
bind "{secondary} Left" { GroupToggle; }
bind "{secondary} Right" { Ungroup; }
bind "{secondary} Middle" { Ignore; }
bind "Right" { ContextMenu; }
bind "{primary} Right" { ContextMenu; }
bind "ScrollUp" { Scroll 3; }
bind "ScrollDown" { Scroll 3; }
bind "ScrollLeft" { ScrollColumns 4; }
bind "ScrollRight" { ScrollColumns 4; }
bind "{primary} ScrollUp" { ResizeScroll "increase"; }
bind "{primary} ScrollDown" { ResizeScroll "decrease"; }
bind "{primary} ScrollLeft" { ScrollColumns 4; }
bind "{primary} ScrollRight" { ScrollColumns 4; }
bind "{secondary} ScrollUp" { ScrollToPrompt "previous"; }
bind "{secondary} ScrollDown" { ScrollToPrompt "next"; }
bind "{secondary} ScrollLeft" "{secondary} ScrollRight" { Ignore; }
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -1
View File
@@ -307,7 +307,7 @@ pub struct OverrideLayoutPayload {
pub struct Action {
#[prost(enumeration="ActionName", tag="1")]
pub name: i32,
#[prost(oneof="action::OptionalPayload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67")]
#[prost(oneof="action::OptionalPayload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68")]
pub optional_payload: ::core::option::Option<action::OptionalPayload>,
}
/// Nested message and enum types in `Action`.
@@ -441,6 +441,8 @@ pub mod action {
TargetTabIdPayload(super::TargetTabIdPayload),
#[prost(message, tag="67")]
MoveTabByTabIdPayload(super::MoveTabByTabIdPayload),
#[prost(message, tag="68")]
MoveTabToPositionPayload(super::MoveTabToPositionPayload),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -465,6 +467,14 @@ pub struct MoveTabByTabIdPayload {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MoveTabToPositionPayload {
#[prost(uint64, tag="1")]
pub tab_id: u64,
#[prost(uint64, tag="2")]
pub position: u64,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SetPaneBorderStylePayload {
#[prost(message, optional, tag="1")]
pub pane_id: ::core::option::Option<PaneId>,
@@ -1212,6 +1222,7 @@ pub enum ActionName {
StartRenameTabByTabId = 124,
DismissInfoPopups = 125,
OpenContextMenu = 126,
MoveTabToPosition = 127,
}
impl ActionName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -1344,6 +1355,7 @@ impl ActionName {
ActionName::StartRenameTabByTabId => "StartRenameTabByTabId",
ActionName::DismissInfoPopups => "DismissInfoPopups",
ActionName::OpenContextMenu => "OpenContextMenu",
ActionName::MoveTabToPosition => "MoveTabToPosition",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -1473,6 +1485,7 @@ impl ActionName {
"StartRenameTabByTabId" => Some(Self::StartRenameTabByTabId),
"DismissInfoPopups" => Some(Self::DismissInfoPopups),
"OpenContextMenu" => Some(Self::OpenContextMenu),
"MoveTabToPosition" => Some(Self::MoveTabToPosition),
_ => None,
}
}
+6 -1
View File
@@ -189,7 +189,7 @@ pub struct NestedSessionModeUpdatePayload {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ContextMenuAction {
#[prost(oneof="context_menu_action::Action", tags="1, 2, 3")]
#[prost(oneof="context_menu_action::Action", tags="1, 2, 3, 4")]
pub action: ::core::option::Option<context_menu_action::Action>,
}
/// Nested message and enum types in `ContextMenuAction`.
@@ -203,6 +203,8 @@ pub mod context_menu_action {
ClickedPane(i32),
#[prost(enumeration="super::ClickedTabAction", tag="3")]
ClickedTab(i32),
#[prost(uint64, tag="4")]
ClickedTabMoveToPosition(u64),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -1522,6 +1524,7 @@ pub enum MouseEventName {
MouseHover = 6,
MouseScrollLeft = 7,
MouseScrollRight = 8,
MouseDoubleClick = 9,
}
impl MouseEventName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -1539,6 +1542,7 @@ impl MouseEventName {
MouseEventName::MouseHover => "MouseHover",
MouseEventName::MouseScrollLeft => "MouseScrollLeft",
MouseEventName::MouseScrollRight => "MouseScrollRight",
MouseEventName::MouseDoubleClick => "MouseDoubleClick",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -1553,6 +1557,7 @@ impl MouseEventName {
"MouseHover" => Some(Self::MouseHover),
"MouseScrollLeft" => Some(Self::MouseScrollLeft),
"MouseScrollRight" => Some(Self::MouseScrollRight),
"MouseDoubleClick" => Some(Self::MouseDoubleClick),
_ => None,
}
}
@@ -4,7 +4,7 @@
pub struct PluginCommand {
#[prost(enumeration="CommandName", tag="1")]
pub name: i32,
#[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200")]
#[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201")]
pub payload: ::core::option::Option<plugin_command::Payload>,
}
/// Nested message and enum types in `PluginCommand`.
@@ -378,6 +378,8 @@ pub mod plugin_command {
SetSelfCollapsedPayload(super::SetSelfCollapsedPayload),
#[prost(message, tag="200")]
SetCollapsedSlotPayload(super::SetCollapsedSlotPayload),
#[prost(message, tag="201")]
ResetMousebindsPayload(super::ResetMousebindsPayload),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -491,6 +493,8 @@ pub struct KeybindsSelectionSnapshot {
pub default_mode: ::core::option::Option<i32>,
#[prost(bool, tag="12")]
pub set_on_command_line: bool,
#[prost(bool, tag="13")]
pub has_own_mousebindings: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -549,6 +553,8 @@ pub struct ReadConfigResponse {
pub default_blocks: ::core::option::Option<ConfigBlocks>,
#[prost(message, repeated, tag="14")]
pub keybindings: ::prost::alloc::vec::Vec<KeybindingEntry>,
#[prost(message, repeated, tag="15")]
pub mousebindings: ::prost::alloc::vec::Vec<MousebindingEntry>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -654,6 +660,50 @@ pub struct KeybindingEntry {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MousebindingEntry {
#[prost(enumeration="super::input_mode::InputMode", tag="1")]
pub mode: i32,
#[prost(string, tag="2")]
pub trigger: ::prost::alloc::string::String,
#[prost(string, tag="3")]
pub target: ::prost::alloc::string::String,
#[prost(string, repeated, tag="4")]
pub actions: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(bool, tag="5")]
pub app_first: bool,
#[prost(enumeration="KeybindingSourceKind", tag="6")]
pub source: i32,
#[prost(string, optional, tag="7")]
pub shared_block: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bool, tag="8")]
pub unbound: bool,
#[prost(bool, tag="9")]
pub has_preset_binding: bool,
#[prost(string, repeated, tag="10")]
pub preset_actions: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(bool, tag="11")]
pub unsaved: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MouseTriggerInMode {
#[prost(enumeration="super::input_mode::InputMode", tag="1")]
pub input_mode: i32,
#[prost(string, tag="2")]
pub trigger: ::prost::alloc::string::String,
#[prost(string, tag="3")]
pub target: ::prost::alloc::string::String,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ResetMousebindsPayload {
#[prost(message, repeated, tag="1")]
pub triggers: ::prost::alloc::vec::Vec<MouseTriggerInMode>,
#[prost(bool, tag="2")]
pub write_config_to_disk: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SaveKeybindsAsPresetPayload {
#[prost(string, tag="1")]
pub new_name: ::prost::alloc::string::String,
@@ -2692,6 +2742,7 @@ pub enum CommandName {
DeleteThemeFile = 255,
SetSelfCollapsed = 256,
SetCollapsedSlot = 257,
ResetMousebinds = 258,
}
impl CommandName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -2932,6 +2983,7 @@ impl CommandName {
CommandName::DeleteThemeFile => "DeleteThemeFile",
CommandName::SetSelfCollapsed => "SetSelfCollapsed",
CommandName::SetCollapsedSlot => "SetCollapsedSlot",
CommandName::ResetMousebinds => "ResetMousebinds",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -3169,6 +3221,7 @@ impl CommandName {
"DeleteThemeFile" => Some(Self::DeleteThemeFile),
"SetSelfCollapsed" => Some(Self::SetSelfCollapsed),
"SetCollapsedSlot" => Some(Self::SetCollapsedSlot),
"ResetMousebinds" => Some(Self::ResetMousebinds),
_ => None,
}
}
@@ -154,7 +154,7 @@ pub struct RgbColor {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Action {
#[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 156, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 157, 158, 159, 160, 161")]
#[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 156, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 157, 158, 159, 160, 161, 162")]
pub action_type: ::core::option::Option<action::ActionType>,
}
/// Nested message and enum types in `Action`.
@@ -484,6 +484,8 @@ pub mod action {
DismissInfoPopups(super::DismissInfoPopupsAction),
#[prost(message, tag="161")]
OpenContextMenu(super::OpenContextMenuAction),
#[prost(message, tag="162")]
MoveTabToPosition(super::MoveTabToPositionAction),
}
}
// Action message definitions (all 92 variants)
@@ -2369,6 +2371,14 @@ pub struct MoveTabByTabIdAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MoveTabToPositionAction {
#[prost(uint64, tag="1")]
pub id: u64,
#[prost(uint64, tag="2")]
pub position: u64,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TogglePaneInGroupByPaneIdAction {
#[prost(message, optional, tag="1")]
pub pane_id: ::core::option::Option<PaneId>,
@@ -326,6 +326,7 @@ message Action {
StartRenameTabByTabIdAction start_rename_tab_by_tab_id = 159;
DismissInfoPopupsAction dismiss_info_popups = 160;
OpenContextMenuAction open_context_menu = 161;
MoveTabToPositionAction move_tab_to_position = 162;
}
}
@@ -1364,6 +1365,7 @@ message NextSwapLayoutByTabIdAction { uint64 id = 1; }
message ApplyTiledSwapLayoutByTabIdAction { uint64 id = 1; string name = 2; }
message ApplyFloatingSwapLayoutByTabIdAction { uint64 id = 1; string name = 2; }
message MoveTabByTabIdAction { uint64 id = 1; Direction direction = 2; }
message MoveTabToPositionAction { uint64 id = 1; uint64 position = 2; }
message TogglePaneInGroupByPaneIdAction { PaneId pane_id = 1; }
message StartRenamePaneByPaneIdAction { PaneId pane_id = 1; }
message StartRenameTabByTabIdAction { uint64 id = 1; }
+212
View File
@@ -912,6 +912,7 @@ pub enum Mouse {
Hold(isize, usize), // line and column
Release(isize, usize), // line and column
Hover(isize, usize), // line and column
DoubleClick(isize, usize),
}
impl Mouse {
@@ -919,6 +920,7 @@ impl Mouse {
// (line, column)
match self {
Mouse::LeftClick(line, column) => Some((*line as usize, *column as usize)),
Mouse::DoubleClick(line, column) => Some((*line as usize, *column as usize)),
Mouse::RightClick(line, column) => Some((*line as usize, *column as usize)),
Mouse::Hold(line, column) => Some((*line as usize, *column as usize)),
Mouse::Release(line, column) => Some((*line as usize, *column as usize)),
@@ -928,6 +930,189 @@ impl Mouse {
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, EnumIter,
)]
pub enum MouseButton {
Left,
Right,
Middle,
ScrollUp,
ScrollDown,
ScrollLeft,
ScrollRight,
}
impl MouseButton {
pub fn is_wheel(&self) -> bool {
matches!(
self,
MouseButton::ScrollUp
| MouseButton::ScrollDown
| MouseButton::ScrollLeft
| MouseButton::ScrollRight
)
}
pub fn name(&self) -> &'static str {
match self {
MouseButton::Left => "Left",
MouseButton::Right => "Right",
MouseButton::Middle => "Middle",
MouseButton::ScrollUp => "ScrollUp",
MouseButton::ScrollDown => "ScrollDown",
MouseButton::ScrollLeft => "ScrollLeft",
MouseButton::ScrollRight => "ScrollRight",
}
}
pub fn from_name(name: &str) -> Option<MouseButton> {
use strum::IntoEnumIterator;
MouseButton::iter().find(|button| button.name().eq_ignore_ascii_case(name))
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
Default,
Serialize,
Deserialize,
EnumIter,
)]
pub enum MouseTarget {
#[default]
Any,
Content,
Frame,
}
impl MouseTarget {
pub fn name(&self) -> &'static str {
match self {
MouseTarget::Any => "any",
MouseTarget::Content => "content",
MouseTarget::Frame => "frame",
}
}
pub fn from_name(name: &str) -> Option<MouseTarget> {
use strum::IntoEnumIterator;
MouseTarget::iter().find(|target| target.name().eq_ignore_ascii_case(name))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MouseTrigger {
pub button: MouseButton,
pub modifiers: BTreeSet<KeyModifier>,
pub click_count: u8,
pub target: MouseTarget,
}
impl MouseTrigger {
pub fn new(button: MouseButton) -> Self {
MouseTrigger {
button,
modifiers: BTreeSet::new(),
click_count: 1,
target: MouseTarget::Any,
}
}
pub fn with_modifiers(mut self, modifiers: BTreeSet<KeyModifier>) -> Self {
self.modifiers = modifiers;
self
}
pub fn with_click_count(mut self, click_count: u8) -> Self {
self.click_count = click_count;
self
}
pub fn with_target(mut self, target: MouseTarget) -> Self {
self.target = target;
self
}
pub fn parse(text: &str, target: MouseTarget) -> Result<MouseTrigger, String> {
let mut parts: Vec<&str> = text.split_ascii_whitespace().collect();
let button_text = parts
.pop()
.ok_or_else(|| "A mouse trigger needs a button".to_owned())?;
let button = MouseButton::from_name(button_text).ok_or_else(|| {
format!(
"Unknown mouse button '{}'; use Left, Right, Middle, ScrollUp, ScrollDown, ScrollLeft or ScrollRight",
button_text
)
})?;
let mut modifiers = BTreeSet::new();
let mut click_count = 1;
for part in parts {
match part.to_ascii_lowercase().as_str() {
"ctrl" => {
modifiers.insert(KeyModifier::Ctrl);
},
"alt" => {
modifiers.insert(KeyModifier::Alt);
},
"shift" => {
modifiers.insert(KeyModifier::Shift);
},
"super" => {
return Err(
"Super cannot be used with the mouse, because terminals do not report it for mouse events"
.to_owned(),
)
},
"double" => click_count = 2,
"triple" => click_count = 3,
_ => {
return Err(format!(
"Unknown word '{}' in the mouse trigger '{}'",
part, text
))
},
}
}
if click_count > 1 && button.is_wheel() {
return Err(format!(
"Double and Triple can only be used with Left, Right and Middle, not {}",
button.name()
));
}
Ok(MouseTrigger {
button,
modifiers,
click_count,
target,
})
}
pub fn to_kdl(&self) -> String {
let mut parts: Vec<String> = self.modifiers.iter().map(|m| m.to_string()).collect();
match self.click_count {
2 => parts.push("Double".to_owned()),
3 => parts.push("Triple".to_owned()),
_ => {},
}
parts.push(self.button.name().to_owned());
parts.join(" ")
}
pub fn same_trigger_ignoring_target(&self, other: &MouseTrigger) -> bool {
self.button == other.button
&& self.modifiers == other.modifiers
&& self.click_count == other.click_count
}
}
impl fmt::Display for MouseTrigger {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.target {
MouseTarget::Any => write!(f, "{}", self.to_kdl()),
target => write!(f, "{} · {}", self.to_kdl(), target.name()),
}
}
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileMetadata {
pub is_dir: bool,
@@ -1178,14 +1363,18 @@ pub enum ClickedTabAction {
Close,
StartRename,
Move(Direction),
MoveToPosition(u64),
}
pub const TAB_POSITION_END: u64 = u64::MAX;
impl ClickedTabAction {
pub fn kdl_name(&self) -> &'static str {
match self {
ClickedTabAction::Close => "CloseTabById",
ClickedTabAction::StartRename => "StartRenameTabByTabId",
ClickedTabAction::Move(_) => "MoveTabByTabId",
ClickedTabAction::MoveToPosition(_) => "MoveTabToPosition",
}
}
pub fn with_tab(&self, id: u64) -> Action {
@@ -1196,6 +1385,10 @@ impl ClickedTabAction {
id,
direction: *direction,
},
ClickedTabAction::MoveToPosition(position) => Action::MoveTabToPosition {
id,
position: *position,
},
}
}
}
@@ -2399,6 +2592,7 @@ pub struct KeybindsSelectionSnapshot {
pub unlock: Option<String>,
pub clears_defaults: bool,
pub has_own_keybindings: bool,
pub has_own_mousebindings: bool,
pub active: KeybindPresetInfo,
pub active_values: BTreeMap<String, String>,
pub error: Option<String>,
@@ -4555,6 +4749,10 @@ pub enum PluginCommand {
keys: Vec<(InputMode, KeyWithModifier)>,
write_config_to_disk: bool,
},
ResetMousebinds {
triggers: Vec<(InputMode, MouseTrigger)>,
write_config_to_disk: bool,
},
Prompt {
request_id: u64,
request: crate::prompt::PromptRequest,
@@ -4699,6 +4897,7 @@ setting_keys! {
WebClientMacOptionIsMeta => (WebClient, "mac_option_is_meta", true, Flag),
WebClientBaseUrl => (WebClient, "base_url", true, Text),
Keybinds => (Keybinds, "keybinds", false, Text),
Mousebinds => (Keybinds, "mousebinds", false, Text),
PluginAliases => (Blocks, "plugins", false, Text),
LoadPlugins => (Blocks, "load_plugins", true, Text),
Env => (Blocks, "env", true, Text),
@@ -4900,6 +5099,18 @@ pub struct KeybindingEntry {
pub unsaved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MousebindingEntry {
pub mode: InputMode,
pub trigger: MouseTrigger,
pub actions: Vec<String>,
pub app_first: bool,
pub source: KeybindingSource,
pub unbound: bool,
pub preset_actions: Option<Vec<String>>,
pub unsaved: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigSnapshot {
pub settings: Vec<ConfigSettingState>,
@@ -4916,6 +5127,7 @@ pub struct ConfigSnapshot {
pub default_blocks: ConfigBlocks,
pub keybindings: Vec<KeybindingEntry>,
pub keybinds: KeybindsSelectionSnapshot,
pub mousebindings: Vec<MousebindingEntry>,
}
impl ConfigSnapshot {
+3
View File
@@ -453,6 +453,7 @@ pub enum ScreenContext {
ScrollPopup,
DismissInfoPopups,
UpdateContextMenuConfig,
UpdateMousebinds,
GetContextMenuItemActions,
ToggleGroupMarking,
SessionSharingStatusChange,
@@ -501,6 +502,7 @@ pub enum ScreenContext {
ApplyTiledSwapLayoutWithTabId,
ApplyFloatingSwapLayoutWithTabId,
MoveTabWithTabId,
MoveTabToPosition,
UpdateBackgroundPluginSubscriptions,
ClearHintTextCache,
BroadcastModeUpdate,
@@ -661,6 +663,7 @@ pub enum ServerContext {
RebindKeys,
ReplaceConfigBlocks,
ResetKeys,
ResetMousebinds,
ReadConfig,
RevertConfig,
UnsetConfigSetting,
+4
View File
@@ -744,6 +744,10 @@ pub enum Action {
id: u64,
direction: Direction,
},
MoveTabToPosition {
id: u64,
position: u64,
},
StartRenameTabByTabId {
id: u64,
},
+33 -1
View File
@@ -18,6 +18,7 @@ use super::context_menu::ContextMenuConfig;
use super::keybind_presets::KeybindsLayers;
use super::keybinds::Keybinds;
use super::layout::RunPluginOrAlias;
use super::mousebinds::Mousebinds;
use super::options::Options;
use super::plugins::{PluginAliases, PluginsConfigError};
use super::theme::{Themes, UiConfig};
@@ -46,6 +47,27 @@ pub struct Config {
pub context_menu: ContextMenuConfig,
#[serde(default)]
pub keybinds_layers: KeybindsLayers,
#[serde(default, with = "shared_mousebinds")]
pub mousebinds: Arc<Mousebinds>,
}
mod shared_mousebinds {
use super::Mousebinds;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::sync::Arc;
pub fn serialize<S: Serializer>(
mousebinds: &Arc<Mousebinds>,
serializer: S,
) -> Result<S::Ok, S::Error> {
mousebinds.as_ref().serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Arc<Mousebinds>, D::Error> {
Mousebinds::deserialize(deserializer).map(Arc::new)
}
}
mod shared_keybinds {
@@ -323,9 +345,19 @@ impl Config {
pub fn merge(&mut self, other: Config) -> Result<(), ConfigError> {
self.options = self.options.merge(other.options);
let other_layers = other.keybinds_layers;
if !other_layers.user.is_empty() || !other_layers.layout.is_empty() {
if !other_layers.user.is_empty()
|| !other_layers.layout.is_empty()
|| !other_layers.user_mouse.is_empty()
|| !other_layers.layout_mouse.is_empty()
{
self.keybinds_layers.user.merge(other_layers.user);
self.keybinds_layers.layout.merge(other_layers.layout);
self.keybinds_layers
.user_mouse
.compose(other_layers.user_mouse);
self.keybinds_layers
.layout_mouse
.compose(other_layers.layout_mouse);
self.resolve_keybinds();
} else if !other.keybinds.0.is_empty() && !Arc::ptr_eq(&self.keybinds, &other.keybinds) {
Arc::make_mut(&mut self.keybinds).merge(Arc::unwrap_or_clone(other.keybinds));
+148 -3
View File
@@ -9,14 +9,16 @@ use super::actions::Action;
use super::config::Config;
use super::config_file_edit::{key_state, KeyState};
use super::context_menu::{context_menu_shortcut, ContextMenuConfig, CONTEXT_MENU_SECTIONS};
use super::keybind_presets::MousebindChanges;
use super::layout::{RunPlugin, RunPluginOrAlias};
use super::mousebinds::MouseBinding;
use super::plugins::PluginAliases;
use super::theme::{Theme, Themes};
use crate::data::{
ConfigBlocks, ContextMenuAction, ContextMenuEntry, EnvVarEntry, InputMode, KeyWithModifier,
KeybindingEntry, KeybindingSource, KeybindsVec, MenuItemEntry, MenuSectionEntries,
MultiplayerColors, PaletteColor, PluginAliasEntry, PluginEntry, StyleDeclaration, Styling,
ThemeEntry, ThemeSource, DEFAULT_STYLES,
KeybindingEntry, KeybindingSource, KeybindsVec, MenuItemEntry, MenuSectionEntries, MouseTarget,
MouseTrigger, MousebindingEntry, MultiplayerColors, PaletteColor, PluginAliasEntry,
PluginEntry, StyleDeclaration, Styling, ThemeEntry, ThemeSource, DEFAULT_STYLES,
};
use crate::envs::EnvironmentVariables;
use crate::home::get_theme_dir;
@@ -977,6 +979,149 @@ pub fn keybinding_entries(
entries
}
pub fn mousebind_change_kdl(
mode: InputMode,
trigger: &MouseTrigger,
actions: Option<&[String]>,
app_first: Option<bool>,
) -> Result<String, String> {
mousebind_change_kdl_for(Some(mode), trigger, actions, app_first)
}
pub fn mousebind_change_kdl_for(
mode: Option<InputMode>,
trigger: &MouseTrigger,
actions: Option<&[String]>,
app_first: Option<bool>,
) -> Result<String, String> {
let statement = match actions {
Some(actions) => {
let mut bind = KdlNode::new("bind");
bind.push(trigger.to_kdl());
if trigger.target != MouseTarget::Any {
bind.insert("on", trigger.target.name());
}
if let Some(app_first) = app_first {
bind.insert("app_first", app_first);
}
let nodes = action_nodes(actions)?;
let mut children = KdlDocument::new();
let has_children = nodes.iter().any(|node| node.children().is_some());
for node in nodes {
children.nodes_mut().push(node);
}
if !has_children {
for node in children.nodes_mut() {
node.set_trailing("; ");
}
children.set_leading(" ");
children.set_trailing("");
}
bind.set_children(children);
bind
},
None => {
let mut unbind = KdlNode::new("unbind");
unbind.push(trigger.to_kdl());
if trigger.target != MouseTarget::Any {
unbind.insert("on", trigger.target.name());
}
unbind
},
};
let text = format!(
"mousebinds {{\n {} {{\n {}\n }}\n}}\n",
mode.map(|mode| format!("{:?}", mode).to_lowercase())
.unwrap_or_else(|| "shared".to_owned()),
statement.to_string().trim()
);
text.parse::<KdlDocument>()
.map_err(|e: kdl::KdlError| e.to_string())?;
Ok(text)
}
#[derive(Debug, Clone, PartialEq)]
enum MouseState {
Bound(MouseBinding),
Unbound,
Untouched,
}
fn mouse_state(changes: &MousebindChanges, mode: InputMode, trigger: &MouseTrigger) -> MouseState {
match changes.modes.get(&mode) {
Some(mode_changes) => match mode_changes.bind.get(trigger) {
Some(binding) => MouseState::Bound(binding.clone()),
None if mode_changes.unbind.contains(trigger) => MouseState::Unbound,
None => MouseState::Untouched,
},
None => MouseState::Untouched,
}
}
pub fn mousebinding_entries(saved: &Config, current: &Config) -> Vec<MousebindingEntry> {
let preset = current.preset_mousebinds();
let user = &current.keybinds_layers.user_mouse;
let saved_user = &saved.keybinds_layers.user_mouse;
let layout = &current.keybinds_layers.layout_mouse;
let mut entries = vec![];
for mode in InputMode::iter() {
let resolved = current.mousebinds.0.get(&mode);
let preset_mode = preset.0.get(&mode);
let mut triggers: BTreeSet<MouseTrigger> = resolved
.map(|bindings| bindings.keys().cloned().collect())
.unwrap_or_default();
if let Some(mode_changes) = user.modes.get(&mode) {
triggers.extend(mode_changes.unbind.iter().cloned());
}
for trigger in triggers {
let user_state = mouse_state(user, mode, &trigger);
let unsaved = mouse_state(saved_user, mode, &trigger) != user_state;
let preset_actions = preset_mode
.and_then(|bindings| bindings.get(&trigger))
.map(|binding| binding.action_texts());
match resolved.and_then(|bindings| bindings.get(&trigger)) {
Some(binding) => {
let source =
if !matches!(mouse_state(layout, mode, &trigger), MouseState::Untouched) {
KeybindingSource::Layout
} else if matches!(user_state, MouseState::Bound(_))
|| preset_actions.is_none()
{
KeybindingSource::User
} else {
KeybindingSource::Preset
};
entries.push(MousebindingEntry {
mode,
trigger,
actions: binding.action_texts(),
app_first: binding.app_first(),
source,
unbound: false,
preset_actions,
unsaved,
});
},
None => {
if user_state == MouseState::Unbound {
entries.push(MousebindingEntry {
mode,
trigger,
actions: vec![],
app_first: false,
source: KeybindingSource::User,
unbound: true,
preset_actions,
unsaved,
});
}
},
}
}
}
entries
}
#[cfg(test)]
#[path = "./unit/config_blocks_test.rs"]
mod config_blocks_test;
@@ -27,6 +27,7 @@ use crate::data::{
};
const KEYBINDS: &str = "keybinds";
const MOUSEBINDS: &str = "mousebinds";
const CONTEXT_MENU: &str = "context_menu";
const PLUGINS: &str = "plugins";
const LOAD_PLUGINS: &str = "load_plugins";
@@ -93,6 +94,7 @@ pub enum ConfigEdit {
theme: ThemeEntry,
slots: Vec<(&'static str, &'static str)>,
},
MousebindsBlock(Option<String>),
}
#[derive(Debug, Clone, PartialEq)]
@@ -510,6 +512,19 @@ fn block_edits(key: SettingKey, saved: &Config, runtime: &Config) -> Vec<ConfigE
keybind_edits(&saved.keybinds_layers.user, &runtime.keybinds_layers.user)
},
SettingKey::ContextMenu => context_menu_edits(&saved.context_menu, &runtime.context_menu),
SettingKey::Mousebinds => {
if saved.keybinds_layers.user_mouse == runtime.keybinds_layers.user_mouse {
vec![]
} else {
vec![ConfigEdit::MousebindsBlock(
runtime
.keybinds_layers
.user_mouse
.to_kdl()
.map(|node| node.to_string()),
)]
}
},
_ => {
let saved_blocks = config_blocks(saved, false, None);
let runtime_blocks = config_blocks(runtime, false, None);
@@ -571,6 +586,7 @@ pub fn edited_settings(edits: &[ConfigEdit]) -> BTreeSet<SettingKey> {
SettingKey::ContextMenu
},
ConfigEdit::Theme { .. } | ConfigEdit::ThemeColours { .. } => SettingKey::Themes,
ConfigEdit::MousebindsBlock(_) => SettingKey::Mousebinds,
})
.collect()
}
@@ -2036,8 +2052,39 @@ fn apply_theme_edit(document: &mut KdlDocument, name: &str, theme: Option<&Theme
remove_empty_top_level_blocks(document, THEMES);
}
fn apply_mousebinds_edit(document: &mut KdlDocument, text: Option<&str>) {
let new_node = text
.and_then(|text| text.parse::<KdlDocument>().ok())
.and_then(|parsed| parsed.nodes().first().cloned());
let position = document
.nodes()
.iter()
.position(|node| node_name(node) == MOUSEBINDS);
match (position, new_node) {
(Some(position), Some(mut node)) => {
let existing = &document.nodes()[position];
node.set_leading(existing.leading().unwrap_or("").to_owned());
node.set_trailing(existing.trailing().unwrap_or("\n").to_owned());
document.nodes_mut()[position] = node;
while let Some(extra) = document
.nodes()
.iter()
.enumerate()
.skip(position + 1)
.find(|(_, node)| node_name(node) == MOUSEBINDS)
.map(|(index, _)| index)
{
remove_node_at(document, extra);
}
},
(None, Some(node)) => push_node(document, node, "", None),
(_, None) => remove_nodes_named(document, MOUSEBINDS),
}
}
fn apply_block_edit(document: &mut KdlDocument, edit: &ConfigEdit) {
match edit {
ConfigEdit::MousebindsBlock(text) => apply_mousebinds_edit(document, text.as_deref()),
ConfigEdit::PluginAlias { name, alias } => set_named_node(
document,
PLUGINS,
@@ -2238,6 +2285,9 @@ fn block_edit_reads_back(
config_file_theme(parsed, name).map(styling_colours)
== config_file_theme(runtime, name).map(styling_colours)
},
ConfigEdit::MousebindsBlock(_) => {
parsed.keybinds_layers.user_mouse == runtime.keybinds_layers.user_mouse
},
_ => true,
};
if matches {
@@ -2291,6 +2341,10 @@ fn copy_block_edit(new_saved: &mut Config, runtime: &Config, edit: &ConfigEdit)
},
}
},
ConfigEdit::MousebindsBlock(_) => {
new_saved.keybinds_layers.user_mouse = runtime.keybinds_layers.user_mouse.clone();
new_saved.resolve_keybinds();
},
_ => {},
}
}
+23
View File
@@ -439,6 +439,7 @@ pub fn setting_values(config: &Config) -> BTreeMap<SettingKey, Option<String>> {
web_client,
context_menu,
keybinds_layers: _keybinds_layers_are_compared_directly,
mousebinds: _mousebinds_are_compared_directly,
} = config;
let mut values = BTreeMap::new();
option_values(options, &mut values);
@@ -456,6 +457,7 @@ pub fn setting_values(config: &Config) -> BTreeMap<SettingKey, Option<String>> {
ui_values(ui, &mut values);
web_client_values(web_client, &mut values);
values.insert(SettingKey::Keybinds, None);
values.insert(SettingKey::Mousebinds, None);
values.insert(
SettingKey::PluginAliases,
Some(plugins.to_kdl(false).to_string()),
@@ -501,9 +503,14 @@ pub fn keybinds_differ(first: &Config, second: &Config) -> bool {
first.keybinds_layers.user != second.keybinds_layers.user
}
pub fn mousebinds_differ(first: &Config, second: &Config) -> bool {
first.keybinds_layers.user_mouse != second.keybinds_layers.user_mouse
}
pub fn settings_differ(first: &Config, second: &Config, key: SettingKey) -> bool {
match key {
SettingKey::Keybinds => keybinds_differ(first, second),
SettingKey::Mousebinds => mousebinds_differ(first, second),
_ => setting_value(first, key) != setting_value(second, key),
}
}
@@ -515,6 +522,7 @@ pub fn differing_settings(first: &Config, second: &Config) -> Vec<SettingKey> {
.into_iter()
.filter(|key| match key {
SettingKey::Keybinds => keybinds_differ(first, second),
SettingKey::Mousebinds => mousebinds_differ(first, second),
_ => first_values.get(key) != second_values.get(key),
})
.collect()
@@ -710,6 +718,10 @@ pub fn copy_setting(target: &mut Config, source: &Config, key: SettingKey) {
target.keybinds_layers.user = source.keybinds_layers.user.clone();
target.resolve_keybinds();
},
SettingKey::Mousebinds => {
target.keybinds_layers.user_mouse = source.keybinds_layers.user_mouse.clone();
target.resolve_keybinds();
},
SettingKey::PluginAliases => target.plugins = source.plugins.clone(),
SettingKey::LoadPlugins => target.background_plugins = source.background_plugins.clone(),
SettingKey::Env => target.env = source.env.clone(),
@@ -729,6 +741,11 @@ pub fn unset_setting(config: &mut Config, key: SettingKey) {
config.resolve_keybinds();
return;
}
if key == SettingKey::Mousebinds {
config.keybinds_layers.user_mouse = Default::default();
config.resolve_keybinds();
return;
}
let defaults = Config {
keybinds: config.keybinds.clone(),
..Default::default()
@@ -748,6 +765,9 @@ pub fn setting_is_default(config: &Config, key: SettingKey) -> bool {
if key == SettingKey::Keybinds {
return config.keybinds_layers.user.is_empty();
}
if key == SettingKey::Mousebinds {
return config.keybinds_layers.user_mouse.is_empty();
}
let mut defaults = config.clone();
unset_setting(&mut defaults, key);
setting_value(&defaults, key) == setting_value(config, key)
@@ -789,12 +809,15 @@ pub fn setting_states(
let saved_values = setting_values(saved);
let current_values = setting_values(current);
let keybinds_changed = keybinds_differ(saved, current);
let mousebinds_changed = mousebinds_differ(saved, current);
SettingKey::all()
.into_iter()
.map(|key| {
let (saved_value, current_value) = if key.is_block() {
let changed = if key == SettingKey::Keybinds {
keybinds_changed
} else if key == SettingKey::Mousebinds {
mousebinds_changed
} else {
saved_values.get(&key) != current_values.get(&key)
};
+5
View File
@@ -477,6 +477,11 @@ mod tests {
})
);
assert_eq!(move_tab.into_action(Some(PaneId::Terminal(2)), None), None);
assert_eq!(
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(0))
.into_action(None, Some(5)),
Some(Action::MoveTabToPosition { id: 5, position: 0 })
);
assert_eq!(
explicit.into_action(Some(PaneId::Terminal(2)), Some(5)),
Some(Action::CloseFocusByPaneId {
+168 -23
View File
@@ -1,15 +1,17 @@
use super::actions::Action;
use super::config::{Config, ConfigError};
use super::keybinds::Keybinds;
use super::mousebinds::{MouseBinding, Mousebinds};
use super::options::Options;
use crate::data::{
InputMode, KeyWithModifier, KeybindPresetInfo, KeybindPresetSource, KeybindPresetWithError,
KeybindsSelectionSnapshot,
KeybindsSelectionSnapshot, MouseTrigger,
};
use crate::home::{find_default_config_dir, get_keybinds_dir};
use kdl::{KdlDocument, KdlEntry, KdlNode};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::hash::Hash;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
@@ -58,37 +60,65 @@ pub fn builtin_keybind_preset_names() -> Vec<&'static str> {
names
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ModeKeybindChanges {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModeBindChanges<K: Ord, V> {
pub clear_defaults: bool,
pub bind: BTreeMap<KeyWithModifier, Vec<Action>>,
pub unbind: BTreeSet<KeyWithModifier>,
pub bind: BTreeMap<K, V>,
pub unbind: BTreeSet<K>,
}
impl ModeKeybindChanges {
impl<K: Ord, V> Default for ModeBindChanges<K, V> {
fn default() -> Self {
ModeBindChanges {
clear_defaults: false,
bind: BTreeMap::new(),
unbind: BTreeSet::new(),
}
}
}
impl<K: Ord, V> ModeBindChanges<K, V> {
pub fn is_empty(&self) -> bool {
!self.clear_defaults && self.bind.is_empty() && self.unbind.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct KeybindChanges {
pub type ModeKeybindChanges = ModeBindChanges<KeyWithModifier, Vec<Action>>;
pub type ModeMousebindChanges = ModeBindChanges<MouseTrigger, MouseBinding>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BindChanges<K: Ord, V> {
pub clear_defaults: bool,
pub modes: BTreeMap<InputMode, ModeKeybindChanges>,
pub modes: BTreeMap<InputMode, ModeBindChanges<K, V>>,
}
impl KeybindChanges {
impl<K: Ord, V> Default for BindChanges<K, V> {
fn default() -> Self {
BindChanges {
clear_defaults: false,
modes: BTreeMap::new(),
}
}
}
pub type KeybindChanges = BindChanges<KeyWithModifier, Vec<Action>>;
pub type MousebindChanges = BindChanges<MouseTrigger, MouseBinding>;
impl<K: Ord + Clone + Hash + Eq, V: Clone> BindChanges<K, V> {
pub fn is_empty(&self) -> bool {
!self.clear_defaults && self.modes.values().all(|mode| mode.is_empty())
}
pub fn has_keybindings(&self) -> bool {
pub fn has_bindings(&self) -> bool {
self.modes.values().any(|mode| !mode.is_empty())
}
fn mode_mut(&mut self, mode: InputMode) -> &mut ModeKeybindChanges {
pub fn has_keybindings(&self) -> bool {
self.has_bindings()
}
fn mode_mut(&mut self, mode: InputMode) -> &mut ModeBindChanges<K, V> {
self.modes.entry(mode).or_default()
}
pub fn clear_all(&mut self) {
*self = KeybindChanges {
*self = BindChanges {
clear_defaults: true,
modes: BTreeMap::new(),
};
@@ -96,23 +126,23 @@ impl KeybindChanges {
pub fn clear_mode(&mut self, mode: InputMode) {
self.modes.insert(
mode,
ModeKeybindChanges {
ModeBindChanges {
clear_defaults: true,
..Default::default()
},
);
}
pub fn bind(&mut self, mode: InputMode, key: KeyWithModifier, actions: Vec<Action>) {
pub fn bind(&mut self, mode: InputMode, key: K, actions: V) {
let mode_changes = self.mode_mut(mode);
mode_changes.unbind.remove(&key);
mode_changes.bind.insert(key, actions);
}
pub fn unbind(&mut self, mode: InputMode, key: KeyWithModifier) {
pub fn unbind(&mut self, mode: InputMode, key: K) {
let mode_changes = self.mode_mut(mode);
mode_changes.bind.remove(&key);
mode_changes.unbind.insert(key);
}
pub fn compose(&mut self, other: KeybindChanges) {
pub fn compose(&mut self, other: BindChanges<K, V>) {
if other.clear_defaults {
*self = other;
return;
@@ -130,13 +160,13 @@ impl KeybindChanges {
}
}
}
pub fn apply(&self, keybinds: &mut Keybinds) {
pub fn apply_to_map(&self, bindings: &mut HashMap<InputMode, HashMap<K, V>>) {
if self.clear_defaults {
keybinds.0.clear();
bindings.clear();
}
for (mode, mode_changes) in &self.modes {
if mode_changes.clear_defaults || !mode_changes.bind.is_empty() {
let keys_in_mode = keybinds.get_input_mode_mut(mode);
let keys_in_mode = bindings.entry(*mode).or_insert_with(HashMap::new);
if mode_changes.clear_defaults {
keys_in_mode.clear();
}
@@ -146,7 +176,7 @@ impl KeybindChanges {
for (key, actions) in &mode_changes.bind {
keys_in_mode.insert(key.clone(), actions.clone());
}
} else if let Some(keys_in_mode) = keybinds.0.get_mut(mode) {
} else if let Some(keys_in_mode) = bindings.get_mut(mode) {
for key in &mode_changes.unbind {
keys_in_mode.remove(key);
}
@@ -155,6 +185,18 @@ impl KeybindChanges {
}
}
impl KeybindChanges {
pub fn apply(&self, keybinds: &mut Keybinds) {
self.apply_to_map(&mut keybinds.0);
}
}
impl MousebindChanges {
pub fn apply(&self, mousebinds: &mut Mousebinds) {
self.apply_to_map(&mut mousebinds.0);
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct KeybindsSelection {
pub preset: Option<String>,
@@ -231,6 +273,10 @@ pub struct KeybindsLayers {
pub injected_default_mode: Option<InputMode>,
pub config_default_mode: Option<InputMode>,
pub active: ActiveKeybindPreset,
#[serde(default)]
pub user_mouse: MousebindChanges,
#[serde(default)]
pub layout_mouse: MousebindChanges,
}
impl KeybindsLayers {
@@ -240,6 +286,12 @@ impl KeybindsLayers {
KeybindsLayer::Layout => &mut self.layout,
}
}
pub fn mouse_layer_mut(&mut self, layer: KeybindsLayer) -> &mut MousebindChanges {
match layer {
KeybindsLayer::User => &mut self.user_mouse,
KeybindsLayer::Layout => &mut self.layout_mouse,
}
}
pub fn effective_preset(&self) -> String {
self.layout
.preset
@@ -285,6 +337,7 @@ pub struct KeybindPreset {
pub defaults: BTreeMap<String, String>,
pub placeholders: BTreeSet<String>,
pub keybinds: KdlNode,
pub mousebinds: Option<KdlNode>,
}
fn first_string_argument(node: &KdlNode) -> Option<String> {
@@ -410,8 +463,12 @@ impl KeybindPreset {
.get("keybinds")
.cloned()
.ok_or_else(|| "The preset has no keybinds block".to_owned())?;
let mousebinds = document.get("mousebinds").cloned();
let mut placeholders = BTreeSet::new();
collect_placeholders(&keybinds, &mut placeholders)?;
if let Some(mousebinds) = &mousebinds {
collect_placeholders(mousebinds, &mut placeholders)?;
}
Ok(KeybindPreset {
display_name,
description,
@@ -420,6 +477,7 @@ impl KeybindPreset {
defaults,
placeholders,
keybinds,
mousebinds,
})
}
pub fn leader_values(
@@ -499,6 +557,55 @@ impl KeybindPreset {
changes.apply(&mut keybinds);
Ok(keybinds)
}
pub fn mousebinds(
&self,
values: &BTreeMap<String, String>,
options: &Options,
) -> Result<Mousebinds, String> {
match &self.mousebinds {
Some(node) => mousebinds_from_node(node, values, options),
None => default_preset_mousebinds(values, options),
}
}
}
fn substitute_placeholders(key: &str, values: &BTreeMap<String, String>) -> Result<String, String> {
let mut substituted = key.to_owned();
for placeholder in placeholders_in(key) {
let value = values
.get(&placeholder)
.ok_or_else(|| format!("No value for the placeholder {{{}}}", placeholder))?;
substituted = substituted.replace(&format!("{{{}}}", placeholder), value);
}
Ok(substituted.split_whitespace().collect::<Vec<_>>().join(" "))
}
fn mousebinds_from_node(
node: &KdlNode,
values: &BTreeMap<String, String>,
options: &Options,
) -> Result<Mousebinds, String> {
let substitute = |key: &str| substitute_placeholders(key, values);
let changes = MousebindChanges::from_kdl_with_key_text(node, options, &substitute)
.map_err(|e| config_error_text(&e))?;
let mut mousebinds = Mousebinds::default();
changes.apply(&mut mousebinds);
Ok(mousebinds)
}
fn default_preset_mousebinds(
values: &BTreeMap<String, String>,
options: &Options,
) -> Result<Mousebinds, String> {
let default_preset = KeybindPreset::from_kdl(DEFAULT_KEYBIND_PRESET_ASSET)?;
let Some(node) = default_preset.mousebinds.as_ref() else {
return Ok(Mousebinds::default());
};
let mut merged_values = default_preset.defaults.clone();
for (placeholder, value) in values {
merged_values.insert(placeholder.clone(), value.clone());
}
mousebinds_from_node(node, &merged_values, options)
}
pub fn command_line_preset_relative_to(options: &mut Options, cwd: &Path) {
@@ -561,6 +668,7 @@ pub fn find_keybind_preset(
#[derive(Debug, Clone)]
pub struct ResolvedKeybindPreset {
pub keybinds: Keybinds,
pub mousebinds: Mousebinds,
pub default_mode: Option<InputMode>,
pub active: ActiveKeybindPreset,
}
@@ -619,8 +727,10 @@ pub fn resolve_keybind_preset(
};
let values = preset.leader_values(requested_values).map_err(failed)?;
let keybinds = preset.keybinds(&values, options).map_err(failed)?;
let mousebinds = preset.mousebinds(&values, options).map_err(failed)?;
Ok(ResolvedKeybindPreset {
keybinds,
mousebinds,
default_mode: preset.default_mode,
active: ActiveKeybindPreset {
info,
@@ -640,6 +750,7 @@ fn builtin_default_preset(options: &Options) -> ResolvedKeybindPreset {
)
.unwrap_or_else(|(active, _)| ResolvedKeybindPreset {
keybinds: Keybinds::default(),
mousebinds: Mousebinds::default(),
default_mode: None,
active,
})
@@ -733,6 +844,20 @@ pub fn is_usable_keybind_preset_name(name: &str) -> bool {
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
fn user_mousebinds_node(
file_contents: Option<&str>,
mouse_changes: &MousebindChanges,
) -> Option<KdlNode> {
let from_file = file_contents
.and_then(|text| text.parse::<KdlDocument>().ok())
.and_then(|document| document.get("mousebinds").cloned());
let mut node = from_file.or_else(|| mouse_changes.to_kdl())?;
node.entries_mut().retain(|entry| entry.name().is_none());
node.set_leading("");
node.set_trailing("\n");
Some(node)
}
fn user_keybinds_node(file_contents: Option<&str>, selection: &KeybindsSelection) -> KdlNode {
let from_file = file_contents
.and_then(|text| text.parse::<KdlDocument>().ok())
@@ -752,6 +877,7 @@ fn user_keybinds_node(file_contents: Option<&str>, selection: &KeybindsSelection
pub fn save_keybinds_as_preset(
file_contents: Option<&str>,
selection: &KeybindsSelection,
mouse_changes: &MousebindChanges,
default_mode: Option<InputMode>,
new_name: &str,
keybinds_dir: &Path,
@@ -778,7 +904,10 @@ pub fn save_keybinds_as_preset(
));
}
header.push_str("}\n");
let text = format!("{}{}", header, user_keybinds_node(file_contents, selection));
let mut text = format!("{}{}", header, user_keybinds_node(file_contents, selection));
if let Some(mousebinds) = user_mousebinds_node(file_contents, mouse_changes) {
text.push_str(&mousebinds.to_string());
}
KeybindPreset::from_kdl(&text)?;
std::fs::create_dir_all(keybinds_dir)
.map_err(|e| format!("Could not create {}: {}", keybinds_dir.display(), e))?;
@@ -878,6 +1007,9 @@ impl Config {
pub fn preset_keybinds(&self) -> Keybinds {
self.resolved_keybind_preset().keybinds
}
pub fn preset_mousebinds(&self) -> Mousebinds {
self.resolved_keybind_preset().mousebinds
}
fn resolved_keybind_preset(&self) -> ResolvedKeybindPreset {
let command_line = self.command_line_keybinds();
let preset_name = command_line
@@ -929,6 +1061,18 @@ impl Config {
if self.keybinds.as_ref() != &keybinds {
self.keybinds = Arc::new(keybinds);
}
let mut mousebinds = resolved.mousebinds;
for changes in [
&self.keybinds_layers.user_mouse,
&self.keybinds_layers.layout_mouse,
] {
if !(preset_given_on_command_line && changes.clear_defaults) {
changes.apply(&mut mousebinds);
}
}
if self.mousebinds.as_ref() != &mousebinds {
self.mousebinds = Arc::new(mousebinds);
}
let config_default_mode = self.keybinds_layers.config_default_mode;
let set_on_command_line = self.options.default_mode
!= self.keybinds_layers.injected_default_mode
@@ -960,6 +1104,7 @@ impl Config {
unlock: layers.user.unlock.clone(),
clears_defaults: layers.user.changes.clear_defaults,
has_own_keybindings: layers.user.changes.has_keybindings(),
has_own_mousebindings: !layers.user_mouse.is_empty(),
active: layers.active.info.clone(),
active_values: layers.active.values.clone(),
error: layers.active.error.clone(),
+1
View File
@@ -10,6 +10,7 @@ pub mod keybind_presets;
pub mod keybinds;
pub mod layout;
pub mod mouse;
pub mod mousebinds;
pub mod options;
pub mod permission;
pub mod plugins;
+363
View File
@@ -0,0 +1,363 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use serde::{Deserialize, Serialize};
use super::actions::Action;
use super::mouse::MouseEvent;
use crate::data::{InputMode, KeyModifier, MouseButton, MouseTarget, MouseTrigger};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PromptDirection {
Previous,
Next,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ResizeScrollDirection {
Increase,
Decrease,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MouseBehaviour {
Click,
Select,
FocusPane,
MovePane,
ResizePane,
ToggleFullscreen,
GroupToggle,
Ungroup,
ContextMenu,
Scroll(usize),
ScrollColumns(usize),
ScrollToPrompt(PromptDirection),
ResizeScroll(ResizeScrollDirection),
PassToApp,
Ignore,
}
pub const MOUSE_BEHAVIOUR_NAMES: [&str; 15] = [
"Click",
"Select",
"FocusPane",
"MovePane",
"ResizePane",
"ToggleFullscreen",
"GroupToggle",
"Ungroup",
"ContextMenu",
"Scroll",
"ScrollColumns",
"ScrollToPrompt",
"ResizeScroll",
"PassToApp",
"Ignore",
];
impl MouseBehaviour {
pub fn name(&self) -> &'static str {
match self {
MouseBehaviour::Click => "Click",
MouseBehaviour::Select => "Select",
MouseBehaviour::FocusPane => "FocusPane",
MouseBehaviour::MovePane => "MovePane",
MouseBehaviour::ResizePane => "ResizePane",
MouseBehaviour::ToggleFullscreen => "ToggleFullscreen",
MouseBehaviour::GroupToggle => "GroupToggle",
MouseBehaviour::Ungroup => "Ungroup",
MouseBehaviour::ContextMenu => "ContextMenu",
MouseBehaviour::Scroll(_) => "Scroll",
MouseBehaviour::ScrollColumns(_) => "ScrollColumns",
MouseBehaviour::ScrollToPrompt(_) => "ScrollToPrompt",
MouseBehaviour::ResizeScroll(_) => "ResizeScroll",
MouseBehaviour::PassToApp => "PassToApp",
MouseBehaviour::Ignore => "Ignore",
}
}
pub fn is_behaviour_name(name: &str) -> bool {
MOUSE_BEHAVIOUR_NAMES.contains(&name)
}
pub fn from_name_and_arguments(
name: &str,
arguments: &[String],
) -> Result<MouseBehaviour, String> {
let no_arguments = |behaviour: MouseBehaviour| {
if arguments.is_empty() {
Ok(behaviour)
} else {
Err(format!("{} does not take arguments", name))
}
};
let count = |default: usize| -> Result<usize, String> {
match arguments {
[] => Ok(default),
[value] => value
.parse::<usize>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| format!("{} needs a positive whole number", name)),
_ => Err(format!("{} takes one number", name)),
}
};
match name {
"Click" => no_arguments(MouseBehaviour::Click),
"Select" => no_arguments(MouseBehaviour::Select),
"FocusPane" => no_arguments(MouseBehaviour::FocusPane),
"MovePane" => no_arguments(MouseBehaviour::MovePane),
"ResizePane" => no_arguments(MouseBehaviour::ResizePane),
"ToggleFullscreen" => no_arguments(MouseBehaviour::ToggleFullscreen),
"GroupToggle" => no_arguments(MouseBehaviour::GroupToggle),
"Ungroup" => no_arguments(MouseBehaviour::Ungroup),
"ContextMenu" => no_arguments(MouseBehaviour::ContextMenu),
"PassToApp" => no_arguments(MouseBehaviour::PassToApp),
"Ignore" => no_arguments(MouseBehaviour::Ignore),
"Scroll" => Ok(MouseBehaviour::Scroll(count(3)?)),
"ScrollColumns" => Ok(MouseBehaviour::ScrollColumns(count(4)?)),
"ScrollToPrompt" => match arguments {
[direction] if direction.eq_ignore_ascii_case("previous") => {
Ok(MouseBehaviour::ScrollToPrompt(PromptDirection::Previous))
},
[direction] if direction.eq_ignore_ascii_case("next") => {
Ok(MouseBehaviour::ScrollToPrompt(PromptDirection::Next))
},
_ => Err("ScrollToPrompt needs \"previous\" or \"next\"".to_owned()),
},
"ResizeScroll" => match arguments {
[direction] if direction.eq_ignore_ascii_case("increase") => Ok(
MouseBehaviour::ResizeScroll(ResizeScrollDirection::Increase),
),
[direction] if direction.eq_ignore_ascii_case("decrease") => Ok(
MouseBehaviour::ResizeScroll(ResizeScrollDirection::Decrease),
),
_ => Err("ResizeScroll needs \"increase\" or \"decrease\"".to_owned()),
},
_ => Err(format!("Unknown mouse behaviour '{}'", name)),
}
}
pub fn arguments(&self) -> Vec<String> {
match self {
MouseBehaviour::Scroll(lines) => vec![lines.to_string()],
MouseBehaviour::ScrollColumns(columns) => vec![columns.to_string()],
MouseBehaviour::ScrollToPrompt(PromptDirection::Previous) => {
vec!["previous".to_owned()]
},
MouseBehaviour::ScrollToPrompt(PromptDirection::Next) => vec!["next".to_owned()],
MouseBehaviour::ResizeScroll(ResizeScrollDirection::Increase) => {
vec!["increase".to_owned()]
},
MouseBehaviour::ResizeScroll(ResizeScrollDirection::Decrease) => {
vec!["decrease".to_owned()]
},
_ => vec![],
}
}
pub fn needs_wheel(&self) -> bool {
matches!(
self,
MouseBehaviour::Scroll(_) | MouseBehaviour::ScrollColumns(_)
)
}
pub fn default_app_first(&self) -> bool {
matches!(
self,
MouseBehaviour::Click
| MouseBehaviour::Select
| MouseBehaviour::FocusPane
| MouseBehaviour::ContextMenu
| MouseBehaviour::Scroll(_)
| MouseBehaviour::ScrollColumns(_)
| MouseBehaviour::ScrollToPrompt(_)
)
}
pub fn kdl_text(&self) -> String {
let arguments: Vec<String> = self
.arguments()
.into_iter()
.map(|argument| match argument.parse::<usize>() {
Ok(_) => argument,
Err(_) => format!("\"{}\"", argument),
})
.collect();
if arguments.is_empty() {
self.name().to_owned()
} else {
format!("{} {}", self.name(), arguments.join(" "))
}
}
}
impl fmt::Display for MouseBehaviour {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.kdl_text())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MouseBindingKind {
Behaviour(MouseBehaviour),
Actions(Vec<Action>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MouseBinding {
pub kind: MouseBindingKind,
pub app_first: Option<bool>,
}
impl MouseBinding {
pub fn behaviour(behaviour: MouseBehaviour) -> Self {
MouseBinding {
kind: MouseBindingKind::Behaviour(behaviour),
app_first: None,
}
}
pub fn actions(actions: Vec<Action>) -> Self {
MouseBinding {
kind: MouseBindingKind::Actions(actions),
app_first: None,
}
}
pub fn with_app_first(mut self, app_first: Option<bool>) -> Self {
self.app_first = app_first;
self
}
pub fn default_app_first(&self) -> bool {
match &self.kind {
MouseBindingKind::Behaviour(behaviour) => behaviour.default_app_first(),
MouseBindingKind::Actions(_) => false,
}
}
pub fn app_first(&self) -> bool {
self.app_first.unwrap_or_else(|| self.default_app_first())
}
pub fn as_behaviour(&self) -> Option<MouseBehaviour> {
match &self.kind {
MouseBindingKind::Behaviour(behaviour) => Some(*behaviour),
MouseBindingKind::Actions(_) => None,
}
}
pub fn action_texts(&self) -> Vec<String> {
match &self.kind {
MouseBindingKind::Behaviour(behaviour) => vec![behaviour.kdl_text()],
MouseBindingKind::Actions(actions) => super::config_blocks::action_texts(actions),
}
}
}
pub fn mouse_button_of_event(event: &MouseEvent) -> Option<MouseButton> {
if event.wheel_up {
Some(MouseButton::ScrollUp)
} else if event.wheel_down {
Some(MouseButton::ScrollDown)
} else if event.wheel_left {
Some(MouseButton::ScrollLeft)
} else if event.wheel_right {
Some(MouseButton::ScrollRight)
} else if event.left {
Some(MouseButton::Left)
} else if event.right {
Some(MouseButton::Right)
} else if event.middle {
Some(MouseButton::Middle)
} else {
None
}
}
pub fn mouse_modifiers_of_event(event: &MouseEvent) -> BTreeSet<KeyModifier> {
let mut modifiers = BTreeSet::new();
if event.ctrl {
modifiers.insert(KeyModifier::Ctrl);
}
if event.alt {
modifiers.insert(KeyModifier::Alt);
}
if event.shift {
modifiers.insert(KeyModifier::Shift);
}
modifiers
}
#[derive(Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Mousebinds(pub HashMap<InputMode, HashMap<MouseTrigger, MouseBinding>>);
impl fmt::Debug for Mousebinds {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let stable_sorted: BTreeMap<&InputMode, BTreeMap<&MouseTrigger, &MouseBinding>> = self
.0
.iter()
.map(|(mode, bindings)| (mode, bindings.iter().collect()))
.collect();
write!(f, "{:#?}", stable_sorted)
}
}
impl Mousebinds {
pub fn get(&self, mode: &InputMode, trigger: &MouseTrigger) -> Option<&MouseBinding> {
self.0.get(mode).and_then(|bindings| bindings.get(trigger))
}
pub fn mode_bindings(&self, mode: &InputMode) -> BTreeMap<MouseTrigger, MouseBinding> {
self.0
.get(mode)
.map(|bindings| {
bindings
.iter()
.map(|(trigger, binding)| (trigger.clone(), binding.clone()))
.collect()
})
.unwrap_or_default()
}
pub fn resolve(
&self,
mode: InputMode,
button: MouseButton,
modifiers: &BTreeSet<KeyModifier>,
click_count: u8,
on_frame: bool,
) -> Option<(MouseTrigger, &MouseBinding)> {
let bindings = self.0.get(&mode)?;
let place = if on_frame {
MouseTarget::Frame
} else {
MouseTarget::Content
};
let mut modifier_sets = vec![modifiers.clone()];
if modifiers.contains(&KeyModifier::Shift) {
let mut without_shift = modifiers.clone();
without_shift.remove(&KeyModifier::Shift);
modifier_sets.push(without_shift);
}
let click_count = if button.is_wheel() {
1
} else {
click_count.clamp(1, 3)
};
for modifiers in modifier_sets {
for count in (1..=click_count).rev() {
for target in [place, MouseTarget::Any] {
let trigger = MouseTrigger {
button,
modifiers: modifiers.clone(),
click_count: count,
target,
};
if let Some(binding) = bindings.get(&trigger) {
return Some((trigger, binding));
}
}
}
}
None
}
pub fn merge(&mut self, other: Mousebinds) {
for (mode, bindings) in other.0 {
self.0.entry(mode).or_default().extend(bindings);
}
}
}
#[cfg(test)]
#[path = "./unit/mousebinds_test.rs"]
mod mousebinds_test;
@@ -96,6 +96,11 @@ fn every_border_override_set(line_style: LineStyle) -> BorderStyleOverride {
}
const EVERY_BLOCK_SET: &str = "keybinds preset=\"default\"
mousebinds {
normal {
bind \"Alt Middle\" { NewTab; }
}
}
plugins {
mine location=\"zellij:strider\" {
size \"3\"
@@ -138,6 +143,7 @@ pub(crate) fn every_setting_set() -> Config {
Config {
keybinds: with_preset.keybinds.clone(),
keybinds_layers: with_preset.keybinds_layers,
mousebinds: with_preset.mousebinds.clone(),
plugins: with_preset.plugins,
background_plugins: with_preset.background_plugins,
env: with_preset.env,
@@ -170,6 +176,7 @@ fn kdl_names_in(config: &Config) -> BTreeSet<(SettingSection, String)> {
let document: KdlDocument = config.to_string(false).parse().unwrap();
let blocks = [
"keybinds",
"mousebinds",
"themes",
"plugins",
"load_plugins",
@@ -183,11 +190,12 @@ fn kdl_names_in(config: &Config) -> BTreeSet<(SettingSection, String)> {
let name = node.name().value();
if !blocks.contains(&name) {
names.insert((SettingSection::TopLevel, name.to_owned()));
} else if !["keybinds", "ui", "web_client"].contains(&name) {
} else if !["keybinds", "mousebinds", "ui", "web_client"].contains(&name) {
names.insert((SettingSection::Blocks, name.to_owned()));
}
}
names.insert((SettingSection::Keybinds, "keybinds".to_owned()));
names.insert((SettingSection::Keybinds, "mousebinds".to_owned()));
let pane_frames = document
.get("ui")
.and_then(|ui| ui.children())
@@ -233,7 +241,7 @@ fn every_setting_has_a_value_when_set_and_reads_back_from_the_file() {
let values = setting_values(&config);
for key in SettingKey::all() {
assert!(values.contains_key(&key), "{} has no value", key);
if key != SettingKey::Keybinds {
if key != SettingKey::Keybinds && key != SettingKey::Mousebinds {
assert!(values[&key].is_some(), "{} has no value", key);
}
}
@@ -244,6 +252,11 @@ fn every_setting_has_a_value_when_set_and_reads_back_from_the_file() {
.unwrap();
assert_eq!(setting_values(&read_back), values);
assert!(!settings_differ(&read_back, &config, SettingKey::Keybinds));
assert!(!settings_differ(
&read_back,
&config,
SettingKey::Mousebinds
));
}
#[test]
@@ -302,6 +315,12 @@ fn unsetting_a_setting_restores_its_default() {
config.keybinds,
Config::from_default_assets().unwrap().keybinds
);
} else if key == SettingKey::Mousebinds {
assert!(config.keybinds_layers.user_mouse.is_empty());
assert_eq!(
config.mousebinds,
Config::from_default_assets().unwrap().mousebinds
);
} else {
assert_eq!(setting_value(&config, key), default_values[&key].clone());
}
@@ -0,0 +1,643 @@
use super::super::actions::Action;
use super::super::config::Config;
use super::super::config_blocks::{
mousebind_change_kdl, mousebind_change_kdl_for, mousebinding_entries,
};
use super::super::config_file_edit::{apply_edits, edits_between, written_file_matches};
use super::super::keybind_presets::{save_keybinds_as_preset, MousebindChanges};
use super::super::mousebinds::*;
use super::super::options::Options;
use crate::data::{
Direction, InputMode, KeyModifier, KeybindingSource, MouseButton, MouseTarget, MouseTrigger,
};
use kdl::KdlDocument;
use std::collections::BTreeSet;
use strum::IntoEnumIterator;
fn defaults() -> Config {
Config::from_default_assets().unwrap()
}
fn user_config(text: &str) -> Config {
Config::from_kdl(text, Some(defaults())).unwrap()
}
fn modifiers(names: &[KeyModifier]) -> BTreeSet<KeyModifier> {
names.iter().copied().collect()
}
fn resolved(
config: &Config,
mode: InputMode,
button: MouseButton,
held: &[KeyModifier],
click_count: u8,
on_frame: bool,
) -> Option<MouseBinding> {
config
.mousebinds
.resolve(mode, button, &modifiers(held), click_count, on_frame)
.map(|(_, binding)| binding.clone())
}
fn behaviour(
config: &Config,
mode: InputMode,
button: MouseButton,
held: &[KeyModifier],
click_count: u8,
on_frame: bool,
) -> Option<MouseBehaviour> {
resolved(config, mode, button, held, click_count, on_frame).and_then(|b| b.as_behaviour())
}
fn changes(text: &str) -> MousebindChanges {
let document: KdlDocument = text.parse().unwrap();
MousebindChanges::from_kdl(document.get("mousebinds").unwrap(), &Options::default()).unwrap()
}
fn parse_error(text: &str) -> String {
let document: KdlDocument = text.parse().unwrap();
format!(
"{:?}",
MousebindChanges::from_kdl(document.get("mousebinds").unwrap(), &Options::default())
.unwrap_err()
)
}
#[test]
fn triggers_parse_modifiers_click_counts_and_buttons() {
let trigger = MouseTrigger::parse("Ctrl Alt Double Left", MouseTarget::Frame).unwrap();
assert_eq!(trigger.button, MouseButton::Left);
assert_eq!(
trigger.modifiers,
modifiers(&[KeyModifier::Ctrl, KeyModifier::Alt])
);
assert_eq!(trigger.click_count, 2);
assert_eq!(trigger.target, MouseTarget::Frame);
assert_eq!(trigger.to_kdl(), "Ctrl Alt Double Left");
assert_eq!(
MouseTrigger::parse("triple middle", MouseTarget::Any)
.unwrap()
.to_kdl(),
"Triple Middle"
);
assert_eq!(
MouseTrigger::parse("Shift ScrollUp", MouseTarget::Any)
.unwrap()
.button,
MouseButton::ScrollUp
);
}
#[test]
fn super_double_scrolls_and_unknown_buttons_are_rejected() {
assert!(MouseTrigger::parse("Super Left", MouseTarget::Any)
.unwrap_err()
.contains("Super"));
assert!(MouseTrigger::parse("Double ScrollUp", MouseTarget::Any)
.unwrap_err()
.contains("Double"));
assert!(MouseTrigger::parse("Ctrl Button4", MouseTarget::Any)
.unwrap_err()
.contains("Unknown mouse button"));
assert!(
parse_error("mousebinds {\n normal {\n bind \"Left\" on=\"edge\" { Click; }\n }\n}")
.contains("'on' must be")
);
}
#[test]
fn the_default_preset_reproduces_the_built_in_mouse_behaviour() {
let config = defaults();
for mode in [InputMode::Normal, InputMode::Locked, InputMode::Pane] {
let b = |button, held: &[KeyModifier], count, on_frame| {
behaviour(&config, mode, button, held, count, on_frame)
};
assert_eq!(
b(MouseButton::Left, &[], 1, false),
Some(MouseBehaviour::Click)
);
assert_eq!(
b(MouseButton::Left, &[], 1, true),
Some(MouseBehaviour::Click)
);
assert_eq!(
b(MouseButton::Left, &[KeyModifier::Shift], 1, false),
Some(MouseBehaviour::Click)
);
assert_eq!(
b(MouseButton::Left, &[], 2, true),
Some(MouseBehaviour::ToggleFullscreen)
);
assert_eq!(
b(MouseButton::Left, &[], 2, false),
Some(MouseBehaviour::Select)
);
assert_eq!(
b(MouseButton::Left, &[], 3, false),
Some(MouseBehaviour::Select)
);
assert_eq!(
b(MouseButton::Left, &[], 3, true),
Some(MouseBehaviour::ToggleFullscreen)
);
assert_eq!(
b(MouseButton::Left, &[KeyModifier::Ctrl], 1, true),
Some(MouseBehaviour::ResizePane)
);
assert_eq!(b(MouseButton::Left, &[KeyModifier::Ctrl], 1, false), None);
assert_eq!(
b(MouseButton::Left, &[KeyModifier::Alt], 1, false),
Some(MouseBehaviour::GroupToggle)
);
assert_eq!(
b(MouseButton::Right, &[KeyModifier::Alt], 1, false),
Some(MouseBehaviour::Ungroup)
);
assert_eq!(
b(MouseButton::Right, &[], 1, false),
Some(MouseBehaviour::ContextMenu)
);
assert_eq!(b(MouseButton::Middle, &[], 1, false), None);
assert_eq!(
b(MouseButton::ScrollUp, &[], 1, false),
Some(MouseBehaviour::Scroll(3))
);
assert_eq!(
b(MouseButton::ScrollUp, &[KeyModifier::Ctrl], 1, false),
Some(MouseBehaviour::ResizeScroll(
ResizeScrollDirection::Increase
))
);
assert_eq!(
b(MouseButton::ScrollDown, &[KeyModifier::Alt], 1, false),
Some(MouseBehaviour::ScrollToPrompt(PromptDirection::Next))
);
assert_eq!(
b(MouseButton::ScrollLeft, &[], 1, false),
Some(MouseBehaviour::ScrollColumns(4))
);
}
}
#[test]
fn the_unlock_first_preset_has_the_same_mouse_bindings_as_the_default_preset() {
let unlock_first = user_config("keybinds preset=\"unlock-first\"");
assert_eq!(unlock_first.mousebinds, defaults().mousebinds);
}
#[test]
fn a_users_block_is_layered_on_the_preset() {
let config = user_config(
"mousebinds {\n normal {\n bind \"Alt Middle\" { NewTab; }\n unbind \"Right\"\n }\n locked clear-defaults=true {\n }\n}\n",
);
assert!(matches!(
resolved(
&config,
InputMode::Normal,
MouseButton::Middle,
&[KeyModifier::Alt],
1,
false
)
.map(|binding| binding.kind),
Some(MouseBindingKind::Actions(_))
));
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::Right,
&[],
1,
false
),
None
);
assert_eq!(
behaviour(&config, InputMode::Pane, MouseButton::Right, &[], 1, false),
Some(MouseBehaviour::ContextMenu)
);
assert!(config
.mousebinds
.0
.get(&InputMode::Locked)
.map(|bindings| bindings.is_empty())
.unwrap_or(true));
}
#[test]
fn a_whole_block_clearing_the_defaults_leaves_only_the_users_bindings() {
let config = user_config(
"mousebinds clear-defaults=true {\n shared {\n bind \"Left\" { Click; }\n }\n}\n",
);
assert_eq!(
behaviour(&config, InputMode::Normal, MouseButton::Left, &[], 1, false),
Some(MouseBehaviour::Click)
);
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::ScrollUp,
&[],
1,
false
),
None
);
}
#[test]
fn leader_values_are_used_in_mouse_triggers() {
let config = user_config("keybinds primary=\"Alt\" secondary=\"Ctrl\"");
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::Left,
&[KeyModifier::Alt],
1,
true
),
Some(MouseBehaviour::ResizePane)
);
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::Left,
&[KeyModifier::Ctrl],
1,
false
),
Some(MouseBehaviour::GroupToggle)
);
}
#[test]
fn a_leader_that_resolves_to_super_skips_only_that_binding() {
let config = user_config("keybinds primary=\"Super\"");
assert_eq!(config.keybinds_layers.active.error, None);
assert_eq!(
behaviour(&config, InputMode::Normal, MouseButton::Left, &[], 1, false),
Some(MouseBehaviour::Click)
);
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::ScrollUp,
&[KeyModifier::Alt],
1,
false
),
Some(MouseBehaviour::ScrollToPrompt(PromptDirection::Previous))
);
}
#[test]
fn a_preset_without_mouse_bindings_uses_the_default_ones() {
let dir = tempfile::tempdir().unwrap();
let keybinds_dir = dir.path().join("keybinds");
std::fs::create_dir_all(&keybinds_dir).unwrap();
std::fs::write(
keybinds_dir.join("fixed.kdl"),
"keybinds {\n normal {\n bind \"Ctrl y\" { SwitchToMode \"Locked\"; }\n }\n}\n",
)
.unwrap();
let mut base = defaults();
base.keybinds_layers.config_dir = Some(dir.path().to_path_buf());
let config = Config::from_kdl("keybinds preset=\"fixed\"", Some(base)).unwrap();
assert_eq!(config.keybinds_layers.active.error, None);
assert_eq!(config.mousebinds, defaults().mousebinds);
}
#[test]
fn a_preset_with_its_own_mouse_bindings_replaces_the_defaults() {
let dir = tempfile::tempdir().unwrap();
let keybinds_dir = dir.path().join("keybinds");
std::fs::create_dir_all(&keybinds_dir).unwrap();
std::fs::write(
keybinds_dir.join("mouse.kdl"),
"keybinds {\n}\nmousebinds {\n shared {\n bind \"Left\" { FocusPane; }\n }\n}\n",
)
.unwrap();
let mut base = defaults();
base.keybinds_layers.config_dir = Some(dir.path().to_path_buf());
let config = Config::from_kdl("keybinds preset=\"mouse\"", Some(base)).unwrap();
assert_eq!(
behaviour(&config, InputMode::Normal, MouseButton::Left, &[], 1, false),
Some(MouseBehaviour::FocusPane)
);
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::Right,
&[],
1,
false
),
None
);
}
#[test]
fn a_layout_block_goes_on_top_of_the_users_block() {
let user =
user_config("mousebinds {\n normal {\n bind \"Middle\" { Ignore; }\n }\n}\n");
let config = Config::from_layout_kdl(
"mousebinds {\n normal {\n bind \"Middle\" { PassToApp; }\n }\n}\n",
Some(user),
)
.unwrap();
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::Middle,
&[],
1,
false
),
Some(MouseBehaviour::PassToApp)
);
assert!(!config.keybinds_layers.layout_mouse.is_empty());
}
#[test]
fn double_and_triple_clicks_fall_back_to_fewer_clicks_and_any_place() {
let config = user_config(
"mousebinds clear-defaults=true {\n normal {\n bind \"Middle\" { FocusPane; }\n bind \"Double Middle\" on=\"frame\" { ToggleFullscreen; }\n }\n}\n",
);
let b = |count, on_frame| {
behaviour(
&config,
InputMode::Normal,
MouseButton::Middle,
&[],
count,
on_frame,
)
};
assert_eq!(b(3, true), Some(MouseBehaviour::ToggleFullscreen));
assert_eq!(b(2, false), Some(MouseBehaviour::FocusPane));
assert_eq!(b(1, true), Some(MouseBehaviour::FocusPane));
}
#[test]
fn behaviours_must_be_alone_and_scrolling_needs_the_wheel() {
assert!(
parse_error("mousebinds {\n normal {\n bind \"Left\" { Click; NewTab; }\n }\n}")
.contains("must be the only item")
);
assert!(
parse_error("mousebinds {\n normal {\n bind \"Left\" { Scroll 3; }\n }\n}")
.contains("can only be bound to")
);
}
#[test]
fn move_pane_with_a_direction_is_the_normal_action() {
let parsed = changes("mousebinds {\n normal {\n bind \"Alt Middle\" { MovePane \"left\"; }\n bind \"Middle\" { MovePane; }\n }\n}");
let normal = parsed.modes.get(&InputMode::Normal).unwrap();
let alt_middle =
MouseTrigger::new(MouseButton::Middle).with_modifiers(modifiers(&[KeyModifier::Alt]));
assert_eq!(
normal.bind.get(&alt_middle).map(|b| b.kind.clone()),
Some(MouseBindingKind::Actions(vec![Action::MovePane {
direction: Some(Direction::Left)
}]))
);
assert_eq!(
normal
.bind
.get(&MouseTrigger::new(MouseButton::Middle))
.and_then(|b| b.as_behaviour()),
Some(MouseBehaviour::MovePane)
);
}
#[test]
fn mouse_changes_round_trip_through_kdl() {
let text = "mousebinds {\n shared_except \"locked\" {\n bind \"Ctrl Double Left\" on=\"frame\" app_first=true { ToggleFullscreen; }\n }\n pane clear-defaults=true {\n bind \"Right\" { NewTab; }\n }\n normal {\n unbind \"Middle\" on=\"content\"\n }\n unbind \"ScrollLeft\"\n}\n";
let parsed = changes(text);
let written = parsed.to_kdl().unwrap().to_string();
assert_eq!(changes(&written), parsed);
}
#[test]
fn a_mouse_binding_change_is_written_as_a_mousebinds_block() {
let trigger = MouseTrigger::parse("Alt Middle", MouseTarget::Frame).unwrap();
let text = mousebind_change_kdl(
InputMode::Normal,
&trigger,
Some(&["NewTab".to_owned()]),
Some(false),
)
.unwrap();
assert_eq!(
text,
"mousebinds {\n normal {\n bind \"Alt Middle\" on=\"frame\" app_first=false { NewTab; }\n }\n}\n"
);
let unbind = mousebind_change_kdl(InputMode::Pane, &trigger, None, None).unwrap();
assert_eq!(
unbind,
"mousebinds {\n pane {\n unbind \"Alt Middle\" on=\"frame\"\n }\n}\n"
);
}
#[test]
fn a_mouse_binding_change_for_every_mode_is_written_as_a_shared_block() {
let trigger = MouseTrigger::parse("Middle", MouseTarget::Any).unwrap();
let text =
mousebind_change_kdl_for(None, &trigger, Some(&["NewTab".to_owned()]), None).unwrap();
assert_eq!(
text,
"mousebinds {\n shared {\n bind \"Middle\" { NewTab; }\n }\n}\n"
);
let parsed = changes(&text);
for mode in InputMode::iter() {
assert!(
parsed.modes[&mode].bind.contains_key(&trigger),
"{:?} is missing the binding",
mode
);
}
let unbind = mousebind_change_kdl_for(None, &trigger, None, None).unwrap();
assert_eq!(
unbind,
"mousebinds {\n shared {\n unbind \"Middle\"\n }\n}\n"
);
let parsed = changes(&unbind);
for mode in InputMode::iter() {
assert!(parsed.modes[&mode].unbind.contains(&trigger), "{:?}", mode);
}
assert!(parsed
.to_kdl()
.unwrap()
.to_string()
.contains("unbind \"Middle\""));
}
#[test]
fn mouse_binding_entries_name_where_they_come_from() {
let saved = defaults();
let current = user_config(
"mousebinds {\n normal {\n bind \"Alt Middle\" { NewTab; }\n unbind \"Right\"\n }\n}\n",
);
let entries = mousebinding_entries(&saved, &current);
let normal: Vec<_> = entries
.iter()
.filter(|entry| entry.mode == InputMode::Normal)
.collect();
let alt_middle = normal
.iter()
.find(|entry| entry.trigger.to_kdl() == "Alt Middle")
.unwrap();
assert_eq!(alt_middle.source, KeybindingSource::User);
assert!(alt_middle.unsaved);
assert_eq!(alt_middle.preset_actions, Some(vec!["Ignore".to_owned()]));
let right = normal
.iter()
.find(|entry| entry.trigger.to_kdl() == "Right")
.unwrap();
assert!(right.unbound);
assert_eq!(right.preset_actions, Some(vec!["ContextMenu".to_owned()]));
let left = normal
.iter()
.find(|entry| entry.trigger.to_kdl() == "Left")
.unwrap();
assert_eq!(left.source, KeybindingSource::Preset);
assert!(left.app_first);
assert!(!left.unsaved);
}
#[test]
fn saving_writes_the_users_mouse_bindings_and_reads_them_back() {
let file = "// mine\nmouse_mode true\n";
let saved = user_config(file);
let runtime = Config::from_kdl(
"mousebinds {\n normal {\n bind \"Alt Middle\" { NewTab; }\n }\n}\n",
Some(saved.clone()),
)
.unwrap();
let edits = edits_between(&saved, &runtime);
let text = apply_edits(Some(file), &edits, &runtime).unwrap();
written_file_matches(&text, &runtime, &edits).unwrap();
assert!(text.starts_with("// mine\nmouse_mode true\n"));
assert!(text.contains("bind \"Alt Middle\" { NewTab; }"));
let reverted = user_config(&text);
let edits = edits_between(&reverted, &saved);
let text = apply_edits(Some(&text), &edits, &saved).unwrap();
assert!(!text.contains("mousebinds"));
}
#[test]
fn save_as_preset_keeps_the_users_mouse_bindings() {
let dir = tempfile::tempdir().unwrap();
let config = user_config(
"mousebinds {\n normal {\n bind \"Alt Middle\" { NewTab; }\n }\n}\n",
);
let path = save_keybinds_as_preset(
None,
&config.keybinds_layers.user,
&config.keybinds_layers.user_mouse,
None,
"mine",
dir.path(),
)
.unwrap();
let text = std::fs::read_to_string(path).unwrap();
assert!(text.contains("mousebinds"));
assert!(text.contains("Alt Middle"));
}
#[test]
fn mouse_events_map_to_buttons_and_modifiers() {
use super::super::mouse::MouseEvent;
use crate::position::Position;
let event = MouseEvent::new_ctrl_scroll_up_event(Position::new(0, 0));
assert_eq!(mouse_button_of_event(&event), Some(MouseButton::ScrollUp));
assert_eq!(
mouse_modifiers_of_event(&event),
modifiers(&[KeyModifier::Ctrl])
);
let event = MouseEvent::new_left_press_with_alt_event(Position::new(0, 0));
assert_eq!(mouse_button_of_event(&event), Some(MouseButton::Left));
assert_eq!(
mouse_modifiers_of_event(&event),
modifiers(&[KeyModifier::Alt])
);
}
fn with_command_line_preset(mut config: Config, preset: &str) -> Config {
let options = Options {
keybinds_preset: Some(preset.to_owned()),
..Default::default()
};
config.apply_command_line_keybinds(&options);
config
}
#[test]
fn a_command_line_preset_ignores_a_users_block_that_clears_the_defaults() {
let config = user_config(
"keybinds preset=\"unlock-first\"\nmousebinds clear-defaults=true {\n shared {\n bind \"Left\" { FocusPane; }\n }\n}\n",
);
assert_eq!(
behaviour(&config, InputMode::Normal, MouseButton::Left, &[], 1, false),
Some(MouseBehaviour::FocusPane)
);
let config = with_command_line_preset(config, "default");
assert_eq!(config.mousebinds, defaults().mousebinds);
}
#[test]
fn a_command_line_preset_keeps_user_and_layout_additions() {
let user = user_config(
"mousebinds {\n normal {\n bind \"Alt Middle\" { NewTab; }\n }\n}\n",
);
let config = Config::from_layout_kdl(
"mousebinds {\n normal {\n bind \"Middle\" { Ignore; }\n }\n}\n",
Some(user),
)
.unwrap();
let config = with_command_line_preset(config, "unlock-first");
assert!(matches!(
resolved(
&config,
InputMode::Normal,
MouseButton::Middle,
&[KeyModifier::Alt],
1,
false
)
.map(|binding| binding.kind),
Some(MouseBindingKind::Actions(_))
));
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::Middle,
&[],
1,
false
),
Some(MouseBehaviour::Ignore)
);
assert_eq!(
behaviour(
&config,
InputMode::Normal,
MouseButton::Right,
&[],
1,
false
),
Some(MouseBehaviour::ContextMenu)
);
}
@@ -1262,6 +1262,7 @@ impl From<crate::input::actions::Action>
MovePaneByPaneIdAction,
MoveTabAction,
MoveTabByTabIdAction,
MoveTabToPositionAction,
NewBlockingPaneAction,
NewFloatingPaneAction,
NewFloatingPluginPaneAction,
@@ -2318,6 +2319,9 @@ impl From<crate::input::actions::Action>
direction: direction_to_proto_i32(direction),
})
},
crate::input::actions::Action::MoveTabToPosition { id, position } => {
ActionType::MoveTabToPosition(MoveTabToPositionAction { id, position })
},
};
Self {
@@ -3354,6 +3358,12 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action>
direction,
})
},
ActionType::MoveTabToPosition(a) => {
Ok(crate::input::actions::Action::MoveTabToPosition {
id: a.id,
position: a.position,
})
},
}
}
}
@@ -3963,6 +3963,21 @@ fn test_client_messages() {
client_id: Some(100),
is_cli_client: true,
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::MoveTabToPosition { id: 2, position: 0 },
terminal_id: Some(1),
client_id: Some(100),
is_cli_client: true,
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::MoveTabToPosition {
id: 2,
position: u64::MAX,
},
terminal_id: Some(1),
client_id: Some(100),
is_cli_client: true,
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::SetPaneFrameStyle(PaneFrameStyle::Full),
terminal_id: Some(1),
+618 -98
View File
@@ -2,11 +2,13 @@ mod kdl_layout_parser;
use crate::data::ClientId;
use crate::data::{
BareKey, BorderStyleOverride, Direction, FloatingPaneCoordinates, InputMode, KeyWithModifier,
LayoutInfo, LayoutMetadata, LineStyle, MultiplayerColors, Palette, PaletteColor, PaneId,
PaneInfo, PaneManifest, PermissionType, Resize, SessionInfo, StyleDeclaration, Styling,
TabInfo, ThemeHue, WebSharing, DEFAULT_STYLES,
LayoutInfo, LayoutMetadata, LineStyle, MouseTarget, MouseTrigger, MultiplayerColors, Palette,
PaletteColor, PaneId, PaneInfo, PaneManifest, PermissionType, Resize, SessionInfo,
StyleDeclaration, Styling, TabInfo, ThemeHue, WebSharing, DEFAULT_STYLES,
};
use crate::data::{
ClickedPaneAction, ClickedTabAction, ContextMenuAction, ContextMenuEntry, TAB_POSITION_END,
};
use crate::data::{ClickedPaneAction, ClickedTabAction, ContextMenuAction, ContextMenuEntry};
use crate::envs::EnvironmentVariables;
use crate::home::{find_default_config_dir, get_layout_dir};
use crate::input::config::{Config, ConfigError, KdlError};
@@ -14,11 +16,14 @@ use crate::input::context_menu::{
context_menu_statements_against, merge_context_menu_entries, ContextMenuConfig, MenuPlacement,
MenuStatement, CONTEXT_MENU_SECTIONS,
};
use crate::input::keybind_presets::{KeybindChanges, KeybindsLayer, KeybindsSelection};
use crate::input::keybind_presets::{
BindChanges, KeybindChanges, KeybindsLayer, KeybindsSelection, MousebindChanges,
};
use crate::input::keybinds::Keybinds;
use crate::input::layout::{
Layout, PercentOrFixed, PluginUserConfiguration, RunPlugin, RunPluginOrAlias, TabLayoutInfo,
};
use crate::input::mousebinds::{MouseBehaviour, MouseBinding, MouseBindingKind};
use crate::input::options::{
Clipboard, OnForceClose, Options, PaneFrameStyle, DEFAULT_WORD_SEPARATORS,
};
@@ -1440,11 +1445,45 @@ impl Action {
node.push(KdlValue::Base10(*id as i64));
Some(node)
},
Action::MoveTabToPosition { id, position } => {
let mut node = KdlNode::new("MoveTabToPosition");
node.push(tab_position_to_kdl_value(*position));
node.push(KdlValue::Base10(*id as i64));
Some(node)
},
_ => None,
}
}
}
fn tab_position_to_kdl_value(position: u64) -> KdlValue {
if position == 0 {
KdlValue::String("start".into())
} else if position > i64::MAX as u64 {
KdlValue::String("end".into())
} else {
KdlValue::Base10(position as i64)
}
}
fn tab_position_from_kdl_entry(entry: &KdlEntry) -> Result<u64, ConfigError> {
match entry.value() {
KdlValue::String(s) if s == "start" => Ok(0),
KdlValue::String(s) if s == "end" => Ok(TAB_POSITION_END),
value => value
.as_i64()
.filter(|p| *p >= 0)
.map(|p| p as u64)
.ok_or_else(|| {
kdl_parsing_error!(
"MoveTabToPosition expects \"start\", \"end\" or a position starting from 0"
.into(),
entry
)
}),
}
}
impl TryFrom<(&str, &KdlDocument)> for PaletteColor {
type Error = ConfigError;
@@ -2539,6 +2578,27 @@ impl TryFrom<(&KdlNode, &Options)> for Action {
})? as u64;
Ok(Action::MoveTabByTabId { id, direction })
},
"MoveTabToPosition" => {
let position_entry = action_arguments.first().ok_or_else(|| {
ConfigError::new_kdl_error(
"MoveTabToPosition expects a position".into(),
kdl_action.span().offset(),
kdl_action.span().len(),
)
})?;
let position = tab_position_from_kdl_entry(position_entry)?;
let id_entry = action_arguments.get(1).ok_or_else(|| {
ConfigError::new_kdl_error(
"MoveTabToPosition needs a tab id after the position (it can only be left out inside context_menu)".into(),
kdl_action.span().offset(),
kdl_action.span().len(),
)
})?;
let id = id_entry.value().as_i64().ok_or_else(|| {
kdl_parsing_error!("MoveTabToPosition expects a tab id".into(), id_entry)
})? as u64;
Ok(Action::MoveTabToPosition { id, position })
},
"TogglePaneInGroup" => Ok(Action::TogglePaneInGroup),
"ToggleGroupMarking" => Ok(Action::ToggleGroupMarking),
_ => Err(ConfigError::new_kdl_error(
@@ -5410,6 +5470,86 @@ impl EnvironmentVariables {
}
}
fn parse_bind_blocks<K: Ord + Clone + std::hash::Hash + Eq, V: Clone>(
kdl_bindings: &KdlNode,
bind_in_block: &dyn Fn(&KdlNode, InputMode, &mut BindChanges<K, V>) -> Result<(), ConfigError>,
global_unbind_keys: &dyn Fn(&KdlNode) -> Result<Vec<K>, ConfigError>,
) -> Result<BindChanges<K, V>, ConfigError> {
let mut changes = BindChanges::default();
if kdl_arg_is_truthy!(kdl_bindings, "clear-defaults") {
changes.clear_all();
}
let Some(children) = kdl_bindings.children() else {
return Ok(changes);
};
for block in children.nodes() {
if kdl_name!(block) == "shared_except" || kdl_name!(block) == "shared" {
let mut modes_to_exclude = vec![];
for mode_name in kdl_string_arguments!(block) {
modes_to_exclude.push(InputMode::from_str(mode_name).map_err(|_| {
ConfigError::new_kdl_error(
format!("Invalid mode: '{}'", mode_name),
block.name().span().offset(),
block.name().span().len(),
)
})?);
}
for mode in InputMode::iter() {
if modes_to_exclude.contains(&mode) {
continue;
}
bind_in_block(block, mode, &mut changes)?;
}
}
if kdl_name!(block) == "shared_among" {
let mut modes_to_include = vec![];
for mode_name in kdl_string_arguments!(block) {
modes_to_include.push(InputMode::from_str(mode_name)?);
}
for mode in InputMode::iter() {
if !modes_to_include.contains(&mode) {
continue;
}
bind_in_block(block, mode, &mut changes)?;
}
}
}
for mode in children.nodes() {
if kdl_name!(mode) == "unbind"
|| kdl_name!(mode) == "shared_except"
|| kdl_name!(mode) == "shared_among"
|| kdl_name!(mode) == "shared"
{
continue;
}
let mode_name = kdl_name!(mode);
let input_mode = InputMode::from_str(mode_name).map_err(|_| {
ConfigError::new_kdl_error(
format!("Invalid mode: '{}'", mode_name),
mode.name().span().offset(),
mode.name().span().len(),
)
})?;
if kdl_arg_is_truthy!(mode, "clear-defaults") {
changes.clear_mode(input_mode);
}
bind_in_block(mode, input_mode, &mut changes)?;
}
for global_unbind in children
.nodes()
.iter()
.filter(|node| kdl_name!(node) == "unbind")
{
let keys = global_unbind_keys(global_unbind)?;
for mode in InputMode::iter() {
for key in &keys {
changes.unbind(mode, key.clone());
}
}
}
Ok(changes)
}
impl KeybindChanges {
fn keys_with_text(
key_block: &KdlNode,
@@ -5475,93 +5615,19 @@ impl KeybindChanges {
config_options: &Options,
key_text: &dyn Fn(&str) -> Result<String, String>,
) -> Result<KeybindChanges, ConfigError> {
let mut changes = KeybindChanges::default();
if kdl_arg_is_truthy!(kdl_keybinds, "clear-defaults") {
changes.clear_all();
}
let Some(children) = kdl_keybinds.children() else {
return Ok(changes);
};
for block in children.nodes() {
if kdl_name!(block) == "shared_except" || kdl_name!(block) == "shared" {
let mut modes_to_exclude = vec![];
for mode_name in kdl_string_arguments!(block) {
modes_to_exclude.push(InputMode::from_str(mode_name).map_err(|_| {
ConfigError::new_kdl_error(
format!("Invalid mode: '{}'", mode_name),
block.name().span().offset(),
block.name().span().len(),
)
})?);
}
for mode in InputMode::iter() {
if modes_to_exclude.contains(&mode) {
continue;
}
KeybindChanges::bind_keys_in_block(
block,
mode,
&mut changes,
config_options,
key_text,
)?;
}
}
if kdl_name!(block) == "shared_among" {
let mut modes_to_include = vec![];
for mode_name in kdl_string_arguments!(block) {
modes_to_include.push(InputMode::from_str(mode_name)?);
}
for mode in InputMode::iter() {
if !modes_to_include.contains(&mode) {
continue;
}
KeybindChanges::bind_keys_in_block(
block,
mode,
&mut changes,
config_options,
key_text,
)?;
}
}
}
for mode in children.nodes() {
if kdl_name!(mode) == "unbind"
|| kdl_name!(mode) == "shared_except"
|| kdl_name!(mode) == "shared_among"
|| kdl_name!(mode) == "shared"
{
continue;
}
let mode_name = kdl_name!(mode);
let input_mode = InputMode::from_str(mode_name).map_err(|_| {
ConfigError::new_kdl_error(
format!("Invalid mode: '{}'", mode_name),
mode.name().span().offset(),
mode.name().span().len(),
parse_bind_blocks(
kdl_keybinds,
&|block, input_mode, changes| {
KeybindChanges::bind_keys_in_block(
block,
input_mode,
changes,
config_options,
key_text,
)
})?;
if kdl_arg_is_truthy!(mode, "clear-defaults") {
changes.clear_mode(input_mode);
}
KeybindChanges::bind_keys_in_block(
mode,
input_mode,
&mut changes,
config_options,
key_text,
)?;
}
if let Some(global_unbind) = children.get("unbind") {
let keys = KeybindChanges::keys_with_text(global_unbind, key_text)?;
for mode in InputMode::iter() {
for key in &keys {
changes.unbind(mode, key.clone());
}
}
}
Ok(changes)
},
&|global_unbind| KeybindChanges::keys_with_text(global_unbind, key_text),
)
}
pub fn to_kdl_children(&self) -> KdlDocument {
let mut document = KdlDocument::new();
@@ -5643,6 +5709,378 @@ impl KeybindChanges {
}
}
fn mouse_trigger_target(key_block: &KdlNode) -> Result<MouseTarget, ConfigError> {
match key_block.get("on") {
Some(entry) => entry
.value()
.as_string()
.and_then(MouseTarget::from_name)
.ok_or_else(|| {
ConfigError::new_kdl_error(
"'on' must be \"content\", \"frame\" or \"any\"".to_owned(),
entry.span().offset(),
entry.span().len(),
)
}),
None => Ok(MouseTarget::Any),
}
}
fn mouse_triggers_with_text(
key_block: &KdlNode,
key_text: &dyn Fn(&str) -> Result<String, String>,
) -> Result<Vec<MouseTrigger>, ConfigError> {
let target = mouse_trigger_target(key_block)?;
let error = |message: String| {
ConfigError::new_kdl_error(message, key_block.span().offset(), key_block.span().len())
};
let mut triggers = vec![];
for entry in key_block.entries().iter().filter(|e| e.name().is_none()) {
let text = entry
.value()
.as_string()
.ok_or_else(|| error("A mouse trigger must be a string".to_owned()))?;
let substituted = key_text(text).map_err(error)?;
match MouseTrigger::parse(&substituted, target) {
Ok(trigger) => triggers.push(trigger),
Err(_)
if text.contains('{')
&& substituted
.split_whitespace()
.any(|word| word.eq_ignore_ascii_case("super")) =>
{
log::warn!(
"Skipping the mouse binding '{}' ('{}'), because terminals do not report Super for mouse events",
text,
substituted
);
},
Err(e) => return Err(error(e)),
}
}
Ok(triggers)
}
fn mouse_binding_from_kdl(
key_block: &KdlNode,
config_options: &Options,
) -> Result<MouseBinding, ConfigError> {
let app_first = match key_block.get("app_first") {
Some(entry) => Some(entry.value().as_bool().ok_or_else(|| {
ConfigError::new_kdl_error(
"app_first must be true or false".to_owned(),
entry.span().offset(),
entry.span().len(),
)
})?),
None => None,
};
let nodes = kdl_children_nodes_or_error!(key_block, "no actions found for the mouse binding");
let is_behaviour = |node: &KdlNode| {
let name = kdl_name!(node);
MouseBehaviour::is_behaviour_name(name)
&& !(name == "MovePane" && node.entries().iter().any(|e| e.name().is_none()))
};
if let Some(behaviour_node) = nodes.iter().find(|node| is_behaviour(node)) {
if nodes.len() != 1 {
return Err(ConfigError::new_kdl_error(
format!(
"{} must be the only item in a mouse binding",
kdl_name!(behaviour_node)
),
behaviour_node.span().offset(),
behaviour_node.span().len(),
));
}
let mut arguments = vec![];
for entry in behaviour_node
.entries()
.iter()
.filter(|e| e.name().is_none())
{
match (entry.value().as_string(), entry.value().as_i64()) {
(Some(text), _) => arguments.push(text.to_owned()),
(None, Some(number)) => arguments.push(number.to_string()),
_ => {
return Err(ConfigError::new_kdl_error(
"Mouse behaviour arguments must be strings or numbers".to_owned(),
entry.span().offset(),
entry.span().len(),
))
},
}
}
let behaviour =
MouseBehaviour::from_name_and_arguments(kdl_name!(behaviour_node), &arguments)
.map_err(|e| {
ConfigError::new_kdl_error(
e,
behaviour_node.span().offset(),
behaviour_node.span().len(),
)
})?;
return Ok(MouseBinding::behaviour(behaviour).with_app_first(app_first));
}
let actions: Vec<Action> = actions_from_kdl!(key_block, config_options);
Ok(MouseBinding::actions(actions).with_app_first(app_first))
}
pub fn mouse_binding_node(trigger: &MouseTrigger, binding: &MouseBinding) -> KdlNode {
let mut node = KdlNode::new("bind");
node.push(trigger.to_kdl());
if trigger.target != MouseTarget::Any {
node.insert("on", trigger.target.name());
}
if let Some(app_first) = binding.app_first {
node.insert("app_first", app_first);
}
let mut children = KdlDocument::new();
let mut have_children = false;
match &binding.kind {
MouseBindingKind::Behaviour(behaviour) => {
let mut behaviour_node = KdlNode::new(behaviour.name());
for argument in behaviour.arguments() {
match argument.parse::<i64>() {
Ok(number) => behaviour_node.push(number),
Err(_) => behaviour_node.push(argument),
}
}
children.nodes_mut().push(behaviour_node);
},
MouseBindingKind::Actions(actions) => {
for action in actions {
if let Some(kdl_action) = action.to_kdl() {
if kdl_action.children().is_some() {
have_children = true;
}
children.nodes_mut().push(kdl_action);
}
}
},
}
if !have_children {
for child in children.nodes_mut() {
child.set_leading("");
child.set_trailing("; ");
}
children.set_leading(" ");
children.set_trailing("");
}
node.set_children(children);
node
}
fn mouse_unbind_nodes(triggers: &BTreeSet<MouseTrigger>) -> Vec<KdlNode> {
let mut by_target: BTreeMap<MouseTarget, Vec<&MouseTrigger>> = BTreeMap::new();
for trigger in triggers {
by_target.entry(trigger.target).or_default().push(trigger);
}
by_target
.into_iter()
.map(|(target, triggers)| {
let mut node = KdlNode::new("unbind");
for trigger in triggers {
node.push(trigger.to_kdl());
}
if target != MouseTarget::Any {
node.insert("on", target.name());
}
node
})
.collect()
}
impl MousebindChanges {
fn bind_triggers_in_block(
block: &KdlNode,
input_mode: InputMode,
changes: &mut MousebindChanges,
config_options: &Options,
key_text: &dyn Fn(&str) -> Result<String, String>,
) -> Result<(), ConfigError> {
let all_nodes = kdl_children_nodes_or_error!(block, "no mouse binding block for mode");
for key_block in all_nodes.iter().filter(|n| kdl_name!(n) == "bind") {
let triggers = mouse_triggers_with_text(key_block, key_text)?;
let binding = mouse_binding_from_kdl(key_block, config_options)?;
if let Some(behaviour) = binding.as_behaviour() {
if let Some(trigger) = triggers
.iter()
.find(|trigger| behaviour.needs_wheel() && !trigger.button.is_wheel())
{
return Err(ConfigError::new_kdl_error(
format!(
"{} can only be bound to ScrollUp, ScrollDown, ScrollLeft or ScrollRight, not '{}'",
behaviour.name(),
trigger.to_kdl()
),
key_block.span().offset(),
key_block.span().len(),
));
}
}
for trigger in triggers {
changes.bind(input_mode, trigger, binding.clone());
}
}
for key_block in all_nodes.iter().filter(|n| kdl_name!(n) == "unbind") {
for trigger in mouse_triggers_with_text(key_block, key_text)? {
changes.unbind(input_mode, trigger);
}
}
for key_block in all_nodes {
if kdl_name!(key_block) != "bind" && kdl_name!(key_block) != "unbind" {
return Err(ConfigError::new_kdl_error(
format!(
"Unknown mouse binding instruction: '{}'",
kdl_name!(key_block)
),
key_block.span().offset(),
key_block.span().len(),
));
}
}
Ok(())
}
pub fn from_kdl(
kdl_mousebinds: &KdlNode,
config_options: &Options,
) -> Result<MousebindChanges, ConfigError> {
MousebindChanges::from_kdl_with_key_text(kdl_mousebinds, config_options, &|key: &str| {
Ok(key.to_owned())
})
}
pub fn from_kdl_with_key_text(
kdl_mousebinds: &KdlNode,
config_options: &Options,
key_text: &dyn Fn(&str) -> Result<String, String>,
) -> Result<MousebindChanges, ConfigError> {
parse_bind_blocks(
kdl_mousebinds,
&|block, input_mode, changes| {
MousebindChanges::bind_triggers_in_block(
block,
input_mode,
changes,
config_options,
key_text,
)
},
&|global_unbind| mouse_triggers_with_text(global_unbind, key_text),
)
}
pub fn to_kdl_children(&self) -> KdlDocument {
let mut document = KdlDocument::new();
let title_maker = Keybinds::default();
let mode_name = |mode: &InputMode| format!("{:?}", mode).to_lowercase();
for (mode, mode_changes) in &self.modes {
if !mode_changes.clear_defaults {
continue;
}
let mut node = KdlNode::new(mode_name(mode));
node.insert("clear-defaults", true);
let mut children = KdlDocument::new();
for (trigger, binding) in &mode_changes.bind {
children
.nodes_mut()
.push(mouse_binding_node(trigger, binding));
}
for unbind in mouse_unbind_nodes(&mode_changes.unbind) {
children.nodes_mut().push(unbind);
}
node.set_children(children);
document.nodes_mut().push(node);
}
let mut grouped: Vec<(BTreeSet<InputMode>, MouseTrigger, MouseBinding)> = vec![];
for (mode, mode_changes) in &self.modes {
if mode_changes.clear_defaults {
continue;
}
for (trigger, binding) in &mode_changes.bind {
match grouped
.iter_mut()
.find(|(_, t, b)| t == trigger && b == binding)
{
Some((modes, _, _)) => {
modes.insert(*mode);
},
None => {
grouped.push((BTreeSet::from([*mode]), trigger.clone(), binding.clone()))
},
}
}
}
let mut blocks: Vec<(BTreeSet<InputMode>, KdlDocument)> = vec![];
for (modes, trigger, binding) in grouped {
let node = mouse_binding_node(&trigger, &binding);
match blocks
.iter_mut()
.find(|(block_modes, _)| *block_modes == modes)
{
Some((_, children)) => children.nodes_mut().push(node),
None => {
let mut children = KdlDocument::new();
children.nodes_mut().push(node);
blocks.push((modes, children));
},
}
}
for (modes, children) in blocks {
let mut title = title_maker.serialize_mode_title_node(&modes);
title.set_children(children);
document.nodes_mut().push(title);
}
let mut unbound_everywhere: Option<BTreeSet<MouseTrigger>> = None;
for mode in InputMode::iter() {
let unbound_in_mode = match self.modes.get(&mode) {
Some(mode_changes) if !mode_changes.clear_defaults => mode_changes.unbind.clone(),
_ => BTreeSet::new(),
};
unbound_everywhere = Some(match unbound_everywhere {
Some(so_far) => so_far.intersection(&unbound_in_mode).cloned().collect(),
None => unbound_in_mode,
});
}
let unbound_everywhere = unbound_everywhere.unwrap_or_default();
for (mode, mode_changes) in &self.modes {
if mode_changes.clear_defaults {
continue;
}
let remaining: BTreeSet<MouseTrigger> = mode_changes
.unbind
.difference(&unbound_everywhere)
.cloned()
.collect();
if !remaining.is_empty() {
let mut node = KdlNode::new(mode_name(mode));
let mut children = KdlDocument::new();
for unbind in mouse_unbind_nodes(&remaining) {
children.nodes_mut().push(unbind);
}
node.set_children(children);
document.nodes_mut().push(node);
}
}
for unbind in mouse_unbind_nodes(&unbound_everywhere) {
document.nodes_mut().push(unbind);
}
document
}
pub fn to_kdl(&self) -> Option<KdlNode> {
if self.is_empty() {
return None;
}
let mut node = KdlNode::new("mousebinds");
if self.clear_defaults {
node.insert("clear-defaults", true);
}
let children = self.to_kdl_children();
if !children.nodes().is_empty() {
node.set_children(children);
}
Some(node)
}
}
impl KeybindsSelection {
pub fn from_kdl(
kdl_keybinds: &KdlNode,
@@ -5938,6 +6376,14 @@ impl Config {
config.apply_keybinds_selection(keybinds_layer, selection);
should_resolve_keybinds = true;
}
if let Some(kdl_mousebinds) = kdl_config.get("mousebinds") {
let changes = MousebindChanges::from_kdl(&kdl_mousebinds, &config.options)?;
config
.keybinds_layers
.mouse_layer_mut(keybinds_layer)
.compose(changes);
should_resolve_keybinds = true;
}
if let Some(kdl_themes) = kdl_config.get("themes") {
let sourced_from_external_file = false;
let config_themes = Themes::from_kdl(kdl_themes, sourced_from_external_file)?;
@@ -5981,6 +6427,10 @@ impl Config {
document.nodes_mut().push(keybinds);
}
if let Some(mousebinds) = self.keybinds_layers.user_mouse.to_kdl() {
document.nodes_mut().push(mousebinds);
}
if let Some(themes) = self.themes.to_kdl() {
document.nodes_mut().push(themes);
}
@@ -6259,6 +6709,12 @@ impl ContextMenuAction {
direction,
)));
}
if action_name == "MoveTabToPosition" && arguments.len() == 1 {
let position = tab_position_from_kdl_entry(arguments[0])?;
return Ok(ContextMenuAction::ClickedTab(
ClickedTabAction::MoveToPosition(position),
));
}
Action::try_from((kdl_action, config_options)).map(ContextMenuAction::Action)
}
pub fn to_kdl(&self) -> Option<KdlNode> {
@@ -6267,13 +6723,19 @@ impl ContextMenuAction {
ContextMenuAction::ClickedPane(action) => Some(KdlNode::new(action.kdl_name())),
ContextMenuAction::ClickedTab(action) => {
let mut node = KdlNode::new(action.kdl_name());
if let ClickedTabAction::Move(direction) = action {
node.push(match direction {
Direction::Left => "left",
Direction::Right => "right",
Direction::Up => "up",
Direction::Down => "down",
});
match action {
ClickedTabAction::Move(direction) => {
node.push(match direction {
Direction::Left => "left",
Direction::Right => "right",
Direction::Up => "up",
Direction::Down => "down",
});
},
ClickedTabAction::MoveToPosition(position) => {
node.push(tab_position_to_kdl_value(*position));
},
_ => {},
}
Some(node)
},
@@ -9413,6 +9875,11 @@ fn every_clicked_pane_or_tab_form_is_written_back_as_it_was_read() {
"MoveTabByTabId \"right\"",
"CloseFocusByPaneId \"terminal_2\"",
"MoveTabByTabId \"left\" 3",
"MoveTabToPosition \"start\"",
"MoveTabToPosition \"end\"",
"MoveTabToPosition 2",
"MoveTabToPosition \"end\" 3",
"MoveTabToPosition 1 3",
];
for text in names {
let document: KdlDocument = text.parse().unwrap();
@@ -9430,6 +9897,59 @@ fn every_clicked_pane_or_tab_form_is_written_back_as_it_was_read() {
}
}
#[test]
fn move_tab_to_position_parses_start_end_and_numbers() {
let cases = [
(
"MoveTabToPosition \"start\"",
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(0)),
),
(
"MoveTabToPosition \"end\"",
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(TAB_POSITION_END)),
),
(
"MoveTabToPosition 2",
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(2)),
),
(
"MoveTabToPosition \"end\" 7",
Action::MoveTabToPosition {
id: 7,
position: TAB_POSITION_END,
}
.into(),
),
(
"MoveTabToPosition 1 7",
Action::MoveTabToPosition { id: 7, position: 1 }.into(),
),
];
for (text, expected) in cases {
let document: KdlDocument = text.parse().unwrap();
let node = document.nodes().first().unwrap();
assert_eq!(
ContextMenuAction::from_kdl(node, &Options::default()).unwrap(),
expected,
"{}",
text
);
}
for bad in [
"MoveTabToPosition \"middle\"",
"MoveTabToPosition -1",
"MoveTabToPosition",
] {
let document: KdlDocument = bad.parse().unwrap();
let node = document.nodes().first().unwrap();
assert!(
ContextMenuAction::from_kdl(node, &Options::default()).is_err(),
"{} should be rejected",
bad
);
}
}
#[test]
fn context_menu_rejects_moving_the_clicked_tab_up_or_down() {
assert!(context_menu_from(
+7
View File
@@ -256,6 +256,7 @@ message Action {
TargetPaneIdPayload target_pane_id_payload = 65;
TargetTabIdPayload target_tab_id_payload = 66;
MoveTabByTabIdPayload move_tab_by_tab_id_payload = 67;
MoveTabToPositionPayload move_tab_to_position_payload = 68;
}
}
@@ -272,6 +273,11 @@ message MoveTabByTabIdPayload {
MoveTabDirection direction = 2;
}
message MoveTabToPositionPayload {
uint64 tab_id = 1;
uint64 position = 2;
}
message SetPaneBorderStylePayload {
PaneId pane_id = 1;
style.BorderStyleOverride border_style = 2;
@@ -550,6 +556,7 @@ enum ActionName {
StartRenameTabByTabId = 124;
DismissInfoPopups = 125;
OpenContextMenu = 126;
MoveTabToPosition = 127;
}
message Position {
+28
View File
@@ -31,6 +31,7 @@ pub use super::generated_api::api::{
MovePanePayload,
MoveTabByTabIdPayload,
MoveTabDirection as ProtobufMoveTabDirection,
MoveTabToPositionPayload,
NameAndValue as ProtobufNameAndValue,
NewBlockingPanePayload,
NewFloatingPanePayload,
@@ -1279,6 +1280,15 @@ impl TryFrom<ProtobufAction> for Action {
},
_ => Err("Wrong payload for Action::MoveTabByTabId"),
},
Some(ProtobufActionName::MoveTabToPosition) => match protobuf_action.optional_payload {
Some(OptionalPayload::MoveTabToPositionPayload(payload)) => {
Ok(Action::MoveTabToPosition {
id: payload.tab_id,
position: payload.position,
})
},
_ => Err("Wrong payload for Action::MoveTabToPosition"),
},
_ => Err("Unknown Action"),
}
}
@@ -1404,6 +1414,15 @@ impl TryFrom<Action> for ProtobufAction {
)),
})
},
Action::MoveTabToPosition { id, position } => Ok(ProtobufAction {
name: ProtobufActionName::MoveTabToPosition as i32,
optional_payload: Some(OptionalPayload::MoveTabToPositionPayload(
MoveTabToPositionPayload {
tab_id: id,
position,
},
)),
}),
Action::SwitchToMode { input_mode } => {
let input_mode: ProtobufInputMode = input_mode.try_into()?;
Ok(ProtobufAction {
@@ -3620,6 +3639,14 @@ mod tests {
id: 10,
direction: Direction::Right,
},
Action::MoveTabToPosition {
id: 11,
position: 3,
},
Action::MoveTabToPosition {
id: 12,
position: crate::data::TAB_POSITION_END,
},
]
}
@@ -3680,6 +3707,7 @@ mod tests {
ProtobufActionName::CloseTabById,
ProtobufActionName::StartRenameTabByTabId,
ProtobufActionName::MoveTabByTabId,
ProtobufActionName::MoveTabToPosition,
] {
let protobuf = ProtobufAction {
name: name as i32,
+2
View File
@@ -222,6 +222,7 @@ message ContextMenuAction {
action.Action plain = 1;
ClickedPaneAction clicked_pane = 2;
ClickedTabAction clicked_tab = 3;
uint64 clicked_tab_move_to_position = 4;
}
}
@@ -476,6 +477,7 @@ enum MouseEventName {
MouseHover = 6;
MouseScrollLeft = 7;
MouseScrollRight = 8;
MouseDoubleClick = 9;
}
message TabUpdatePayload {
+58
View File
@@ -2099,6 +2099,14 @@ impl TryFrom<MouseEventPayload> for Mouse {
),
_ => Err("Malformed payload for mouse hover"),
},
Some(MouseEventName::MouseDoubleClick) => {
match mouse_event_payload.mouse_event_payload {
Some(mouse_event_payload::MouseEventPayload::Position(position)) => Ok(
Mouse::DoubleClick(position.line as isize, position.column as usize),
),
_ => Err("Malformed payload for mouse double click"),
}
},
None => Err("Malformed payload for MouseEventName"),
}
}
@@ -2186,6 +2194,16 @@ impl TryFrom<Mouse> for MouseEventPayload {
)),
..Default::default()
}),
Mouse::DoubleClick(line, column) => Ok(MouseEventPayload {
mouse_event_name: MouseEventName::MouseDoubleClick as i32,
mouse_event_payload: Some(mouse_event_payload::MouseEventPayload::Position(
ProtobufPosition {
line: line as i64,
column: column as i64,
},
)),
..Default::default()
}),
}
}
}
@@ -4212,6 +4230,9 @@ impl TryFrom<ProtobufContextMenuAction> for ContextMenuAction {
};
Ok(ContextMenuAction::ClickedTab(action))
},
Some(ProtobufContextMenuActionKind::ClickedTabMoveToPosition(position)) => Ok(
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(position)),
),
None => Err("Empty action in a context menu entry"),
}
}
@@ -4239,6 +4260,9 @@ impl TryFrom<ContextMenuAction> for ProtobufContextMenuAction {
};
ProtobufContextMenuActionKind::ClickedPane(action as i32)
},
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(position)) => {
ProtobufContextMenuActionKind::ClickedTabMoveToPosition(position)
},
ContextMenuAction::ClickedTab(action) => {
let action = match action {
ClickedTabAction::Close => ProtobufClickedTabAction::Close,
@@ -4248,6 +4272,9 @@ impl TryFrom<ContextMenuAction> for ProtobufContextMenuAction {
ClickedTabAction::Move(_) => {
return Err("Tabs can only be moved left or right")
},
ClickedTabAction::MoveToPosition(_) => {
return Err("Unexpected clicked tab action")
},
};
ProtobufContextMenuActionKind::ClickedTab(action as i32)
},
@@ -4258,6 +4285,32 @@ impl TryFrom<ContextMenuAction> for ProtobufContextMenuAction {
}
}
#[cfg(test)]
mod mouse_event_tests {
use super::*;
#[test]
fn every_mouse_event_survives_the_protobuf_round_trip() {
let events = [
Mouse::ScrollUp(3),
Mouse::ScrollDown(2),
Mouse::ScrollLeft(4),
Mouse::ScrollRight(5),
Mouse::LeftClick(1, 2),
Mouse::RightClick(3, 4),
Mouse::Hold(5, 6),
Mouse::Release(7, 8),
Mouse::Hover(9, 10),
Mouse::DoubleClick(11, 12),
];
for event in events {
let payload: MouseEventPayload = event.try_into().expect("encode");
let decoded: Mouse = payload.try_into().expect("decode");
assert_eq!(decoded, event);
}
}
}
#[cfg(test)]
mod context_menu_tests {
use super::*;
@@ -4272,6 +4325,11 @@ mod context_menu_tests {
ContextMenuAction::ClickedTab(ClickedTabAction::StartRename),
ContextMenuAction::ClickedTab(ClickedTabAction::Move(Direction::Left)),
ContextMenuAction::ClickedTab(ClickedTabAction::Move(Direction::Right)),
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(0)),
ContextMenuAction::ClickedTab(ClickedTabAction::MoveToPosition(
crate::data::TAB_POSITION_END,
)),
ContextMenuAction::Action(Action::MoveTabToPosition { id: 4, position: 2 }),
ContextMenuAction::Action(Action::Detach),
ContextMenuAction::Action(Action::CloseFocusByPaneId {
pane_id: PaneId::Terminal(3),
@@ -249,6 +249,7 @@ enum CommandName {
DeleteThemeFile = 255;
SetSelfCollapsed = 256;
SetCollapsedSlot = 257;
ResetMousebinds = 258;
}
message PluginCommand {
@@ -438,6 +439,7 @@ message PluginCommand {
string delete_theme_file_payload = 198;
SetSelfCollapsedPayload set_self_collapsed_payload = 199;
SetCollapsedSlotPayload set_collapsed_slot_payload = 200;
ResetMousebindsPayload reset_mousebinds_payload = 201;
}
}
@@ -499,6 +501,7 @@ message KeybindsSelectionSnapshot {
bool set_by_layout = 10;
optional input_mode.InputMode default_mode = 11;
bool set_on_command_line = 12;
bool has_own_mousebindings = 13;
}
message ReadConfigPayload {}
@@ -531,6 +534,7 @@ message ReadConfigResponse {
ConfigBlocks saved_blocks = 12;
ConfigBlocks default_blocks = 13;
repeated KeybindingEntry keybindings = 14;
repeated MousebindingEntry mousebindings = 15;
}
message ConfigPair {
@@ -606,6 +610,31 @@ message KeybindingEntry {
bool preset_same_as_actions = 10;
}
message MousebindingEntry {
input_mode.InputMode mode = 1;
string trigger = 2;
string target = 3;
repeated string actions = 4;
bool app_first = 5;
KeybindingSourceKind source = 6;
optional string shared_block = 7;
bool unbound = 8;
bool has_preset_binding = 9;
repeated string preset_actions = 10;
bool unsaved = 11;
}
message MouseTriggerInMode {
input_mode.InputMode input_mode = 1;
string trigger = 2;
string target = 3;
}
message ResetMousebindsPayload {
repeated MouseTriggerInMode triggers = 1;
bool write_config_to_disk = 2;
}
message SaveKeybindsAsPresetPayload {
string new_name = 1;
}
+218 -11
View File
@@ -72,9 +72,10 @@ pub use super::generated_api::api::{
KillSessionsResponse as ProtobufKillSessionsResponse, LeaderValue as ProtobufLeaderValue,
ListTokensResponse, LoadNewPluginPayload, MenuItemEntry as ProtobufMenuItemEntry,
MenuSectionEntries as ProtobufMenuSectionEntries, MessageToPluginPayload,
MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, MovePayload,
NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload,
NewTabResponse as ProtobufNewTabResponse, NewTabUnfocusedPayload,
MouseTriggerInMode as ProtobufMouseTriggerInMode,
MousebindingEntry as ProtobufMousebindingEntry, MovePaneWithPaneIdInDirectionPayload,
MovePaneWithPaneIdPayload, MovePayload, NewPluginArgs as ProtobufNewPluginArgs,
NewTabPayload, NewTabResponse as ProtobufNewTabResponse, NewTabUnfocusedPayload,
NewTabUnfocusedResponse as ProtobufNewTabUnfocusedResponse,
NewTabsResponse as ProtobufNewTabsResponse, NewTabsWithLayoutInfoPayload,
NewTiledPaneInTabPayload, NewTiledPaneInTabResponse as ProtobufNewTiledPaneInTabResponse,
@@ -125,11 +126,11 @@ pub use super::generated_api::api::{
RenameLayoutResponse as ProtobufRenameLayoutResponse, RenameTabWithIdPayload,
RenameWebLoginTokenPayload, RenameWebTokenResponse, ReplacePaneWithExistingPanePayload,
ReplyToPromptPayload, RequestPluginPermissionPayload, RerunCommandPanePayload,
ResetKeysPayload, ResizePaneIdWithDirectionPayload, ResizePayload, RevertConfigPayload,
RevokeAllWebTokensResponse, RevokeTokenResponse, RevokeWebLoginTokenPayload,
RunActionPayload, RunCommandPayload, RunningCommand as ProtobufRunningCommand,
SaveConfigPayload, SaveKeybindsAsPresetPayload, SaveLayoutPayload,
SaveLayoutResponse as ProtobufSaveLayoutResponse, SaveSessionPayload,
ResetKeysPayload, ResetMousebindsPayload, ResizePaneIdWithDirectionPayload, ResizePayload,
RevertConfigPayload, RevokeAllWebTokensResponse, RevokeTokenResponse,
RevokeWebLoginTokenPayload, RunActionPayload, RunCommandPayload,
RunningCommand as ProtobufRunningCommand, SaveConfigPayload, SaveKeybindsAsPresetPayload,
SaveLayoutPayload, SaveLayoutResponse as ProtobufSaveLayoutResponse, SaveSessionPayload,
SaveSessionResponse as ProtobufSaveSessionResponse, ScrollDownInPaneIdPayload,
ScrollToBottomInPaneIdPayload, ScrollToTopInPaneIdPayload, ScrollUpInPaneIdPayload,
SessionListSnapshot as ProtobufSessionListSnapshot, SetCliPipeExitCodePayload,
@@ -160,9 +161,10 @@ use crate::data::{
GetPaneCwdResponse, GetPanePidResponse, GetPaneRunningCommandResponse, GetSessionListResponse,
HighlightLayer, HighlightStyle, HttpVerb, InputMode, KeyWithModifier, KeybindingEntry,
KeybindingSource, KeybindsSelectionSnapshot, KillSessionsResponse, MenuItemEntry,
MenuSectionEntries, MessageToPlugin, NewPluginArgs, PaneId, PermissionType, PluginAliasEntry,
PluginCommand, PluginEntry, RegexHighlight, RenameLayoutResponse, SaveLayoutResponse,
SessionInfo, SessionListSnapshot, SettingKey, ThemeEntry, ThemeSource,
MenuSectionEntries, MessageToPlugin, MouseTarget, MouseTrigger, MousebindingEntry,
NewPluginArgs, PaneId, PermissionType, PluginAliasEntry, PluginCommand, PluginEntry,
RegexHighlight, RenameLayoutResponse, SaveLayoutResponse, SessionInfo, SessionListSnapshot,
SettingKey, ThemeEntry, ThemeSource,
};
use crate::input::actions::Action;
use crate::input::layout::PercentOrFixed;
@@ -2717,6 +2719,19 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
}),
_ => Err("Mismatched payload for ReplyToPrompt"),
},
Some(CommandName::ResetMousebinds) => match protobuf_plugin_command.payload {
Some(Payload::ResetMousebindsPayload(payload)) => {
Ok(PluginCommand::ResetMousebinds {
triggers: payload
.triggers
.into_iter()
.filter_map(mouse_trigger_in_mode_from_protobuf)
.collect(),
write_config_to_disk: payload.write_config_to_disk,
})
},
_ => Err("Mismatched payload for ResetMousebinds"),
},
Some(CommandName::ResetKeys) => match protobuf_plugin_command.payload {
Some(Payload::ResetKeysPayload(payload)) => Ok(PluginCommand::ResetKeys {
keys: payload
@@ -4713,6 +4728,23 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
name: CommandName::DeleteThemeFile as i32,
payload: Some(Payload::DeleteThemeFilePayload(name)),
}),
PluginCommand::ResetMousebinds {
triggers,
write_config_to_disk,
} => Ok(ProtobufPluginCommand {
name: CommandName::ResetMousebinds as i32,
payload: Some(Payload::ResetMousebindsPayload(ResetMousebindsPayload {
triggers: triggers
.into_iter()
.map(|(mode, trigger)| ProtobufMouseTriggerInMode {
input_mode: mode as i32,
trigger: trigger.to_kdl(),
target: trigger.target.name().to_owned(),
})
.collect(),
write_config_to_disk,
})),
}),
PluginCommand::ResetKeys {
keys,
write_config_to_disk,
@@ -5720,6 +5752,89 @@ mod tests {
}
}
#[test]
fn reset_mousebinds_protobuf_round_trip() {
use crate::data::{InputMode, MouseTarget, MouseTrigger};
let triggers = vec![
(
InputMode::Normal,
MouseTrigger::parse("Ctrl Double Left", MouseTarget::Frame).unwrap(),
),
(
InputMode::Locked,
MouseTrigger::parse("Alt ScrollUp", MouseTarget::Any).unwrap(),
),
];
let original = PluginCommand::ResetMousebinds {
triggers: triggers.clone(),
write_config_to_disk: true,
};
let protobuf: ProtobufPluginCommand = original.try_into().expect("encode");
let decoded: PluginCommand = protobuf.try_into().expect("decode");
match decoded {
PluginCommand::ResetMousebinds {
triggers: decoded_triggers,
write_config_to_disk,
} => {
assert_eq!(decoded_triggers, triggers);
assert!(write_config_to_disk);
},
other => panic!("expected ResetMousebinds, got {:?}", other),
}
}
#[test]
fn mouse_binding_entries_survive_the_config_snapshot_round_trip() {
use crate::data::{
ConfigSnapshot, InputMode, KeybindingSource, KeybindsSelectionSnapshot, MouseTarget,
MouseTrigger, MousebindingEntry,
};
let entries = vec![
MousebindingEntry {
mode: InputMode::Normal,
trigger: MouseTrigger::parse("Double Left", MouseTarget::Frame).unwrap(),
actions: vec!["ToggleFullscreen".to_owned()],
app_first: false,
source: KeybindingSource::Preset,
unbound: false,
preset_actions: Some(vec!["ToggleFullscreen".to_owned()]),
unsaved: false,
},
MousebindingEntry {
mode: InputMode::Pane,
trigger: MouseTrigger::parse("Shift Triple Middle", MouseTarget::Content).unwrap(),
actions: vec![],
app_first: true,
source: KeybindingSource::Shared("shared_except \"locked\"".to_owned()),
unbound: true,
preset_actions: None,
unsaved: true,
},
MousebindingEntry {
mode: InputMode::Locked,
trigger: MouseTrigger::parse("ScrollRight", MouseTarget::Any).unwrap(),
actions: vec!["NewTab".to_owned(), "GoToNextTab".to_owned()],
app_first: false,
source: KeybindingSource::Layout,
unbound: false,
preset_actions: Some(vec!["ScrollColumns 4".to_owned()]),
unsaved: false,
},
];
let snapshot = ConfigSnapshot {
mousebindings: entries.clone(),
keybinds: KeybindsSelectionSnapshot {
has_own_mousebindings: true,
..Default::default()
},
..Default::default()
};
let protobuf: ProtobufReadConfigResponse = snapshot.into();
let decoded: ConfigSnapshot = protobuf.into();
assert_eq!(decoded.mousebindings, entries);
assert!(decoded.keybinds.has_own_mousebindings);
}
#[test]
fn theme_file_commands_protobuf_round_trip() {
let commands = vec![
@@ -6019,6 +6134,7 @@ impl From<ConfigSnapshot> for ProtobufReadConfigResponse {
saved_blocks: Some(snapshot.saved_blocks.into()),
default_blocks: Some(snapshot.default_blocks.into()),
keybindings: snapshot.keybindings.into_iter().map(Into::into).collect(),
mousebindings: snapshot.mousebindings.into_iter().map(Into::into).collect(),
}
}
}
@@ -6201,6 +6317,90 @@ impl From<KeybindingEntry> for ProtobufKeybindingEntry {
}
}
fn mouse_trigger_from_protobuf(trigger: &str, target: &str) -> Option<MouseTrigger> {
let target = MouseTarget::from_name(target).unwrap_or_default();
MouseTrigger::parse(trigger, target).ok()
}
fn mouse_trigger_in_mode_from_protobuf(
entry: ProtobufMouseTriggerInMode,
) -> Option<(InputMode, MouseTrigger)> {
let mode: InputMode = ProtobufInputMode::try_from(entry.input_mode)
.ok()?
.try_into()
.ok()?;
Some((
mode,
mouse_trigger_from_protobuf(&entry.trigger, &entry.target)?,
))
}
fn keybinding_source_to_protobuf(
source: KeybindingSource,
) -> (ProtobufKeybindingSourceKind, Option<String>) {
match source {
KeybindingSource::Preset => (ProtobufKeybindingSourceKind::KeybindingSourcePreset, None),
KeybindingSource::User => (ProtobufKeybindingSourceKind::KeybindingSourceUser, None),
KeybindingSource::Shared(block) => (
ProtobufKeybindingSourceKind::KeybindingSourceShared,
Some(block),
),
KeybindingSource::Layout => (ProtobufKeybindingSourceKind::KeybindingSourceLayout, None),
}
}
fn keybinding_source_from_protobuf(source: i32, shared_block: Option<String>) -> KeybindingSource {
match ProtobufKeybindingSourceKind::try_from(source) {
Ok(ProtobufKeybindingSourceKind::KeybindingSourceUser) => KeybindingSource::User,
Ok(ProtobufKeybindingSourceKind::KeybindingSourceShared) => {
KeybindingSource::Shared(shared_block.unwrap_or_default())
},
Ok(ProtobufKeybindingSourceKind::KeybindingSourceLayout) => KeybindingSource::Layout,
_ => KeybindingSource::Preset,
}
}
impl From<MousebindingEntry> for ProtobufMousebindingEntry {
fn from(entry: MousebindingEntry) -> Self {
let (source, shared_block) = keybinding_source_to_protobuf(entry.source);
ProtobufMousebindingEntry {
mode: entry.mode as i32,
trigger: entry.trigger.to_kdl(),
target: entry.trigger.target.name().to_owned(),
actions: entry.actions,
app_first: entry.app_first,
source: source as i32,
shared_block,
unbound: entry.unbound,
has_preset_binding: entry.preset_actions.is_some(),
preset_actions: entry.preset_actions.unwrap_or_default(),
unsaved: entry.unsaved,
}
}
}
fn mousebinding_entry_from_protobuf(entry: ProtobufMousebindingEntry) -> Option<MousebindingEntry> {
let mode: InputMode = ProtobufInputMode::try_from(entry.mode)
.ok()?
.try_into()
.ok()?;
let trigger = mouse_trigger_from_protobuf(&entry.trigger, &entry.target)?;
Some(MousebindingEntry {
mode,
trigger,
actions: entry.actions,
app_first: entry.app_first,
source: keybinding_source_from_protobuf(entry.source, entry.shared_block),
unbound: entry.unbound,
preset_actions: if entry.has_preset_binding {
Some(entry.preset_actions)
} else {
None
},
unsaved: entry.unsaved,
})
}
fn keybinding_entry_from_protobuf(entry: ProtobufKeybindingEntry) -> Option<KeybindingEntry> {
let mode: InputMode = ProtobufInputMode::try_from(entry.mode)
.ok()?
@@ -6242,6 +6442,7 @@ impl From<KeybindsSelectionSnapshot> for ProtobufKeybindsSelectionSnapshot {
unlock: snapshot.unlock,
clears_defaults: snapshot.clears_defaults,
has_own_keybindings: snapshot.has_own_keybindings,
has_own_mousebindings: snapshot.has_own_mousebindings,
active: Some(snapshot.active.into()),
active_values: snapshot
.active_values
@@ -6268,6 +6469,7 @@ impl From<ProtobufKeybindsSelectionSnapshot> for KeybindsSelectionSnapshot {
unlock: snapshot.unlock,
clears_defaults: snapshot.clears_defaults,
has_own_keybindings: snapshot.has_own_keybindings,
has_own_mousebindings: snapshot.has_own_mousebindings,
active: snapshot.active.map(Into::into).unwrap_or_default(),
active_values: snapshot
.active_values
@@ -6321,6 +6523,11 @@ impl From<ProtobufReadConfigResponse> for ConfigSnapshot {
.filter_map(keybinding_entry_from_protobuf)
.collect(),
keybinds: response.keybinds.map(Into::into).unwrap_or_default(),
mousebindings: response
.mousebindings
.into_iter()
.filter_map(mousebinding_entry_from_protobuf)
.collect(),
}
}
}

Some files were not shown because too many files have changed in this diff Show More