Interactive configuration, popups, notifications, right-click menu (#5685)

* ui components

* right click menu + popups

* settings screen

* various fixes

* presets in config

* zellij prompt - ui elements in the cli

* context menu shortcuts and toggle, keybindings in popups, info popups + zellij prompt notify

* smart config writing and some leftovers

* keybindings/right-click/config editors

* some wrapups

* feat(plugins): add prompt() plugin API for zellij:prompt, dialog forms, and Text labels in widgets

* theme editors

* upstream by-id action types, config env for new panes only, smaller saved menu config, web right-click, example config

* upstreamify

* rustfmt

* fix windows and theme editor layout

* add pr
This commit is contained in:
Aram Drevekenin
2026-10-05 18:46:12 +02:00
committed by GitHub
parent 1dbc292398
commit 201c1593da
228 changed files with 66023 additions and 6056 deletions
+1 -1
View File
@@ -13,4 +13,4 @@ linker = "x86_64-w64-mingw32-gcc"
target-dir = "target"
[target.wasm32-wasip1]
rustflags = ["-C", "link-arg=-zstack-size=262144"]
rustflags = ["-C", "link-arg=-zstack-size=524288"]
+1
View File
@@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* fix: erasing wide CJK character correctness (https://github.com/zellij-org/zellij/pull/5657)
* fix: plugins hidden with `hide_self` keep receiving mode, tab and pane updates (https://github.com/zellij-org/zellij/pull/5680)
* fix: screen surface stability (https://github.com/zellij-org/zellij/pull/5683)
* feat: right-click menu, interactive configuration, popups, notifications (https://github.com/zellij-org/zellij/pull/5685)
## [0.45.1] - 2026-08-28
* fix: nested-session detection over SSH (https://github.com/zellij-org/zellij/pull/5522)
Generated
+24
View File
@@ -685,7 +685,9 @@ dependencies = [
name = "configuration"
version = "0.1.0"
dependencies = [
"kdl",
"zellij-tile",
"zellij-utils",
]
[[package]]
@@ -714,6 +716,14 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "context-menu"
version = "0.1.0"
dependencies = [
"zellij-tile",
"zellij-utils",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -2731,6 +2741,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prompt"
version = "0.1.0"
dependencies = [
"regex",
"serde_json",
"zellij-tile",
"zellij-utils",
]
[[package]]
name = "prost"
version = "0.12.6"
@@ -5002,6 +5022,7 @@ dependencies = [
"axum",
"axum-extra",
"axum-server",
"clap",
"crossterm",
"daemonix",
"dialoguer",
@@ -5046,6 +5067,7 @@ dependencies = [
"anyhow",
"async-trait",
"base64",
"clap",
"crossbeam",
"flate2",
"insta",
@@ -5119,6 +5141,7 @@ dependencies = [
"serde_json",
"strum",
"strum_macros",
"unicode-width 0.2.2",
"zellij-utils",
]
@@ -5164,6 +5187,7 @@ dependencies = [
"openssl-sys",
"percent-encoding",
"prost",
"regex",
"rusqlite",
"serde",
"serde_json",
+17
View File
@@ -52,6 +52,8 @@ members = [
"default-plugins/share",
"default-plugins/multiple-select",
"default-plugins/layout-manager",
"default-plugins/prompt",
"default-plugins/context-menu",
"zellij-client",
"zellij-integration-tests",
"zellij-server",
@@ -120,6 +122,21 @@ zellij-utils = { path = "zellij-utils/", version = "0.46.0" }
[profile.dev]
debug = "line-tables-only"
[profile.dev.package.wasmi]
opt-level = 3
[profile.dev.package.wasmi_core]
opt-level = 3
[profile.dev.package.wasmi_ir]
opt-level = 3
[profile.dev.package.wasmi_collections]
opt-level = 3
[profile.dev.package.vte]
opt-level = 3
[profile.dev-opt]
inherits = "dev"
+10 -1
View File
@@ -9,7 +9,7 @@ use std::cmp::{max, min};
use std::collections::{BTreeMap, BTreeSet};
use std::convert::TryInto;
use tab::get_tab_to_focus;
use tab::{get_clicked_line_part, get_tab_to_focus};
use zellij_tile::prelude::*;
use crate::keybinds::KeybindStore;
@@ -477,6 +477,15 @@ impl CompactBar {
switch_tab_to(tab_idx.try_into().unwrap());
}
},
Mouse::RightClick(line, col) => {
let target = match get_clicked_line_part(&slot_client.tab_line, *col)
.and_then(|line_part| line_part.tab_index)
{
Some(tab_index) => ContextMenuTarget::Tab(tab_index),
None => ContextMenuTarget::Bar,
};
open_context_menu(target, (*line).max(0) as usize, *col);
},
Mouse::ScrollUp(_) => {
let next_tab = min(client.active_tab_idx + 1, client.tabs.len());
switch_tab_to(next_tab as u32);
@@ -392,6 +392,9 @@ impl StatusBar {
client.display_system_clipboard_failure = false;
},
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) {
+10 -1
View File
@@ -5,7 +5,7 @@ use std::cmp::{max, min};
use std::collections::{BTreeMap, VecDeque};
use std::convert::TryInto;
use tab::get_tab_to_focus;
use tab::{get_clicked_line_part, get_tab_to_focus};
use zellij_tile::prelude::*;
use crate::keybinds::KeybindStore;
@@ -294,6 +294,15 @@ impl TabBar {
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)
{
Some(tab_index) => ContextMenuTarget::Tab(tab_index),
None => ContextMenuTarget::Bar,
};
open_context_menu(target, (*line).max(0) as usize, *col);
},
Mouse::ScrollUp(_) => {
switch_tab_to(min(client.active_tab_idx + 1, client.tabs.len()) as u32);
},
+2
View File
@@ -7,3 +7,5 @@ license.workspace = true
[dependencies]
zellij-tile = { path = "../../zellij-tile" }
zellij-utils = { workspace = true }
kdl = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,706 @@
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
use zellij_utils::input::config_blocks::{
env_kdl, load_plugins_kdl, menu_section_kdl, plugin_aliases_kdl,
};
use crate::list_editor::{EntryKind, FieldSpec, FieldValue, ListEditor, Section, SectionedList};
use crate::page::{actions_summary, is_plain, is_shift_tab, print_dim, Effect, Page, PageResponse};
fn text_field(label: &'static str, placeholder: &'static str) -> FieldSpec {
FieldSpec::text(label, placeholder)
}
fn pairs_field(label: &'static str) -> FieldSpec {
FieldSpec::pairs(label)
}
pub fn check_plugin_location(text: &str, allow_alias: bool) -> Result<String, String> {
let text = text.trim();
let error = if allow_alias {
"Use a plugin URL (https://…), zellij:name, file:/path, or an alias name"
} else {
"Use a plugin URL (https://…), zellij:name or file:/path"
};
if text.is_empty() || text.chars().any(|c| c.is_control()) {
return Err(error.to_owned());
}
if let Some(name) = text.strip_prefix("zellij:") {
if name.is_empty() || name.contains(char::is_whitespace) {
return Err(error.to_owned());
}
return Ok(text.to_owned());
}
if let Some(path) = text.strip_prefix("file:") {
if path.is_empty() {
return Err(error.to_owned());
}
return Ok(text.to_owned());
}
if text.starts_with("https://") || text.starts_with("http://") {
if text.len() <= "https://".len() || text.contains(char::is_whitespace) {
return Err(error.to_owned());
}
return Ok(text.to_owned());
}
if text.starts_with('/') || text.starts_with('~') {
return Ok(format!("file:{}", text));
}
if allow_alias
&& text
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Ok(text.to_owned());
}
Err(error.to_owned())
}
pub fn check_name(text: &str, what: &str) -> Result<String, String> {
let text = text.trim();
if text.is_empty() {
return Err(format!("A {} is needed", what));
}
if !text
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(format!("A {} can use letters, digits, '-' and '_'", what));
}
Ok(text.to_owned())
}
pub fn check_env_name(text: &str) -> Result<String, String> {
let text = text.trim();
let mut characters = text.chars();
let valid = match characters.next() {
Some(first) => {
(first.is_ascii_alphabetic() || first == '_')
&& characters.all(|c| c.is_ascii_alphanumeric() || c == '_')
},
None => false,
};
if valid {
Ok(text.to_owned())
} else {
Err(
"A variable name starts with a letter or '_' and uses letters, digits and '_'"
.to_owned(),
)
}
}
fn check_path(text: &str) -> Result<Option<String>, String> {
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
if text.chars().any(|c| c.is_control()) {
return Err("A path cannot contain control characters".to_owned());
}
Ok(Some(text.to_owned()))
}
fn check_pairs(pairs: Vec<(String, String)>) -> Result<Vec<(String, String)>, String> {
let mut checked: Vec<(String, String)> = vec![];
for (key, value) in pairs {
let key = check_name(&key, "config key")?;
if checked.iter().any(|(existing, _)| *existing == key) {
return Err(format!("{} is set twice", key));
}
checked.push((key, value));
}
checked.sort();
Ok(checked)
}
fn plugin_details(entry: &PluginEntry) -> String {
let mut details = vec![];
if let Some(cwd) = &entry.cwd {
details.push(format!("in {}", cwd));
}
if !entry.configuration.is_empty() {
details.push(
entry
.configuration
.iter()
.map(|(key, value)| format!("{}={}", key, value))
.collect::<Vec<_>>()
.join(", "),
);
}
details.join(" · ")
}
fn plugin_summary(entry: &PluginEntry) -> String {
let mut summary = entry.location.clone();
if let Some(cwd) = &entry.cwd {
summary.push_str(&format!(" (in {})", cwd));
}
if !entry.configuration.is_empty() {
summary.push_str(&format!(
" {{ {} }}",
entry
.configuration
.iter()
.map(|(key, value)| format!("{}={}", key, value))
.collect::<Vec<_>>()
.join(", ")
));
}
summary
}
pub struct AliasKind;
impl EntryKind for AliasKind {
type Entry = PluginAliasEntry;
fn title(&self) -> String {
"Plugin aliases".to_owned()
}
fn heading_note(&self) -> Option<String> {
Some("⟳ applies after a restart".to_owned())
}
fn noun(&self) -> &'static str {
"plugin alias"
}
fn identity(&self, entry: &PluginAliasEntry) -> String {
entry.name.clone()
}
fn columns(&self, entry: &PluginAliasEntry) -> Vec<String> {
vec![
entry.name.clone(),
entry.plugin.location.clone(),
plugin_details(&entry.plugin),
]
}
fn summary(&self, entry: &PluginAliasEntry) -> String {
format!("{} → {}", entry.name, plugin_summary(&entry.plugin))
}
fn fields(&self) -> Vec<FieldSpec> {
vec![
text_field("Name", "my-plugin"),
text_field("Location", "zellij:name, file:/path or https://…"),
text_field("Folder", "optional"),
pairs_field("Config"),
]
}
fn to_values(&self, entry: &PluginAliasEntry) -> Vec<FieldValue> {
vec![
FieldValue::Text(entry.name.clone()),
FieldValue::Text(entry.plugin.location.clone()),
FieldValue::Text(entry.plugin.cwd.clone().unwrap_or_default()),
FieldValue::Pairs(entry.plugin.configuration.clone()),
]
}
fn from_values(&self, values: &[FieldValue]) -> Result<PluginAliasEntry, String> {
Ok(PluginAliasEntry {
name: check_name(&values[0].text(), "name")?,
plugin: PluginEntry {
location: check_plugin_location(&values[1].text(), false)?,
cwd: check_path(&values[2].text())?,
configuration: check_pairs(values[3].pairs())?,
},
})
}
fn block_kdl(&self, entries: &[PluginAliasEntry]) -> Result<String, String> {
Ok(plugin_aliases_kdl(entries))
}
fn block_key(&self) -> SettingKey {
SettingKey::PluginAliases
}
fn entries(&self, blocks: &ConfigBlocks) -> Vec<PluginAliasEntry> {
blocks.plugin_aliases.clone()
}
fn can_delete(
&self,
entry: &PluginAliasEntry,
defaults: &[PluginAliasEntry],
) -> Result<(), String> {
if defaults.iter().any(|default| default.name == entry.name) {
Err(format!(
"{} is built in and cannot be removed; edit it, or press r to undo your change",
entry.name
))
} else {
Ok(())
}
}
}
pub struct LoadPluginKind;
impl EntryKind for LoadPluginKind {
type Entry = PluginEntry;
fn title(&self) -> String {
"Plugins loaded at start (load_plugins)".to_owned()
}
fn short_title(&self) -> String {
"Plugins loaded at start".to_owned()
}
fn noun(&self) -> &'static str {
"plugin"
}
fn identity(&self, entry: &PluginEntry) -> String {
entry.location.clone()
}
fn columns(&self, entry: &PluginEntry) -> Vec<String> {
vec![entry.location.clone(), plugin_details(entry)]
}
fn summary(&self, entry: &PluginEntry) -> String {
plugin_summary(entry)
}
fn fields(&self) -> Vec<FieldSpec> {
vec![
text_field("Plugin", "alias, zellij:name, file:/path or https://…"),
text_field("Folder", "optional"),
pairs_field("Config"),
]
}
fn to_values(&self, entry: &PluginEntry) -> Vec<FieldValue> {
vec![
FieldValue::Text(entry.location.clone()),
FieldValue::Text(entry.cwd.clone().unwrap_or_default()),
FieldValue::Pairs(entry.configuration.clone()),
]
}
fn from_values(&self, values: &[FieldValue]) -> Result<PluginEntry, String> {
Ok(PluginEntry {
location: check_plugin_location(&values[0].text(), true)?,
cwd: check_path(&values[1].text())?,
configuration: check_pairs(values[2].pairs())?,
})
}
fn block_kdl(&self, entries: &[PluginEntry]) -> Result<String, String> {
Ok(load_plugins_kdl(entries))
}
fn block_key(&self) -> SettingKey {
SettingKey::LoadPlugins
}
fn entries(&self, blocks: &ConfigBlocks) -> Vec<PluginEntry> {
blocks.load_plugins.clone()
}
fn ordered(&self) -> bool {
true
}
fn restart_only(&self) -> bool {
true
}
}
pub struct EnvKind;
impl EntryKind for EnvKind {
type Entry = EnvVarEntry;
fn title(&self) -> String {
"Environment variables for new panes (env)".to_owned()
}
fn short_title(&self) -> String {
"Environment variables".to_owned()
}
fn heading_note(&self) -> Option<String> {
Some("new panes, for everyone in this session".to_owned())
}
fn noun(&self) -> &'static str {
"variable"
}
fn identity(&self, entry: &EnvVarEntry) -> String {
entry.name.clone()
}
fn columns(&self, entry: &EnvVarEntry) -> Vec<String> {
vec![entry.name.clone(), entry.value.clone()]
}
fn summary(&self, entry: &EnvVarEntry) -> String {
format!("{}={}", entry.name, entry.value)
}
fn fields(&self) -> Vec<FieldSpec> {
vec![text_field("Name", "MY_VARIABLE"), text_field("Value", "")]
}
fn to_values(&self, entry: &EnvVarEntry) -> Vec<FieldValue> {
vec![
FieldValue::Text(entry.name.clone()),
FieldValue::Text(entry.value.clone()),
]
}
fn from_values(&self, values: &[FieldValue]) -> Result<EnvVarEntry, String> {
let value = match &values[1] {
FieldValue::Text(text) => text.clone(),
_ => String::new(),
};
Ok(EnvVarEntry {
name: check_env_name(&values[0].text())?,
value,
})
}
fn block_kdl(&self, entries: &[EnvVarEntry]) -> Result<String, String> {
Ok(env_kdl(entries))
}
fn block_key(&self) -> SettingKey {
SettingKey::Env
}
fn entries(&self, blocks: &ConfigBlocks) -> Vec<EnvVarEntry> {
blocks.env.clone()
}
}
pub struct MenuKind {
pub section: &'static str,
pub shortcuts: BTreeMap<(Option<String>, Vec<String>), String>,
}
pub fn without_shortcut(entry: &MenuItemEntry) -> MenuItemEntry {
MenuItemEntry {
label: entry.label.clone(),
actions: entry.actions.clone(),
shortcut: None,
}
}
const ITEM: &str = "item";
const SEPARATOR: &str = "separator";
const MENU_ENTRY_KINDS: &[&str] = &[ITEM, SEPARATOR];
fn section_title(section: &str) -> String {
match section {
"pane" => "Pane menu (right click on a pane or its frame)".to_owned(),
"tab" => "Tab menu (right click on a tab)".to_owned(),
"bar" => "Bar menu (right click on an empty part of the tab bar)".to_owned(),
"common" => "Common items (added at the end of every menu)".to_owned(),
other => format!("{} menu", other),
}
}
impl EntryKind for MenuKind {
type Entry = MenuItemEntry;
fn title(&self) -> String {
section_title(self.section)
}
fn menu_like(&self) -> bool {
true
}
fn short_title(&self) -> String {
match self.section {
"pane" => "Pane menu".to_owned(),
"tab" => "Tab menu".to_owned(),
"bar" => "Bar menu".to_owned(),
"common" => "Common items".to_owned(),
other => format!("{} menu", other),
}
}
fn noun(&self) -> &'static str {
"item"
}
fn identity(&self, entry: &MenuItemEntry) -> String {
entry.label.clone().unwrap_or_else(|| SEPARATOR.to_owned())
}
fn columns(&self, entry: &MenuItemEntry) -> Vec<String> {
match &entry.label {
None => vec![
"──────── separator".to_owned(),
String::new(),
String::new(),
],
Some(label) => vec![
label.clone(),
actions_summary(&entry.actions),
self.shortcuts
.get(&(entry.label.clone(), entry.actions.clone()))
.cloned()
.unwrap_or_default(),
],
}
}
fn summary(&self, entry: &MenuItemEntry) -> String {
match &entry.label {
None => "──────── separator".to_owned(),
Some(label) => {
let shortcut = self
.shortcuts
.get(&(entry.label.clone(), entry.actions.clone()))
.map(|shortcut| format!(" [{}]", shortcut))
.unwrap_or_default();
format!(
"{}{} · {}",
label,
shortcut,
actions_summary(&entry.actions)
)
},
}
}
fn fields(&self) -> Vec<FieldSpec> {
vec![
FieldSpec::choice("Kind", MENU_ENTRY_KINDS),
text_field("Label", "shown in the menu").when(0, ITEM),
FieldSpec::actions("Actions").when(0, ITEM),
]
}
fn to_values(&self, entry: &MenuItemEntry) -> Vec<FieldValue> {
let kind = if entry.is_separator() {
SEPARATOR
} else {
ITEM
};
vec![
FieldValue::Text(kind.to_owned()),
FieldValue::Text(entry.label.clone().unwrap_or_default()),
FieldValue::Actions(entry.actions.clone()),
]
}
fn from_values(&self, values: &[FieldValue]) -> Result<MenuItemEntry, String> {
if values[0].text() == SEPARATOR {
return Ok(MenuItemEntry::separator());
}
let label = values[1].text();
if label.is_empty() {
return Err("A label is needed".to_owned());
}
let actions = values[2].actions();
if actions.is_empty() {
return Err("Choose at least one action".to_owned());
}
Ok(MenuItemEntry {
label: Some(label),
actions,
shortcut: None,
})
}
fn block_kdl(&self, entries: &[MenuItemEntry]) -> Result<String, String> {
menu_section_kdl(self.section, entries)
}
fn block_key(&self) -> SettingKey {
SettingKey::ContextMenu
}
fn entries(&self, blocks: &ConfigBlocks) -> Vec<MenuItemEntry> {
blocks
.menu_section(self.section)
.iter()
.map(without_shortcut)
.collect()
}
fn refresh(&mut self, snapshot: &ConfigSnapshot) {
self.shortcuts = snapshot
.blocks
.menu_section(self.section)
.iter()
.filter_map(|entry| {
entry
.shortcut
.clone()
.map(|shortcut| ((entry.label.clone(), entry.actions.clone()), shortcut))
})
.collect();
}
fn ordered(&self) -> bool {
true
}
fn unique_identity(&self) -> bool {
false
}
fn separator(&self) -> Option<MenuItemEntry> {
Some(MenuItemEntry::separator())
}
fn can_restore_defaults(&self) -> bool {
true
}
fn replace_when_reverting(&self) -> bool {
true
}
}
const MENU_SECTIONS: [&str; 4] = ["pane", "tab", "bar", "common"];
pub struct BlockPage {
note: &'static str,
list: SectionedList,
}
impl BlockPage {
pub fn plugins_and_environment() -> Self {
BlockPage {
note: "",
list: SectionedList::new(vec![
Box::new(ListEditor::new(AliasKind).dialog_form()),
Box::new(ListEditor::new(LoadPluginKind).dialog_form()),
Box::new(ListEditor::new(EnvKind).dialog_form()),
])
.styled("Plugins and environment", "Show"),
}
}
pub fn right_click_menu() -> Self {
let sections: Vec<Box<dyn Section>> = MENU_SECTIONS
.iter()
.map(|section| {
Box::new(
ListEditor::new(MenuKind {
section: *section,
shortcuts: BTreeMap::new(),
})
.for_menu()
.dialog_form(),
) as Box<dyn Section>
})
.collect();
BlockPage {
note: "",
list: SectionedList::new(sections)
.with_cross_moves()
.with_shared_columns()
.styled("Right-click menu", "Menu"),
}
}
}
impl Page for BlockPage {
fn set_snapshot(&mut self, snapshot: &ConfigSnapshot) {
self.list.set_snapshot(snapshot);
}
fn handle_key(&mut self, key: &KeyWithModifier) -> PageResponse {
let busy = self.list.is_busy();
if self.list.handle_key(key) || busy {
return PageResponse::Handled;
}
if is_plain(key, BareKey::Esc) {
return PageResponse::Close;
}
if is_plain(key, BareKey::Tab)
|| is_plain(key, BareKey::Left)
|| is_shift_tab(key)
|| is_plain(key, BareKey::Up)
{
return PageResponse::LeaveToMenu;
}
PageResponse::NotHandled
}
fn handle_mouse(&mut self, mouse: Mouse) -> PageResponse {
if self.list.handle_mouse(mouse) {
PageResponse::Handled
} else {
PageResponse::NotHandled
}
}
fn handle_timer(&mut self) -> bool {
self.list.handle_timer()
}
fn render(&mut self, x: usize, y: usize, width: usize, height: usize) {
if self.list.is_busy() || self.note.is_empty() {
self.list.render(x, y, width, height);
} else {
print_dim(self.note, x, y, width);
self.list.render(x, y + 2, width, height.saturating_sub(2));
}
}
fn render_overlays(&mut self, rows: usize, cols: usize) {
self.list.render_overlays(rows, cols);
}
fn captures_keys(&self) -> bool {
self.list.captures_keys()
}
fn hints(&self) -> Vec<(&'static str, &'static str)> {
let mut hints = self.list.hints();
if !self.list.is_busy() {
hints.push(("<Tab>", "categories"));
}
hints
}
fn take_effects(&mut self) -> Vec<Effect> {
self.list.take_effects()
}
fn take_notice(&mut self) -> Option<String> {
self.list.take_notice()
}
fn leave(&mut self) {
self.list.close();
}
fn set_focused(&mut self, focused: bool) {
self.list.set_focused(focused);
}
fn prompt_result(&mut self, request_id: u64, result: &PromptResult) -> bool {
self.list.prompt_result(request_id, result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugin_locations_are_checked() {
assert_eq!(
check_plugin_location("zellij:tab-bar", false),
Ok("zellij:tab-bar".to_owned())
);
assert_eq!(
check_plugin_location("/tmp/p.wasm", false),
Ok("file:/tmp/p.wasm".to_owned())
);
assert!(check_plugin_location("https://example.com/p.wasm", false).is_ok());
assert!(check_plugin_location("my-alias", false).is_err());
assert_eq!(
check_plugin_location("my-alias", true),
Ok("my-alias".to_owned())
);
assert!(check_plugin_location("zellij:", false).is_err());
}
#[test]
fn variable_names_are_checked() {
assert!(check_env_name("EDITOR").is_ok());
assert!(check_env_name("_x1").is_ok());
assert!(check_env_name("1X").is_err());
assert!(check_env_name("MY-VAR").is_err());
}
#[test]
fn a_built_in_alias_cannot_be_deleted() {
let alias = PluginAliasEntry {
name: "tab-bar".to_owned(),
plugin: PluginEntry::default(),
};
assert!(AliasKind.can_delete(&alias, &[alias.clone()]).is_err());
assert!(AliasKind.can_delete(&alias, &[]).is_ok());
}
#[test]
fn an_alias_form_writes_a_plugins_block_that_reads_back() {
let entry = AliasKind
.from_values(&[
FieldValue::Text("mine".to_owned()),
FieldValue::Text("/tmp/mine.wasm".to_owned()),
FieldValue::Text(String::new()),
FieldValue::Pairs(vec![("size".to_owned(), "3".to_owned())]),
])
.unwrap();
let kdl = AliasKind.block_kdl(&[entry]).unwrap();
let mut config = zellij_utils::input::config::Config::default();
zellij_utils::input::config_blocks::replace_config_blocks(&mut config, &kdl).unwrap();
let alias = &config.plugins.aliases["mine"];
assert_eq!(alias.location.display(), "file:/tmp/mine.wasm");
assert_eq!(alias.configuration.inner()["size"], "3");
}
#[test]
fn a_separator_can_be_edited_into_an_item_and_back() {
let kind = MenuKind {
section: "pane",
shortcuts: BTreeMap::new(),
};
let separator = MenuItemEntry::separator();
let mut values = kind.to_values(&separator);
assert_eq!(values[0], FieldValue::Text("separator".to_owned()));
assert_eq!(kind.from_values(&values), Ok(MenuItemEntry::separator()));
values[0] = FieldValue::Text("item".to_owned());
assert!(kind.from_values(&values).is_err());
values[1] = FieldValue::Text("Hello".to_owned());
values[2] = FieldValue::Actions(vec!["NewTab".to_owned()]);
let item = kind.from_values(&values).unwrap();
assert_eq!(item.label.as_deref(), Some("Hello"));
let kdl = kind.block_kdl(&[item, separator]).unwrap();
let mut config = zellij_utils::input::config::Config::default();
zellij_utils::input::config_blocks::replace_config_blocks(&mut config, &kdl).unwrap();
assert_eq!(config.context_menu.pane.len(), 2);
assert_eq!(config.context_menu.pane[0].label(), Some("Hello"));
assert_eq!(config.context_menu.pane[1], ContextMenuEntry::Separator);
}
}
@@ -0,0 +1,215 @@
use zellij_tile::prelude::*;
use zellij_utils::input::config_blocks::{
slot_colour, styling_from_colours, theme_slots, MULTIPLAYER_COLOURS,
};
pub const LOW_CONTRAST: f64 = 3.0;
pub const GOOD_CONTRAST: f64 = 4.5;
const SYSTEM_COLOURS: [(u8, u8, u8); 16] = [
(0, 0, 0),
(205, 0, 0),
(0, 205, 0),
(205, 205, 0),
(0, 0, 238),
(205, 0, 205),
(0, 205, 205),
(229, 229, 229),
(127, 127, 127),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(92, 92, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
const PAGE_BACKGROUND_STYLES: [&str; 6] = [
"table_title",
"frame_unselected",
"frame_selected",
"frame_highlight",
"exit_code_success",
"exit_code_error",
];
pub fn rgb(colour: PaletteColor) -> (u8, u8, u8) {
match colour {
PaletteColor::Rgb(rgb) => rgb,
PaletteColor::EightBit(index) if index < 16 => SYSTEM_COLOURS[index as usize],
PaletteColor::EightBit(index) if index < 232 => {
let cube = index - 16;
(
CUBE_LEVELS[(cube / 36) as usize],
CUBE_LEVELS[((cube / 6) % 6) as usize],
CUBE_LEVELS[(cube % 6) as usize],
)
},
PaletteColor::EightBit(index) => {
let level = 8 + (index - 232) * 10;
(level, level, level)
},
}
}
fn channel(value: u8) -> f64 {
let value = value as f64 / 255.0;
if value <= 0.03928 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
fn luminance((r, g, b): (u8, u8, u8)) -> f64 {
0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
pub fn contrast(first: PaletteColor, second: PaletteColor) -> f64 {
let (a, b) = (luminance(rgb(first)), luminance(rgb(second)));
let (light, dark) = if a > b { (a, b) } else { (b, a) };
(light + 0.05) / (dark + 0.05)
}
fn blend(from: (u8, u8, u8), to: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
(mix(from.0, to.0), mix(from.1, to.1), mix(from.2, to.2))
}
pub fn suggest(foreground: PaletteColor, background: PaletteColor, target: f64) -> PaletteColor {
let start = rgb(foreground);
let mut best: Option<(f64, (u8, u8, u8))> = None;
for end in [(255, 255, 255), (0, 0, 0)] {
for step in 0..=50 {
let candidate = blend(start, end, step as f64 / 50.0);
let ratio = contrast(PaletteColor::Rgb(candidate), background);
if ratio >= target {
let distance = step as f64;
if best
.map(|(previous, _)| distance < previous)
.unwrap_or(true)
{
best = Some((distance, candidate));
}
break;
}
}
}
let fallback = || {
let white = PaletteColor::Rgb((255, 255, 255));
let black = PaletteColor::Rgb((0, 0, 0));
if contrast(white, background) >= contrast(black, background) {
(255, 255, 255)
} else {
(0, 0, 0)
}
};
PaletteColor::Rgb(best.map(|(_, colour)| colour).unwrap_or_else(fallback))
}
#[derive(Debug, Clone, PartialEq)]
pub struct SlotContrast {
pub ratio: f64,
pub foreground: PaletteColor,
pub background: PaletteColor,
pub background_slot: String,
}
pub fn background_slot(style: &str) -> String {
if PAGE_BACKGROUND_STYLES.contains(&style) {
"text_unselected.background".to_owned()
} else {
format!("{}.background", style)
}
}
pub fn slot_contrasts(colours: &[String]) -> Vec<Option<SlotContrast>> {
let styling = styling_from_colours(colours);
theme_slots()
.iter()
.map(|(style, component)| {
if *style == MULTIPLAYER_COLOURS || *component == "background" {
return None;
}
let foreground = slot_colour(&styling, style, component)?;
let background_slot = background_slot(style);
let (background_style, background_component) = background_slot.split_once('.')?;
let background = slot_colour(&styling, background_style, background_component)?;
Some(SlotContrast {
ratio: contrast(foreground, background),
foreground,
background,
background_slot,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const BLACK: PaletteColor = PaletteColor::Rgb((0, 0, 0));
const WHITE: PaletteColor = PaletteColor::Rgb((255, 255, 255));
#[test]
fn black_on_white_has_the_highest_contrast() {
assert!((contrast(BLACK, WHITE) - 21.0).abs() < 0.01);
assert!((contrast(WHITE, BLACK) - 21.0).abs() < 0.01);
assert!((contrast(WHITE, WHITE) - 1.0).abs() < 0.01);
}
#[test]
fn palette_numbers_are_turned_into_rgb() {
assert_eq!(rgb(PaletteColor::EightBit(1)), (205, 0, 0));
assert_eq!(rgb(PaletteColor::EightBit(16)), (0, 0, 0));
assert_eq!(rgb(PaletteColor::EightBit(196)), (255, 0, 0));
assert_eq!(rgb(PaletteColor::EightBit(231)), (255, 255, 255));
assert_eq!(rgb(PaletteColor::EightBit(232)), (8, 8, 8));
assert_eq!(rgb(PaletteColor::EightBit(255)), (238, 238, 238));
}
#[test]
fn a_suggestion_reaches_the_target_and_keeps_its_direction() {
let background = PaletteColor::Rgb((30, 30, 30));
let dim = PaletteColor::Rgb((60, 70, 80));
let suggested = suggest(dim, background, GOOD_CONTRAST);
assert!(contrast(suggested, background) >= GOOD_CONTRAST);
assert!(rgb(suggested).0 > 60);
let light_background = PaletteColor::Rgb((240, 240, 240));
let pale = PaletteColor::Rgb((200, 210, 220));
let suggested = suggest(pale, light_background, GOOD_CONTRAST);
assert!(contrast(suggested, light_background) >= GOOD_CONTRAST);
assert!(rgb(suggested).0 < 200);
}
#[test]
fn an_impossible_target_falls_back_to_black_or_white() {
let grey = PaletteColor::Rgb((128, 128, 128));
assert_eq!(suggest(grey, grey, 30.0), BLACK);
}
#[test]
fn page_level_styles_are_checked_against_the_text_background() {
assert_eq!(
background_slot("frame_selected"),
"text_unselected.background"
);
assert_eq!(background_slot("table_title"), "text_unselected.background");
assert_eq!(background_slot("list_selected"), "list_selected.background");
}
#[test]
fn backgrounds_and_player_colors_are_not_checked() {
let contrasts = slot_contrasts(&[]);
for ((style, component), found) in theme_slots().iter().zip(contrasts.iter()) {
let skipped = *style == MULTIPLAYER_COLOURS
|| *component == "background"
|| *style == "frame_unselected";
assert_eq!(found.is_none(), skipped, "{}.{}", style, component);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+152 -182
View File
@@ -1,229 +1,199 @@
mod presets;
mod presets_screen;
mod rebind_leaders_screen;
mod action_picker;
mod blocks_screen;
mod contrast;
mod keybindings_screen;
mod keys_screen;
mod list_editor;
mod page;
mod settings;
mod settings_screen;
mod theme_preview;
mod theme_sample;
mod themes_screen;
mod ui_components;
use zellij_tile::prelude::*;
use presets_screen::PresetsScreen;
use rebind_leaders_screen::RebindLeadersScreen;
use ui_components::top_tab_menu;
use keys_screen::KeysScreen;
use settings_screen::SettingsScreen;
use ui_components::set_close_directly;
use std::collections::BTreeMap;
pub static UI_SIZE: usize = 15;
pub static WIDTH_BREAKPOINTS: (usize, usize) = (62, 35);
pub static POSSIBLE_MODIFIERS: [KeyModifier; 4] = [
KeyModifier::Ctrl,
KeyModifier::Alt,
KeyModifier::Super,
KeyModifier::Shift,
];
const SETUP_WIZARD_WIDTH: usize = 72;
const SETUP_WIZARD_HEIGHT: usize = 18;
#[derive(Debug)]
enum Screen {
RebindLeaders(RebindLeadersScreen),
Presets(PresetsScreen),
}
impl Screen {
pub fn reset_state(&mut self, is_setup_wizard: bool) {
if is_setup_wizard {
Screen::new_reset_keybindings_screen(Some(0));
} else {
match self {
Screen::RebindLeaders(r) => {
let notification = r.drain_notification();
*r = Default::default();
r.set_notification(notification);
},
Screen::Presets(r) => {
let notification = r.drain_notification();
*r = Default::default();
r.set_notification(notification);
},
}
}
}
pub fn update_mode_info(&mut self, latest_mode_info: ModeInfo) {
match self {
Screen::RebindLeaders(r) => r.update_mode_info(latest_mode_info),
Screen::Presets(r) => r.update_mode_info(latest_mode_info),
}
}
SetupWizard(KeysScreen),
Settings(SettingsScreen),
}
impl Default for Screen {
fn default() -> Self {
Screen::RebindLeaders(Default::default())
}
}
impl Screen {
pub fn new_reset_keybindings_screen(selected_index: Option<usize>) -> Self {
Screen::Presets(PresetsScreen::new(selected_index))
Screen::SetupWizard(KeysScreen::new(true))
}
}
#[derive(Default)]
struct State {
notification: Option<String>,
is_setup_wizard: bool,
ui_size: usize,
current_screen: Screen,
latest_mode_info: Option<ModeInfo>,
colors: Styling,
}
impl Default for State {
fn default() -> Self {
State {
notification: None,
is_setup_wizard: false,
ui_size: UI_SIZE,
current_screen: Screen::default(),
latest_mode_info: None,
colors: Palette::default().into(),
}
}
screen: Screen,
}
register_plugin!(State);
impl ZellijPlugin for State {
fn load(&mut self, configuration: BTreeMap<String, String>) {
self.is_setup_wizard = configuration
let is_setup_wizard = configuration
.get("is_setup_wizard")
.map(|v| v == "true")
.unwrap_or(false);
subscribe(&[
EventType::Key,
EventType::FailedToWriteConfigToDisk,
EventType::ModeUpdate,
]);
let own_plugin_id = get_plugin_ids().plugin_id;
if self.is_setup_wizard {
self.ui_size = 18;
self.current_screen = Screen::new_reset_keybindings_screen(Some(0));
if is_setup_wizard {
subscribe(&[
EventType::Key,
EventType::Mouse,
EventType::Timer,
EventType::FailedToWriteConfigToDisk,
EventType::AvailableKeybindPresets,
]);
set_close_directly(true);
let mut keys_screen = KeysScreen::new(true);
keys_screen.set_snapshot(&read_config());
self.screen = Screen::SetupWizard(keys_screen);
rename_plugin_pane(own_plugin_id, "First Run Setup Wizard (Step 1/1)");
resize_focused_pane(Resize::Increase);
resize_focused_pane(Resize::Increase);
resize_focused_pane(Resize::Increase);
} else {
subscribe(&[
EventType::Key,
EventType::Mouse,
EventType::Timer,
EventType::FailedToWriteConfigToDisk,
EventType::ConfigWasWrittenToDisk,
EventType::ConfigChangesDropped,
EventType::ModeUpdate,
EventType::AvailableKeybindPresets,
EventType::ConfigFileChangedSinceRead,
EventType::BeforeClose,
EventType::Visible,
EventType::PromptResult,
]);
set_close_directly(false);
self.screen = Screen::Settings(SettingsScreen::new());
rename_plugin_pane(own_plugin_id, "Configuration");
if let Some(coordinates) = FloatingPaneCoordinates::new(
Some("10%".to_owned()),
Some("10%".to_owned()),
Some("80%".to_owned()),
Some("80%".to_owned()),
None,
None,
) {
change_floating_panes_coordinates(vec![(
PaneId::Plugin(own_plugin_id),
coordinates,
)]);
}
}
}
fn update(&mut self, event: Event) -> bool {
let mut should_render = false;
match event {
Event::ModeUpdate(mode_info) => {
self.colors = mode_info.style.colors;
if self.latest_mode_info.as_ref().and_then(|l| l.base_mode) != mode_info.base_mode {
// reset ui state
self.current_screen.reset_state(self.is_setup_wizard);
}
self.latest_mode_info = Some(mode_info.clone());
self.current_screen.update_mode_info(mode_info.clone());
should_render = true;
},
Event::Key(key) => {
if self.notification.is_some() {
self.notification = None;
should_render = true;
} else if key.bare_key == BareKey::Tab
&& key.has_no_modifiers()
&& !self.is_setup_wizard
{
self.switch_screen();
should_render = true;
} else {
should_render = match &mut self.current_screen {
Screen::RebindLeaders(rebind_leaders_screen) => {
rebind_leaders_screen.handle_key(key)
match &mut self.screen {
Screen::SetupWizard(keys_screen) => match event {
Event::AvailableKeybindPresets(presets, errors) => {
keys_screen.set_presets(presets, errors);
true
},
Event::Key(key) => {
if self.notification.is_some() {
self.notification = None;
return true;
}
let should_render = keys_screen.handle_key(key);
if keys_screen.take_needs_refresh() {
keys_screen.set_snapshot(&read_config());
}
should_render
},
Event::Mouse(mouse) => {
let should_render = keys_screen.handle_mouse(mouse);
let refreshed = keys_screen.take_needs_refresh();
if refreshed {
keys_screen.set_snapshot(&read_config());
}
should_render || refreshed
},
Event::Timer(_) => keys_screen.handle_timer(),
Event::FailedToWriteConfigToDisk(config_file_path) => {
self.notification = Some(match config_file_path {
Some(failed_path) => {
format!("Failed to write configuration file: {}", failed_path)
},
Screen::Presets(presets_screen) => {
if self.is_setup_wizard {
presets_screen.handle_setup_wizard_key(key)
} else {
presets_screen.handle_presets_key(key)
}
},
};
}
None => "Failed to write configuration file.".to_owned(),
});
true
},
_ => false,
},
Event::FailedToWriteConfigToDisk(config_file_path) => {
match config_file_path {
Some(failed_path) => {
self.notification = Some(format!(
"Failed to write configuration file: {}",
failed_path
));
},
None => {
self.notification = Some(format!("Failed to write configuration file."));
},
}
should_render = true;
Screen::Settings(settings_screen) => match event {
Event::ModeUpdate(mode_info) => {
settings_screen.update_mode_info(mode_info);
true
},
Event::Key(key) => settings_screen.handle_key(key),
Event::Mouse(mouse) => settings_screen.handle_mouse(mouse),
Event::Timer(_) => settings_screen.handle_timer(),
Event::ConfigWasWrittenToDisk => {
settings_screen.config_written();
true
},
Event::FailedToWriteConfigToDisk(config_file_path) => {
settings_screen.config_write_failed(config_file_path);
true
},
Event::ConfigChangesDropped(dropped) => {
settings_screen.changes_dropped(dropped);
true
},
Event::AvailableKeybindPresets(presets, errors) => {
settings_screen.update_keybind_presets(presets, errors);
true
},
Event::ConfigFileChangedSinceRead => {
settings_screen.config_file_changed_since_read();
true
},
Event::PromptResult(request_id, result) => {
settings_screen.prompt_result(request_id, result)
},
Event::BeforeClose => {
settings_screen.before_close();
false
},
Event::Visible(false) => {
settings_screen.hidden();
true
},
_ => false,
},
_ => (),
};
should_render
}
}
fn render(&mut self, rows: usize, cols: usize) {
let notification = self.notification.clone();
if self.is_in_main_screen() {
top_tab_menu(cols, &self.current_screen, &self.colors);
}
match &mut self.current_screen {
Screen::RebindLeaders(rebind_leaders_screen) => {
rebind_leaders_screen.render(rows, cols, self.ui_size, notification);
},
Screen::Presets(presets_screen) => {
if self.is_setup_wizard {
presets_screen.render_setup_wizard_screen(
rows,
cols,
self.ui_size,
notification,
)
} else {
presets_screen.render_reset_keybindings_screen(
rows,
cols,
self.ui_size,
notification,
)
page::clear_overlays();
match &mut self.screen {
Screen::SetupWizard(keys_screen) => {
if self.notification.is_some() {
keys_screen.set_notice(self.notification.clone());
}
let width = SETUP_WIZARD_WIDTH.min(cols);
let height = SETUP_WIZARD_HEIGHT.min(rows);
let x = cols.saturating_sub(width) / 2;
let y = rows.saturating_sub(height) / 2;
keys_screen.render(x, y, width, height);
keys_screen.render_overlays(rows, cols);
},
};
}
}
impl State {
fn is_in_main_screen(&self) -> bool {
match &self.current_screen {
Screen::RebindLeaders(_) => true,
Screen::Presets(presets_screen) => {
if self.is_setup_wizard || presets_screen.rebinding_leaders() {
false
} else {
true
}
},
}
}
fn switch_screen(&mut self) {
match &self.current_screen {
Screen::RebindLeaders(_) => {
self.current_screen = Screen::Presets(Default::default());
},
Screen::Presets(_) => {
self.current_screen = Screen::RebindLeaders(
RebindLeadersScreen::default().with_mode_info(self.latest_mode_info.clone()),
);
},
}
if let Some(mode_info) = &self.latest_mode_info {
self.current_screen.update_mode_info(mode_info.clone());
Screen::Settings(settings_screen) => settings_screen.render(rows, cols),
}
}
}
+861
View File
@@ -0,0 +1,861 @@
use std::cell::{Cell, RefCell};
use zellij_tile::prelude::*;
thread_local! {
static OVERLAYS: RefCell<Vec<Rect>> = RefCell::new(vec![]);
static CONFIRM_REMOVALS: Cell<bool> = Cell::new(true);
}
pub const SHORT_LABEL_WIDTH: usize = 8;
pub const SHORT_FIELD_WIDTH: usize = 34;
pub const BESIDE_SHORT_FIELD: usize = SHORT_FIELD_WIDTH + 2;
pub const DIM: usize = usize::MAX;
pub fn confirm_removals() -> bool {
CONFIRM_REMOVALS.with(|confirm| confirm.get())
}
pub const DONT_ASK_AGAIN: &str = "dont_ask_again";
pub fn removal_prompt(question: impl Into<StyledText>) -> PromptRequest {
PromptRequest::form(vec![FormField::toggle(DONT_ASK_AGAIN, "Don't ask again")])
.title("Confirm")
.message(question)
.buttons("Delete", "Cancel")
}
pub fn removal_confirmed(result: &PromptResult) -> bool {
match result {
PromptResult::Answered(PromptValue::Form(values)) => {
if values.get(DONT_ASK_AGAIN) == Some(&PromptValue::Bool(true)) {
CONFIRM_REMOVALS.with(|confirm| confirm.set(false));
}
true
},
_ => false,
}
}
pub fn clear_overlays() {
OVERLAYS.with(|overlays| overlays.borrow_mut().clear());
}
pub fn note_dropdown(dropdown: &Dropdown) {
if dropdown.is_open() {
if let Some(area) = dropdown.list_area() {
OVERLAYS.with(|overlays| overlays.borrow_mut().push(area));
}
}
}
pub fn note_overlay(area: Rect) {
OVERLAYS.with(|overlays| overlays.borrow_mut().push(area));
}
pub fn render_frame(title: &str, x: usize, y: usize, width: usize, height: usize) {
let frame = ConfirmDialog::new(title, "")
.buttons(Vec::<String>::new())
.width(width);
let (_, base_height) = frame.size_for(width);
let mut frame = frame
.footer_rows(height.saturating_sub(base_height))
.opened();
frame.render(x, y, width, height);
}
pub fn note_group<K: Clone + PartialEq>(group: &FocusGroup<K>) {
for key in group.keys() {
if let Some(dropdown) = group.dropdown(&key) {
note_dropdown(dropdown);
}
}
}
pub fn under_overlay(line: isize, column: usize) -> bool {
OVERLAYS.with(|overlays| {
overlays
.borrow()
.iter()
.any(|area| area.contains(line, column))
})
}
pub fn changed_by<T: std::fmt::Debug, R>(
value: &mut T,
change: impl FnOnce(&mut T) -> R,
) -> (R, bool) {
let before = format!("{:?}", value);
let result = change(value);
let changed = format!("{:?}", value) != before;
(result, changed)
}
pub fn outside_overlays(mouse: Mouse) -> Mouse {
match mouse {
Mouse::Hover(line, column) if under_overlay(line, column) => Mouse::Hover(-1, 0),
other => other,
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Effect {
Reconfigure(String),
ReplaceBlocks(String),
Revert(SettingKey),
ResetKeys(Vec<(InputMode, KeyWithModifier)>),
SwitchMode(InputMode),
Refresh,
}
pub fn run_effects(effects: Vec<Effect>) -> bool {
let mut changed_config = false;
for effect in effects {
match effect {
Effect::Reconfigure(kdl) => {
reconfigure(kdl, false);
changed_config = true;
},
Effect::ReplaceBlocks(kdl) => {
replace_config_blocks(kdl);
changed_config = true;
},
Effect::Revert(key) => {
revert_config(Some(key));
changed_config = true;
},
Effect::ResetKeys(keys) => {
reset_keys(keys, false);
changed_config = true;
},
Effect::SwitchMode(mode) => switch_to_input_mode(&mode),
Effect::Refresh => changed_config = true,
}
}
changed_config
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageResponse {
Handled,
NotHandled,
LeaveToMenu,
LeaveUp,
Close,
}
pub trait Page {
fn set_snapshot(&mut self, snapshot: &ConfigSnapshot);
fn set_mode_info(&mut self, _mode_info: &ModeInfo) {}
fn handle_key(&mut self, key: &KeyWithModifier) -> PageResponse;
fn handle_mouse(&mut self, mouse: Mouse) -> PageResponse;
fn handle_timer(&mut self) -> bool {
false
}
fn render(&mut self, x: usize, y: usize, width: usize, height: usize);
fn render_overlays(&mut self, _rows: usize, _cols: usize) {}
fn captures_keys(&self) -> bool;
fn hints(&self) -> Vec<(&'static str, &'static str)>;
fn take_effects(&mut self) -> Vec<Effect>;
fn take_notice(&mut self) -> Option<String>;
fn leave(&mut self) {}
fn set_focused(&mut self, _focused: bool) {}
fn full_screen(&self) -> bool {
false
}
fn header_file(&self) -> Option<String> {
None
}
fn window_title(&self) -> Option<String> {
None
}
fn header_hints(&self) -> Option<Vec<(&'static str, &'static str)>> {
None
}
fn prompt_result(&mut self, _request_id: u64, _result: &PromptResult) -> bool {
false
}
}
pub fn is_plain(key: &KeyWithModifier, bare_key: BareKey) -> bool {
key.bare_key == bare_key && key.has_no_modifiers()
}
pub fn is_move_key(key: &KeyWithModifier, bare_key: BareKey) -> bool {
key.bare_key == bare_key
&& (key.has_modifiers(&[KeyModifier::Alt]) || key.has_modifiers(&[KeyModifier::Shift]))
}
pub fn is_shift_tab(key: &KeyWithModifier) -> bool {
key.bare_key == BareKey::Tab && key.has_modifiers(&[KeyModifier::Shift])
}
pub fn typed(key: &KeyWithModifier, character: char) -> bool {
key.bare_key == BareKey::Char(character)
&& (key.has_no_modifiers() || key.has_modifiers(&[KeyModifier::Shift]))
}
pub fn truncate(text: &str, width: usize) -> String {
if text.chars().count() <= width {
text.to_owned()
} else if width == 0 {
String::new()
} else {
let mut truncated: String = text.chars().take(width.saturating_sub(1)).collect();
truncated.push('…');
truncated
}
}
pub fn pad(text: &str, width: usize) -> String {
let text = truncate(text, width);
let length = text.chars().count();
format!("{}{}", text, " ".repeat(width.saturating_sub(length)))
}
pub fn short_action_text(text: &str) -> String {
let mut short = String::new();
let mut previous_was_space = false;
for character in text.chars() {
let character = if character == '\n' { ' ' } else { character };
if character == ' ' {
if previous_was_space {
continue;
}
previous_was_space = true;
} else {
previous_was_space = false;
}
short.push(character);
}
short.trim().trim_end_matches(';').trim().to_owned()
}
pub fn mode_label(value: &str) -> Option<&'static str> {
const LABELS: [&str; 13] = [
"Normal",
"Locked",
"Pane",
"Tab",
"Resize",
"Move",
"Scroll",
"Search",
"EnterSearch",
"RenameTab",
"RenamePane",
"Session",
"Tmux",
];
LABELS
.iter()
.copied()
.find(|label| label.eq_ignore_ascii_case(value))
}
pub fn action_display_text(text: &str) -> String {
let short = short_action_text(text);
let Some((name, rest)) = short.split_once(' ') else {
return short;
};
if !name.contains("Mode") {
return short;
}
let rest = rest
.split(' ')
.map(|word| {
let inner = word.trim_matches('"');
match mode_label(inner) {
Some(label) if word.starts_with('"') && word.ends_with('"') && word.len() > 1 => {
format!("\"{}\"", label)
},
_ => word.to_owned(),
}
})
.collect::<Vec<_>>()
.join(" ");
format!("{} {}", name, rest)
}
pub fn action_argument_range(text: &str) -> Option<std::ops::Range<usize>> {
let space = text.find(' ')?;
let start = text[..space].chars().count() + 1;
Some(start..text.chars().count())
}
pub fn actions_summary(actions: &[String]) -> String {
actions
.iter()
.map(|action| action_display_text(action))
.collect::<Vec<_>>()
.join("; ")
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RowLook {
pub selected: bool,
pub hovered: bool,
}
impl RowLook {
pub fn new(selected: bool, hovered: bool) -> Self {
RowLook { selected, hovered }
}
pub fn apply(&self, text: Text) -> Text {
if self.selected {
text.selected()
} else if self.hovered {
text.selected().unbold_all()
} else {
text
}
}
}
pub type ColumnStyle = (usize, usize, std::ops::Range<usize>);
pub const ROW_INDENT: usize = 2;
pub const COLUMN_GAP: usize = 2;
const MIN_COLUMN_WIDTH: usize = 8;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ColumnLayout {
pub widths: Vec<usize>,
pub marker_width: usize,
pub marker_column: usize,
pub indent: usize,
}
impl ColumnLayout {
pub fn new<'a>(
rows: impl IntoIterator<Item = (&'a [String], &'a str)>,
available: usize,
) -> Self {
let mut widths: Vec<usize> = vec![];
let mut marker_width = 0;
for (columns, marker) in rows {
if widths.len() < columns.len() {
widths.resize(columns.len(), 0);
}
for (index, column) in columns.iter().enumerate() {
widths[index] = widths[index].max(column.chars().count());
}
marker_width = marker_width.max(marker.chars().count());
}
let shown = widths.iter().filter(|width| **width > 0).count();
let fixed = ROW_INDENT
+ COLUMN_GAP * shown.saturating_sub(1)
+ if marker_width > 0 {
COLUMN_GAP + marker_width
} else {
0
};
let mut total = widths.iter().sum::<usize>() + fixed;
for index in (0..widths.len()).rev() {
if total <= available {
break;
}
let reducible = widths[index].saturating_sub(MIN_COLUMN_WIDTH);
let cut = reducible.min(total - available);
widths[index] -= cut;
total -= cut;
}
ColumnLayout {
widths,
marker_width,
marker_column: 0,
indent: ROW_INDENT,
}
}
pub fn fit(mut self, available: usize, order: &[usize]) -> Self {
let shown = self.widths.iter().filter(|width| **width > 0).count();
let fixed = self.indent
+ COLUMN_GAP * shown.saturating_sub(1)
+ if self.marker_width > 0 {
COLUMN_GAP + self.marker_width
} else {
0
};
let mut total = self.widths.iter().sum::<usize>() + fixed;
for index in order {
if total <= available {
break;
}
let Some(width) = self.widths.get_mut(*index) else {
continue;
};
let reducible = width.saturating_sub(MIN_COLUMN_WIDTH);
let cut = reducible.min(total - available);
*width -= cut;
total -= cut;
}
self
}
pub fn with_indent(mut self, indent: usize) -> Self {
self.indent = indent;
self
}
pub fn text_width(&self) -> usize {
let shown: Vec<usize> = self.widths.iter().copied().filter(|w| *w > 0).collect();
self.indent + shown.iter().sum::<usize>() + COLUMN_GAP * shown.len().saturating_sub(1)
}
pub fn align_markers(layouts: &mut [ColumnLayout]) {
let column = layouts
.iter()
.map(|layout| layout.text_width())
.max()
.unwrap_or(0);
let marker_width = layouts
.iter()
.map(|layout| layout.marker_width)
.max()
.unwrap_or(0);
for layout in layouts.iter_mut() {
layout.marker_column = column;
layout.marker_width = marker_width;
}
}
pub fn natural_width(&self, min_marker_width: usize) -> usize {
let marker_width = self.marker_width.max(min_marker_width);
let text_end = self.text_width().max(self.marker_column);
if marker_width > 0 {
text_end + COLUMN_GAP + marker_width
} else {
text_end
}
}
fn column_start(&self, column: usize) -> usize {
let mut start = self.indent;
for (index, width) in self.widths.iter().enumerate() {
if *width == 0 {
continue;
}
if index == column {
return start;
}
start += width + COLUMN_GAP;
}
start
}
pub fn line(&self, columns: &[String], marker: &str) -> (String, usize) {
let mut line = " ".repeat(self.indent);
let mut first = true;
for (index, width) in self.widths.iter().enumerate() {
if *width == 0 {
continue;
}
if !first {
line.push_str(&" ".repeat(COLUMN_GAP));
}
first = false;
let column = columns.get(index).map(|c| c.as_str()).unwrap_or("");
line.push_str(&pad(column, *width));
}
let text_end = line.chars().count();
if self.marker_column > text_end {
line.push_str(&" ".repeat(self.marker_column - text_end));
}
let marker_start = if self.marker_width > 0 {
line.push_str(&" ".repeat(COLUMN_GAP));
let start = line.chars().count();
line.push_str(&pad(marker, self.marker_width));
start
} else {
line.chars().count()
};
(line, marker_start)
}
pub fn print(
&self,
columns: &[String],
marker: &str,
x: usize,
y: usize,
width: usize,
look: RowLook,
) {
self.print_styled(columns, marker, x, y, width, look, &[]);
}
pub fn print_styled(
&self,
columns: &[String],
marker: &str,
x: usize,
y: usize,
width: usize,
look: RowLook,
styles: &[ColumnStyle],
) {
let (line, marker_start) = self.line(columns, marker);
let line = pad(&line, width);
let mut rendered = Text::from(&line);
if marker_start < width {
let marker_end = |label: &str| (marker_start + label.chars().count()).min(width);
if marker.starts_with('●') {
rendered = rendered.color_range(1, marker_start..marker_end("● unsaved"));
} else if marker.starts_with('○') {
rendered = rendered.dim_range(marker_start..marker_end("○ default"));
}
}
for (column, color, range) in styles {
let dim = *color == DIM;
let column_width = self.widths.get(*column).copied().unwrap_or(0);
if column_width == 0 {
continue;
}
let start = self.column_start(*column);
let from = (start + range.start.min(column_width)).min(width);
let to = (start + range.end.min(column_width)).min(width);
if from < to && dim {
rendered = rendered.dim_range(from..to);
} else if from < to {
rendered = rendered.color_range(*color, from..to);
}
}
print_text_with_coordinates(look.apply(rendered), x, y, None, None);
}
}
pub fn print_heading(text: &str, x: usize, y: usize, width: usize) {
print_text_with_coordinates(
Text::from(truncate(text, width)).color_all(2),
x,
y,
None,
None,
);
}
pub fn print_dim(text: &str, x: usize, y: usize, width: usize) {
print_text_with_coordinates(
Text::from(truncate(text, width)).dim_all(),
x,
y,
None,
None,
);
}
pub fn markers(unsaved: bool, is_default: bool, restart_only: bool) -> String {
let mut marker = String::new();
if unsaved {
marker.push_str("● unsaved");
} else if is_default {
marker.push_str("○ default");
}
if restart_only {
if !marker.is_empty() {
marker.push(' ');
}
marker.push_str("⟳ restart");
}
marker
}
#[derive(Debug, Default, Clone)]
pub struct RowScroll {
view: ScrollView,
pub hovered: Option<usize>,
follow: bool,
area: Option<(usize, usize, usize, usize)>,
on_top: bool,
}
impl RowScroll {
pub fn on_top() -> Self {
RowScroll {
on_top: true,
..RowScroll::default()
}
}
pub fn follow(&mut self) {
self.follow = true;
}
pub fn reset(&mut self) {
self.view.set_offset(0);
}
pub fn layout(
&mut self,
x: usize,
y: usize,
width: usize,
height: usize,
total: usize,
selected: Option<usize>,
) -> std::ops::Range<usize> {
let height = height.max(1);
self.area = Some((x, y, width, height));
self.view.set_total_rows(total);
let range = self.view.layout(x, y, width, height);
if self.follow {
if let Some(selected) = selected {
self.view.ensure_visible(selected);
}
self.follow = false;
}
self.view.render_indicators();
let _ = range;
self.view.visible_range()
}
pub fn fitted_width(
total: usize,
height: usize,
content_width: usize,
available: usize,
) -> usize {
let height = height.max(1);
let width = if total > height && height >= 2 {
content_width + 7 + total.to_string().len() + 1
} else {
content_width
};
width.min(available)
}
pub fn row_width(&self) -> usize {
self.area
.map(|(_, _, width, _)| {
let gutter = self.view.gutter_width();
if gutter > 0 {
width.saturating_sub(gutter + 1)
} else {
width
}
})
.unwrap_or(0)
}
pub fn clear(&mut self) {
self.area = None;
self.hovered = None;
self.view.clear_area();
}
pub fn is_hovered(&self, row: usize) -> bool {
self.hovered == Some(row)
}
pub fn hover(&mut self, mouse: &Mouse) -> Option<bool> {
match mouse {
Mouse::Hover(line, column) => {
let indicator_changed = self.view.handle_mouse(*mouse) == UiResponse::Consumed;
let hovered = self.row_at(*line, *column);
let changed = hovered != self.hovered || indicator_changed;
self.hovered = hovered;
Some(changed)
},
_ => None,
}
}
pub fn screen_row(&self, row: usize) -> Option<usize> {
self.area?;
self.view.screen_row(row)
}
pub fn contains(&self, line: isize, column: usize) -> bool {
match self.area {
Some((x, y, _, height)) => {
line >= y as isize
&& (line as usize) < y + height
&& column >= x
&& column < x + self.row_width()
},
None => false,
}
}
pub fn row_at(&self, line: isize, column: usize) -> Option<usize> {
if !self.contains(line, column) || (!self.on_top && under_overlay(line, column)) {
return None;
}
self.view.row_at(line)
}
pub fn scroll_by(&mut self, delta: isize) -> bool {
self.view.scroll_by(delta)
}
pub fn is_dragging(&self) -> bool {
self.view.is_dragging()
}
pub fn handle_wheel(&mut self, mouse: &Mouse) -> Option<bool> {
match mouse {
Mouse::ScrollUp(lines) => Some(self.scroll_by(-((*lines).max(1) as isize))),
Mouse::ScrollDown(lines) => Some(self.scroll_by((*lines).max(1) as isize)),
Mouse::LeftClick(..) | Mouse::Hold(..) | Mouse::Release(..) => {
match self.view.handle_mouse(*mouse) {
UiResponse::NotHandled => None,
UiResponse::Changed(_) => Some(true),
_ => Some(false),
}
},
_ => None,
}
}
}
pub struct ButtonRow {
buttons: Vec<Button>,
}
impl ButtonRow {
pub fn new(labels: &[&str]) -> Self {
ButtonRow {
buttons: labels.iter().map(|label| Button::new(*label)).collect(),
}
}
pub fn render(&mut self, x: usize, y: usize, width: usize) {
let mut column = x;
for button in self.buttons.iter_mut() {
let button_width = button.natural_width();
if column + button_width <= x + width {
button.render(column, y);
column += button_width + 1;
} else {
button.clear_area();
}
}
}
pub fn clear(&mut self) {
for button in self.buttons.iter_mut() {
button.clear_area();
}
}
pub fn handle_mouse_with_hover(&mut self, mouse: Mouse) -> (Option<usize>, bool) {
let mouse = outside_overlays(mouse);
let mut activated = None;
let mut hover_changed = false;
for (index, button) in self.buttons.iter_mut().enumerate() {
let was_hovered = button.is_hovered();
if let UiResponse::Activated = button.handle_mouse(mouse) {
activated = Some(index);
}
hover_changed |= was_hovered != button.is_hovered();
}
(activated, hover_changed)
}
pub fn handle_timer(&mut self) -> bool {
let mut changed = false;
for button in self.buttons.iter_mut() {
changed |= button.handle_timer();
}
changed
}
}
pub fn is_click(mouse: &Mouse) -> Option<(isize, usize)> {
match mouse {
Mouse::LeftClick(line, column) => Some((*line, *column)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(columns: &[&str]) -> Vec<String> {
columns.iter().map(|c| c.to_string()).collect()
}
#[test]
fn columns_take_the_width_of_their_longest_entry() {
let rows = vec![
(
strings(&["New pane", "Alt n", "NewPane"]),
"○ default".to_owned(),
),
(
strings(&["Fullscreen", "", "ToggleFocusFullscreen"]),
"● unsaved".to_owned(),
),
(strings(&["Hi", "", "NewTab"]), String::new()),
];
let layout = ColumnLayout::new(rows.iter().map(|(c, m)| (c.as_slice(), m.as_str())), 200);
assert_eq!(layout.widths, vec![10, 5, 21]);
assert_eq!(layout.marker_width, 9);
let (first, marker_start) = layout.line(&rows[0].0, &rows[0].1);
let (second, second_marker_start) = layout.line(&rows[1].0, &rows[1].1);
assert_eq!(marker_start, second_marker_start);
assert_eq!(
first,
" New pane Alt n NewPane ○ default"
);
assert_eq!(
second,
" Fullscreen ToggleFocusFullscreen ● unsaved"
);
}
#[test]
fn a_narrow_list_shrinks_the_last_columns_first() {
let rows = vec![(
strings(&["name", "a very long value that does not fit"]),
String::new(),
)];
let layout = ColumnLayout::new(rows.iter().map(|(c, m)| (c.as_slice(), m.as_str())), 30);
assert_eq!(layout.widths, vec![4, 22]);
assert_eq!(layout.line(&rows[0].0, "").0.chars().count(), 30);
}
#[test]
fn an_empty_column_takes_no_space() {
let rows = vec![(strings(&["A", ""]), String::new())];
let layout = ColumnLayout::new(rows.iter().map(|(c, m)| (c.as_slice(), m.as_str())), 80);
assert_eq!(layout.line(&rows[0].0, "").0, " A");
}
#[test]
fn markers_of_different_lists_can_share_one_column() {
let first = vec![(strings(&["a", "1"]), "○ default".to_owned())];
let second = vec![(strings(&["longer name", "2"]), "● unsaved".to_owned())];
let mut layouts = vec![
ColumnLayout::new(first.iter().map(|(c, m)| (c.as_slice(), m.as_str())), 80),
ColumnLayout::new(second.iter().map(|(c, m)| (c.as_slice(), m.as_str())), 80),
];
ColumnLayout::align_markers(&mut layouts);
assert_eq!(
layouts[0].line(&first[0].0, &first[0].1).1,
layouts[1].line(&second[0].0, &second[0].1).1
);
}
#[test]
fn mode_names_are_capitalized_only_for_display() {
assert_eq!(
action_display_text("SwitchToMode \"tmux\";"),
"SwitchToMode \"Tmux\""
);
assert_eq!(
action_display_text("SwitchToMode \"entersearch\""),
"SwitchToMode \"EnterSearch\""
);
assert_eq!(action_display_text("NewTab \"tmux\""), "NewTab \"tmux\"");
assert_eq!(mode_label("renamepane"), Some("RenamePane"));
assert_eq!(mode_label("nothing"), None);
}
#[test]
fn a_fitted_layout_shrinks_the_columns_in_the_given_order() {
let rows = vec![(
strings(&["Label of the item", "SomeVeryLongActionName", "Ctrl p"]),
String::new(),
)];
let layout = ColumnLayout::new(
rows.iter()
.map(|(columns, marker)| (columns.as_slice(), marker.as_str())),
usize::MAX,
)
.with_indent(0)
.fit(40, &[1, 0, 2]);
assert_eq!(layout.widths[2], 6);
assert_eq!(layout.widths[0], 17);
assert!(layout.text_width() <= 40);
}
#[test]
fn items_move_with_alt_or_shift_arrows() {
let down = KeyWithModifier::new(BareKey::Down);
assert!(is_move_key(
&down.clone().with_shift_modifier(),
BareKey::Down
));
assert!(is_move_key(
&down.clone().with_alt_modifier(),
BareKey::Down
));
assert!(!is_move_key(&down, BareKey::Down));
}
}
File diff suppressed because it is too large Load Diff
@@ -1,879 +0,0 @@
use zellij_tile::prelude::*;
use crate::ui_components::info_line;
use crate::rebind_leaders_screen::RebindLeadersScreen;
use std::collections::BTreeSet;
use crate::presets::{default_keybinds, unlock_first_keybinds};
#[derive(Debug)]
pub struct PresetsScreen {
selected_index: Option<usize>,
latest_mode_info: Option<ModeInfo>,
notification: Option<String>,
primary_modifier: BTreeSet<KeyModifier>,
secondary_modifier: BTreeSet<KeyModifier>,
rebind_leaders_screen: Option<RebindLeadersScreen>,
}
impl Default for PresetsScreen {
fn default() -> Self {
let mut primary_modifier = BTreeSet::new();
primary_modifier.insert(KeyModifier::Ctrl);
let mut secondary_modifier = BTreeSet::new();
secondary_modifier.insert(KeyModifier::Alt);
PresetsScreen {
primary_modifier,
secondary_modifier,
selected_index: None,
latest_mode_info: None,
notification: None,
rebind_leaders_screen: None,
}
}
}
impl PresetsScreen {
pub fn new(selected_index: Option<usize>) -> Self {
PresetsScreen {
selected_index,
..Default::default()
}
}
pub fn rebinding_leaders(&self) -> bool {
self.rebind_leaders_screen.is_some()
}
pub fn handle_presets_key(&mut self, key: KeyWithModifier) -> bool {
if let Some(rebind_leaders_screen) = self.rebind_leaders_screen.as_mut() {
match key.bare_key {
BareKey::Esc if key.has_no_modifiers() => {
// consume screen without applying its modifiers
drop(self.rebind_leaders_screen.take());
return true;
},
BareKey::Enter if key.has_no_modifiers() => {
// consume screen and apply its modifiers
let (primary_modifier, secondary_modifier) =
rebind_leaders_screen.primary_and_secondary_modifiers();
self.primary_modifier = primary_modifier;
self.secondary_modifier = secondary_modifier;
drop(self.rebind_leaders_screen.take());
return true;
},
_ => {
return rebind_leaders_screen.handle_key(key);
},
}
}
let mut should_render = false;
if self.notification.is_some() {
self.notification = None;
should_render = true;
} else if key.bare_key == BareKey::Down && key.has_no_modifiers() {
self.move_selected_index_down();
should_render = true;
} else if key.bare_key == BareKey::Up && key.has_no_modifiers() {
self.move_selected_index_up();
should_render = true;
} else if key.bare_key == BareKey::Enter && key.has_no_modifiers() {
if let Some(selected_index) = self.selected_index.take() {
let write_to_disk = false;
self.reconfigure(selected_index, write_to_disk);
self.notification = Some("Configuration applied to current session.".to_owned());
} else {
self.reset_selected_index();
}
should_render = true;
} else if key.bare_key == BareKey::Char('a') && key.has_modifiers(&[KeyModifier::Ctrl]) {
if let Some(selected_index) = self.take_selected_index() {
let write_to_disk = true;
self.reconfigure(selected_index, write_to_disk);
self.notification = Some("Configuration applied and saved to disk.".to_owned());
should_render = true;
}
} else if key.bare_key == BareKey::Char('l') && key.has_no_modifiers() {
// for the time being this screen has been disabled because it was deemed too confusing
// and its use-cases are very limited (it's possible to achieve the same results by
// applying a preset and then rebinding the leader keys)
//
// the code is left here in case someone feels strongly about implementing this on
// their own, and because at the time of writing I'm a little ambiguous about this
// decision. At some point it should be refactored away
// self.rebind_leaders_screen = Some(
// RebindLeadersScreen::default()
// .with_rebinding_for_presets()
// .with_mode_info(self.latest_mode_info.clone()),
// );
// should_render = true;
} else if (key.bare_key == BareKey::Esc && key.has_no_modifiers())
|| key.is_key_with_ctrl_modifier(BareKey::Char('c'))
{
close_self();
should_render = true;
}
should_render
}
pub fn handle_setup_wizard_key(&mut self, key: KeyWithModifier) -> bool {
if let Some(rebind_leaders_screen) = self.rebind_leaders_screen.as_mut() {
match key.bare_key {
BareKey::Esc if key.has_no_modifiers() => {
// consume screen without applying its modifiers
drop(self.rebind_leaders_screen.take());
return true;
},
BareKey::Enter if key.has_no_modifiers() => {
// consume screen and apply its modifiers
let (primary_modifier, secondary_modifier) =
rebind_leaders_screen.primary_and_secondary_modifiers();
self.primary_modifier = primary_modifier;
self.secondary_modifier = secondary_modifier;
drop(self.rebind_leaders_screen.take());
return true;
},
_ => {
return rebind_leaders_screen.handle_key(key);
},
}
}
let mut should_render = false;
if self.notification.is_some() {
self.notification = None;
should_render = true;
} else if key.bare_key == BareKey::Down && key.has_no_modifiers() {
self.move_selected_index_down();
should_render = true;
} else if key.bare_key == BareKey::Up && key.has_no_modifiers() {
self.move_selected_index_up();
should_render = true;
} else if key.bare_key == BareKey::Enter && key.has_no_modifiers() {
if let Some(selected_index) = self.take_selected_index() {
let write_to_disk = true;
self.reconfigure(selected_index, write_to_disk);
close_self();
} else {
self.reset_selected_index();
should_render = true;
}
} else if key.bare_key == BareKey::Char('l') && key.has_no_modifiers() {
// for the time being this screen has been disabled because it was deemed too confusing
// and its use-cases are very limited (it's possible to achieve the same results by
// applying a preset and then rebinding the leader keys)
//
// the code is left here in case someone feels strongly about implementing this on
// their own, and because at the time of writing I'm a little ambiguous about this
// decision. At some point it should be refactored away
// self.rebind_leaders_screen =
// Some(RebindLeadersScreen::default().with_rebinding_for_presets());
// should_render = true;
} else if (key.bare_key == BareKey::Esc && key.has_no_modifiers())
|| key.is_key_with_ctrl_modifier(BareKey::Char('c'))
{
close_self();
should_render = true;
}
should_render
}
pub fn update_mode_info(&mut self, mode_info: ModeInfo) {
if let Some(rebind_leaders_screen) = self.rebind_leaders_screen.as_mut() {
rebind_leaders_screen.update_mode_info(mode_info.clone());
}
self.latest_mode_info = Some(mode_info);
}
pub fn move_selected_index_down(&mut self) {
if self.selected_index.is_none() {
self.selected_index = Some(0);
} else if self.selected_index < Some(1) {
self.selected_index = Some(1);
} else {
self.selected_index = None;
}
}
pub fn move_selected_index_up(&mut self) {
if self.selected_index.is_none() {
self.selected_index = Some(1);
} else if self.selected_index == Some(1) {
self.selected_index = Some(0);
} else {
self.selected_index = None;
}
}
pub fn take_selected_index(&mut self) -> Option<usize> {
self.selected_index.take()
}
pub fn reset_selected_index(&mut self) {
self.selected_index = Some(0);
}
pub fn drain_notification(&mut self) -> Option<String> {
self.notification.take()
}
pub fn set_notification(&mut self, notification: Option<String>) {
self.notification = notification;
}
fn reconfigure(&self, selected: usize, write_to_disk: bool) {
if selected == 0 {
// TODO: these should be part of a "transaction" when they are
// implemented
reconfigure(
default_keybinds(
self.primary_modifier
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(" "),
self.secondary_modifier
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(" "),
),
write_to_disk,
);
switch_to_input_mode(&InputMode::Normal);
} else if selected == 1 {
// TODO: these should be part of a "transaction" when they are
// implemented
reconfigure(
unlock_first_keybinds(
self.primary_modifier
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(" "),
self.secondary_modifier
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(" "),
),
write_to_disk,
);
switch_to_input_mode(&InputMode::Locked);
}
}
pub fn render_setup_wizard_screen(
&mut self,
rows: usize,
cols: usize,
ui_size: usize,
notification: Option<String>,
) {
if let Some(rebind_leaders_screen) = self.rebind_leaders_screen.as_mut() {
return rebind_leaders_screen.render(rows, cols, ui_size, notification);
}
let primary_modifier_key_text = self.primary_modifier_text();
let secondary_modifier_key_text = self.secondary_modifier_text();
self.render_setup_wizard_title(rows, cols, &primary_modifier_key_text, ui_size);
self.render_first_bulletin(rows + 8, cols, &primary_modifier_key_text, ui_size);
self.render_second_bulletin(rows + 8, cols, &primary_modifier_key_text, ui_size);
self.render_leader_keys_indication(
rows + 8,
cols,
&primary_modifier_key_text,
&secondary_modifier_key_text,
ui_size,
);
info_line(
rows + 8,
cols,
ui_size,
notification,
self.warning_text(cols),
Some(self.main_screen_widths(&primary_modifier_key_text)),
);
// self.render_info_line(rows + 8, cols);
self.render_help_text_setup_wizard(rows + 8, cols);
}
pub fn render_reset_keybindings_screen(
&mut self,
rows: usize,
cols: usize,
ui_size: usize,
notification: Option<String>,
) {
if let Some(rebind_leaders_screen) = self.rebind_leaders_screen.as_mut() {
return rebind_leaders_screen.render(rows, cols, ui_size, notification);
}
let primary_modifier_key_text = self.primary_modifier_text();
let secondary_modifier_key_text = self.secondary_modifier_text();
self.render_override_title(rows, cols, &primary_modifier_key_text, ui_size);
self.render_first_bulletin(rows, cols, &primary_modifier_key_text, ui_size);
self.render_second_bulletin(rows, cols, &primary_modifier_key_text, ui_size);
self.render_leader_keys_indication(
rows,
cols,
&primary_modifier_key_text,
&secondary_modifier_key_text,
ui_size,
);
let notification = notification.clone().or_else(|| self.notification.clone());
let warning_text = self.warning_text(cols);
info_line(
rows,
cols,
ui_size,
notification,
warning_text,
Some(self.main_screen_widths(&primary_modifier_key_text)),
);
self.render_help_text_main(rows, cols);
}
fn primary_modifier_text(&self) -> String {
if self.primary_modifier.is_empty() {
"<UNBOUND>".to_owned()
} else {
self.primary_modifier
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("-")
}
}
fn secondary_modifier_text(&self) -> String {
if self.secondary_modifier.is_empty() {
"<UNBOUND>".to_owned()
} else {
self.secondary_modifier
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("-")
}
}
fn render_setup_wizard_title(
&self,
rows: usize,
cols: usize,
primary_modifier_key_text: &str,
ui_size: usize,
) {
let widths = self.main_screen_widths(primary_modifier_key_text);
if cols >= widths.0 {
let title_text_1 = "Hi there! How would you like to interact with Zellij?";
let title_text_2 = "Not sure? Press <ENTER> to choose Default.";
let title_text_3 = "Everything can always be changed later.";
let title_text_4 = "Tips appear on screen - you don't need to remember anything.";
let left_padding = cols.saturating_sub(widths.0) / 2;
let first_row_coords = (rows.saturating_sub(ui_size) / 2).saturating_sub(1);
print_text_with_coordinates(
Text::from(title_text_1).color_range(2, ..),
left_padding,
first_row_coords,
None,
None,
);
print_text_with_coordinates(
Text::from(title_text_2)
.color_range(0, ..10)
.color_range(2, 16..23)
.color_range(1, 34..41),
left_padding,
first_row_coords + 2,
None,
None,
);
print_text_with_coordinates(
Text::from(title_text_3),
left_padding,
first_row_coords + 4,
None,
None,
);
print_text_with_coordinates(
Text::from(title_text_4),
left_padding,
first_row_coords + 5,
None,
None,
);
} else {
let title_text_1 = "Hi there! Which do you prefer?";
let title_text_2 = "Not sure? Press <ENTER>";
let title_text_3 = "Can be changed later. Tips appear";
let title_text_4 = "on screen - no need to remember";
let left_padding = if cols >= widths.1 {
cols.saturating_sub(widths.1) / 2
} else {
cols.saturating_sub(widths.2) / 2
};
let first_row_coords = (rows.saturating_sub(ui_size) / 2).saturating_sub(1);
print_text_with_coordinates(
Text::from(title_text_1).color_range(2, ..),
left_padding,
first_row_coords,
None,
None,
);
print_text_with_coordinates(
Text::from(title_text_2)
.color_range(0, ..10)
.color_range(2, 16..23)
.color_range(1, 40..49),
left_padding,
first_row_coords + 2,
None,
None,
);
print_text_with_coordinates(
Text::from(title_text_3),
left_padding,
first_row_coords + 4,
None,
None,
);
print_text_with_coordinates(
Text::from(title_text_4),
left_padding,
first_row_coords + 5,
None,
None,
);
}
}
fn main_screen_widths(&self, primary_modifier_text: &str) -> (usize, usize, usize) {
let primary_modifier_key_text_len = primary_modifier_text.chars().count();
let full_width = 61 + primary_modifier_key_text_len;
let mid_width = 36 + primary_modifier_key_text_len;
let min_width = 26 + primary_modifier_key_text_len;
(full_width, mid_width, min_width)
}
fn render_first_bulletin(
&self,
rows: usize,
cols: usize,
primary_modifier_key_text: &str,
ui_size: usize,
) {
let widths = self.main_screen_widths(primary_modifier_key_text);
let primary_modifier_key_text_len = primary_modifier_key_text.chars().count();
let default_text = "1. Default";
let (mut list_items, max_width) = if cols >= widths.0 {
let list_items = vec![
NestedListItem::new(Text::from(default_text).color_range(1, ..)),
NestedListItem::new("All modes available directly from the base mode, eg.:".into())
.indent(1),
NestedListItem::new(
Text::from(format!(
"{} p - to enter PANE mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
2,
primary_modifier_key_text_len + 14..primary_modifier_key_text_len + 18,
),
)
.indent(1),
NestedListItem::new(
Text::from(format!(
"{} t - to enter TAB mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
2,
primary_modifier_key_text_len + 14..primary_modifier_key_text_len + 17,
),
)
.indent(1),
];
let max_width = widths.0;
(list_items, max_width)
} else if cols >= widths.1 {
let list_items = vec![
NestedListItem::new(Text::from(default_text).color_range(1, ..)),
NestedListItem::new("Modes available directly, eg.:".into()).indent(1),
NestedListItem::new(
Text::from(format!(
"{} p - to enter PANE mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
2,
primary_modifier_key_text_len + 14..primary_modifier_key_text_len + 18,
),
)
.indent(1),
NestedListItem::new(
Text::from(format!(
"{} t - to enter TAB mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
2,
primary_modifier_key_text_len + 14..primary_modifier_key_text_len + 17,
),
)
.indent(1),
];
let max_width = widths.1;
(list_items, max_width)
} else {
let list_items = vec![
NestedListItem::new(Text::from(default_text).color_range(1, ..)),
NestedListItem::new("Directly, eg.:".into()).indent(1),
NestedListItem::new(
Text::from(format!("{} p - PANE mode", primary_modifier_key_text))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
2,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 10,
),
)
.indent(1),
NestedListItem::new(
Text::from(format!("{} t - TAB mode", primary_modifier_key_text))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
2,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 9,
),
)
.indent(1),
];
let max_width = widths.2;
(list_items, max_width)
};
if self.selected_index == Some(0) {
list_items = list_items.drain(..).map(|i| i.selected()).collect();
}
let left_padding = cols.saturating_sub(max_width) / 2;
let top_coordinates = if rows > 14 {
(rows.saturating_sub(ui_size) / 2) + 3
} else {
(rows.saturating_sub(ui_size) / 2) + 2
};
print_nested_list_with_coordinates(list_items, left_padding, top_coordinates, None, None);
}
fn render_second_bulletin(
&self,
rows: usize,
cols: usize,
primary_modifier_key_text: &str,
ui_size: usize,
) {
let unlock_first_text = "2. Unlock First (non-colliding)";
let widths = self.main_screen_widths(primary_modifier_key_text);
let primary_modifier_key_text_len = primary_modifier_key_text.chars().count();
let (mut list_items, max_width) = if cols >= widths.0 {
let list_items = vec![
NestedListItem::new(Text::from(unlock_first_text).color_range(1, ..)),
NestedListItem::new(
format!(
"Single key modes available after unlocking with {} g, eg.:",
primary_modifier_key_text
)
.into(),
)
.indent(1),
NestedListItem::new(
Text::from(format!(
"{} g + p to enter PANE mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
3,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 7,
)
.color_range(
2,
primary_modifier_key_text_len + 16..primary_modifier_key_text_len + 21,
),
)
.indent(1),
NestedListItem::new(
Text::from(format!(
"{} g + t to enter TAB mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
3,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 7,
)
.color_range(
2,
primary_modifier_key_text_len + 16..primary_modifier_key_text_len + 20,
),
)
.indent(1),
];
let max_width = widths.0;
(list_items, max_width)
} else if cols >= widths.1 {
let list_items = vec![
NestedListItem::new(Text::from(unlock_first_text).color_range(1, ..)),
NestedListItem::new(
format!(
"Single key modes after {} g, eg.:",
primary_modifier_key_text
)
.into(),
)
.indent(1),
NestedListItem::new(
Text::from(format!(
"{} g + p to enter PANE mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
3,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 7,
)
.color_range(
2,
primary_modifier_key_text_len + 16..primary_modifier_key_text_len + 21,
),
)
.indent(1),
NestedListItem::new(
Text::from(format!(
"{} g + t to enter TAB mode",
primary_modifier_key_text
))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
3,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 7,
)
.color_range(
2,
primary_modifier_key_text_len + 16..primary_modifier_key_text_len + 20,
),
)
.indent(1),
];
let max_width = widths.1;
(list_items, max_width)
} else {
let list_items = vec![
NestedListItem::new(Text::from("2. Unlock First").color_range(1, ..)),
NestedListItem::new(
format!("{} g + single key, eg.:", primary_modifier_key_text).into(),
)
.indent(1),
NestedListItem::new(
Text::from(format!("{} g + p PANE mode", primary_modifier_key_text))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
3,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 7,
)
.color_range(
2,
primary_modifier_key_text_len + 7..primary_modifier_key_text_len + 11,
),
)
.indent(1),
NestedListItem::new(
Text::from(format!("{} g + t TAB mode", primary_modifier_key_text))
.color_range(3, ..primary_modifier_key_text_len + 3)
.color_range(
3,
primary_modifier_key_text_len + 5..primary_modifier_key_text_len + 7,
)
.color_range(
2,
primary_modifier_key_text_len + 7..primary_modifier_key_text_len + 10,
),
)
.indent(1),
];
let max_width = widths.2;
(list_items, max_width)
};
if self.selected_index == Some(1) {
list_items = list_items.drain(..).map(|i| i.selected()).collect();
}
let left_padding = cols.saturating_sub(max_width) / 2;
let top_coordinates = if rows > 14 {
(rows.saturating_sub(ui_size) / 2) + 8
} else {
(rows.saturating_sub(ui_size) / 2) + 6
};
print_nested_list_with_coordinates(list_items, left_padding, top_coordinates, None, None);
}
fn render_leader_keys_indication(
&self,
rows: usize,
cols: usize,
primary_modifier_key_text: &str,
secondary_modifier_key_text: &str,
ui_size: usize,
) {
let widths = self.main_screen_widths(primary_modifier_key_text);
let primary_modifier_key_text_len = primary_modifier_key_text.chars().count();
let secondary_modifier_key_text_len = secondary_modifier_key_text.chars().count();
let top_coordinates = if rows > 14 {
(rows.saturating_sub(ui_size) / 2) + 13
} else {
(rows.saturating_sub(ui_size) / 2) + 10
};
if cols >= widths.0 {
let leader_key_text = format!(
"Leader keys: {} - modes, {} - quicknav and shortcuts",
primary_modifier_key_text, secondary_modifier_key_text
);
let left_padding = cols.saturating_sub(widths.0) / 2;
print_text_with_coordinates(
Text::from(leader_key_text)
.color_range(2, ..12)
.color_range(3, 13..primary_modifier_key_text_len + 14)
.color_range(
0,
primary_modifier_key_text_len + 23
..primary_modifier_key_text_len + 23 + secondary_modifier_key_text_len,
),
left_padding,
top_coordinates,
None,
None,
)
} else {
let leader_key_text = format!(
"Leaders: {}, {}",
primary_modifier_key_text, secondary_modifier_key_text
);
let left_padding = if cols >= widths.1 {
cols.saturating_sub(widths.1) / 2
} else {
cols.saturating_sub(widths.2) / 2
};
print_text_with_coordinates(
Text::from(leader_key_text)
.color_range(2, ..8)
.color_range(3, 9..primary_modifier_key_text_len + 10)
.color_range(
0,
primary_modifier_key_text_len + 11
..primary_modifier_key_text_len + 12 + secondary_modifier_key_text_len,
),
left_padding,
top_coordinates,
None,
None,
)
};
}
fn render_help_text_setup_wizard(&self, rows: usize, cols: usize) {
let full_help_text = "Help: <↓↑> - navigate, <ENTER> - apply & save, <ESC> - close";
let short_help_text = "Help: <↓↑> / <ENTER> / <ESC>";
if cols >= full_help_text.chars().count() {
print_text_with_coordinates(
Text::from(full_help_text)
.color_range(2, 6..10)
.color_range(2, 23..30)
.color_range(2, 47..=50),
0,
rows,
None,
None,
);
} else {
print_text_with_coordinates(
Text::from(short_help_text)
.color_range(2, 6..10)
.color_range(2, 13..20)
.color_range(2, 23..=27),
0,
rows,
None,
None,
);
}
}
fn render_override_title(
&self,
rows: usize,
cols: usize,
primary_modifier_key_text: &str,
ui_size: usize,
) {
let widths = self.main_screen_widths(primary_modifier_key_text);
if cols >= widths.0 {
let title_text = "Override keybindings with one of the following presets:";
let left_padding = cols.saturating_sub(widths.0) / 2;
print_text_with_coordinates(
Text::from(title_text).color_range(2, ..),
left_padding,
(rows.saturating_sub(ui_size) / 2) + 1,
None,
None,
);
} else {
let title_text = "Override keybindings:";
let left_padding = if cols >= widths.1 {
cols.saturating_sub(widths.1) / 2
} else {
cols.saturating_sub(widths.2) / 2
};
print_text_with_coordinates(
Text::from(title_text).color_range(2, ..),
left_padding,
(rows.saturating_sub(ui_size) / 2) + 1,
None,
None,
);
}
}
fn render_help_text_main(&self, rows: usize, cols: usize) {
let full_help_text =
"Help: <↓↑> - navigate, <ENTER> - apply, <Ctrl a> - apply & save, <ESC> - close";
let short_help_text = "Help: <↓↑> / <ENTER> / <Ctrl a> / <ESC>";
if cols >= full_help_text.chars().count() {
print_text_with_coordinates(
Text::from(full_help_text)
.color_range(2, 6..10)
.color_range(2, 23..30)
.color_range(2, 40..48)
.color_range(2, 65..=69),
0,
rows,
None,
None,
);
} else {
print_text_with_coordinates(
Text::from(short_help_text)
.color_range(2, 6..10)
.color_range(2, 13..20)
.color_range(2, 23..31)
.color_range(2, 34..=38),
0,
rows,
None,
None,
);
}
}
fn warning_text(&self, max_width: usize) -> Option<String> {
if self.needs_kitty_support() {
if max_width >= 38 {
Some(String::from("Warning: requires supporting terminal."))
} else {
Some(String::from("Requires supporting terminal"))
}
} else if self.primary_modifier.is_empty() && self.secondary_modifier.is_empty() {
if max_width >= 49 {
Some(String::from(
"Warning: no leaders defined. UI will be disabled.",
))
} else {
Some(String::from("No leaders. UI will be unusable."))
}
} else {
None
}
}
fn needs_kitty_support(&self) -> bool {
self.primary_modifier.len() > 1
|| self.secondary_modifier.len() > 1
|| self.primary_modifier.contains(&KeyModifier::Super)
|| self.secondary_modifier.contains(&KeyModifier::Super)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
use zellij_tile::prelude::*;
pub const PREVIEWED_THEME_SETTINGS: [SettingKey; 3] = [
SettingKey::Theme,
SettingKey::ThemeDark,
SettingKey::ThemeLight,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreviewAction {
Set(SettingKey, String),
Unset(SettingKey),
Revert(SettingKey),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Preview {
key: SettingKey,
original: Option<String>,
original_was_unsaved: bool,
shown_originally: String,
shown: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ThemePreview {
active: Option<Preview>,
}
impl ThemePreview {
pub fn active_key(&self) -> Option<SettingKey> {
self.active.as_ref().map(|preview| preview.key)
}
pub fn is_previewing(&self, key: SettingKey) -> bool {
self.active_key() == Some(key)
}
fn restore_action(preview: &Preview) -> Option<PreviewAction> {
if preview.shown.as_deref() == Some(preview.shown_originally.as_str()) {
return None;
}
Some(if preview.original_was_unsaved {
match &preview.original {
Some(original) => PreviewAction::Set(preview.key, original.clone()),
None => PreviewAction::Unset(preview.key),
}
} else {
PreviewAction::Revert(preview.key)
})
}
pub fn highlight(
&mut self,
key: SettingKey,
highlighted: &str,
shown_originally: &str,
state: Option<&ConfigSettingState>,
unset_label: &str,
) -> Vec<PreviewAction> {
let mut actions = vec![];
if self
.active_key()
.map(|active| active != key)
.unwrap_or(false)
{
actions.extend(self.restore());
}
let preview = self.active.get_or_insert_with(|| Preview {
key,
original: state.and_then(|state| state.current_value.clone()),
original_was_unsaved: state.map(|state| state.is_unsaved()).unwrap_or(false),
shown_originally: shown_originally.to_owned(),
shown: Some(shown_originally.to_owned()),
});
if preview.shown.as_deref() == Some(highlighted) {
return actions;
}
if highlighted == preview.shown_originally {
actions.extend(Self::restore_action(preview));
} else if highlighted == unset_label {
actions.push(PreviewAction::Unset(key));
} else {
actions.push(PreviewAction::Set(key, highlighted.to_owned()));
}
preview.shown = Some(highlighted.to_owned());
actions
}
pub fn restore(&mut self) -> Option<PreviewAction> {
self.active
.take()
.and_then(|preview| Self::restore_action(&preview))
}
pub fn commit(&mut self, key: SettingKey) {
if self.is_previewing(key) {
self.active = None;
}
}
pub fn file_replaced(&mut self, dropped: &[SettingKey]) {
if let Some(preview) = self.active.as_mut() {
if dropped.contains(&preview.key) {
preview.original_was_unsaved = false;
preview.original = None;
preview.shown = None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const UNSET: &str = "(not set)";
fn state(saved: Option<&str>, current: Option<&str>) -> ConfigSettingState {
ConfigSettingState {
key: SettingKey::Theme,
saved_value: saved.map(|v| v.to_owned()),
current_value: current.map(|v| v.to_owned()),
set_in_file: saved.is_some(),
}
}
#[test]
fn highlighting_applies_each_new_theme_once() {
let mut preview = ThemePreview::default();
let saved = state(Some("nord"), Some("nord"));
assert_eq!(
preview.highlight(SettingKey::Theme, "nord", "nord", Some(&saved), UNSET),
vec![]
);
assert_eq!(
preview.highlight(SettingKey::Theme, "dracula", "nord", Some(&saved), UNSET),
vec![PreviewAction::Set(SettingKey::Theme, "dracula".to_owned())]
);
assert_eq!(
preview.highlight(SettingKey::Theme, "dracula", "nord", Some(&saved), UNSET),
vec![]
);
assert!(preview.is_previewing(SettingKey::Theme));
}
#[test]
fn a_saved_original_is_restored_by_reverting_to_the_saved_value() {
let mut preview = ThemePreview::default();
let saved = state(Some("nord"), Some("nord"));
preview.highlight(SettingKey::Theme, "dracula", "nord", Some(&saved), UNSET);
assert_eq!(
preview.restore(),
Some(PreviewAction::Revert(SettingKey::Theme))
);
assert_eq!(preview.active_key(), None);
assert_eq!(preview.restore(), None);
}
#[test]
fn an_unset_original_is_never_written_as_default() {
let mut preview = ThemePreview::default();
let unset = state(None, None);
preview.highlight(SettingKey::Theme, "dracula", "default", Some(&unset), UNSET);
assert_eq!(
preview.highlight(SettingKey::Theme, "default", "default", Some(&unset), UNSET),
vec![PreviewAction::Revert(SettingKey::Theme)]
);
assert_eq!(preview.restore(), None);
let mut preview = ThemePreview::default();
preview.highlight(SettingKey::Theme, "dracula", "default", Some(&unset), UNSET);
assert_eq!(
preview.restore(),
Some(PreviewAction::Revert(SettingKey::Theme))
);
}
#[test]
fn an_unsaved_original_is_set_again_and_an_unsaved_unset_is_unset_again() {
let mut preview = ThemePreview::default();
let unsaved = state(Some("nord"), Some("gruvbox-dark"));
preview.highlight(
SettingKey::Theme,
"dracula",
"gruvbox-dark",
Some(&unsaved),
UNSET,
);
assert_eq!(
preview.restore(),
Some(PreviewAction::Set(
SettingKey::Theme,
"gruvbox-dark".to_owned()
))
);
let mut preview = ThemePreview::default();
let unsaved_unset = ConfigSettingState {
key: SettingKey::ThemeDark,
saved_value: Some("nord".to_owned()),
current_value: None,
set_in_file: true,
};
assert_eq!(
preview.highlight(
SettingKey::ThemeDark,
"dracula",
UNSET,
Some(&unsaved_unset),
UNSET
),
vec![PreviewAction::Set(
SettingKey::ThemeDark,
"dracula".to_owned()
)]
);
assert_eq!(
preview.restore(),
Some(PreviewAction::Unset(SettingKey::ThemeDark))
);
}
#[test]
fn highlighting_the_unset_choice_previews_an_unset_theme() {
let mut preview = ThemePreview::default();
let saved = ConfigSettingState {
key: SettingKey::ThemeLight,
saved_value: Some("nord".to_owned()),
current_value: Some("nord".to_owned()),
set_in_file: true,
};
assert_eq!(
preview.highlight(SettingKey::ThemeLight, UNSET, "nord", Some(&saved), UNSET),
vec![PreviewAction::Unset(SettingKey::ThemeLight)]
);
assert_eq!(
preview.restore(),
Some(PreviewAction::Revert(SettingKey::ThemeLight))
);
}
#[test]
fn a_file_change_that_replaced_the_theme_restores_the_new_saved_value() {
let mut preview = ThemePreview::default();
let unsaved = state(Some("nord"), Some("gruvbox-dark"));
preview.highlight(
SettingKey::Theme,
"dracula",
"gruvbox-dark",
Some(&unsaved),
UNSET,
);
preview.file_replaced(&[SettingKey::MouseMode]);
assert!(preview.is_previewing(SettingKey::Theme));
preview.file_replaced(&[SettingKey::Theme]);
assert_eq!(
preview.restore(),
Some(PreviewAction::Revert(SettingKey::Theme))
);
}
#[test]
fn a_file_change_during_preview_reapplies_the_highlighted_theme() {
let mut preview = ThemePreview::default();
let saved = state(Some("nord"), Some("nord"));
preview.highlight(SettingKey::Theme, "dracula", "nord", Some(&saved), UNSET);
preview.file_replaced(&[SettingKey::Theme]);
assert_eq!(
preview.highlight(SettingKey::Theme, "dracula", "nord", Some(&saved), UNSET),
vec![PreviewAction::Set(SettingKey::Theme, "dracula".to_owned())]
);
}
#[test]
fn choosing_ends_the_preview_without_restoring() {
let mut preview = ThemePreview::default();
let saved = state(Some("nord"), Some("nord"));
preview.highlight(SettingKey::Theme, "dracula", "nord", Some(&saved), UNSET);
preview.commit(SettingKey::Theme);
assert_eq!(preview.restore(), None);
}
#[test]
fn previewing_another_setting_restores_the_first_one() {
let mut preview = ThemePreview::default();
let saved = state(Some("nord"), Some("nord"));
preview.highlight(SettingKey::Theme, "dracula", "nord", Some(&saved), UNSET);
let dark = ConfigSettingState {
key: SettingKey::ThemeDark,
saved_value: None,
current_value: None,
set_in_file: false,
};
assert_eq!(
preview.highlight(SettingKey::ThemeDark, "nord", UNSET, Some(&dark), UNSET),
vec![
PreviewAction::Revert(SettingKey::Theme),
PreviewAction::Set(SettingKey::ThemeDark, "nord".to_owned())
]
);
}
}
@@ -0,0 +1,715 @@
use zellij_tile::prelude::*;
use zellij_utils::input::config_blocks::styling_from_colours;
const LABEL_WIDTH: usize = 12;
pub const SAMPLE_MIN_WIDTH: usize = LABEL_WIDTH + 40;
fn fg_code(colour: PaletteColor) -> String {
match colour {
PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[38;2;{};{};{}m", r, g, b),
PaletteColor::EightBit(index) => format!("\u{1b}[38;5;{}m", index),
}
}
fn bg_code(colour: PaletteColor) -> String {
match colour {
PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[48;2;{};{};{}m", r, g, b),
PaletteColor::EightBit(index) => format!("\u{1b}[48;5;{}m", index),
}
}
#[derive(Clone, Copy)]
enum Part {
Base,
Emphasis(usize),
}
struct Line {
text: String,
width: usize,
max: usize,
}
impl Line {
fn new(max: usize) -> Self {
Line {
text: String::new(),
width: 0,
max,
}
}
fn push(
&mut self,
text: &str,
fg: Option<PaletteColor>,
bg: Option<PaletteColor>,
bold: bool,
) -> &mut Self {
let room = self.max.saturating_sub(self.width);
let shown: String = text.chars().take(room).collect();
if shown.is_empty() {
return self;
}
self.width += shown.chars().count();
self.text.push_str("\u{1b}[0m");
if bold {
self.text.push_str("\u{1b}[1m");
}
if let Some(fg) = fg {
self.text.push_str(&fg_code(fg));
}
if let Some(bg) = bg {
self.text.push_str(&bg_code(bg));
}
self.text.push_str(&shown);
self
}
fn styled(
&mut self,
declaration: &StyleDeclaration,
parts: &[(&str, Part)],
with_background: bool,
) -> &mut Self {
let bg = if with_background {
Some(declaration.background)
} else {
None
};
for (text, part) in parts {
let (fg, bold) = match part {
Part::Base => (declaration.base, false),
Part::Emphasis(0) => (declaration.emphasis_0, true),
Part::Emphasis(1) => (declaration.emphasis_1, true),
Part::Emphasis(2) => (declaration.emphasis_2, true),
Part::Emphasis(_) => (declaration.emphasis_3, true),
};
self.push(text, Some(fg), bg, bold);
}
self
}
fn print(&self, x: usize, y: usize) {
print!("\u{1b}[{};{}H{}\u{1b}[0m", y + 1, x + 1, self.text);
}
}
const EMPHASIS: [(&str, Part); 8] = [
(" ", Part::Base),
("e0", Part::Emphasis(0)),
(" ", Part::Base),
("e1", Part::Emphasis(1)),
(" ", Part::Base),
("e2", Part::Emphasis(2)),
(" ", Part::Base),
("e3", Part::Emphasis(3)),
];
fn with_emphasis<'a>(before: &'a str, after: &'a str) -> Vec<(&'a str, Part)> {
let mut parts = vec![(before, Part::Base)];
parts.extend(EMPHASIS.iter().copied());
parts.push((after, Part::Base));
parts
}
const ARROW: &str = "\u{e0b0}";
fn ribbon(
line: &mut Line,
declaration: &StyleDeclaration,
surrounding: PaletteColor,
label: &str,
arrows: bool,
) {
if arrows {
line.push(
ARROW,
Some(surrounding),
Some(declaration.background),
false,
);
}
let label = format!(" {}", label);
line.styled(declaration, &with_emphasis(&label, " "), true);
if arrows {
line.push(
ARROW,
Some(declaration.background),
Some(surrounding),
false,
);
}
}
const FRAMES_PER_ROW: usize = 3;
fn frame_lines(styling: &Styling, width: usize, stacked: bool) -> Vec<Line> {
let frames = [
("Pane", styling.frame_unselected),
("Focused", Some(styling.frame_selected)),
("Marked", Some(styling.frame_highlight)),
];
let inner = 13;
let per_row = if stacked { 1 } else { FRAMES_PER_ROW };
let mut lines: Vec<Line> = vec![];
for (index, (title, declaration)) in frames.into_iter().enumerate() {
if index % per_row == 0 {
lines.extend([Line::new(width), Line::new(width), Line::new(width)]);
} else {
let start = lines.len() - 3;
for line in lines[start..].iter_mut() {
line.push(" ", None, None, false);
}
}
let start = lines.len() - 3;
let block = &mut lines[start..];
let colour = declaration.map(|declaration| declaration.base);
let title_part = format!(" {} ", title);
let rule = inner - title_part.chars().count().min(inner);
block[0].push(
&format!("┌{}{}┐", title_part, "─".repeat(rule)),
colour,
None,
false,
);
block[1].push(&format!("│{}│", " ".repeat(inner)), colour, None, false);
block[2].push("└", colour, None, false);
match declaration {
Some(declaration) => {
block[2].styled(&declaration, &with_emphasis("", " "), false);
},
None => {
block[2].push(" e0 e1 e2 e3 ", None, None, false);
},
}
block[2].push("┘", colour, None, false);
}
lines
}
struct Group {
label: &'static str,
styles: &'static [&'static str],
lines: Vec<Line>,
}
fn styled_line(
width: usize,
declaration: &StyleDeclaration,
before: &str,
after: &str,
background: bool,
) -> Line {
let mut line = Line::new(width);
line.styled(declaration, &with_emphasis(before, after), background);
line
}
const RIBBONS: usize = 2;
const FRAMES: usize = 5;
const PLAYERS: usize = 7;
const GROUP_COUNT: usize = 8;
const STACKABLE: [usize; 3] = [RIBBONS, FRAMES, PLAYERS];
fn sample_groups(
styling: &Styling,
width: usize,
arrows: bool,
stacked: &[bool; GROUP_COUNT],
) -> Vec<Group> {
let mut groups = vec![];
groups.push(Group {
label: "Text",
styles: &["text_unselected"],
lines: vec![styled_line(
width,
&styling.text_unselected,
"Text",
" ",
true,
)],
});
groups.push(Group {
label: "Selected",
styles: &["text_selected"],
lines: vec![styled_line(
width,
&styling.text_selected,
"Selected text",
" ",
true,
)],
});
let surrounding = styling.text_unselected.background;
let mut ribbon_lines = vec![Line::new(width)];
ribbon(
&mut ribbon_lines[0],
&styling.ribbon_unselected,
surrounding,
"Tab",
arrows,
);
if stacked[RIBBONS] {
ribbon_lines.push(Line::new(width));
} else {
ribbon_lines[0].push(" ", None, Some(surrounding), false);
}
ribbon(
ribbon_lines.last_mut().unwrap(),
&styling.ribbon_selected,
surrounding,
"Active",
arrows,
);
groups.push(Group {
label: "Ribbons",
styles: &["ribbon_unselected", "ribbon_selected"],
lines: ribbon_lines,
});
groups.push(Group {
label: "Table",
styles: &[
"table_title",
"table_cell_unselected",
"table_cell_selected",
],
lines: vec![
styled_line(width, &styling.table_title, "NAME ", " ", false),
styled_line(
width,
&styling.table_cell_unselected,
"notes.txt",
" ",
true,
),
styled_line(width, &styling.table_cell_selected, "todo.md ", " ", true),
],
});
groups.push(Group {
label: "List",
styles: &["list_unselected", "list_selected"],
lines: vec![
styled_line(width, &styling.list_unselected, "• First ", " ", true),
styled_line(width, &styling.list_selected, "• Second", " ", true),
],
});
groups.push(Group {
label: "Frames",
styles: &["frame_unselected", "frame_selected", "frame_highlight"],
lines: frame_lines(styling, width, stacked[FRAMES]),
});
groups.push(Group {
label: "Exit codes",
styles: &["exit_code_success", "exit_code_error"],
lines: vec![
styled_line(
width,
&styling.exit_code_success,
"EXIT CODE: 0",
" ",
false,
),
styled_line(width, &styling.exit_code_error, "EXIT CODE: 1", " ", false),
],
});
let players = styling.multiplayer_user_colors;
let colours = [
players.player_1,
players.player_2,
players.player_3,
players.player_4,
players.player_5,
players.player_6,
players.player_7,
players.player_8,
players.player_9,
players.player_10,
];
let per_line = if stacked[PLAYERS] {
colours.len() / 2
} else {
colours.len()
};
let mut users = vec![];
for chunk in colours.chunks(per_line) {
if !users.is_empty() {
users.push(Line::new(width));
}
let mut line = Line::new(width);
for (index, colour) in chunk.iter().enumerate() {
if index > 0 {
line.push(" ", None, None, false);
}
line.push("██", Some(*colour), None, false);
}
users.push(line);
}
groups.push(Group {
label: "Players",
styles: &["multiplayer_user_colors"],
lines: users,
});
groups
}
const COLUMN_GAP: usize = 3;
const HEADER_ROWS: usize = 2;
const NATURAL_WIDTH: usize = 1000;
const MAX_COLUMNS: usize = 3;
const TIERS: [(usize, usize); 6] = [(1, 1), (2, 1), (1, 0), (2, 0), (3, 1), (3, 0)];
#[derive(Debug, Clone, Copy)]
struct Size {
width: usize,
height: usize,
}
#[derive(Debug, Clone)]
struct Dims {
label_width: usize,
sizes: [Size; 2],
}
fn group_dims(styling: &Styling, arrows: bool) -> Vec<Dims> {
let size_of = |group: &Group| Size {
width: group.lines.iter().map(|line| line.width).max().unwrap_or(0),
height: group.lines.len(),
};
let side_by_side = sample_groups(styling, NATURAL_WIDTH, arrows, &[false; GROUP_COUNT]);
let stacked = sample_groups(styling, NATURAL_WIDTH, arrows, &[true; GROUP_COUNT]);
side_by_side
.iter()
.zip(stacked.iter())
.map(|(wide, tall)| Dims {
label_width: wide.label.chars().count() + 2,
sizes: [size_of(wide), size_of(tall)],
})
.collect()
}
#[derive(Debug, Clone)]
struct Column {
groups: Vec<usize>,
label_width: usize,
line_width: usize,
}
#[derive(Debug, Clone)]
struct Layout {
columns: Vec<Column>,
gap: usize,
stacked: [bool; GROUP_COUNT],
}
impl Layout {
fn total_width(&self) -> usize {
self.columns
.iter()
.map(|column| column.label_width + column.line_width)
.sum::<usize>()
+ COLUMN_GAP * self.columns.len().saturating_sub(1)
}
fn column_height(&self, dims: &[Dims], column: &Column) -> usize {
column
.groups
.iter()
.map(|index| dims[*index].sizes[self.stacked[*index] as usize].height)
.sum::<usize>()
+ self.gap * column.groups.len().saturating_sub(1)
}
fn tallest_column(&self, dims: &[Dims]) -> usize {
self.columns
.iter()
.map(|column| self.column_height(dims, column))
.max()
.unwrap_or(0)
}
}
fn stacking_choices() -> Vec<[bool; GROUP_COUNT]> {
let mut choices: Vec<[bool; GROUP_COUNT]> = (0..1usize << STACKABLE.len())
.map(|mask| {
let mut stacked = [false; GROUP_COUNT];
for (bit, index) in STACKABLE.iter().enumerate() {
stacked[*index] = mask & (1 << bit) != 0;
}
stacked
})
.collect();
choices.sort_by_key(|stacked| stacked.iter().filter(|s| **s).count());
choices
}
fn build_layout(
dims: &[Dims],
assignment: &[usize],
column_count: usize,
gap: usize,
stacked: [bool; GROUP_COUNT],
) -> Layout {
let columns = (0..column_count)
.map(|column_index| {
let groups: Vec<usize> = assignment
.iter()
.enumerate()
.filter(|(_, column)| **column == column_index)
.map(|(group, _)| group)
.collect();
let label_width = groups
.iter()
.map(|index| dims[*index].label_width)
.max()
.unwrap_or(0);
let line_width = groups
.iter()
.map(|index| dims[*index].sizes[stacked[*index] as usize].width)
.max()
.unwrap_or(0);
Column {
groups,
label_width,
line_width,
}
})
.collect();
Layout {
columns,
gap,
stacked,
}
}
fn search(
dims: &[Dims],
assignment: &mut Vec<usize>,
heights: &mut Vec<usize>,
column_count: usize,
gap: usize,
stacked: [bool; GROUP_COUNT],
limits: Size,
best: &mut Option<Layout>,
) {
let next = assignment.len();
if next == dims.len() {
if heights.len() != column_count {
return;
}
let layout = build_layout(dims, assignment, column_count, gap, stacked);
if layout.total_width() > limits.width {
return;
}
let better = match best {
None => true,
Some(current) => {
(layout.tallest_column(dims), layout.total_width())
< (current.tallest_column(dims), current.total_width())
},
};
if better {
*best = Some(layout);
}
return;
}
let height = dims[next].sizes[stacked[next] as usize].height;
let open_columns = (heights.len() + 1).min(column_count);
for column in 0..open_columns {
let added = if column < heights.len() {
heights[column] + gap + height
} else {
height
};
if added > limits.height {
continue;
}
let is_new = column == heights.len();
if is_new {
heights.push(added);
} else {
heights[column] = added;
}
assignment.push(column);
search(
dims,
assignment,
heights,
column_count,
gap,
stacked,
limits,
best,
);
assignment.pop();
if is_new {
heights.pop();
} else {
heights[column] -= gap + height;
}
}
}
fn arrange(dims: &[Dims], width: usize, rows: usize) -> Layout {
let limits = Size {
width,
height: rows,
};
for (column_count, gap) in TIERS {
if column_count > MAX_COLUMNS {
continue;
}
for stacked in stacking_choices() {
let mut best = None;
search(
dims,
&mut vec![],
&mut vec![],
column_count,
gap,
stacked,
limits,
&mut best,
);
if let Some(layout) = best {
return layout;
}
}
}
let mut fallback = build_layout(dims, &vec![0; dims.len()], 1, 1, [false; GROUP_COUNT]);
for column in fallback.columns.iter_mut() {
column.label_width = column.label_width.max(LABEL_WIDTH);
}
fallback
}
pub fn render_sample(
colours: &[String],
focused_style: Option<&str>,
arrows: bool,
x: usize,
y: usize,
width: usize,
height: usize,
) {
print_text_with_coordinates(
Text::from(crate::page::truncate(
"Preview (e0-e3 show the emphasis colors)",
width,
))
.color_range(2, ..7)
.dim_range(7..),
x,
y,
None,
None,
);
let styling = styling_from_colours(colours);
let rows = height.saturating_sub(HEADER_ROWS);
let dims = group_dims(&styling, arrows);
let layout = arrange(&dims, width, rows);
let column_count = layout.columns.len();
let mut column_x = x;
for (column_index, column) in layout.columns.iter().enumerate() {
let used = column_x.saturating_sub(x);
let line_width = if column_index + 1 == column_count {
width.saturating_sub(used + column.label_width)
} else {
column.line_width
};
let groups = sample_groups(&styling, line_width, arrows, &layout.stacked);
let mut row = 0;
for (position, group_index) in column.groups.iter().enumerate() {
let group = &groups[*group_index];
if position > 0 {
row += layout.gap;
}
let focused = focused_style
.map(|style| group.styles.contains(&style))
.unwrap_or(false);
for (line_index, line) in group.lines.iter().enumerate() {
if row >= rows {
break;
}
let screen_row = y + HEADER_ROWS + row;
if line_index == 0 {
let label =
crate::page::truncate(group.label, column.label_width.saturating_sub(1));
let text = if focused {
Text::from(label).color_all(3)
} else {
Text::from(label).dim_all()
};
print_text_with_coordinates(text, column_x, screen_row, None, None);
}
line.print(column_x + column.label_width, screen_row);
row += 1;
}
}
column_x += column.label_width + column.line_width + COLUMN_GAP;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dims() -> Vec<Dims> {
let colours = vec![String::new(); 128];
group_dims(&styling_from_colours(&colours), true)
}
fn assert_fits(layout: &Layout, dims: &[Dims], width: usize, rows: usize) {
assert!(layout.total_width() <= width);
assert!(layout.tallest_column(dims) <= rows);
let mut shown: Vec<usize> = layout
.columns
.iter()
.flat_map(|column| column.groups.clone())
.collect();
shown.sort();
assert_eq!(shown, (0..GROUP_COUNT).collect::<Vec<_>>());
for column in &layout.columns {
assert!(column.groups.windows(2).all(|pair| pair[0] < pair[1]));
}
}
fn single_column_height(dims: &[Dims]) -> usize {
dims.iter().map(|d| d.sizes[0].height).sum::<usize>() + GROUP_COUNT - 1
}
#[test]
fn everything_fits_in_one_column_when_there_is_room() {
let dims = dims();
let rows = single_column_height(&dims);
let layout = arrange(&dims, 200, rows);
assert_eq!(layout.columns.len(), 1);
assert_eq!(layout.gap, 1);
assert!(!layout.stacked.iter().any(|s| *s));
assert_fits(&layout, &dims, 200, rows);
}
#[test]
fn a_short_area_gets_two_columns_with_the_gaps_kept() {
let dims = dims();
let rows = single_column_height(&dims) - 8;
let layout = arrange(&dims, 200, rows);
assert_eq!(layout.columns.len(), 2);
assert_eq!(layout.gap, 1);
assert!(!layout.stacked.iter().any(|s| *s));
assert_fits(&layout, &dims, 200, rows);
}
#[test]
fn a_narrow_short_area_stacks_wide_samples_to_stay_in_two_columns() {
let dims = dims();
let layout = arrange(&dims, 75, 16);
assert_eq!(layout.columns.len(), 2);
assert_eq!(layout.gap, 1);
assert!(layout.stacked.iter().any(|s| *s));
assert_fits(&layout, &dims, 75, 16);
}
#[test]
fn an_area_too_small_for_any_arrangement_falls_back_to_one_column() {
let dims = dims();
let layout = arrange(&dims, 30, 5);
assert_eq!(layout.columns.len(), 1);
assert_eq!(layout.gap, 1);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,92 +1,40 @@
use crate::{Screen, WIDTH_BREAKPOINTS};
use std::cell::Cell;
use zellij_tile::prelude::*;
pub fn top_tab_menu(cols: usize, current_screen: &Screen, colors: &Styling) {
let background = colors.text_unselected.background;
let bg_color = match background {
PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[48;2;{};{};{}m\u{1b}[0K", r, g, b),
PaletteColor::EightBit(color) => format!("\u{1b}[48;5;{}m\u{1b}[0K", color),
};
let first_ribbon_text_long = "Rebind leader keys";
let second_ribbon_text_long = "Change mode behavior";
let first_ribbon_text_short = "Rebind keys";
let second_ribbon_text_short = "Mode behavior";
let (first_ribbon_is_selected, second_ribbon_is_selected) = match current_screen {
Screen::RebindLeaders(_) => (true, false),
Screen::Presets(_) => (false, true),
};
let (first_ribbon_text, second_ribbon_text, starting_positions) = if cols
>= first_ribbon_text_long.chars().count() + second_ribbon_text_long.chars().count() + 14
{
(first_ribbon_text_long, second_ribbon_text_long, (6, 28))
} else {
(first_ribbon_text_short, second_ribbon_text_short, (6, 21))
};
let mut first_ribbon = Text::from(first_ribbon_text);
let mut second_ribbon = Text::from(second_ribbon_text);
if first_ribbon_is_selected {
first_ribbon = first_ribbon.selected();
}
if second_ribbon_is_selected {
second_ribbon = second_ribbon.selected();
}
let switch_key = Text::from("<TAB>").color_range(3, ..).opaque();
print_text_with_coordinates(switch_key, 0, 0, None, None);
print!("\u{1b}[{};{}H{}", 0, starting_positions.0, bg_color);
print_ribbon_with_coordinates(first_ribbon, starting_positions.0, 0, None, None);
print_ribbon_with_coordinates(second_ribbon, starting_positions.1, 0, None, None);
thread_local! {
static CLOSE_REQUESTED: Cell<bool> = Cell::new(false);
static CLOSE_DIRECTLY: Cell<bool> = Cell::new(true);
}
pub fn back_to_presets() {
let esc = Text::from("<ESC>").color_range(3, ..);
let first_ribbon = Text::from("Back to Presets");
print_text_with_coordinates(esc, 0, 0, None, None);
print_ribbon_with_coordinates(first_ribbon, 6, 0, None, None);
pub fn set_close_directly(close_directly: bool) {
CLOSE_DIRECTLY.with(|c| c.set(close_directly));
}
pub fn info_line(
rows: usize,
cols: usize,
ui_size: usize,
notification: Option<String>,
warning_text: Option<String>,
widths: Option<(usize, usize, usize)>,
) {
let top_coordinates = if rows > 14 {
(rows.saturating_sub(ui_size) / 2) + 14
pub fn request_close() {
if CLOSE_DIRECTLY.with(|c| c.get()) {
close_self();
} else {
(rows.saturating_sub(ui_size) / 2) + 10
};
let left_padding = if let Some(widths) = widths {
if cols >= widths.0 {
cols.saturating_sub(widths.0) / 2
} else if cols >= widths.1 {
cols.saturating_sub(widths.1) / 2
} else {
cols.saturating_sub(widths.2) / 2
}
} else {
if cols >= WIDTH_BREAKPOINTS.0 {
cols.saturating_sub(WIDTH_BREAKPOINTS.0) / 2
} else {
cols.saturating_sub(WIDTH_BREAKPOINTS.1) / 2
}
};
if let Some(notification) = notification {
print_text_with_coordinates(
Text::from(notification).color_range(3, ..),
left_padding,
top_coordinates,
None,
None,
);
} else if let Some(warning_text) = warning_text {
print_text_with_coordinates(
Text::from(warning_text).color_range(3, ..),
left_padding,
top_coordinates,
None,
None,
);
CLOSE_REQUESTED.with(|c| c.set(true));
}
}
pub fn take_close_request() -> bool {
CLOSE_REQUESTED.with(|c| c.replace(false))
}
pub fn print_link(link: &str, x: usize, y: usize, color: Option<PaletteColor>, hovered: bool) {
let color = match color {
Some(PaletteColor::Rgb((r, g, b))) => format!("\u{1b}[38;2;{};{};{}m", r, g, b),
Some(PaletteColor::EightBit(index)) => format!("\u{1b}[38;5;{}m", index),
None => String::new(),
};
let hover = if hovered { "\u{1b}[3;4m" } else { "" };
print!(
"\u{1b}[{};{}H\u{1b}[0m\u{1b}[1m{}{}{}\u{1b}[0m",
y + 1,
x + 1,
color,
hover,
link
);
}
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
+1
View File
@@ -0,0 +1 @@
/target
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "context-menu"
version = "0.1.0"
authors = ["Aram Drevekenin <[email protected]>"]
edition.workspace = true
license.workspace = true
[dependencies]
zellij-tile = { path = "../../zellij-tile" }
zellij-utils = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Zellij contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+487
View File
@@ -0,0 +1,487 @@
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
use zellij_utils::input::context_menu::context_menu_shortcut;
const MAX_MENU_HEIGHT: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ItemState {
Enabled,
Disabled,
Hidden,
}
#[derive(Default)]
struct ContextMenuPlugin {
context: Option<ContextMenuContext>,
entries: Vec<ContextMenuEntry>,
mode_info: Option<ModeInfo>,
menu: Option<MenuList>,
item_entry_indices: Vec<Option<usize>>,
requested_size: Option<(usize, usize)>,
}
register_plugin!(ContextMenuPlugin);
fn item_state(actions: &[ContextMenuAction], context: &ContextMenuContext) -> ItemState {
let has_pane_target = context.target_pane_id().is_some();
let has_tab_target = context.target_tab_id().is_some();
let mut state = ItemState::Enabled;
for action in actions {
match action {
ContextMenuAction::ClickedPane(_) if !has_pane_target => return ItemState::Hidden,
ContextMenuAction::ClickedTab(_) if !has_tab_target => return ItemState::Hidden,
ContextMenuAction::ClickedPane(ClickedPaneAction::TogglePinned)
if !context.pane_is_floating =>
{
return ItemState::Hidden;
},
ContextMenuAction::ClickedTab(ClickedTabAction::Move(Direction::Left))
if context.tab_index == Some(0) =>
{
state = ItemState::Disabled;
},
ContextMenuAction::ClickedTab(ClickedTabAction::Move(Direction::Right))
if context
.tab_index
.map(|tab_index| tab_index + 1 >= context.tab_count)
.unwrap_or(false) =>
{
state = ItemState::Disabled;
},
_ => {},
}
}
state
}
fn shortcut_for(mode_info: &ModeInfo, wanted: &[ContextMenuAction]) -> Option<String> {
context_menu_shortcut(
&mode_info.keybinds,
mode_info.base_mode.unwrap_or(InputMode::Normal),
wanted,
)
}
impl ContextMenuPlugin {
fn rebuild(&mut self) {
let Some(context) = self.context.as_ref() else {
return;
};
let mut items = vec![];
let mut item_entry_indices = vec![];
for (entry_index, entry) in self.entries.iter().enumerate() {
match entry {
ContextMenuEntry::Separator => {
let previous_is_separator = items
.last()
.map(|item: &MenuItem| item.is_separator())
.unwrap_or(true);
if !previous_is_separator {
items.push(MenuItem::separator());
item_entry_indices.push(None);
}
},
ContextMenuEntry::Item { label, actions } => {
let state = item_state(actions, context);
if state == ItemState::Hidden {
continue;
}
let mut item = MenuItem::new(label.clone());
if let Some(shortcut) = self
.mode_info
.as_ref()
.and_then(|mode_info| shortcut_for(mode_info, actions))
{
item = item.shortcut(shortcut);
}
if state == ItemState::Disabled {
item = item.disabled();
}
items.push(item);
item_entry_indices.push(Some(entry_index));
},
}
}
while items
.last()
.map(|item| item.is_separator())
.unwrap_or(false)
{
items.pop();
item_entry_indices.pop();
}
let highlighted = self.menu.as_ref().and_then(|menu| menu.highlighted_index());
let mut menu = MenuList::new(items).with_border().focused();
if let Some(highlighted) = highlighted {
menu.set_highlighted(Some(highlighted));
}
let size = (menu.natural_width(), menu.height_for(MAX_MENU_HEIGHT));
if self.requested_size != Some(size) {
self.requested_size = Some(size);
set_popup_size(size.0, size.1);
}
self.menu = Some(menu);
self.item_entry_indices = item_entry_indices;
}
fn run_item(&mut self, index: usize) {
if let Some(Some(entry_index)) = self.item_entry_indices.get(index) {
run_context_menu_item(*entry_index);
}
close_self();
}
fn handle_response(&mut self, response: UiResponse) -> bool {
match response {
UiResponse::Submitted(UiValue::Choice { index, .. }) => {
self.run_item(index);
false
},
UiResponse::Cancelled => {
close_self();
false
},
UiResponse::NotHandled => false,
_ => true,
}
}
fn handle_key(&mut self, key: KeyWithModifier) -> bool {
let Some(menu) = self.menu.as_mut() else {
if key.is_key_without_modifier(BareKey::Esc) {
close_self();
}
return false;
};
let response = menu.handle_key(&key);
self.handle_response(response)
}
fn handle_mouse(&mut self, mouse: Mouse) -> bool {
let Some(menu) = self.menu.as_mut() else {
return false;
};
match mouse {
Mouse::Release(..) | Mouse::RightClick(..) => false,
mouse => {
let response = menu.handle_mouse(mouse);
self.handle_response(response)
},
}
}
}
impl ZellijPlugin for ContextMenuPlugin {
fn load(&mut self, _configuration: BTreeMap<String, String>) {
subscribe(&[
EventType::Key,
EventType::Mouse,
EventType::Timer,
EventType::ModeUpdate,
EventType::ContextMenu,
]);
}
fn update(&mut self, event: Event) -> bool {
match event {
Event::ContextMenu(context, entries) => {
self.context = Some(context);
self.entries = entries;
self.rebuild();
true
},
Event::ModeUpdate(mode_info) => {
self.mode_info = Some(mode_info);
self.rebuild();
true
},
Event::Key(key) => self.handle_key(key),
Event::Mouse(mouse) => self.handle_mouse(mouse),
Event::Timer(_) => self
.menu
.as_mut()
.map(|menu| menu.handle_timer())
.unwrap_or(false),
_ => false,
}
}
fn render(&mut self, rows: usize, cols: usize) {
if let Some(menu) = self.menu.as_mut() {
menu.render(0, 0, cols, rows);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zellij_tile::prelude::actions::Action;
use zellij_utils::input::layout::{PluginAlias, RunPluginOrAlias};
fn key(c: char) -> KeyWithModifier {
KeyWithModifier::new(BareKey::Char(c))
}
fn ctrl(c: char) -> KeyWithModifier {
KeyWithModifier::new(BareKey::Char(c)).with_ctrl_modifier()
}
fn alt(c: char) -> KeyWithModifier {
KeyWithModifier::new(BareKey::Char(c)).with_alt_modifier()
}
fn pane(action: ClickedPaneAction) -> ContextMenuAction {
ContextMenuAction::ClickedPane(action)
}
fn tab(action: ClickedTabAction) -> ContextMenuAction {
ContextMenuAction::ClickedTab(action)
}
#[test]
fn items_for_the_clicked_pane_or_tab_follow_the_context() {
let context = |kind, pane_is_floating, tab_index| ContextMenuContext {
kind,
pane_id: Some(PaneId::Terminal(1)),
pane_is_floating,
tab_index: Some(tab_index),
tab_id: Some(tab_index),
tab_count: 2,
line: 0,
column: 0,
client_id: 1,
};
let pinned = [pane(ClickedPaneAction::TogglePinned)];
let close_pane = [pane(ClickedPaneAction::CloseFocus)];
let left = [tab(ClickedTabAction::Move(Direction::Left))];
let right = [tab(ClickedTabAction::Move(Direction::Right))];
let tiled = context(ContextMenuKind::Pane, false, 0);
assert_eq!(item_state(&pinned, &tiled), ItemState::Hidden);
assert_eq!(item_state(&close_pane, &tiled), ItemState::Enabled);
assert_eq!(item_state(&left, &tiled), ItemState::Disabled);
assert_eq!(item_state(&right, &tiled), ItemState::Enabled);
let floating = context(ContextMenuKind::Pane, true, 1);
assert_eq!(item_state(&pinned, &floating), ItemState::Enabled);
assert_eq!(item_state(&right, &floating), ItemState::Disabled);
let tab_bar = context(ContextMenuKind::Tab, false, 1);
assert_eq!(item_state(&close_pane, &tab_bar), ItemState::Hidden);
assert_eq!(item_state(&left, &tab_bar), ItemState::Enabled);
}
fn switch_to(input_mode: InputMode) -> Action {
Action::SwitchToMode { input_mode }
}
fn plugin_launch(name: &str, configuration: &[(&str, &str)], should_float: bool) -> Action {
let configuration: BTreeMap<String, String> = configuration
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Action::LaunchOrFocusPlugin {
plugin: RunPluginOrAlias::Alias(PluginAlias::new(name, &Some(configuration), None)),
should_float,
move_to_focused_tab: true,
should_open_in_place: false,
close_replaced_pane: false,
skip_cache: false,
tab_id: None,
}
}
fn mode_info(base_mode: InputMode) -> ModeInfo {
let back = switch_to(base_mode);
let normal = vec![
(ctrl('p'), vec![switch_to(InputMode::Pane)]),
(ctrl('t'), vec![switch_to(InputMode::Tab)]),
(ctrl('o'), vec![switch_to(InputMode::Session)]),
(
alt('n'),
vec![Action::NewPane {
direction: Some(Direction::Right),
pane_name: None,
start_suppressed: false,
}],
),
(
alt('i'),
vec![Action::MoveTab {
direction: Direction::Left,
}],
),
];
let pane = vec![
(key('f'), vec![Action::ToggleFocusFullscreen, back.clone()]),
(key('x'), vec![Action::CloseFocus, back.clone()]),
(
key('c'),
vec![
switch_to(InputMode::RenamePane),
Action::PaneNameInput { input: vec![0] },
],
),
];
let tab = vec![
(
key('r'),
vec![
switch_to(InputMode::RenameTab),
Action::TabNameInput { input: vec![0] },
],
),
(key('x'), vec![Action::CloseTab, back.clone()]),
];
let session = vec![
(key('d'), vec![Action::Detach]),
(
key('c'),
vec![plugin_launch("configuration", &[], true), back.clone()],
),
];
ModeInfo {
mode: base_mode,
base_mode: Some(base_mode),
keybinds: vec![
(base_mode, normal),
(InputMode::Pane, pane),
(InputMode::Tab, tab),
(InputMode::Session, session),
],
..Default::default()
}
}
#[test]
fn by_id_pane_actions_match_their_focused_pane_bindings() {
let mode_info = mode_info(InputMode::Normal);
assert_eq!(
shortcut_for(
&mode_info,
&[pane(ClickedPaneAction::ToggleFocusFullscreen)]
),
Some("Ctrl p, f".to_owned())
);
assert_eq!(
shortcut_for(&mode_info, &[pane(ClickedPaneAction::CloseFocus)]),
Some("Ctrl p, x".to_owned())
);
}
#[test]
fn by_id_tab_actions_match_their_focused_tab_bindings() {
let mode_info = mode_info(InputMode::Normal);
assert_eq!(
shortcut_for(&mode_info, &[tab(ClickedTabAction::Close)]),
Some("Ctrl t, x".to_owned())
);
assert_eq!(
shortcut_for(&mode_info, &[tab(ClickedTabAction::Move(Direction::Left))]),
Some("Alt i".to_owned())
);
assert_eq!(
shortcut_for(&mode_info, &[tab(ClickedTabAction::Move(Direction::Right))]),
None
);
}
#[test]
fn rename_actions_match_their_key_sequences() {
let mode_info = mode_info(InputMode::Normal);
assert_eq!(
shortcut_for(&mode_info, &[pane(ClickedPaneAction::StartRename)]),
Some("Ctrl p, c".to_owned())
);
assert_eq!(
shortcut_for(&mode_info, &[tab(ClickedTabAction::StartRename)]),
Some("Ctrl t, r".to_owned())
);
}
#[test]
fn plugin_items_match_by_location_only() {
let mode_info = mode_info(InputMode::Normal);
assert_eq!(
shortcut_for(
&mode_info,
&[plugin_launch("configuration", &[("x", "y")], false).into()]
),
Some("Ctrl o, c".to_owned())
);
assert_eq!(
shortcut_for(
&mode_info,
&[plugin_launch("session-manager", &[], true).into()]
),
None
);
}
#[test]
fn new_pane_without_a_direction_matches_any_new_pane_binding() {
let mode_info = mode_info(InputMode::Normal);
let new_pane = |direction| Action::NewPane {
direction,
pane_name: None,
start_suppressed: false,
};
assert_eq!(
shortcut_for(&mode_info, &[new_pane(None).into()]),
Some("Alt n".to_owned())
);
assert_eq!(
shortcut_for(&mode_info, &[new_pane(Some(Direction::Down)).into()]),
None
);
}
#[test]
fn shortcuts_follow_mode_switches_from_the_base_mode() {
let mut mode_info = mode_info(InputMode::Locked);
mode_info.mode = InputMode::Pane;
assert_eq!(
shortcut_for(&mode_info, &[Action::Detach.into()]),
Some("Ctrl o, d".to_owned())
);
assert_eq!(
shortcut_for(
&mode_info,
&[pane(ClickedPaneAction::ToggleFocusFullscreen)]
),
Some("Ctrl p, f".to_owned())
);
}
#[test]
fn the_tmux_mode_is_only_used_when_no_other_mode_has_the_key() {
let mut mode_info = mode_info(InputMode::Normal);
mode_info.keybinds[0]
.1
.push((ctrl('b'), vec![switch_to(InputMode::Tmux)]));
mode_info.keybinds.push((
InputMode::Tmux,
vec![
(
key('x'),
vec![Action::CloseFocus, switch_to(InputMode::Normal)],
),
(
key('z'),
vec![Action::TogglePaneInGroup, switch_to(InputMode::Normal)],
),
],
));
assert_eq!(
shortcut_for(&mode_info, &[pane(ClickedPaneAction::CloseFocus)]),
Some("Ctrl p, x".to_owned())
);
assert_eq!(
shortcut_for(&mode_info, &[pane(ClickedPaneAction::ToggleInGroup)]),
Some("Ctrl b, z".to_owned())
);
}
#[test]
fn actions_without_a_binding_show_nothing() {
let mode_info = mode_info(InputMode::Normal);
assert_eq!(
shortcut_for(&mode_info, &[pane(ClickedPaneAction::ToggleInGroup)]),
None
);
assert_eq!(shortcut_for(&mode_info, &[]), None);
}
}
@@ -18,6 +18,44 @@ struct State {
configuration: BTreeMap<String, String>,
message_to_plugin_payload: Option<String>,
explicit_string_to_render: Option<String>,
prompt_result: Option<String>,
}
#[cfg(target_family = "wasm")]
impl State {
fn handle_prompt_message(&mut self, name: &str, args: &BTreeMap<String, String>) {
let request = match name {
"prompt_confirm" => {
PromptRequest::confirm(Text::from("Delete branch?").color_range(3, 7..13))
.yes("Delete")
.no("Keep")
},
"prompt_choose_multi" => PromptRequest::choose(vec!["alpha", "beta", "gamma"])
.multi()
.title("Pick branches"),
"prompt_form" => PromptRequest::form(vec![
FormField::input("name", "Name").default_value("demo"),
FormField::toggle("ci", "Use CI").default_value(true),
FormField::number("port", "Port").default_value(8080),
]),
"prompt_input_timeout" => {
let request =
PromptRequest::input("Name").timeout(std::time::Duration::from_secs(1));
match args.get("default") {
Some(default) => request.default(default.clone()),
None => request,
}
},
"prompt_invalid" => PromptRequest::number("Port").min(5).max(1),
"close_self" => {
close_self();
return;
},
_ => return,
};
let request_id = prompt(request);
self.prompt_result = Some(format!("Prompt {} sent", request_id));
}
}
#[allow(dead_code)] // used when compiled as wasm plugin, not in native test target
@@ -91,6 +129,7 @@ impl ZellijPlugin for State {
EventType::BeforeClose,
EventType::PluginConfigurationChanged,
EventType::HighlightClicked,
EventType::PromptResult,
]);
if should_subscribe_initial_keybinds {
subscribe(&[EventType::InitialKeybinds, EventType::ModeUpdate]);
@@ -108,6 +147,10 @@ impl ZellijPlugin for State {
}
fn update(&mut self, event: Event) -> bool {
if let Event::PromptResult(request_id, result) = &event {
self.prompt_result = Some(format!("Prompt result {}: {:?}", request_id, result));
return true;
}
match &event {
Event::Key(key) => match key.bare_key {
BareKey::Char('a') if key.has_no_modifiers() => {
@@ -1029,13 +1072,28 @@ impl ZellijPlugin for State {
should_render
}
fn pipe(&mut self, pipe_message: PipeMessage) -> bool {
let input_pipe_id = match pipe_message.source {
let input_pipe_id = match &pipe_message.source {
PipeSource::Cli(id) => id.clone(),
PipeSource::Plugin(id) => format!("{}", id),
PipeSource::Keybind => format!("keybind"),
PipeSource::PromptRequest { request_id, .. } => format!("prompt_{}", request_id),
};
let name = pipe_message.name;
let payload = pipe_message.payload;
if name.starts_with("prompt_") || name == "close_self" {
if let PipeSource::Cli(pipe_id) = &pipe_message.source {
unblock_cli_pipe_input(pipe_id);
}
let is_for_this_user = pipe_message
.args
.get("only_client")
.map(|client_id| *client_id == get_plugin_ids().client_id.to_string())
.unwrap_or(true);
if is_for_this_user {
self.handle_prompt_message(&name, &pipe_message.args);
}
return true;
}
if name == "message_name" && payload == Some("message_payload".to_owned()) {
unblock_cli_pipe_input(&input_pipe_id);
} else if name == "message_name_block" {
@@ -1056,7 +1114,9 @@ impl ZellijPlugin for State {
}
fn render(&mut self, rows: usize, cols: usize) {
if let Some(payload) = self.received_payload.as_ref() {
if let Some(prompt_result) = self.prompt_result.as_ref() {
println!("{}", prompt_result);
} else if let Some(payload) = self.received_payload.as_ref() {
println!("Payload from worker: {:?}", payload);
} else if let Some(payload) = self.message_to_plugin_payload.take() {
println!("Payload from self: {:?}", payload);
@@ -1,6 +1,5 @@
mod errors;
mod screens;
mod text_input;
mod ui;
use errors::format_kdl_error;
@@ -1,6 +1,5 @@
use super::{KeyResponse, LayoutListScreen, OptimisticUpdate, Screen};
use crate::errors::{format_kdl_error, ErrorScreen};
use crate::text_input::{InputAction, TextInput};
use crate::ui::{truncate_with_ellipsis_start, LayoutDetail, Title};
use crate::{DisplayLayout, LayoutInfo};
use zellij_tile::prelude::*;
@@ -63,14 +62,10 @@ impl ImportLayoutScreen {
}
fn handle_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse {
let action = self.name_input.handle_key(key);
match action {
InputAction::Continue => KeyResponse::render(),
InputAction::Submit => self.exit_editing_mode(),
InputAction::Cancel => self.exit_editing_mode(),
InputAction::Complete => KeyResponse::none(),
InputAction::NoAction => KeyResponse::none(),
match self.name_input.handle_key(&key) {
UiResponse::Changed(_) | UiResponse::Consumed => KeyResponse::render(),
UiResponse::Submitted(_) | UiResponse::Cancelled => self.exit_editing_mode(),
_ => KeyResponse::none(),
}
}
@@ -1,7 +1,6 @@
mod search;
use super::{KeyResponse, OptimisticUpdate, Screen};
use crate::text_input::InputAction;
use crate::ui::{Controls, LayoutDetail, LayoutsTable};
use crate::DisplayLayout;
use search::SearchState;
@@ -182,14 +181,14 @@ impl LayoutListScreen {
}
// Pass remaining keys to TextInput
let action = self.search_state.get_filter_input_mut().handle_key(key);
let action = self.search_state.get_filter_input_mut().handle_key(&key);
match action {
InputAction::Continue => {
UiResponse::Changed(_) | UiResponse::Consumed => {
self.update_filter(display_layouts);
KeyResponse::render()
},
InputAction::Cancel => {
UiResponse::Cancelled => {
// Ctrl-C - clear text first; only exit to management mode if already empty
if self.search_state.get_filter_input().is_empty() {
self.clear_filter();
@@ -199,12 +198,7 @@ impl LayoutListScreen {
}
KeyResponse::render()
},
InputAction::Submit => {
// Enter handled above
KeyResponse::none()
},
InputAction::Complete => KeyResponse::none(),
InputAction::NoAction => KeyResponse::none(),
_ => KeyResponse::none(),
}
}
@@ -224,7 +218,7 @@ impl LayoutListScreen {
match dump_session_layout() {
Ok((session_layout, Some(session_layout_metadata))) => {
Screen::NewLayoutFromSession(super::NewLayoutFromCurrentSessionScreen {
name_input: crate::text_input::TextInput::empty(),
name_input: TextInput::empty(),
session_layout,
current_layout_metadata: session_layout_metadata,
editing_name: false,
@@ -1,4 +1,3 @@
use crate::text_input::TextInput;
use crate::DisplayLayout;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
@@ -1,5 +1,4 @@
use super::{ErrorScreen, KeyResponse, LayoutListScreen, OptimisticUpdate, Screen};
use crate::text_input::{InputAction, TextInput};
use crate::ui::{truncate_with_ellipsis_start, LayoutDetail};
use crate::DisplayLayout;
use zellij_tile::prelude::*;
@@ -37,14 +36,10 @@ impl NewLayoutFromCurrentSessionScreen {
}
fn handle_editing_mode_key(&mut self, key: KeyWithModifier) -> KeyResponse {
let action = self.name_input.handle_key(key);
match action {
InputAction::Continue => KeyResponse::render(),
InputAction::Submit => self.exit_editing_mode(),
InputAction::Cancel => self.exit_editing_mode(),
InputAction::Complete => KeyResponse::none(),
InputAction::NoAction => KeyResponse::none(),
match self.name_input.handle_key(&key) {
UiResponse::Changed(_) | UiResponse::Consumed => KeyResponse::render(),
UiResponse::Submitted(_) | UiResponse::Cancelled => self.exit_editing_mode(),
_ => KeyResponse::none(),
}
}
@@ -1,5 +1,4 @@
use super::{ErrorScreen, KeyResponse, LayoutListScreen, OptimisticUpdate, Screen};
use crate::text_input::{InputAction, TextInput};
use crate::ui::truncate_with_ellipsis_start;
use zellij_tile::prelude::*;
@@ -20,14 +19,11 @@ impl RenameLayoutScreen {
}
pub fn handle_key(&mut self, key: KeyWithModifier) -> KeyResponse {
let action = self.name_input.handle_key(key);
match action {
InputAction::Continue => KeyResponse::render(),
InputAction::Submit => self.attempt_rename(),
InputAction::Cancel => KeyResponse::new_screen(self.cancel_rename()),
InputAction::Complete => KeyResponse::none(),
InputAction::NoAction => KeyResponse::none(),
match self.name_input.handle_key(&key) {
UiResponse::Changed(_) | UiResponse::Consumed => KeyResponse::render(),
UiResponse::Submitted(_) => self.attempt_rename(),
UiResponse::Cancelled => KeyResponse::new_screen(self.cancel_rename()),
_ => KeyResponse::none(),
}
}
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
+1
View File
@@ -0,0 +1 @@
/target
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "prompt"
version = "0.1.0"
authors = ["Aram Drevekenin <[email protected]>"]
edition.workspace = true
license.workspace = true
[dependencies]
zellij-tile = { path = "../../zellij-tile" }
serde_json = { workspace = true }
regex = { workspace = true }
zellij-utils = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Zellij contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+825
View File
@@ -0,0 +1,825 @@
use serde_json::{Map, Value};
use zellij_tile::prelude::*;
use zellij_utils::prompt::{FormField, FormFieldKind, FormSpec};
use crate::outcome::{Answer, Outcome};
use crate::request::Pattern;
use crate::ui::{check_text, print_error, text_width, truncate, Step};
const CHOOSE_ROWS: usize = 6;
const DIALOG_TEXT_WIDTH: usize = 56;
const DIALOG_PADDING: usize = 2;
pub struct FormScreen {
spec: FormSpec,
patterns: Vec<Option<Pattern>>,
group: FocusGroup<usize>,
errors: Vec<Option<String>>,
tried: bool,
scroll: ScrollView,
follow_focus: bool,
label_width: usize,
}
fn default_string(field: &FormField, defaults: &Option<Map<String, Value>>) -> Option<Value> {
defaults
.as_ref()
.and_then(|defaults| defaults.get(&field.id).cloned())
.or_else(|| field.default.clone())
}
fn choose_items(field: &FormField, marked: &[bool]) -> Vec<MenuItem> {
field
.options
.iter()
.zip(marked.iter())
.map(|(option, marked)| {
let item = MenuItem::new(option.clone());
if *marked {
item.marked()
} else {
item
}
})
.collect()
}
impl FormScreen {
pub fn new(
spec: FormSpec,
patterns: Vec<Option<Pattern>>,
defaults: Option<Map<String, Value>>,
) -> Self {
let label_width = spec
.fields
.iter()
.filter(|field| field.kind != FormFieldKind::Choose)
.map(|field| text_width(&field.label) + 1)
.max()
.unwrap_or(0)
.min(24);
let mut group = FocusGroup::new();
for (index, field) in spec.fields.iter().enumerate() {
let default = default_string(field, &defaults);
match field.kind {
FormFieldKind::Input => {
let text = default
.as_ref()
.and_then(|d| d.as_str())
.unwrap_or_default()
.to_owned();
let mut input = TextInput::new(text)
.label(field.label.clone())
.label_width(label_width);
if let Some(placeholder) = &field.placeholder {
input = input.placeholder(placeholder.clone());
}
group.add(index, input);
},
FormFieldKind::Select => {
let selected = default
.as_ref()
.and_then(|d| d.as_str())
.and_then(|d| field.options.iter().position(|o| o == d))
.unwrap_or(0);
group.add(
index,
Dropdown::new(field.label.clone(), field.options.clone())
.selected(selected)
.label_width(label_width)
.max_list_rows(field.options.len().clamp(1, CHOOSE_ROWS)),
);
},
FormFieldKind::Toggle => {
let on = default.as_ref().and_then(|d| d.as_bool()).unwrap_or(false);
group.add(
index,
Toggle::new(field.label.clone(), on).label_width(label_width),
);
},
FormFieldKind::Number => {
let mut value = default
.as_ref()
.and_then(|d| d.as_i64())
.or(field.min)
.unwrap_or(0);
if let Some(min) = field.min {
value = value.max(min);
}
if let Some(max) = field.max {
value = value.min(max);
}
let mut stepper = NumberStepper::new(field.label.clone(), value)
.step(field.step.unwrap_or(1))
.label_width(label_width);
if let Some(min) = field.min {
stepper = stepper.min(min);
}
if let Some(max) = field.max {
stepper = stepper.max(max);
}
group.add(index, stepper);
},
FormFieldKind::Choose => {
let defaults: Vec<String> = match &default {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(values)) => values
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect(),
_ => vec![],
};
let marked: Vec<bool> = field
.options
.iter()
.map(|option| defaults.contains(option))
.collect();
group.add(
index,
MenuList::new(choose_items(field, &marked)).with_border(),
);
},
}
}
let submit_key = spec.fields.len();
group.add(submit_key, Button::new(spec.submit_label.clone()));
group.add(submit_key + 1, Button::new(spec.cancel_label.clone()));
group.focus_first();
let field_count = spec.fields.len();
FormScreen {
spec,
patterns,
group,
errors: vec![None; field_count],
tried: false,
scroll: ScrollView::new(0),
follow_focus: true,
label_width,
}
}
fn submit_key(&self) -> usize {
self.spec.fields.len()
}
pub fn is_dialog(&self) -> bool {
self.spec.message.is_some()
}
pub fn has_switches(&self) -> bool {
self.spec
.fields
.iter()
.any(|field| matches!(field.kind, FormFieldKind::Toggle | FormFieldKind::Choose))
}
fn message_lines(&self, width: usize) -> Vec<Text> {
match &self.spec.message {
Some(message) => Text::from(message.clone()).wrap(width.min(DIALOG_TEXT_WIDTH).max(1)),
None => vec![],
}
}
fn header_rows(&self, width: usize) -> usize {
if self.is_dialog() {
1 + self.message_lines(width).len() + 1
} else {
0
}
}
fn field_body_width(&self, field: &FormField) -> usize {
match field.kind {
FormFieldKind::Choose | FormFieldKind::Select => {
field
.options
.iter()
.map(|o| text_width(o))
.max()
.unwrap_or(0)
+ 12
},
FormFieldKind::Toggle if self.is_dialog() => TOGGLE_WIDTH,
FormFieldKind::Number if self.is_dialog() => 16,
_ => 30,
}
}
fn fields_width(&self) -> usize {
self.label_width
+ self
.spec
.fields
.iter()
.map(|field| self.field_body_width(field))
.max()
.unwrap_or(0)
}
fn buttons_width(&self) -> usize {
let width_of = |key: usize| {
self.group
.button(&key)
.map(|b| b.natural_width())
.unwrap_or(8)
};
width_of(self.submit_key()) + 2 + width_of(self.cancel_key())
}
fn cancel_key(&self) -> usize {
self.spec.fields.len() + 1
}
fn choose_marked(&self, index: usize) -> Vec<bool> {
self.group
.menu_list(&index)
.map(|list| list.items().iter().map(|item| item.is_marked()).collect())
.unwrap_or_default()
}
fn toggle_choice(&mut self, index: usize, position: Option<usize>) -> Step {
let Some(field) = self.spec.fields.get(index).cloned() else {
return Step::Nothing;
};
let Some(list) = self.group.menu_list_mut(&index) else {
return Step::Nothing;
};
let Some(position) = position.or_else(|| list.highlighted_index()) else {
return Step::Nothing;
};
let mut marked: Vec<bool> = list.items().iter().map(|item| item.is_marked()).collect();
let was_marked = marked.get(position).copied().unwrap_or(false);
if !field.multi {
marked.iter_mut().for_each(|m| *m = false);
}
if let Some(entry) = marked.get_mut(position) {
*entry = !was_marked;
}
list.set_items(choose_items(&field, &marked));
list.set_highlighted(Some(position));
self.recheck();
Step::Redraw
}
fn field_error(&self, index: usize) -> Option<String> {
let field = &self.spec.fields[index];
match field.kind {
FormFieldKind::Input => {
let text = self
.group
.text_input(&index)
.map(|input| input.get_text().to_owned())
.unwrap_or_default();
check_text(&text, field.required, self.patterns[index].as_ref()).err()
},
FormFieldKind::Choose => {
if field.required && !self.choose_marked(index).iter().any(|m| *m) {
Some("Choose at least one option".to_owned())
} else {
None
}
},
_ => None,
}
}
fn recheck(&mut self) {
if self.tried {
for index in 0..self.spec.fields.len() {
self.errors[index] = self.field_error(index);
}
}
}
#[cfg(test)]
pub fn errors(&self) -> &[Option<String>] {
&self.errors
}
fn commit_number_edits(&mut self) {
let enter = KeyWithModifier::new(BareKey::Enter);
for index in 0..self.spec.fields.len() {
if let Some(stepper) = self.group.number_stepper_mut(&index) {
if stepper.is_editing() {
stepper.handle_key(&enter);
}
}
}
}
pub fn values(&self) -> Vec<(String, Value)> {
self.spec
.fields
.iter()
.enumerate()
.map(|(index, field)| {
let value = match field.kind {
FormFieldKind::Input => Value::String(
self.group
.text_input(&index)
.map(|input| input.get_text().to_owned())
.unwrap_or_default(),
),
FormFieldKind::Select => self
.group
.dropdown(&index)
.and_then(|dropdown| dropdown.selected_value())
.map(|value| Value::String(value.to_owned()))
.unwrap_or(Value::Null),
FormFieldKind::Toggle => Value::Bool(
self.group
.toggle(&index)
.map(|toggle| toggle.is_on())
.unwrap_or(false),
),
FormFieldKind::Number => self
.group
.number_stepper(&index)
.map(|stepper| Value::from(stepper.value()))
.unwrap_or(Value::Null),
FormFieldKind::Choose => {
let chosen: Vec<Value> = field
.options
.iter()
.zip(self.choose_marked(index))
.filter(|(_, marked)| *marked)
.map(|(option, _)| Value::String(option.clone()))
.collect();
if field.multi {
Value::Array(chosen)
} else {
chosen.into_iter().next().unwrap_or(Value::Null)
}
},
};
(field.id.clone(), value)
})
.collect()
}
pub fn try_submit(&mut self) -> Step {
self.commit_number_edits();
self.tried = true;
self.recheck();
if let Some(first_bad) = self.errors.iter().position(|e| e.is_some()) {
self.group.focus(&first_bad);
self.follow_focus = true;
return Step::Redraw;
}
Step::Done(Outcome::Answered(Answer::Form(self.values())))
}
fn respond(&mut self, event: FocusEvent<usize>, from_mouse: bool) -> Step {
match event {
FocusEvent::Element { key, response } => {
if key == self.submit_key() && response == UiResponse::Activated {
return self.try_submit();
}
if key == self.cancel_key() && response == UiResponse::Activated {
return Step::Done(Outcome::Cancelled);
}
let is_choose = self
.spec
.fields
.get(key)
.map(|field| field.kind == FormFieldKind::Choose)
.unwrap_or(false);
if is_choose {
if let UiResponse::Submitted(UiValue::Choice { index, .. }) = response {
return self.toggle_choice(key, Some(index));
}
}
if !from_mouse {
if let UiResponse::Submitted(_) = response {
self.group.focus_next();
self.follow_focus = true;
}
}
self.recheck();
Step::Redraw
},
FocusEvent::FocusChanged(_) => {
self.follow_focus = true;
Step::Redraw
},
FocusEvent::NotHandled => Step::Nothing,
}
}
pub fn handle_key(&mut self, key: &KeyWithModifier) -> Step {
if self.group.has_open_overlay() {
let event = self.group.handle_key(key);
return self.respond(event, false);
}
let focused = self.group.focused_key().copied();
let focused_is_editing_number = focused
.and_then(|k| self.group.number_stepper(&k))
.map(|stepper| stepper.is_editing())
.unwrap_or(false);
if key.is_key_without_modifier(BareKey::Esc) && !focused_is_editing_number {
return Step::Done(Outcome::Cancelled);
}
let focused_is_button = focused
.map(|k| k == self.submit_key() || k == self.cancel_key())
.unwrap_or(false);
if self.is_dialog()
&& key.is_key_without_modifier(BareKey::Enter)
&& !focused_is_button
&& !focused_is_editing_number
{
return self.try_submit();
}
if key.is_key_with_ctrl_modifier(BareKey::Char('a'))
|| key.is_key_with_ctrl_modifier(BareKey::Char('s'))
{
return self.try_submit();
}
if let Some(focused) = focused {
let is_choose = self
.spec
.fields
.get(focused)
.map(|field| field.kind == FormFieldKind::Choose)
.unwrap_or(false);
if is_choose && key.is_key_without_modifier(BareKey::Char(' ')) {
return self.toggle_choice(focused, None);
}
let is_number = self.group.number_stepper(&focused).is_some();
if is_number
&& !focused_is_editing_number
&& key.is_key_without_modifier(BareKey::Enter)
{
self.group.focus_next();
self.follow_focus = true;
return Step::Redraw;
}
}
if key.is_key_without_modifier(BareKey::Down) && !self.focused_uses_up_down() {
self.group.focus_next();
self.follow_focus = true;
return Step::Redraw;
}
if key.is_key_without_modifier(BareKey::Up) && !self.focused_uses_up_down() {
self.group.focus_prev();
self.follow_focus = true;
return Step::Redraw;
}
let event = self.group.handle_key(key);
if !event.is_handled() {
if self.scroll.handle_key(key).is_handled() {
return Step::Redraw;
}
}
self.respond(event, false)
}
fn focused_uses_up_down(&self) -> bool {
self.group
.focused_key()
.map(|key| self.group.menu_list(key).is_some())
.unwrap_or(false)
}
pub fn handle_mouse(&mut self, mouse: Mouse) -> Step {
let event = self.group.handle_mouse(mouse);
match mouse {
Mouse::Hover(..) => {
self.scroll.handle_mouse(mouse);
Step::Redraw
},
Mouse::ScrollUp(_) | Mouse::ScrollDown(_) if !event.is_handled() => {
Step::redraw_if(self.scroll.handle_mouse(mouse).is_handled())
},
_ => self.respond(event, true),
}
}
pub fn handle_timer(&mut self) -> bool {
self.group.handle_timer()
}
fn block_height(&self, index: usize) -> usize {
let field = &self.spec.fields[index];
let body = match field.kind {
FormFieldKind::Choose => {
1 + self
.group
.menu_list(&index)
.map(|list| list.height_for(field.options.len().min(CHOOSE_ROWS) + 2))
.unwrap_or(3)
},
_ => 1,
};
body + if self.errors[index].is_some() { 1 } else { 0 }
}
fn content_height(&self, width: usize) -> usize {
let fields = (0..self.spec.fields.len())
.map(|index| self.block_height(index))
.sum::<usize>()
+ 2;
let bottom_padding = if self.is_dialog() { 1 } else { 0 };
self.header_rows(width) + fields + bottom_padding
}
pub fn render(&mut self, x: usize, y: usize, width: usize, height: usize) {
self.group.clear_areas();
let total = self.content_height(width.saturating_sub(DIALOG_PADDING * 2));
self.scroll.set_total_rows(total);
self.scroll.layout(x, y, width, height);
let origin = x;
let full_width = self.scroll.content_width();
let centered_x = |item_width: usize| origin + full_width.saturating_sub(item_width) / 2;
let (x, content_width) = if self.is_dialog() {
let fields_width = self.fields_width().min(full_width);
(
x + full_width.saturating_sub(fields_width) / 2,
fields_width,
)
} else {
(x, full_width)
};
let message_lines = self.message_lines(width.saturating_sub(DIALOG_PADDING * 2));
for (index, line) in message_lines.into_iter().enumerate() {
if let Some(screen_y) = self.scroll.screen_row(1 + index) {
let line_width = text_width(line.content()).min(full_width);
print_text_with_coordinates(
line,
centered_x(line_width),
screen_y,
Some(line_width),
None,
);
}
}
let mut starts = vec![];
let mut row = self.header_rows(width.saturating_sub(DIALOG_PADDING * 2));
for index in 0..self.spec.fields.len() {
starts.push(row);
row += self.block_height(index);
}
let buttons_row = row + 1;
if self.follow_focus {
if let Some(focused) = self.group.focused_key().copied() {
let (start, block) = if focused < self.spec.fields.len() {
(starts[focused], self.block_height(focused))
} else {
(buttons_row, 1)
};
self.scroll.ensure_range_visible(start, block);
}
self.follow_focus = false;
}
for index in 0..self.spec.fields.len() {
let start = starts[index];
let block = self.block_height(index);
if !self.scroll.is_row_range_visible(start, block) {
continue;
}
let Some(screen_y) = self.scroll.screen_row(start) else {
continue;
};
let field = self.spec.fields[index].clone();
let mut body_rows = 1;
match field.kind {
FormFieldKind::Input => {
if let Some(input) = self.group.text_input_mut(&index) {
input.render(x, screen_y, content_width);
}
},
FormFieldKind::Select => {
if let Some(dropdown) = self.group.dropdown_mut(&index) {
dropdown.render(x, screen_y, content_width);
}
},
FormFieldKind::Toggle => {
if let Some(toggle) = self.group.toggle_mut(&index) {
toggle.render(x, screen_y);
}
},
FormFieldKind::Number => {
if let Some(stepper) = self.group.number_stepper_mut(&index) {
stepper.render(x, screen_y);
}
},
FormFieldKind::Choose => {
let hint = if field.multi {
" (Space ticks)"
} else {
" (Space picks)"
};
let label = truncate(&format!("{}{}", field.label, hint), content_width);
let label_length = text_width(&field.label).min(label.chars().count());
print_text_with_coordinates(
Text::from(label.clone())
.color_range(0, 0..label_length)
.dim_range(label_length..),
x,
screen_y,
Some(content_width),
None,
);
let list_height = self.block_height(index)
- 1
- if self.errors[index].is_some() { 1 } else { 0 };
if let Some(list) = self.group.menu_list_mut(&index) {
list.render(x, screen_y + 1, content_width.min(60), list_height);
}
body_rows = 1 + list_height;
},
}
if let Some(error) = &self.errors[index] {
let indent = if field.kind == FormFieldKind::Choose {
0
} else {
self.label_width
};
print_error(
error,
x + indent,
screen_y + body_rows,
content_width.saturating_sub(indent),
);
}
}
if self.scroll.is_row_range_visible(buttons_row, 1) {
if let Some(screen_y) = self.scroll.screen_row(buttons_row) {
let submit_key = self.submit_key();
let cancel_key = self.cancel_key();
let submit_width = self
.group
.button(&submit_key)
.map(|b| b.natural_width())
.unwrap_or(8);
let buttons_x = if self.is_dialog() {
centered_x(self.buttons_width())
} else {
x
};
if let Some(button) = self.group.button_mut(&submit_key) {
button.render(buttons_x, screen_y);
}
if let Some(button) = self.group.button_mut(&cancel_key) {
button.render(buttons_x + submit_width + 2, screen_y);
}
}
}
self.scroll.render_indicators();
}
pub fn render_overlays(&mut self, rows: usize, cols: usize) {
self.group.render_overlays(rows, cols);
}
pub fn desired_size(&self) -> (usize, usize) {
if self.is_dialog() {
let message_width = self
.spec
.message
.as_ref()
.map(|message| text_width(&message.text).min(DIALOG_TEXT_WIDTH))
.unwrap_or(0);
let inner = self
.fields_width()
.max(message_width)
.max(self.buttons_width());
return (inner + DIALOG_PADDING * 2, self.content_height(inner));
}
let widest = self
.spec
.fields
.iter()
.map(|field| match field.kind {
FormFieldKind::Choose | FormFieldKind::Select => {
field
.options
.iter()
.map(|o| text_width(o))
.max()
.unwrap_or(0)
+ 12
},
_ => 30,
})
.max()
.unwrap_or(30);
(
self.label_width + widest + 4,
self.content_height(usize::MAX),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use zellij_utils::prompt::parse_form_spec;
fn key(bare_key: BareKey) -> KeyWithModifier {
KeyWithModifier::new(bare_key)
}
fn form(text: &str) -> FormScreen {
let spec = parse_form_spec(text).unwrap();
let patterns = spec
.fields
.iter()
.map(|field| {
field
.validate
.as_ref()
.map(|p| Pattern(regex::Regex::new(p).unwrap()))
})
.collect();
FormScreen::new(spec, patterns, None)
}
const EXAMPLE: &str = r#"{ "title": "New project",
"fields": [
{ "id": "name", "type": "input", "label": "Name", "validate": "^[a-z-]+$", "required": true },
{ "id": "license", "type": "select", "label": "License", "options": ["MIT", "Apache-2.0"], "default": "MIT" },
{ "id": "ci", "type": "toggle", "label": "Use CI", "default": true },
{ "id": "port", "type": "number", "label": "Port", "min": 1, "max": 65535, "default": 8080 },
{ "id": "files", "type": "choose", "label": "Files", "options": ["a", "b"], "multi": true } ],
"buttons": { "submit": "Create", "cancel": "Cancel" } }"#;
#[test]
fn submit_is_refused_while_a_required_field_is_empty() {
let mut screen = form(EXAMPLE);
assert_eq!(screen.try_submit(), Step::Redraw);
assert_eq!(
screen.errors()[0].as_deref(),
Some("This field is required")
);
}
#[test]
fn submit_is_refused_while_a_check_fails() {
let mut screen = form(EXAMPLE);
screen.handle_key(&key(BareKey::Char('X')));
assert_eq!(screen.try_submit(), Step::Redraw);
assert!(screen.errors()[0]
.as_deref()
.unwrap()
.starts_with("Must match"));
screen.handle_key(&key(BareKey::Backspace));
screen.handle_key(&key(BareKey::Char('x')));
assert_eq!(screen.errors()[0], None);
}
#[test]
fn the_example_form_answers_one_object_in_field_order() {
let mut screen = form(EXAMPLE);
for c in "demo".chars() {
screen.handle_key(&key(BareKey::Char(c)));
}
for _ in 0..4 {
screen.handle_key(&key(BareKey::Tab));
}
assert_eq!(screen.handle_key(&key(BareKey::Char(' '))), Step::Redraw);
match screen.try_submit() {
Step::Done(Outcome::Answered(Answer::Form(values))) => {
assert_eq!(
crate::outcome::ordered_object(&values),
r#"{"name":"demo","license":"MIT","ci":true,"port":8080,"files":["a"]}"#
);
},
other => panic!("unexpected {:?}", other),
}
}
const DIALOG: &str = r#"{ "message": "Delete Alt n from Normal mode?",
"fields": [ { "id": "again", "type": "toggle", "label": "Don't ask again" } ],
"buttons": { "submit": "Delete", "cancel": "Cancel" } }"#;
#[test]
fn a_form_with_a_message_submits_on_enter_and_flips_toggles_on_space() {
let mut screen = form(DIALOG);
assert!(screen.is_dialog());
assert_eq!(screen.handle_key(&key(BareKey::Char(' '))), Step::Redraw);
match screen.handle_key(&key(BareKey::Enter)) {
Step::Done(Outcome::Answered(Answer::Form(values))) => {
assert_eq!(values, vec![("again".to_owned(), Value::Bool(true))]);
},
other => panic!("unexpected {:?}", other),
}
let mut screen = form(DIALOG);
screen.handle_key(&key(BareKey::Tab));
screen.handle_key(&key(BareKey::Tab));
assert_eq!(
screen.handle_key(&key(BareKey::Enter)),
Step::Done(Outcome::Cancelled)
);
}
#[test]
fn a_form_with_a_message_leaves_room_for_it_above_the_fields() {
let dialog = form(DIALOG);
let (width, height) = dialog.desired_size();
assert!(width >= "Delete Alt n from Normal mode?".len() + 4);
let plain =
form(r#"{"fields":[{"id":"again","type":"toggle","label":"Don't ask again"}]}"#);
assert!(!plain.is_dialog());
assert_eq!(height, plain.desired_size().1 + 4);
let mut plain = plain;
plain.handle_key(&key(BareKey::Char(' ')));
assert_eq!(plain.handle_key(&key(BareKey::Enter)), Step::Redraw);
}
#[test]
fn required_choose_needs_a_ticked_option_and_esc_cancels() {
let mut screen =
form(r#"{"fields":[{"id":"f","type":"choose","options":["a","b"],"required":true}]}"#);
assert_eq!(screen.try_submit(), Step::Redraw);
assert!(screen.errors()[0].is_some());
screen.handle_key(&key(BareKey::Down));
screen.handle_key(&key(BareKey::Char(' ')));
match screen.try_submit() {
Step::Done(Outcome::Answered(Answer::Form(values))) => {
assert_eq!(
values,
vec![("f".to_owned(), Value::String("b".to_owned()))]
);
},
other => panic!("unexpected {:?}", other),
}
let mut screen = form(r#"{"fields":[{"id":"t","type":"toggle"}]}"#);
assert_eq!(
screen.handle_key(&key(BareKey::Esc)),
Step::Done(Outcome::Cancelled)
);
}
}
+73
View File
@@ -0,0 +1,73 @@
mod app;
mod form;
mod outcome;
mod reply;
mod request;
mod store;
mod ui;
use app::{App, Effect};
use std::collections::BTreeMap;
use std::time::Instant;
use zellij_tile::prelude::*;
#[derive(Default)]
struct Prompt {
app: App,
}
register_plugin!(Prompt);
#[cfg(target_family = "wasm")]
fn apply(effects: Vec<Effect>) {
for effect in effects {
match effect {
Effect::BlockPipe(pipe_id) => block_cli_pipe_input(&pipe_id),
Effect::SetExitCode(pipe_id, exit_code) => set_cli_pipe_exit_code(&pipe_id, exit_code),
Effect::Output(pipe_id, output) => cli_pipe_output(&pipe_id, &output),
Effect::UnblockPipe(pipe_id) => unblock_cli_pipe_input(&pipe_id),
Effect::ReplyToPrompt(request_id, result) => reply_to_prompt(request_id, result),
Effect::SetPopupSize(width, height) => set_popup_size(width, height),
Effect::SetTimeout(seconds) => set_timeout(seconds),
Effect::ShowSelf => show_self(true),
Effect::CloseSelf => close_self(),
Effect::FocusPane(pane_id) => focus_pane_with_id(pane_id, true, false),
Effect::WatchNames => subscribe(&[EventType::PaneUpdate, EventType::TabUpdate]),
}
}
}
#[cfg(not(target_family = "wasm"))]
fn apply(_effects: Vec<Effect>) {}
impl Prompt {
fn flush(&mut self) {
apply(self.app.take_effects());
}
}
impl ZellijPlugin for Prompt {
fn load(&mut self, _configuration: BTreeMap<String, String>) {
subscribe(&[EventType::Key, EventType::Mouse, EventType::Timer]);
}
fn update(&mut self, event: Event) -> bool {
let should_render = match event {
Event::Key(key) => self.app.handle_key(key),
Event::Mouse(mouse) => self.app.handle_mouse(mouse),
Event::Timer(_) => self.app.handle_timer(Instant::now()),
Event::PaneUpdate(pane_manifest) => self.app.handle_pane_update(&pane_manifest),
Event::TabUpdate(tabs) => self.app.handle_tab_update(&tabs),
_ => false,
};
self.flush();
should_render
}
fn pipe(&mut self, pipe_message: PipeMessage) -> bool {
let should_render = self.app.handle_pipe(pipe_message, Instant::now());
self.flush();
should_render
}
fn render(&mut self, rows: usize, cols: usize) {
self.app.render(rows, cols, Instant::now());
}
}
+288
View File
@@ -0,0 +1,288 @@
use serde_json::{json, Value};
use zellij_utils::prompt::{parse_bool, EXIT_ANSWERED, EXIT_CANCELLED, EXIT_ERROR, EXIT_TIMEOUT};
use crate::request::{Request, Spec};
#[derive(Debug, Clone, PartialEq)]
pub enum Answer {
Confirmed(bool),
Text(String),
Number(i64),
Bool(bool),
Choice(String),
Choices(Vec<String>),
Form(Vec<(String, Value)>),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
Answered(Answer),
Cancelled,
TimedOut(Option<Answer>),
Error(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Reply {
pub output: String,
pub exit_code: i32,
}
impl Answer {
fn to_json(&self) -> Value {
match self {
Answer::Confirmed(yes) => Value::Bool(*yes),
Answer::Text(text) => Value::String(text.clone()),
Answer::Number(number) => json!(number),
Answer::Bool(on) => Value::Bool(*on),
Answer::Choice(choice) => Value::String(choice.clone()),
Answer::Choices(choices) => {
Value::Array(choices.iter().cloned().map(Value::String).collect())
},
Answer::Form(values) => Value::Object(values.iter().cloned().collect()),
}
}
fn exit_code(&self) -> i32 {
match self {
Answer::Confirmed(false) => EXIT_CANCELLED,
_ => EXIT_ANSWERED,
}
}
fn to_text(&self, null: bool) -> String {
let end = if null { "\0" } else { "\n" };
match self {
Answer::Confirmed(_) => String::new(),
Answer::Text(text) => format!("{}{}", text, end),
Answer::Number(number) => format!("{}{}", number, end),
Answer::Bool(on) => format!("{}{}", on, end),
Answer::Choice(choice) => format!("{}{}", choice, end),
Answer::Choices(choices) => choices
.iter()
.map(|choice| format!("{}{}", choice, end))
.collect(),
Answer::Form(values) => format!("{}\n", ordered_object(values)),
}
}
}
pub fn ordered_object(values: &[(String, Value)]) -> String {
let members: Vec<String> = values
.iter()
.map(|(key, value)| format!("{}:{}", Value::String(key.clone()), value))
.collect();
format!("{{{}}}", members.join(","))
}
pub fn default_answer(request: &Request) -> Result<Option<Answer>, String> {
let Some(default) = &request.common.default else {
return Ok(None);
};
let answer = match &request.spec {
Spec::Confirm { .. } => Answer::Confirmed(parse_bool(default)?),
Spec::Choose { multi: true, .. } => Answer::Choices(vec![default.clone()]),
Spec::Choose { .. } | Spec::Select { .. } | Spec::Menu { .. } => {
Answer::Choice(default.clone())
},
Spec::Input { .. } => Answer::Text(default.clone()),
Spec::Number { .. } => Answer::Number(
default
.trim()
.parse()
.map_err(|_| format!("'{}' is not a whole number", default))?,
),
Spec::Toggle { .. } => Answer::Bool(parse_bool(default)?),
Spec::Form { default, .. } => {
Answer::Form(default.clone().unwrap_or_default().into_iter().collect())
},
Spec::Notify { .. } => return Ok(None),
};
Ok(Some(answer))
}
pub fn reply_for(outcome: &Outcome, json_output: bool, null: bool) -> Reply {
if json_output {
let (value, exit_code) = match outcome {
Outcome::Answered(Answer::Form(values)) => (
Value::String(format!(
"{{\"result\":\"answered\",\"value\":{}}}",
ordered_object(values)
)),
EXIT_ANSWERED,
),
Outcome::Answered(answer) => (
json!({"result": "answered", "value": answer.to_json()}),
answer.exit_code(),
),
Outcome::Cancelled => (json!({"result": "cancelled"}), EXIT_CANCELLED),
Outcome::TimedOut(Some(answer)) => (
json!({"result": "timeout", "value": answer.to_json()}),
answer.exit_code(),
),
Outcome::TimedOut(None) => (json!({"result": "timeout"}), EXIT_TIMEOUT),
Outcome::Error(message) => (json!({"result": "error", "error": message}), EXIT_ERROR),
};
let output = match value {
Value::String(raw) => raw,
other => other.to_string(),
};
return Reply {
output: format!("{}\n", output),
exit_code,
};
}
match outcome {
Outcome::Answered(answer) | Outcome::TimedOut(Some(answer)) => Reply {
output: answer.to_text(null),
exit_code: answer.exit_code(),
},
Outcome::Cancelled => Reply {
output: String::new(),
exit_code: EXIT_CANCELLED,
},
Outcome::TimedOut(None) => Reply {
output: String::new(),
exit_code: EXIT_TIMEOUT,
},
Outcome::Error(message) => Reply {
output: format!("zellij prompt: {}\n", message),
exit_code: EXIT_ERROR,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request::parse_request;
use std::collections::BTreeMap;
fn request(name: &str, pairs: &[(&str, &str)]) -> Request {
let args: BTreeMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
parse_request(name, &args, None).unwrap()
}
#[test]
fn plain_text_output_for_each_answer() {
let cases = vec![
(Answer::Confirmed(true), "", 0),
(Answer::Confirmed(false), "", 1),
(Answer::Text("hi".to_owned()), "hi\n", 0),
(Answer::Number(8080), "8080\n", 0),
(Answer::Bool(true), "true\n", 0),
(Answer::Bool(false), "false\n", 0),
(Answer::Choice("main".to_owned()), "main\n", 0),
(
Answer::Choices(vec!["a".to_owned(), "b".to_owned()]),
"a\nb\n",
0,
),
(Answer::Choices(vec![]), "", 0),
];
for (answer, output, exit_code) in cases {
let reply = reply_for(&Outcome::Answered(answer.clone()), false, false);
assert_eq!(reply.output, output, "{:?}", answer);
assert_eq!(reply.exit_code, exit_code, "{:?}", answer);
}
}
#[test]
fn nul_separated_output() {
let reply = reply_for(
&Outcome::Answered(Answer::Choices(vec!["a b".to_owned(), "c".to_owned()])),
false,
true,
);
assert_eq!(reply.output, "a b\0c\0");
}
#[test]
fn form_output_is_one_json_object() {
let values = vec![
("name".to_owned(), json!("demo")),
("license".to_owned(), json!("MIT")),
("ci".to_owned(), json!(true)),
("port".to_owned(), json!(8080)),
("files".to_owned(), json!(["a"])),
];
let reply = reply_for(
&Outcome::Answered(Answer::Form(values.clone())),
false,
false,
);
assert_eq!(
reply.output,
"{\"name\":\"demo\",\"license\":\"MIT\",\"ci\":true,\"port\":8080,\"files\":[\"a\"]}\n"
);
assert_eq!(reply.exit_code, 0);
let reply = reply_for(&Outcome::Answered(Answer::Form(values)), true, false);
assert!(reply
.output
.starts_with("{\"result\":\"answered\",\"value\":{\"name\":\"demo\""));
}
#[test]
fn json_output_says_why_the_prompt_ended() {
let answered = reply_for(
&Outcome::Answered(Answer::Choice("x".to_owned())),
true,
false,
);
assert_eq!(
answered.output,
"{\"result\":\"answered\",\"value\":\"x\"}\n"
);
assert_eq!(answered.exit_code, 0);
let cancelled = reply_for(&Outcome::Cancelled, true, false);
assert_eq!(cancelled.output, "{\"result\":\"cancelled\"}\n");
assert_eq!(cancelled.exit_code, 1);
let timeout = reply_for(&Outcome::TimedOut(None), true, false);
assert_eq!(timeout.output, "{\"result\":\"timeout\"}\n");
assert_eq!(timeout.exit_code, 124);
let refused = reply_for(&Outcome::Answered(Answer::Confirmed(false)), true, false);
assert_eq!(
refused.output,
"{\"result\":\"answered\",\"value\":false}\n"
);
assert_eq!(refused.exit_code, 1);
}
#[test]
fn exit_codes_for_cancel_timeout_and_error() {
assert_eq!(reply_for(&Outcome::Cancelled, false, false).exit_code, 1);
assert_eq!(
reply_for(&Outcome::TimedOut(None), false, false).exit_code,
124
);
let error = reply_for(&Outcome::Error("bad".to_owned()), false, false);
assert_eq!(error.exit_code, 2);
assert!(error.output.contains("bad"));
}
#[test]
fn timeout_uses_the_default_when_given() {
let with_default = request("input", &[("timeout", "1s"), ("default", "fallback")]);
let answer = default_answer(&with_default).unwrap();
let reply = reply_for(&Outcome::TimedOut(answer), false, false);
assert_eq!(reply.output, "fallback\n");
assert_eq!(reply.exit_code, 0);
let without_default = request("input", &[("timeout", "1s")]);
let reply = reply_for(
&Outcome::TimedOut(default_answer(&without_default).unwrap()),
false,
false,
);
assert_eq!(reply.output, "");
assert_eq!(reply.exit_code, 124);
let confirm = request("confirm", &[("default", "no")]);
let reply = reply_for(
&Outcome::TimedOut(default_answer(&confirm).unwrap()),
true,
false,
);
assert_eq!(reply.output, "{\"result\":\"timeout\",\"value\":false}\n");
assert_eq!(reply.exit_code, 1);
}
}
+255
View File
@@ -0,0 +1,255 @@
use crate::app::Effect;
use crate::outcome::{reply_for, Answer, Outcome};
use serde_json::Value;
use std::collections::BTreeMap;
use zellij_utils::prompt::{
FormFieldKind, PipePrompt, PromptResult, PromptSpec, PromptValue, EXIT_ANSWERED, EXIT_CANCELLED,
};
#[derive(Debug, Clone, PartialEq)]
pub enum Destination {
Cli {
pipe_id: String,
json: bool,
null: bool,
},
Plugin {
request_id: u64,
field_kinds: BTreeMap<String, FormFieldKind>,
},
}
impl Destination {
pub fn cli(pipe_id: String) -> Self {
Destination::Cli {
pipe_id,
json: false,
null: false,
}
}
pub fn plugin(request_id: u64) -> Self {
Destination::Plugin {
request_id,
field_kinds: BTreeMap::new(),
}
}
pub fn is_cli(&self) -> bool {
matches!(self, Destination::Cli { .. })
}
pub fn for_request(self, pipe_prompt: &PipePrompt) -> Self {
match self {
Destination::Cli { pipe_id, .. } => Destination::Cli {
pipe_id,
json: pipe_prompt.json,
null: pipe_prompt.null,
},
Destination::Plugin { request_id, .. } => Destination::Plugin {
request_id,
field_kinds: match &pipe_prompt.request.spec {
PromptSpec::Form { spec } => spec
.fields
.iter()
.map(|field| (field.id.clone(), field.kind))
.collect(),
_ => BTreeMap::new(),
},
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Intent {
Do(Effect),
Hold(Destination),
Pending(Destination),
Acknowledge(Destination),
NoticeClicked(Destination),
Reply(Destination, Outcome),
}
pub fn expand(intents: Vec<Intent>) -> Vec<Effect> {
let mut effects = vec![];
for intent in intents {
match intent {
Intent::Do(effect) => effects.push(effect),
Intent::Hold(Destination::Cli { pipe_id, .. }) => {
effects.push(Effect::BlockPipe(pipe_id))
},
Intent::Pending(Destination::Cli { pipe_id, .. }) => {
effects.push(Effect::SetExitCode(pipe_id, EXIT_CANCELLED))
},
Intent::Acknowledge(Destination::Cli { pipe_id, .. }) => {
effects.push(Effect::SetExitCode(pipe_id.clone(), EXIT_ANSWERED));
effects.push(Effect::UnblockPipe(pipe_id));
},
Intent::NoticeClicked(Destination::Plugin { request_id, .. }) => effects.push(
Effect::ReplyToPrompt(request_id, PromptResult::Answered(PromptValue::Bool(true))),
),
Intent::Reply(
Destination::Cli {
pipe_id,
json,
null,
},
outcome,
) => {
let reply = reply_for(&outcome, json, null);
if !reply.output.is_empty() {
effects.push(Effect::Output(pipe_id.clone(), reply.output));
}
effects.push(Effect::SetExitCode(pipe_id.clone(), reply.exit_code));
effects.push(Effect::UnblockPipe(pipe_id));
},
Intent::Reply(
Destination::Plugin {
request_id,
field_kinds,
},
outcome,
) => effects.push(Effect::ReplyToPrompt(
request_id,
prompt_result(&outcome, &field_kinds),
)),
Intent::Hold(Destination::Plugin { .. })
| Intent::Pending(Destination::Plugin { .. })
| Intent::Acknowledge(Destination::Plugin { .. })
| Intent::NoticeClicked(Destination::Cli { .. }) => {},
}
}
effects
}
pub fn prompt_result(
outcome: &Outcome,
field_kinds: &BTreeMap<String, FormFieldKind>,
) -> PromptResult {
match outcome {
Outcome::Answered(Answer::Confirmed(yes)) => PromptResult::Confirmed(*yes),
Outcome::Answered(answer) => PromptResult::Answered(prompt_value(answer, field_kinds)),
Outcome::Cancelled => PromptResult::Cancelled,
Outcome::TimedOut(answer) => PromptResult::TimedOut(
answer
.as_ref()
.map(|answer| prompt_value(answer, field_kinds)),
),
Outcome::Error(message) => PromptResult::Error(message.clone()),
}
}
fn prompt_value(answer: &Answer, field_kinds: &BTreeMap<String, FormFieldKind>) -> PromptValue {
match answer {
Answer::Confirmed(yes) => PromptValue::Bool(*yes),
Answer::Text(text) => PromptValue::Text(text.clone()),
Answer::Number(number) => PromptValue::Number(*number),
Answer::Bool(on) => PromptValue::Bool(*on),
Answer::Choice(choice) => PromptValue::Choice(choice.clone()),
Answer::Choices(choices) => PromptValue::Choices(choices.clone()),
Answer::Form(values) => PromptValue::Form(
values
.iter()
.filter_map(|(id, value)| {
form_value(value, field_kinds.get(id)).map(|value| (id.clone(), value))
})
.collect(),
),
}
}
fn form_value(value: &Value, kind: Option<&FormFieldKind>) -> Option<PromptValue> {
match value {
Value::Null => None,
Value::Bool(on) => Some(PromptValue::Bool(*on)),
Value::Number(number) => {
Some(PromptValue::Number(number.as_i64().unwrap_or_else(|| {
number.as_f64().unwrap_or_default() as i64
})))
},
Value::String(text) => match kind {
Some(FormFieldKind::Select) | Some(FormFieldKind::Choose) => {
Some(PromptValue::Choice(text.clone()))
},
_ => Some(PromptValue::Text(text.clone())),
},
Value::Array(values) => Some(PromptValue::Choices(
values
.iter()
.map(|value| match value {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.collect(),
)),
Value::Object(_) => Some(PromptValue::Text(value.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_form_answer_becomes_a_typed_map() {
let mut field_kinds = BTreeMap::new();
field_kinds.insert("name".to_owned(), FormFieldKind::Input);
field_kinds.insert("license".to_owned(), FormFieldKind::Select);
field_kinds.insert("ci".to_owned(), FormFieldKind::Toggle);
field_kinds.insert("port".to_owned(), FormFieldKind::Number);
field_kinds.insert("files".to_owned(), FormFieldKind::Choose);
let answer = Answer::Form(vec![
("name".to_owned(), json!("app")),
("license".to_owned(), json!("MIT")),
("ci".to_owned(), json!(true)),
("port".to_owned(), json!(8080)),
("files".to_owned(), json!(["a", "b"])),
]);
let mut expected = BTreeMap::new();
expected.insert("name".to_owned(), PromptValue::Text("app".to_owned()));
expected.insert("license".to_owned(), PromptValue::Choice("MIT".to_owned()));
expected.insert("ci".to_owned(), PromptValue::Bool(true));
expected.insert("port".to_owned(), PromptValue::Number(8080));
expected.insert(
"files".to_owned(),
PromptValue::Choices(vec!["a".to_owned(), "b".to_owned()]),
);
assert_eq!(
prompt_result(&Outcome::Answered(answer), &field_kinds),
PromptResult::Answered(PromptValue::Form(expected))
);
}
#[test]
fn every_outcome_has_a_plugin_result() {
let kinds = BTreeMap::new();
assert_eq!(
prompt_result(&Outcome::Answered(Answer::Confirmed(false)), &kinds),
PromptResult::Confirmed(false)
);
assert_eq!(
prompt_result(&Outcome::Cancelled, &kinds),
PromptResult::Cancelled
);
assert_eq!(
prompt_result(&Outcome::TimedOut(Some(Answer::Number(3))), &kinds),
PromptResult::TimedOut(Some(PromptValue::Number(3)))
);
assert_eq!(
prompt_result(&Outcome::Error("x".to_owned()), &kinds),
PromptResult::Error("x".to_owned())
);
assert_eq!(
expand(vec![Intent::Reply(
Destination::plugin(4),
Outcome::Cancelled
)]),
vec![Effect::ReplyToPrompt(4, PromptResult::Cancelled)]
);
assert!(expand(vec![
Intent::Hold(Destination::plugin(4)),
Intent::Pending(Destination::plugin(4)),
Intent::Acknowledge(Destination::plugin(4)),
])
.is_empty());
}
}
+524
View File
@@ -0,0 +1,524 @@
use regex::Regex;
use serde_json::Value;
use std::time::Duration;
use zellij_utils::data::{PaneId, StyledText};
pub use zellij_utils::prompt::split_lines;
#[cfg(test)]
use zellij_utils::prompt::strip_line_ending;
use zellij_utils::prompt::{
self, parse_bool, ChoiceItem, FormSpec, PipePrompt, PromptElement, PromptSpec,
};
#[derive(Debug, Clone, PartialEq)]
pub struct Common {
pub title: Option<String>,
pub timeout: Option<Duration>,
pub default: Option<String>,
pub json: bool,
pub in_popup: bool,
}
#[derive(Debug, Clone)]
pub struct Pattern(pub Regex);
impl PartialEq for Pattern {
fn eq(&self, other: &Self) -> bool {
self.0.as_str() == other.0.as_str()
}
}
impl Pattern {
pub fn is_match(&self, text: &str) -> bool {
self.0.is_match(text)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Spec {
Confirm {
message: StyledText,
yes: String,
no: String,
default_yes: Option<bool>,
},
Choose {
items: Vec<ChoiceItem>,
multi: bool,
labels: bool,
null: bool,
selected: Vec<String>,
streaming: bool,
},
Input {
message: Option<StyledText>,
placeholder: Option<String>,
validate: Option<Pattern>,
required: bool,
default: Option<String>,
},
Number {
message: Option<StyledText>,
min: Option<i64>,
max: Option<i64>,
step: i64,
default: Option<i64>,
},
Toggle {
message: Option<StyledText>,
default: bool,
},
Select {
message: StyledText,
options: Vec<String>,
default: Option<usize>,
},
Menu {
items: Vec<ChoiceItem>,
},
Form {
spec: FormSpec,
patterns: Vec<Option<Pattern>>,
default: Option<serde_json::Map<String, Value>>,
},
Notify {
message: StyledText,
pane_name: Option<String>,
tab_name: Option<String>,
show_pane_name: bool,
show_tab_name: bool,
caller_pane: Option<PaneId>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
pub element: PromptElement,
pub common: Common,
pub spec: Spec,
}
fn compile(pattern: &str) -> Result<Pattern, String> {
prompt::compile_pattern(pattern).map(Pattern)
}
fn parse_default<T>(
default: &Option<String>,
parse: impl Fn(&str) -> Result<T, String>,
) -> Result<Option<T>, String> {
match default {
Some(default) => parse(default)
.map(Some)
.map_err(|e| format!("default: {}", e)),
None => Ok(None),
}
}
#[cfg(test)]
pub fn parse_request(
name: &str,
args: &std::collections::BTreeMap<String, String>,
payload: Option<&str>,
) -> Result<Request, String> {
request_from_pipe_prompt(prompt::parse_request(name, args, payload)?)
}
pub fn request_from_pipe_prompt(pipe_prompt: PipePrompt) -> Result<Request, String> {
let in_popup = pipe_prompt.in_popup();
let PipePrompt {
request,
json,
null,
labels,
streaming,
caller,
} = pipe_prompt;
let default = request.default.clone();
let mut common = Common {
title: request.title.clone(),
timeout: request.timeout,
default: default.clone(),
json,
in_popup,
};
let spec = match request.spec {
PromptSpec::Confirm { message, yes, no } => Spec::Confirm {
message,
yes,
no,
default_yes: parse_default(&default, parse_bool)?,
},
PromptSpec::Choose {
items,
multi,
selected,
} => Spec::Choose {
items,
multi,
labels,
null,
selected,
streaming,
},
PromptSpec::Input {
message,
placeholder,
validate,
required,
} => Spec::Input {
message,
placeholder,
validate: match validate {
Some(pattern) => Some(compile(&pattern).map_err(|e| format!("validate: {}", e))?),
None => None,
},
required,
default,
},
PromptSpec::Number {
message,
min,
max,
step,
} => Spec::Number {
message,
min,
max,
step,
default: parse_default(&default, |d| {
d.trim()
.parse::<i64>()
.map_err(|_| format!("'{}' is not a whole number", d))
})?,
},
PromptSpec::Toggle { message } => Spec::Toggle {
message,
default: parse_default(&default, parse_bool)?.unwrap_or(false),
},
PromptSpec::Select { message, options } => {
let default = default
.as_ref()
.and_then(|default| options.iter().position(|o| o == default));
Spec::Select {
message,
options,
default,
}
},
PromptSpec::Menu { items } => Spec::Menu { items },
PromptSpec::Form { spec } => {
let patterns = spec
.fields
.iter()
.map(|field| match &field.validate {
Some(pattern) => compile(pattern).map(Some),
None => Ok(None),
})
.collect::<Result<Vec<_>, _>>()?;
let default = match &default {
Some(default) => match serde_json::from_str::<Value>(default) {
Ok(Value::Object(object)) => Some(object),
_ => return Err("default: must be a JSON object for form".to_owned()),
},
None => None,
};
if common.title.is_none() {
common.title = spec.title.clone();
}
Spec::Form {
spec,
patterns,
default,
}
},
PromptSpec::Notify {
message,
show_pane_name,
show_tab_name,
} => Spec::Notify {
message,
pane_name: caller.pane_title,
tab_name: caller.tab_name,
show_pane_name,
show_tab_name,
caller_pane: caller.pane_id,
},
};
Ok(Request {
element: request.element,
common,
spec,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn confirm_reads_labels_default_and_popup_marker() {
let request = parse_request(
"confirm",
&args(&[
("message", "Force push?"),
("yes", "Push"),
("no", "Cancel"),
("default", "no"),
("_caller_pane_title", "zsh"),
]),
None,
)
.unwrap();
assert_eq!(
request.spec,
Spec::Confirm {
message: StyledText::plain("Force push?"),
yes: "Push".to_owned(),
no: "Cancel".to_owned(),
default_yes: Some(false),
}
);
assert!(request.common.in_popup);
let floating = parse_request("confirm", &args(&[]), None).unwrap();
assert!(!floating.common.in_popup);
}
#[test]
fn notify_reads_its_text_and_the_calling_pane_title() {
let request = parse_request(
"notify",
&args(&[
("message", "Build finished"),
("timeout", "5s"),
("_caller_pane_title", "make"),
("_caller_tab_name", "Build"),
("_caller_pane_id", "terminal_3"),
]),
None,
)
.unwrap();
assert_eq!(
request.spec,
Spec::Notify {
message: StyledText::plain("Build finished"),
pane_name: Some("make".to_owned()),
tab_name: Some("Build".to_owned()),
show_pane_name: true,
show_tab_name: true,
caller_pane: Some(PaneId::Terminal(3)),
}
);
let without_names = parse_request(
"notify",
&args(&[
("message", "Saved"),
("no_pane_name", "true"),
("no_tab_name", "true"),
]),
None,
)
.unwrap();
assert_eq!(
without_names.spec,
Spec::Notify {
message: StyledText::plain("Saved"),
pane_name: None,
tab_name: None,
show_pane_name: false,
show_tab_name: false,
caller_pane: None,
}
);
assert_eq!(request.common.timeout, Some(Duration::from_secs(5)));
assert!(parse_request("notify", &args(&[]), None).is_err());
}
#[test]
fn confirm_takes_the_message_from_the_payload() {
let request = parse_request("confirm", &args(&[]), Some("Continue?\n")).unwrap();
match request.spec {
Spec::Confirm { message, yes, .. } => {
assert_eq!(message.text, "Continue?");
assert_eq!(yes, "Yes");
},
other => panic!("unexpected {:?}", other),
}
}
#[test]
fn choose_streams_from_the_payload_when_no_items_are_given() {
let request = parse_request(
"choose",
&args(&[("multi", "true"), ("labels", "true")]),
Some("main\tMain branch\n"),
)
.unwrap();
match request.spec {
Spec::Choose {
items,
multi,
streaming,
..
} => {
assert_eq!(items, vec![ChoiceItem::labeled("main", "Main branch")]);
assert!(multi);
assert!(streaming);
},
other => panic!("unexpected {:?}", other),
}
}
#[test]
fn choose_reads_items_and_selected_from_arguments() {
let request = parse_request(
"choose",
&args(&[
("items", r#"["a",["b","Bee"]]"#),
("selected", r#"["b"]"#),
("null", "true"),
]),
None,
)
.unwrap();
match request.spec {
Spec::Choose {
items,
selected,
null,
streaming,
..
} => {
assert_eq!(
items,
vec![ChoiceItem::plain("a"), ChoiceItem::labeled("b", "Bee")]
);
assert_eq!(selected, vec!["b".to_owned()]);
assert!(null);
assert!(!streaming);
},
other => panic!("unexpected {:?}", other),
}
}
#[test]
fn a_payload_with_several_lines_becomes_several_items() {
assert_eq!(
split_lines("a\r\nb\n\nc\n", false, false),
vec![
ChoiceItem::plain("a"),
ChoiceItem::plain("b"),
ChoiceItem::plain(""),
ChoiceItem::plain("c")
]
);
assert_eq!(
split_lines("x\ty\0z\0", true, true),
vec![ChoiceItem::labeled("x", "y"), ChoiceItem::plain("z")]
);
}
#[test]
fn nul_separated_payloads_keep_newlines() {
assert_eq!(strip_line_ending("a\nb\0", true), "a\nb");
assert_eq!(strip_line_ending("a\r\n", false), "a");
assert_eq!(strip_line_ending("a\n\n", false), "a\n");
}
#[test]
fn input_rejects_a_bad_pattern() {
let error = parse_request("input", &args(&[("validate", "(")]), None).unwrap_err();
assert!(error.starts_with("validate:"), "{}", error);
}
#[test]
fn number_checks_bounds_step_and_default() {
assert!(parse_request("number", &args(&[("min", "5"), ("max", "1")]), None).is_err());
assert!(parse_request("number", &args(&[("step", "0")]), None).is_err());
assert!(parse_request("number", &args(&[("default", "x")]), None).is_err());
assert!(parse_request(
"number",
&args(&[("min", "1"), ("max", "5"), ("default", "9")]),
None
)
.is_err());
let request = parse_request(
"number",
&args(&[("min", "1"), ("max", "65535"), ("default", "8080")]),
None,
)
.unwrap();
assert_eq!(
request.spec,
Spec::Number {
message: None,
min: Some(1),
max: Some(65535),
step: 1,
default: Some(8080),
}
);
}
#[test]
fn toggle_and_select_read_their_defaults() {
let toggle = parse_request("toggle", &args(&[("default", "on")]), None).unwrap();
assert_eq!(
toggle.spec,
Spec::Toggle {
message: None,
default: true
}
);
let select = parse_request(
"select",
&args(&[
("message", "License"),
("options", r#"["MIT","Apache-2.0"]"#),
("default", "Apache-2.0"),
]),
None,
)
.unwrap();
assert_eq!(
select.spec,
Spec::Select {
message: StyledText::plain("License"),
options: vec!["MIT".to_owned(), "Apache-2.0".to_owned()],
default: Some(1),
}
);
assert!(parse_request(
"select",
&args(&[("options", "MIT|GPL"), ("default", "BSD")]),
None
)
.is_err());
}
#[test]
fn unknown_elements_and_bad_timeouts_are_errors() {
assert!(parse_request("slider", &args(&[]), None).is_err());
assert!(parse_request("confirm", &args(&[("timeout", "soon")]), None).is_err());
let request = parse_request("confirm", &args(&[("timeout", "2s")]), None).unwrap();
assert_eq!(request.common.timeout, Some(Duration::from_secs(2)));
}
#[test]
fn form_errors_name_the_field() {
let error = parse_request(
"form",
&args(&[]),
Some(r#"{"fields":[{"id":"name","type":"input","validate":"("}]}"#),
)
.unwrap_err();
assert!(error.contains("\"name\""), "{}", error);
assert!(parse_request("form", &args(&[]), None).is_err());
}
}
+126
View File
@@ -0,0 +1,126 @@
use zellij_utils::prompt::ChoiceItem;
const CHUNK_BYTES: usize = 64 * 1024;
const NO_LABEL: u32 = u32::MAX;
#[derive(Debug, Clone, Copy)]
struct Span {
chunk: u32,
start: u32,
value_len: u32,
label_len: u32,
}
#[derive(Debug, Default)]
pub struct ItemStore {
chunks: Vec<String>,
spans: Vec<Span>,
text_bytes: usize,
}
impl ItemStore {
pub fn len(&self) -> usize {
self.spans.len()
}
pub fn is_empty(&self) -> bool {
self.spans.is_empty()
}
#[cfg(test)]
pub fn text_bytes(&self) -> usize {
self.text_bytes
}
pub fn footprint(&self) -> usize {
self.chunks
.iter()
.map(|chunk| chunk.capacity())
.sum::<usize>()
+ self.spans.capacity() * std::mem::size_of::<Span>()
}
fn chunk_with_room(&mut self, needed: usize) -> usize {
let fits = self
.chunks
.last()
.map(|chunk| chunk.capacity() - chunk.len() >= needed)
.unwrap_or(false);
if !fits {
self.chunks
.push(String::with_capacity(needed.max(CHUNK_BYTES)));
}
self.chunks.len() - 1
}
pub fn push(&mut self, item: &ChoiceItem) {
let label = item.label.as_deref();
let needed = item.value.len() + label.map(|l| l.len()).unwrap_or(0);
let chunk_index = self.chunk_with_room(needed);
let chunk = &mut self.chunks[chunk_index];
let start = chunk.len();
chunk.push_str(&item.value);
if let Some(label) = label {
chunk.push_str(label);
}
self.text_bytes += needed;
self.spans.push(Span {
chunk: chunk_index as u32,
start: start as u32,
value_len: item.value.len() as u32,
label_len: label.map(|l| l.len() as u32).unwrap_or(NO_LABEL),
});
}
pub fn value(&self, index: usize) -> &str {
let span = self.spans[index];
let start = span.start as usize;
&self.chunks[span.chunk as usize][start..start + span.value_len as usize]
}
pub fn display(&self, index: usize) -> &str {
let span = self.spans[index];
if span.label_len == NO_LABEL {
return self.value(index);
}
let start = span.start as usize + span.value_len as usize;
&self.chunks[span.chunk as usize][start..start + span.label_len as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_and_labels_come_back_as_stored() {
let mut store = ItemStore::default();
store.push(&ChoiceItem::plain("main"));
store.push(&ChoiceItem::labeled("rm", "Delete file"));
store.push(&ChoiceItem::labeled("x", ""));
assert_eq!(store.len(), 3);
assert_eq!((store.value(0), store.display(0)), ("main", "main"));
assert_eq!((store.value(1), store.display(1)), ("rm", "Delete file"));
assert_eq!((store.value(2), store.display(2)), ("x", ""));
assert_eq!(store.text_bytes(), 4 + 2 + 11 + 1);
}
#[test]
fn many_lines_share_few_allocations() {
let mut store = ItemStore::default();
for i in 0..57_000 {
store.push(&ChoiceItem::plain(format!(
"./some/path/to/file-{:06}.rs",
i
)));
}
assert_eq!(store.value(56_999), "./some/path/to/file-056999.rs");
let text = store.text_bytes();
assert!(store.footprint() < text + text / 4 + 57_000 * 32);
assert!(store.chunks.len() < 40);
}
#[test]
fn a_line_longer_than_a_chunk_gets_its_own_chunk() {
let mut store = ItemStore::default();
store.push(&ChoiceItem::plain("short"));
let long = "y".repeat(CHUNK_BYTES * 2);
store.push(&ChoiceItem::plain(long.clone()));
store.push(&ChoiceItem::plain("after"));
assert_eq!(store.value(1), long);
assert_eq!(store.value(2), "after");
}
}
File diff suppressed because it is too large Load Diff
+194 -10
View File
@@ -1,5 +1,15 @@
// If you'd like to override the default keybindings completely, be sure to change "keybinds" to "keybinds clear-defaults=true"
keybinds {
// Keybindings come from a preset: a file in the "keybinds" folder next to this file, or one of
// the presets built into Zellij ("default" and "unlock-first"). Print a built-in preset with
// "zellij setup --dump-keybinds <name>".
// "primary", "secondary" and "unlock" set the leader keys of presets that use them.
// If a leader key is empty or two of them are equal, Zellij warns and uses the default preset.
// Keybindings that come from a layout are shown in the settings screen but cannot be edited there.
// Bindings inside the block are applied on top of the preset. To replace the preset
// completely, add clear-defaults=true to the block.
// For a single session, "zellij options --keybinds-preset <name>" (and --keybinds-primary,
// --keybinds-secondary, --keybinds-unlock) choose a preset without changing this file.
//
keybinds preset="default" primary="Ctrl" secondary="Alt" unlock="Ctrl g" {
normal {
// uncomment this and adjust key if using copy_on_select=false
// bind "Alt c" { Copy; }
@@ -237,11 +247,19 @@ keybinds {
// Plugin aliases - can be used to change the implementation of Zellij
// changing these requires a restart to take effect
// Built-in aliases cannot be deleted: this block can only add to or override them
// (removing an override brings back the built-in one)
plugins {
tab-bar location="zellij:tab-bar"
status-bar location="zellij:status-bar"
tab-bar location="zellij:bars" {
role "tab-bar"
}
status-bar location="zellij:bars" {
role "status-bar"
}
strider location="zellij:strider"
compact-bar location="zellij:compact-bar"
compact-bar location="zellij:bars" {
role "compact-bar"
}
session-manager location="zellij:session-manager"
welcome-screen location="zellij:session-manager" {
welcome_screen true
@@ -252,15 +270,73 @@ plugins {
configuration location="zellij:configuration"
plugin-manager location="zellij:plugin-manager"
about location="zellij:about"
context-menu location="zellij:context-menu"
prompt location="zellij:prompt"
}
// Plugins to load in the background when a new session starts
// (Requires restart)
load_plugins {
"zellij:link"
"zellij:bars" {
role "link"
}
// "file:/path/to/my-plugin.wasm"
// "https://example.com/my-plugin.wasm"
}
// Right-click menu items, per clicked target: "pane", "tab", "bar" (tab bar or status bar background)
// "common" items are added to the end of every menu
// Actions ending in "ByPaneId"/"ById"/"ByTabId" apply to the clicked pane or tab when no id is given
// Add clear-defaults=true to the block or to a section to replace the default items
// Without clear-defaults, a section changes the default items:
// item "Label" { ... } replaces the item with that label, or adds it at the end
// item "Label" after="Other" { ... } places the item after "Other" (before="Other" places it before)
// separator after="Other" adds a separator after (or before=) "Other"
// remove "Label" removes the item with that label
// Unknown labels are ignored by remove and place the item at the end with after/before
// An empty "context_menu clear-defaults=true {}" leaves the menu empty, so it never opens,
// but right clicks are still taken by Zellij; use "context_menu_enabled false" to turn the menu off
// context_menu {
// pane {
// item "New pane" { NewPane; }
// item "New floating pane" { NewFloatingPane; }
// separator
// item "Fullscreen" { ToggleFocusFullscreenByPaneId; }
// item "Float / Embed" { TogglePaneEmbedOrFloatingByPaneId; }
// item "Rename pane" { StartRenamePaneByPaneId; }
// item "Add to group" { TogglePaneInGroupByPaneId; }
// separator
// item "Close pane" { CloseFocusByPaneId; }
// }
// tab {
// item "New tab" { NewTab; }
// item "Rename tab" { StartRenameTabByTabId; }
// item "Move tab left" { MoveTabByTabId "left"; }
// item "Move tab right" { MoveTabByTabId "right"; }
// separator
// item "Close tab" { CloseTabById; }
// }
// bar {
// item "New pane" { NewPane; }
// item "New tab" { NewTab; }
// }
// common {
// item "Settings…" {
// LaunchOrFocusPlugin "configuration" {
// floating true
// move_to_focused_tab true
// }
// }
// item "Session manager" {
// LaunchOrFocusPlugin "session-manager" {
// floating true
// move_to_focused_tab true
// }
// }
// item "Detach" { Detach; }
// }
// }
// Choose what to do when zellij receives SIGTERM, SIGINT, SIGQUIT or SIGHUP
// eg. when terminal window with an active zellij session is closed
// (Requires restart)
@@ -270,6 +346,15 @@ load_plugins {
//
// on_force_close "quit"
// Choose how to handle a nested Zellij session detected running inside a pane
// Options:
// - ask (Default - prompt with a modal to zoom in or control on focus)
// - fullscreen (always zoom into the nested session)
// - descend (always control the nested session on focus)
// - never (never prompt or descend; do it manually)
//
// nested_session_handling "ask"
// Send a request for a simplified ui (without arrow fonts) to plugins
// Options:
// - true
@@ -293,6 +378,12 @@ load_plugins {
//
// pane_frames true
// Set the pane frame style when pane_frames is enabled
// Options:
// - titles (default - draw a title above each pane)
// - full (draw a full frame around each pane)
// - none (equivalent to pane_frames false)
//
// pane_frame_style "titles"
// Configure the look of the pane frame borders
@@ -355,6 +446,7 @@ load_plugins {
// Define color themes for Zellij
// For more examples, see: https://github.com/zellij-org/zellij/tree/main/example/themes
// Once these themes are defined, one of them should to be selected in the "theme" section of this file
// Built-in themes and themes from theme_dir are read-only in the settings screen (copy one to edit it)
//
// themes {
// dracula {
@@ -377,13 +469,29 @@ load_plugins {
//
// theme "default"
// Themes to use when the host terminal reports a dark or light color palette.
// Both must be set for automatic switching to take place, otherwise `theme`
// remains authoritative. Until the host terminal reports (or if it never does),
// the dark theme is used.
//
// theme_dark "nightfox"
// theme_light "dayfox"
// Pin the session to a dark or light appearance, ignoring what the host
// terminal reports. Resolved before the first render. When unset, the host
// terminal decides.
// Options: dark, light
//
// explicit_theme_hue "dark"
// The name of the default layout to load on startup
// Default: "default"
// (Requires restart)
//
// default_layout "compact"
// Choose the mode that zellij uses when starting up.
// Choose the mode that zellij uses when starting up and returns to after actions.
// Changes apply to the running session without a restart.
// Default: normal
//
// default_mode "locked"
@@ -445,15 +553,20 @@ load_plugins {
// mirror_session true
// The folder in which Zellij will look for layouts
// (Requires restart)
//
// layout_dir "/path/to/my/layout_dir"
// The folder in which Zellij will look for themes
// (Requires restart)
// Changes apply to the running session without a restart.
// Themes from this folder are read-only in the settings screen (copy one to edit it)
//
// theme_dir "/path/to/my/theme_dir"
// The folder in which Zellij will look for keybinding presets
// Default: a "keybinds" folder in the config directory
//
// keybinds_dir "/path/to/my/keybinds_dir"
// Enable or disable the rendering of styled and colored underlines (undercurl).
// May need to be disabled for certain unsupported terminals
// (Requires restart)
@@ -521,16 +634,19 @@ load_plugins {
// A path to a certificate file to be used when setting up the web client to serve the
// connection over HTTPs
// (Requires restart)
//
// web_server_cert "/path/to/my/cert.pem"
// A path to a key file to be used when setting up the web client to serve the
// connection over HTTPs
// (Requires restart)
//
// web_server_key "/path/to/my/key.pem"
// Whether to enforce https connections to the web server when it is bound to localhost
// (127.0.0.0/8)
// (Requires restart)
//
// Note: https is ALWAYS enforced when bound to non-local interfaces
//
@@ -549,12 +665,21 @@ load_plugins {
//
// stacked_resize false
// Whether to let programs running inside panes read the paste buffer (clipboard)
// with the OSC 52 escape sequence. When enabled, any program in any pane -
// including one running on a remote machine over SSH - can read the clipboard
// without the user being asked.
// Default: false
//
// dangerously_enable_paste_buffer_read true
// Whether stacked panes display as a list with the expanded pane pinned to the bottom
// Default: true
//
// stacked_pane_list false
// Whether to show release notes on first version run
// (Requires restart)
// Default: true
//
// show_release_notes false
@@ -584,7 +709,20 @@ load_plugins {
//
// mouse_hover_tips false
// Whether triple-clicking inside command output marked by the shell (OSC 133) selects
// the command and its output instead of the logical line
// Default: true
//
// osc133_command_selection false
// Characters that terminate a word when double-clicking to select it
// whitespace is always a separator and need not be listed here
// Default: "[]{}<>()"
//
// word_separators "[]{}<>()"
// Whether to output OSC8 hyperlink sequences
// (Requires restart)
// Default: true
//
// osc8_hyperlinks false
@@ -598,13 +736,59 @@ load_plugins {
// post_command_discovery_hook "echo $RESURRECT_COMMAND | sed <your_regex_here>"
// Number of async worker tasks to spawn per active client.
// (Requires restart)
//
// Allocating few tasks may result in resource contention and lags. Small values (around 4) should
// typically work best. Set to 0 to use the number of (physical) CPU cores.
// NOTE: This only applies to web clients at the moment."#;
// NOTE: This only applies to web clients at the moment.
// client_async_worker_tasks 4
// How often (in seconds) sessions are saved to disk so they can be resurrected
// (Requires restart)
// Default: 60
//
// serialization_interval 60
// Whether to show a startup tip when a new session starts
// (Requires restart)
// Default: true
//
// show_startup_tips false
// Whether to flash pane and tab frames (and add a [!] suffix) when a terminal bell rings
// Default: true
//
// visual_bell false
// Whether hovering the mouse over a pane focuses it
// Default: false
//
// focus_follows_mouse true
// Whether the click that focuses a pane is also sent to the program running in it
// Default: false
//
// mouse_click_through true
// Whether a right click opens the right-click menu
// When off, right clicks go to the focused pane only and Alt + right click clears the pane group
// Plugins can still open the menu through their API
// Default: true
//
// context_menu_enabled false
// How notifications from programs inside Zellij are passed to the outer terminal
// Options:
// - auto (Default - pick the best protocol the terminal supports)
// - osc9
// - osc99
// - bell
// - off
//
// host_notification_protocol "auto"
// Settings for the web client (font, font_size, cursor_blink, cursor_style, ...)
// (Requires restart)
web_client {
font "monospace"
}
+42
View File
@@ -508,6 +508,48 @@ pub(crate) fn subscribe_to_session(
);
}
pub(crate) fn prompt_in_session(
prompt_cli: zellij_utils::cli::PromptCli,
requested_session_name: Option<String>,
config: Option<Config>,
) -> ! {
let session_name = match requested_session_name.or_else(|| envs::get_session_name().ok()) {
Some(session_name) => session_name,
None => {
eprintln!("zellij prompt must be run inside a Zellij session");
process::exit(zellij_utils::prompt::EXIT_ERROR);
},
};
let existing_sessions: Vec<String> = get_sessions()
.unwrap_or_default()
.iter()
.map(|s| s.0.clone())
.collect();
if !existing_sessions.contains(&session_name) {
eprintln!("Session '{}' not found", session_name);
process::exit(zellij_utils::prompt::EXIT_ERROR);
}
let plugin_url = match &config {
Some(config)
if config
.plugins
.aliases
.contains_key(zellij_utils::prompt::PROMPT_PLUGIN_ALIAS) =>
{
zellij_utils::prompt::PROMPT_PLUGIN_ALIAS.to_owned()
},
_ => zellij_utils::prompt::PROMPT_PLUGIN_URL.to_owned(),
};
let os_input = get_os_input(zellij_client::os_input_output::get_cli_client_os_input);
let exit_status = zellij_client::cli_client::start_prompt_client(
Box::new(os_input),
&session_name,
prompt_cli,
plugin_url,
);
process::exit(exit_status);
}
fn attach_with_cli_client(
cli_action: zellij_utils::cli::CliAction,
session_name: &str,
+5
View File
@@ -51,6 +51,9 @@ fn main() {
commands::subscribe_to_session(subscribe_cli, opts.session, config);
std::process::exit(0);
}
if let Some(Command::Prompt(prompt_cli)) = opts.command {
commands::prompt_in_session(prompt_cli, opts.session, config);
}
if let Some(Command::Sessions(Sessions::Run {
command,
direction,
@@ -252,6 +255,8 @@ fn main() {
in_place_plugin: None,
plugin_cwd: None,
plugin_title: None,
popup: None,
popup_no_focus: false,
};
commands::send_action_to_session(command_cli_action, opts.session, config);
std::process::exit(0);
+10
View File
@@ -85,6 +85,16 @@ fn workspace_members() -> &'static Vec<WorkspaceMember> {
build: true,
extra_artifacts: &[],
},
WorkspaceMember {
crate_name: "default-plugins/prompt",
build: true,
extra_artifacts: &[],
},
WorkspaceMember {
crate_name: "default-plugins/context-menu",
build: true,
extra_artifacts: &[],
},
WorkspaceMember {
crate_name: "zellij-utils",
build: false,
+1
View File
@@ -62,6 +62,7 @@ windows-sys = { workspace = true, features = [
] }
[dev-dependencies]
clap = { workspace = true }
insta = "1.48"
rcgen = "0.14"
serde_json = "1.0"
+1 -1
View File
@@ -1041,7 +1041,7 @@ function installMouseHandlers(term, terminalElement, sendFunction) {
});
document.addEventListener("contextmenu", function (event) {
if (event.altKey) {
if (event.altKey || term.modes.mouseTrackingMode !== "none") {
event.preventDefault();
}
});
+1 -1
View File
@@ -19,6 +19,6 @@
<body>
<div id="terminal" tabindex="0"></div>
<script src="assets/modals.js" integrity="sha384-iDRet9veierC1HWbStHC4LLKCSzjeIZTrPRePuKxiUxr7UvdmJ5epb+PYqgHYqte"></script>
<script type="module" src="assets/app.js" integrity="sha384-zEYVY/2DhkJGDOJDhP5qx+pWO6zp80b3z6IXnbDUlNjRjCHgSdwSSwpRU5rQLndq"></script>
<script type="module" src="assets/app.js" integrity="sha384-DZE0VehfYlynA0H8ANBt7WO6QXca4YQjztKX0Xp5fO7/wwSUsaeh5BRpNnmXWNoC"></script>
</body>
</html>
+1 -1
View File
@@ -3,7 +3,7 @@
"addon-fit.js": "sha384-zZmjmUWgxteKU0acxOtNmpWCMwSnn1/pSIhFBXVx6ARJ4pD/eD3E0dDCccITHWWD",
"addon-web-links.js": "sha384-Zb/L38m4THfRbsxxv8gWAi8cQ6xcWQnv0BgR0qwbTDwtBDaPi6pkyfgfBIH6C8ZG",
"addon-webgl.js": "sha384-AkzdQDeATxpW8V7I2EPcwg17qAoLQMjEFHb//1QaAAIyIlbDAHGKno5+huH7RKxq",
"app.js": "sha384-zEYVY/2DhkJGDOJDhP5qx+pWO6zp80b3z6IXnbDUlNjRjCHgSdwSSwpRU5rQLndq",
"app.js": "sha384-DZE0VehfYlynA0H8ANBt7WO6QXca4YQjztKX0Xp5fO7/wwSUsaeh5BRpNnmXWNoC",
"modals.js": "sha384-iDRet9veierC1HWbStHC4LLKCSzjeIZTrPRePuKxiUxr7UvdmJ5epb+PYqgHYqte",
"style.css": "sha384-qvSJURWrtlva+87171/c2KHjzg2kzTYgjlEkCLTCdadhZtJA5MyGgt7VgOj9dE0D",
"xterm.css": "sha384-Mz9RDc16Iua5vrzALPqUZn39WVfelFzZvwpqCNkMKji5gpYeP3kNCMPGypMFf0AD",
+1 -1
View File
@@ -26,7 +26,7 @@ export function installMouseHandlers(term, terminalElement, sendFunction) {
});
document.addEventListener("contextmenu", function (event) {
if (event.altKey) {
if (event.altKey || term.modes.mouseTrackingMode !== "none") {
event.preventDefault();
}
});
+643 -118
View File
@@ -1,7 +1,7 @@
//! The `[cli_client]` is used to attach to a running server session
//! and dispatch actions, that are specified through the command line.
use std::collections::{BTreeMap, HashSet};
use std::io::{self, BufRead, Write};
use std::collections::{BTreeMap, HashSet, VecDeque};
use std::io::{self, BufRead, Read, Write};
use std::process;
use std::str::FromStr;
use std::{fs, path::PathBuf};
@@ -9,11 +9,12 @@ use std::{fs, path::PathBuf};
use crate::os_input_output::ClientOsApi;
use uuid::Uuid;
use zellij_utils::{
cli::{SubscribeCli, SubscribeFormat},
data::PaneId,
cli::{PromptCli, PromptElementCli, SubscribeCli, SubscribeFormat},
data::{PaneId, PipePopupPlacement},
errors::prelude::*,
input::actions::Action,
ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg},
prompt::{self, ChoiceItem, PromptElement},
};
pub fn start_cli_client(
@@ -49,23 +50,33 @@ pub fn start_cli_client(
in_place,
cwd,
pane_title,
popup,
popup_no_focus,
} => {
pipe_client(
let exit_status = pipe_client(
&mut os_input,
pipe_id,
name,
PipeRequest {
pipe_id,
name,
plugin,
args,
configuration,
launch_new,
skip_cache,
floating,
in_place,
pane_id,
cwd,
pane_title,
popup,
popup_no_focus,
},
payload,
plugin,
args,
configuration,
launch_new,
skip_cache,
floating,
in_place,
pane_id,
cwd,
pane_title,
);
if exit_status != 0 {
os_input.send_to_server(ClientToServerMsg::ClientExited);
return exit_status;
}
},
action => {
if let Some(exit_status) =
@@ -80,14 +91,12 @@ pub fn start_cli_client(
0
}
fn pipe_client(
os_input: &mut Box<dyn ClientOsApi>,
struct PipeRequest {
pipe_id: String,
mut name: Option<String>,
mut payload: Option<String>,
name: Option<String>,
plugin: Option<String>,
args: Option<BTreeMap<String, String>>,
mut configuration: Option<BTreeMap<String, String>>,
configuration: Option<BTreeMap<String, String>>,
launch_new: bool,
skip_cache: bool,
floating: Option<bool>,
@@ -95,120 +104,636 @@ fn pipe_client(
pane_id: Option<u32>,
cwd: Option<PathBuf>,
pane_title: Option<String>,
) {
let mut stdin = os_input.get_stdin_reader();
let name = name
// first we try to take the explicitly supplied message name
.take()
// then we use the plugin, to facilitate using aliases
.or_else(|| plugin.clone())
// then we use a uuid to at least have some sort of identifier for this message
.or_else(|| Some(Uuid::new_v4().to_string()));
if launch_new {
// we do this to make sure the plugin is unique (has a unique configuration parameter) so
// that a new one would be launched, but we'll still send it to the same instance rather
// than launching a new one in every iteration of the loop
configuration
.get_or_insert_with(BTreeMap::new)
.insert("_zellij_id".to_owned(), Uuid::new_v4().to_string());
popup: Option<PipePopupPlacement>,
popup_no_focus: bool,
}
impl PipeRequest {
fn prepare(mut self) -> Self {
self.name = self
.name
.take()
.or_else(|| self.plugin.clone())
.or_else(|| Some(Uuid::new_v4().to_string()));
if self.launch_new {
self.configuration
.get_or_insert_with(BTreeMap::new)
.insert("_zellij_id".to_owned(), Uuid::new_v4().to_string());
}
self
}
let create_msg = |payload: Option<String>| -> ClientToServerMsg {
fn message(&self, payload: Option<String>) -> ClientToServerMsg {
ClientToServerMsg::Action {
action: Action::CliPipe {
pipe_id: pipe_id.clone(),
name: name.clone(),
pipe_id: self.pipe_id.clone(),
name: self.name.clone(),
payload,
args: args.clone(),
plugin: plugin.clone(),
configuration: configuration.clone(),
floating,
in_place,
launch_new,
skip_cache,
cwd: cwd.clone(),
pane_title: pane_title.clone(),
args: self.args.clone(),
plugin: self.plugin.clone(),
configuration: self.configuration.clone(),
floating: self.floating,
in_place: self.in_place,
launch_new: self.launch_new,
skip_cache: self.skip_cache,
cwd: self.cwd.clone(),
pane_title: self.pane_title.clone(),
popup: self.popup,
popup_no_focus: self.popup_no_focus,
},
terminal_id: pane_id,
terminal_id: self.pane_id,
client_id: None,
is_cli_client: true,
}
};
let is_piped = !os_input.stdin_is_terminal();
loop {
if let Some(payload) = payload.take() {
let msg = create_msg(Some(payload));
os_input.send_to_server(msg);
} else if !is_piped {
// here we send an empty message to trigger the plugin, because we don't have any more
// data
let msg = create_msg(None);
os_input.send_to_server(msg);
} else {
// we didn't get payload from the command line, meaning we listen on STDIN because this
// signifies the user is about to pipe more (eg. cat my-large-file | zellij pipe ...)
let mut buffer = String::new();
let _ = stdin.read_line(&mut buffer);
if buffer.is_empty() {
let msg = create_msg(None);
os_input.send_to_server(msg);
break;
}
}
enum PipeWaitOutcome {
Released(Option<i32>),
Exit(i32),
}
fn handle_pipe_server_message(
os_input: &mut Box<dyn ClientOsApi>,
pipe_id: &str,
message: ServerToClientMsg,
) -> Option<PipeWaitOutcome> {
match message {
ServerToClientMsg::UnblockCliPipeInput {
pipe_name,
exit_code,
} => {
if pipe_name == pipe_id {
Some(PipeWaitOutcome::Released(exit_code))
} else {
// we've got data! send it down the pipe (most common)
let msg = create_msg(Some(buffer));
os_input.send_to_server(msg);
None
}
},
ServerToClientMsg::CliPipeOutput { pipe_name, output } => {
let err_context = "Failed to write to stdout";
if pipe_name == pipe_id {
let mut stdout = os_input.get_stdout_writer();
stdout
.write_all(output.as_bytes())
.context(err_context)
.non_fatal();
stdout.flush().context(err_context).non_fatal();
}
None
},
ServerToClientMsg::Log { lines: log_lines } => {
log_lines.iter().for_each(|line| println!("{line}"));
Some(PipeWaitOutcome::Exit(0))
},
ServerToClientMsg::LogError { lines: log_lines } => {
log_lines.iter().for_each(|line| eprintln!("{line}"));
Some(PipeWaitOutcome::Exit(2))
},
ServerToClientMsg::Exit { exit_reason } => match exit_reason {
ExitReason::Error(e) => {
eprintln!("{}", e);
Some(PipeWaitOutcome::Exit(2))
},
ExitReason::CustomExitStatus(exit_status) => Some(PipeWaitOutcome::Exit(exit_status)),
_ => Some(PipeWaitOutcome::Exit(0)),
},
_ => None,
}
}
fn wait_for_pipe_release(os_input: &mut Box<dyn ClientOsApi>, pipe_id: &str) -> PipeWaitOutcome {
loop {
match os_input.recv_from_server() {
Some((message, _)) => {
if let Some(outcome) = handle_pipe_server_message(os_input, pipe_id, message) {
return outcome;
}
},
None => return PipeWaitOutcome::Exit(2),
}
loop {
// wait for a response and act accordingly
match os_input.recv_from_server() {
Some((ServerToClientMsg::UnblockCliPipeInput { pipe_name }, _)) => {
// unblock this pipe, meaning we need to stop waiting for a response and read
// once more from STDIN
if pipe_name == pipe_id {
if !is_piped {
// if this client is not piped, we need to exit the process completely
// rather than wait for more data
process::exit(0);
} else {
break;
}
}
enum PipeEvent {
Server(ServerToClientMsg),
ServerGone,
Payload(Option<String>),
}
const STREAM_BATCH_BYTES: usize = 256 * 1024;
pub(crate) fn next_payload(
queued: &mut VecDeque<Option<String>>,
batch: bool,
) -> Option<Option<String>> {
let first = queued.pop_front()?;
let Some(mut payload) = first else {
return Some(None);
};
if batch {
while let Some(Some(next)) = queued.front() {
if payload.len() + next.len() > STREAM_BATCH_BYTES {
break;
}
payload.push_str(next);
queued.pop_front();
}
}
Some(Some(payload))
}
fn drive_streaming_pipe(
os_input: &mut Box<dyn ClientOsApi>,
request: &PipeRequest,
first_payload: Option<String>,
separator: u8,
batch: bool,
) -> i32 {
let (sender, receiver) = std::sync::mpsc::channel();
{
let sender = sender.clone();
let stdin_os_input = os_input.box_clone();
std::thread::spawn(move || {
if let Some(payload) = first_payload {
if sender.send(PipeEvent::Payload(Some(payload))).is_err() {
return;
}
}
let payloads = StdinPayloads {
reader: stdin_os_input.get_stdin_reader(),
separator,
ended: false,
};
for payload in payloads {
if sender.send(PipeEvent::Payload(payload)).is_err() {
return;
}
}
});
}
{
let server_os_input = os_input.box_clone();
std::thread::spawn(move || loop {
match server_os_input.recv_from_server() {
Some((message, _)) => {
if sender.send(PipeEvent::Server(message)).is_err() {
return;
}
},
None => {
let _ = sender.send(PipeEvent::ServerGone);
return;
},
}
});
}
let mut queued: VecDeque<Option<String>> = VecDeque::new();
let mut waiting_for_release = false;
let mut input_ended = false;
loop {
match receiver.recv() {
Ok(PipeEvent::Payload(payload)) => {
if payload.is_none() {
input_ended = true;
}
queued.push_back(payload);
},
Ok(PipeEvent::Server(message)) => {
match handle_pipe_server_message(os_input, &request.pipe_id, message) {
Some(PipeWaitOutcome::Released(Some(exit_code))) => return exit_code,
Some(PipeWaitOutcome::Released(None)) => {
waiting_for_release = false;
if input_ended && queued.is_empty() {
return 0;
}
}
},
Some((ServerToClientMsg::CliPipeOutput { pipe_name, output }, _)) => {
// send data to STDOUT, this *does not* mean we need to unblock the input
let err_context = "Failed to write to stdout";
if pipe_name == pipe_id {
let mut stdout = os_input.get_stdout_writer();
stdout
.write_all(output.as_bytes())
.context(err_context)
.non_fatal();
stdout.flush().context(err_context).non_fatal();
}
},
Some((ServerToClientMsg::Log { lines: log_lines }, _)) => {
log_lines.iter().for_each(|line| println!("{line}"));
process::exit(0);
},
Some((ServerToClientMsg::LogError { lines: log_lines }, _)) => {
log_lines.iter().for_each(|line| eprintln!("{line}"));
process::exit(2);
},
Some((ServerToClientMsg::Exit { exit_reason }, _)) => match exit_reason {
ExitReason::Error(e) => {
eprintln!("{}", e);
process::exit(2);
},
_ => {
process::exit(0);
},
},
_ => {},
Some(PipeWaitOutcome::Exit(exit_code)) => return exit_code,
None => {},
}
},
Ok(PipeEvent::ServerGone) | Err(_) => return 2,
}
if !waiting_for_release {
if let Some(payload) = next_payload(&mut queued, batch) {
os_input.send_to_server(request.message(payload));
waiting_for_release = true;
}
}
}
}
fn drive_pipe(
os_input: &mut Box<dyn ClientOsApi>,
request: &PipeRequest,
mut payloads: impl Iterator<Item = Option<String>>,
) -> i32 {
let mut next_payload = payloads.next();
while let Some(payload) = next_payload {
os_input.send_to_server(request.message(payload));
match wait_for_pipe_release(os_input, &request.pipe_id) {
PipeWaitOutcome::Released(Some(exit_code)) => return exit_code,
PipeWaitOutcome::Released(None) => {},
PipeWaitOutcome::Exit(exit_code) => return exit_code,
}
next_payload = payloads.next();
}
0
}
struct StdinPayloads {
reader: Box<dyn BufRead>,
separator: u8,
ended: bool,
}
impl Iterator for StdinPayloads {
type Item = Option<String>;
fn next(&mut self) -> Option<Self::Item> {
if self.ended {
return None;
}
let mut buffer = vec![];
let _ = self.reader.read_until(self.separator, &mut buffer);
if buffer.is_empty() {
self.ended = true;
return Some(None);
}
Some(Some(String::from_utf8_lossy(&buffer).into_owned()))
}
}
fn pipe_client(
os_input: &mut Box<dyn ClientOsApi>,
request: PipeRequest,
payload: Option<String>,
) -> i32 {
let request = request.prepare();
let is_piped = !os_input.stdin_is_terminal();
if !is_piped {
return drive_pipe(os_input, &request, std::iter::once(payload));
}
drive_streaming_pipe(os_input, &request, payload, b'\n', false)
}
pub fn start_prompt_client(
mut os_input: Box<dyn ClientOsApi>,
session_name: &str,
prompt_cli: PromptCli,
plugin_url: String,
) -> i32 {
let is_piped = !os_input.stdin_is_terminal();
let plan = match plan_prompt(
&prompt_cli,
|| {
let mut text = String::new();
os_input
.get_stdin_reader()
.read_to_string(&mut text)
.map(|_| text)
.map_err(|e| format!("failed to read stdin: {}", e))
},
is_piped,
) {
Ok(plan) => plan,
Err(e) => {
eprint!(
"{}",
prompt_error_message(
&e,
prompt_cli.element.name(),
std::io::IsTerminal::is_terminal(&std::io::stderr()),
)
);
return prompt::EXIT_ERROR;
},
};
let pane_id: Option<u32> = os_input
.env_variable("ZELLIJ_PANE_ID")
.and_then(|e| e.trim().parse().ok());
let Some(pane_id) = pane_id else {
eprintln!("zellij prompt must be run inside a Zellij pane (ZELLIJ_PANE_ID is not set)");
return prompt::EXIT_ERROR;
};
let zellij_ipc_pipe: PathBuf = {
let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone();
fs::create_dir_all(&sock_dir).unwrap();
zellij_utils::shared::set_permissions(&sock_dir, 0o700).unwrap();
sock_dir.push(session_name);
sock_dir
};
crate::check_ipc_pipe_length(&zellij_ipc_pipe);
os_input.connect_to_server(&*zellij_ipc_pipe);
let request = PipeRequest {
pipe_id: Uuid::new_v4().to_string(),
name: Some(plan.element.name().to_owned()),
plugin: Some(plugin_url),
args: Some(plan.args),
configuration: None,
launch_new: true,
skip_cache: false,
floating: None,
in_place: None,
pane_id: Some(pane_id),
cwd: None,
pane_title: Some(format!("prompt: {}", plan.element.name())),
popup: Some(prompt_cli.placement()),
popup_no_focus: !prompt_cli.takes_focus(),
}
.prepare();
let exit_status = match plan.input {
PromptInput::Single(payload) => {
drive_pipe(&mut os_input, &request, std::iter::once(payload))
},
PromptInput::StreamStdin { null } => drive_streaming_pipe(
&mut os_input,
&request,
None,
if null { 0 } else { b'\n' },
true,
),
};
os_input.send_to_server(ClientToServerMsg::ClientExited);
exit_status
}
#[derive(Debug, Clone, PartialEq)]
pub enum PromptInput {
Single(Option<String>),
StreamStdin { null: bool },
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptPlan {
pub element: PromptElement,
pub args: BTreeMap<String, String>,
pub input: PromptInput,
}
const USAGE_ERROR_MARKER: &str = "\u{0}usage";
fn usage_error(message: &str) -> String {
format!("{}{}", USAGE_ERROR_MARKER, message)
}
pub fn is_usage_error(error: &str) -> bool {
error.starts_with(USAGE_ERROR_MARKER)
}
pub fn error_text(error: &str) -> &str {
error.strip_prefix(USAGE_ERROR_MARKER).unwrap_or(error)
}
pub fn prompt_error_message(error: &str, element: &str, color: bool) -> String {
let mut message = format!("error: {}\n", error_text(error));
if is_usage_error(error) {
message.push('\n');
message.push_str(&zellij_utils::cli::prompt_element_help(element, color));
}
message
}
fn check_pattern(pattern: &str) -> Result<(), String> {
regex::Regex::new(pattern)
.map(|_| ())
.map_err(|e| e.to_string())
}
pub fn plan_prompt(
prompt_cli: &PromptCli,
read_stdin: impl FnOnce() -> Result<String, String>,
stdin_is_piped: bool,
) -> Result<PromptPlan, String> {
let mut args = BTreeMap::new();
let answer_args = prompt_cli.answer_args();
if let Some(title) = &prompt_cli.title {
args.insert(prompt::ARG_TITLE.to_owned(), title.clone());
}
if let Some(timeout) = &prompt_cli.timeout {
prompt::parse_duration(timeout).map_err(|e| format!("--timeout: {}", e))?;
args.insert(prompt::ARG_TIMEOUT.to_owned(), timeout.clone());
}
if let Some(default) = &answer_args.default {
args.insert(prompt::ARG_DEFAULT.to_owned(), default.clone());
}
if answer_args.json {
args.insert(prompt::ARG_JSON.to_owned(), "true".to_owned());
}
let mut input = PromptInput::Single(None);
let element = match &prompt_cli.element {
PromptElementCli::Confirm {
message, yes, no, ..
} => {
let message = message
.as_ref()
.ok_or_else(|| usage_error("confirm: the question is missing"))?;
args.insert(prompt::ARG_MESSAGE.to_owned(), message.clone());
if let Some(yes) = yes {
args.insert(prompt::ARG_YES.to_owned(), yes.clone());
}
if let Some(no) = no {
args.insert(prompt::ARG_NO.to_owned(), no.clone());
}
if let Some(default) = &answer_args.default {
prompt::parse_bool(default).map_err(|e| format!("--default: {}", e))?;
}
PromptElement::Confirm
},
PromptElementCli::Choose {
items,
item,
multi,
labels,
null,
selected,
..
} => {
let mut choices: Vec<ChoiceItem> =
items.iter().map(|i| ChoiceItem::plain(i.clone())).collect();
choices.extend(item.iter().map(|i| ChoiceItem::from_item_arg(i)));
if choices.is_empty() {
if !stdin_is_piped {
return Err(usage_error(
"choose: no items given; pass them as arguments or pipe them into stdin",
));
}
input = PromptInput::StreamStdin { null: *null };
} else {
args.insert(prompt::ARG_ITEMS.to_owned(), prompt::encode_items(&choices));
}
if *multi {
args.insert(prompt::ARG_MULTI.to_owned(), "true".to_owned());
}
if *labels {
args.insert(prompt::ARG_LABELS.to_owned(), "true".to_owned());
}
if *null {
args.insert(prompt::ARG_NULL.to_owned(), "true".to_owned());
}
if !selected.is_empty() {
args.insert(
prompt::ARG_SELECTED.to_owned(),
prompt::encode_list(selected),
);
}
PromptElement::Choose
},
PromptElementCli::Input {
message,
placeholder,
validate,
required,
..
} => {
if let Some(message) = message {
args.insert(prompt::ARG_MESSAGE.to_owned(), message.clone());
}
if let Some(placeholder) = placeholder {
args.insert(prompt::ARG_PLACEHOLDER.to_owned(), placeholder.clone());
}
if let Some(validate) = validate {
check_pattern(validate).map_err(|e| format!("--validate: {}", e))?;
args.insert(prompt::ARG_VALIDATE.to_owned(), validate.clone());
}
if *required {
args.insert(prompt::ARG_REQUIRED.to_owned(), "true".to_owned());
}
PromptElement::Input
},
PromptElementCli::Number {
message,
min,
max,
step,
..
} => {
if let Some(message) = message {
args.insert(prompt::ARG_MESSAGE.to_owned(), message.clone());
}
if let (Some(min), Some(max)) = (min, max) {
if min > max {
return Err("--min is larger than --max".to_owned());
}
}
if let Some(step) = step {
if *step <= 0 {
return Err("--step must be larger than 0".to_owned());
}
args.insert(prompt::ARG_STEP.to_owned(), step.to_string());
}
if let Some(min) = min {
args.insert(prompt::ARG_MIN.to_owned(), min.to_string());
}
if let Some(max) = max {
args.insert(prompt::ARG_MAX.to_owned(), max.to_string());
}
if let Some(default) = &answer_args.default {
let value = default
.trim()
.parse::<i64>()
.map_err(|_| format!("--default: '{}' is not a whole number", default))?;
let below = min.map(|min| value < min).unwrap_or(false);
let above = max.map(|max| value > max).unwrap_or(false);
if below || above {
let range = match (min, max) {
(Some(min), Some(max)) => format!("between {} and {}", min, max),
(Some(min), None) => format!("at least {}", min),
(_, Some(max)) => format!("at most {}", max),
_ => String::new(),
};
return Err(format!("--default: {} is not {}", value, range));
}
}
PromptElement::Number
},
PromptElementCli::Toggle { message, .. } => {
let message = message
.as_ref()
.ok_or_else(|| usage_error("toggle: the label is missing"))?;
args.insert(prompt::ARG_MESSAGE.to_owned(), message.clone());
if let Some(default) = &answer_args.default {
prompt::parse_bool(default).map_err(|e| format!("--default: {}", e))?;
}
PromptElement::Toggle
},
PromptElementCli::Select {
message, options, ..
} => {
if options.is_empty() {
return Err(usage_error("select: give a label followed by the options"));
}
if let Some(message) = message {
args.insert(prompt::ARG_MESSAGE.to_owned(), message.clone());
}
if let Some(default) = &answer_args.default {
if !options.contains(default) {
return Err(format!(
"--default: '{}' is not one of the options",
default
));
}
}
args.insert(prompt::ARG_OPTIONS.to_owned(), prompt::encode_list(options));
PromptElement::Select
},
PromptElementCli::Menu { items, item, .. } => {
let mut choices: Vec<ChoiceItem> =
items.iter().map(|i| ChoiceItem::plain(i.clone())).collect();
choices.extend(item.iter().map(|i| ChoiceItem::from_item_arg(i)));
if choices.is_empty() {
return Err(usage_error("menu: no items given"));
}
args.insert(prompt::ARG_ITEMS.to_owned(), prompt::encode_items(&choices));
PromptElement::Menu
},
PromptElementCli::Form { spec, .. } => {
let text = match spec {
Some(path) => fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {}", path.display(), e))?,
None => {
if !stdin_is_piped {
return Err(usage_error(
"form: pass the form description with --spec or on stdin",
));
}
read_stdin()?
},
};
let form_spec = prompt::parse_form_spec(&text)?;
prompt::check_form_patterns(&form_spec, check_pattern)?;
if let Some(default) = &answer_args.default {
match serde_json::from_str::<serde_json::Value>(default) {
Ok(serde_json::Value::Object(_)) => {},
_ => return Err("--default: must be a JSON object for form".to_owned()),
}
}
input = PromptInput::Single(Some(text));
PromptElement::Form
},
PromptElementCli::Notify {
message,
no_pane_name,
no_tab_name,
..
} => {
let message = message
.as_ref()
.ok_or_else(|| usage_error("notify: the text is missing"))?;
args.insert(prompt::ARG_MESSAGE.to_owned(), message.clone());
if *no_pane_name {
args.insert(prompt::ARG_NO_PANE_NAME.to_owned(), "true".to_owned());
}
if *no_tab_name {
args.insert(prompt::ARG_NO_TAB_NAME.to_owned(), "true".to_owned());
}
PromptElement::Notify
},
};
Ok(PromptPlan {
element,
args,
input,
})
}
fn individual_messages_client(
os_input: &mut Box<dyn ClientOsApi>,
action: Action,
+583
View File
@@ -0,0 +1,583 @@
use crate::cli_client::{
error_text, is_usage_error, plan_prompt, prompt_error_message, start_cli_client, PromptInput,
};
use crate::os_input_output::ClientOsApi;
use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use zellij_utils::cli::PromptCli;
use zellij_utils::data::Palette;
use zellij_utils::errors::ErrorContext;
use zellij_utils::input::actions::Action;
use zellij_utils::ipc::{ClientToServerMsg, ServerToClientMsg};
use zellij_utils::pane_size::Size;
use zellij_utils::prompt::PromptElement;
#[derive(Clone, Debug, Default)]
struct ScriptedOsApi {
piped_stdin: Option<Vec<u8>>,
replies: Arc<Mutex<VecDeque<Vec<ServerToClientMsg>>>>,
inbox: Arc<Mutex<VecDeque<ServerToClientMsg>>>,
sent_payloads: Arc<Mutex<Vec<Option<String>>>>,
stdout: Arc<Mutex<Vec<u8>>>,
}
impl ScriptedOsApi {
fn new(piped_stdin: Option<&str>, replies: Vec<Vec<ServerToClientMsg>>) -> Self {
ScriptedOsApi {
piped_stdin: piped_stdin.map(|s| s.as_bytes().to_vec()),
replies: Arc::new(Mutex::new(replies.into_iter().collect())),
..Default::default()
}
}
fn sent_payloads(&self) -> Vec<Option<String>> {
self.sent_payloads.lock().unwrap().clone()
}
fn stdout(&self) -> String {
String::from_utf8_lossy(&self.stdout.lock().unwrap()).into_owned()
}
}
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl ClientOsApi for ScriptedOsApi {
fn get_terminal_size(&self) -> Size {
Size { rows: 24, cols: 80 }
}
fn set_raw_mode(&mut self) {}
fn unset_raw_mode(&self) -> Result<(), io::Error> {
Ok(())
}
fn get_stdout_writer(&self) -> Box<dyn Write> {
Box::new(SharedWriter(self.stdout.clone()))
}
fn get_stdin_reader(&self) -> Box<dyn io::BufRead> {
Box::new(io::Cursor::new(
self.piped_stdin.clone().unwrap_or_default(),
))
}
fn stdin_is_terminal(&self) -> bool {
self.piped_stdin.is_none()
}
fn update_session_name(&mut self, _new_session_name: String) {}
fn read_from_stdin(&mut self) -> Result<Vec<u8>, &'static str> {
Ok(vec![])
}
fn box_clone(&self) -> Box<dyn ClientOsApi> {
Box::new(self.clone())
}
fn send_to_server(&self, msg: ClientToServerMsg) {
if let ClientToServerMsg::Action {
action: Action::CliPipe { payload, .. },
..
} = msg
{
self.sent_payloads.lock().unwrap().push(payload);
if let Some(replies) = self.replies.lock().unwrap().pop_front() {
self.inbox.lock().unwrap().extend(replies);
}
}
}
fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if let Some(msg) = self.inbox.lock().unwrap().pop_front() {
return Some((msg, ErrorContext::default()));
}
if std::time::Instant::now() > deadline {
return None;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
fn handle_signals(
&self,
_sigwinch_cb: Box<dyn Fn()>,
_quit_cb: Box<dyn Fn()>,
_resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
) {
}
fn connect_to_server(&self, _path: &std::path::Path) {}
fn load_palette(&self) -> Palette {
Palette::default()
}
fn enable_mouse(&self) -> anyhow::Result<()> {
Ok(())
}
fn disable_mouse(&self) -> anyhow::Result<()> {
Ok(())
}
}
fn pipe_action(payload: Option<&str>) -> Action {
Action::CliPipe {
pipe_id: "pipe".to_owned(),
name: Some("name".to_owned()),
payload: payload.map(|p| p.to_owned()),
args: None,
plugin: None,
configuration: None,
launch_new: false,
skip_cache: false,
floating: None,
in_place: None,
cwd: None,
pane_title: None,
popup: None,
popup_no_focus: false,
}
}
fn release(exit_code: Option<i32>) -> ServerToClientMsg {
ServerToClientMsg::UnblockCliPipeInput {
pipe_name: "pipe".to_owned(),
exit_code,
}
}
fn output(text: &str) -> ServerToClientMsg {
ServerToClientMsg::CliPipeOutput {
pipe_name: "pipe".to_owned(),
output: text.to_owned(),
}
}
fn run(os_api: &ScriptedOsApi, action: Action) -> i32 {
start_cli_client(Box::new(os_api.clone()), "session", vec![action])
}
#[test]
fn piped_stdin_waits_for_the_release_after_the_input_ends() {
let os_api = ScriptedOsApi::new(
Some("a\nb\n"),
vec![
vec![release(None)],
vec![release(None)],
vec![output("answer\n"), release(None)],
],
);
assert_eq!(run(&os_api, pipe_action(None)), 0);
assert_eq!(
os_api.sent_payloads(),
vec![Some("a\n".to_owned()), Some("b\n".to_owned()), None]
);
assert_eq!(os_api.stdout(), "answer\n");
}
#[test]
fn the_exit_code_of_the_release_is_passed_through() {
let os_api = ScriptedOsApi::new(None, vec![vec![output("x\n"), release(Some(124))]]);
assert_eq!(run(&os_api, pipe_action(None)), 124);
assert_eq!(os_api.stdout(), "x\n");
}
#[test]
fn a_release_without_an_exit_code_still_exits_zero() {
let os_api = ScriptedOsApi::new(None, vec![vec![release(None)]]);
assert_eq!(run(&os_api, pipe_action(Some("payload"))), 0);
assert_eq!(os_api.sent_payloads(), vec![Some("payload".to_owned())]);
}
#[test]
fn a_final_release_ends_piped_input_early() {
let os_api = ScriptedOsApi::new(Some("a\nb\nc\n"), vec![vec![release(Some(1))]]);
assert_eq!(run(&os_api, pipe_action(None)), 1);
assert_eq!(os_api.sent_payloads(), vec![Some("a\n".to_owned())]);
}
#[test]
fn a_log_error_from_the_server_exits_two() {
let os_api = ScriptedOsApi::new(
None,
vec![vec![ServerToClientMsg::LogError {
lines: vec!["boom".to_owned()],
}]],
);
assert_eq!(run(&os_api, pipe_action(None)), 2);
}
fn prompt_cli(args: &[&str]) -> PromptCli {
let mut command_line = vec!["prompt"];
command_line.extend_from_slice(args);
<PromptCli as clap::Parser>::try_parse_from(command_line).unwrap()
}
fn no_stdin() -> Result<String, String> {
Err("stdin should not be read".to_owned())
}
#[test]
fn prompt_options_become_pipe_arguments() {
let plan = plan_prompt(
&prompt_cli(&[
"confirm",
"Force push?",
"--yes",
"Push",
"--no",
"Cancel",
"--timeout",
"5s",
"--json",
]),
no_stdin,
false,
)
.unwrap();
assert_eq!(plan.element, PromptElement::Confirm);
assert_eq!(plan.args.get("message").unwrap(), "Force push?");
assert_eq!(plan.args.get("yes").unwrap(), "Push");
assert_eq!(plan.args.get("no").unwrap(), "Cancel");
assert_eq!(plan.args.get("timeout").unwrap(), "5s");
assert_eq!(plan.args.get("json").unwrap(), "true");
assert_eq!(plan.input, PromptInput::Single(None));
}
#[test]
fn choose_without_items_streams_stdin() {
let plan = plan_prompt(&prompt_cli(&["choose", "-0", "--multi"]), no_stdin, true).unwrap();
assert_eq!(plan.input, PromptInput::StreamStdin { null: true });
assert!(plan.args.get("items").is_none());
let plan = plan_prompt(
&prompt_cli(&["choose", "a", "--item", "v=Label", "--selected", "a"]),
no_stdin,
true,
)
.unwrap();
assert_eq!(plan.input, PromptInput::Single(None));
assert_eq!(plan.args.get("items").unwrap(), r#"["a",["v","Label"]]"#);
assert_eq!(plan.args.get("selected").unwrap(), r#"["a"]"#);
}
#[test]
fn bad_prompt_arguments_are_refused_before_connecting() {
assert!(plan_prompt(&prompt_cli(&["choose"]), no_stdin, false).is_err());
assert!(plan_prompt(&prompt_cli(&["input", "--validate", "("]), no_stdin, false).is_err());
assert!(plan_prompt(
&prompt_cli(&["number", "--min", "5", "--max", "1"]),
no_stdin,
false
)
.is_err());
assert!(plan_prompt(
&prompt_cli(&["confirm", "x", "--timeout", "soon"]),
no_stdin,
false
)
.is_err());
assert!(plan_prompt(
&prompt_cli(&["toggle", "CI", "--default", "maybe"]),
no_stdin,
false
)
.is_err());
let error = plan_prompt(
&prompt_cli(&[
"number",
"Port",
"--min",
"1",
"--max",
"10",
"--default",
"42",
]),
no_stdin,
false,
)
.unwrap_err();
assert_eq!(error, "--default: 42 is not between 1 and 10");
assert!(plan_prompt(
&prompt_cli(&["select", "L", "a", "--default", "b"]),
no_stdin,
false
)
.is_err());
let error = plan_prompt(
&prompt_cli(&["form"]),
|| Ok(r#"{"fields":[{"id":"port","type":"number","min":"x"}]}"#.to_owned()),
true,
)
.unwrap_err();
assert!(error.contains("\"port\""), "{}", error);
}
#[test]
fn form_description_is_sent_as_the_payload() {
let spec = r#"{"fields":[{"id":"name","type":"input"}]}"#;
let plan = plan_prompt(&prompt_cli(&["form"]), || Ok(spec.to_owned()), true).unwrap();
assert_eq!(plan.element, PromptElement::Form);
assert_eq!(plan.input, PromptInput::Single(Some(spec.to_owned())));
}
#[test]
fn missing_required_arguments_are_usage_errors_that_show_the_help() {
for args in [
vec!["confirm"],
vec!["toggle"],
vec!["choose"],
vec!["select"],
vec!["select", "License"],
vec!["menu"],
vec!["form"],
] {
let error = plan_prompt(&prompt_cli(&args), no_stdin, false).unwrap_err();
assert!(is_usage_error(&error), "{:?}: {}", args, error);
}
let error =
plan_prompt(&prompt_cli(&["input", "--validate", "("]), no_stdin, false).unwrap_err();
assert!(!is_usage_error(&error));
let help = zellij_utils::cli::prompt_element_help("confirm", false);
assert!(help.contains("Usage: zellij prompt confirm"), "{}", help);
assert!(help.contains("Examples:"), "{}", help);
assert!(
help.contains("zellij prompt confirm \"Force push?\""),
"{}",
help
);
assert_eq!(error_text(&error), error);
}
#[test]
fn a_missing_argument_prints_the_error_and_the_element_help() {
let error = plan_prompt(&prompt_cli(&["toggle"]), no_stdin, false).unwrap_err();
let message = prompt_error_message(&error, "toggle", false);
assert!(
message.starts_with("error: toggle: the label is missing\n\n"),
"{}",
message
);
assert!(
message.contains("Usage: zellij prompt toggle"),
"{}",
message
);
assert!(message.contains("Examples:"), "{}", message);
assert!(!message.contains('\u{1b}'), "{}", message);
}
#[test]
fn a_bad_value_prints_only_the_error() {
let error = plan_prompt(
&prompt_cli(&["number", "N", "--min", "5", "--max", "1"]),
no_stdin,
false,
)
.unwrap_err();
let message = prompt_error_message(&error, "number", false);
assert_eq!(message, "error: --min is larger than --max\n");
}
#[test]
fn the_help_is_coloured_for_a_terminal_and_plain_otherwise() {
let coloured = zellij_utils::cli::prompt_element_help("choose", true);
let plain = zellij_utils::cli::prompt_element_help("choose", false);
assert!(coloured.contains("\u{1b}["), "{}", coloured);
assert!(!plain.contains('\u{1b}'), "{}", plain);
assert!(plain.contains("Examples:"));
}
#[test]
fn every_element_has_an_examples_section() {
for element in [
"confirm", "choose", "input", "number", "toggle", "select", "menu", "form", "notify",
] {
let help = zellij_utils::cli::prompt_element_help(element, false);
assert!(
help.contains(&format!("Examples:\n zellij prompt {}", element))
|| help.contains("Examples:\n git branch")
|| help.contains("Examples:\n echo"),
"{}: {}",
element,
help
);
}
}
#[test]
fn bare_prompt_command_shows_the_help() {
let result = std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(|| {
<zellij_utils::cli::CliArgs as clap::Parser>::try_parse_from(["zellij", "prompt"])
.map(|_| ())
.map_err(|e| (e.kind(), e.render().to_string()))
})
.unwrap()
.join()
.unwrap();
let (kind, rendered) = result.unwrap_err();
assert_eq!(
kind,
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
assert!(
rendered.contains("confirm") && rendered.contains("choose"),
"{}",
rendered
);
}
#[test]
fn placement_flags_choose_where_the_popup_opens() {
use zellij_utils::data::PipePopupPlacement;
assert_eq!(
prompt_cli(&["toggle", "x"]).placement(),
PipePopupPlacement::Pane
);
assert_eq!(
prompt_cli(&["toggle", "x", "--at-center"]).placement(),
PipePopupPlacement::Center
);
assert_eq!(
prompt_cli(&["toggle", "x", "--at-mouse"]).placement(),
PipePopupPlacement::Mouse
);
assert_eq!(
prompt_cli(&["toggle", "x", "--at-cursor"]).placement(),
PipePopupPlacement::Cursor
);
assert!(<PromptCli as clap::Parser>::try_parse_from([
"prompt",
"toggle",
"x",
"--at-mouse",
"--at-cursor"
])
.is_err());
}
#[test]
fn notify_defaults_to_the_top_right_corner_and_does_not_take_focus() {
use zellij_utils::data::PipePopupPlacement;
let notify = prompt_cli(&["notify", "Build finished"]);
assert_eq!(notify.placement(), PipePopupPlacement::TopRight);
assert!(!notify.takes_focus());
assert_eq!(
prompt_cli(&["notify", "x", "--at", "bottom-left"]).placement(),
PipePopupPlacement::BottomLeft
);
assert!(prompt_cli(&["toggle", "x"]).takes_focus());
assert!(<PromptCli as clap::Parser>::try_parse_from([
"prompt", "notify", "x", "--at", "nowhere"
])
.is_err());
}
#[test]
fn notify_does_not_take_the_answer_and_placement_flags() {
for flag in ["--json", "--at-cursor", "--at-mouse", "--at-center"] {
assert!(
<PromptCli as clap::Parser>::try_parse_from(["prompt", "notify", "x", flag]).is_err(),
"{}",
flag
);
assert!(
<PromptCli as clap::Parser>::try_parse_from(["prompt", "toggle", "x", flag]).is_ok(),
"{}",
flag
);
}
assert!(<PromptCli as clap::Parser>::try_parse_from([
"prompt",
"notify",
"x",
"--default",
"y"
])
.is_err());
assert!(<PromptCli as clap::Parser>::try_parse_from([
"prompt",
"notify",
"x",
"--title",
"t",
"--timeout",
"5s"
])
.is_ok());
let help = zellij_utils::cli::prompt_element_help("notify", false);
for flag in [
"--json",
"--at-cursor",
"--at-mouse",
"--at-center",
"--default",
] {
assert!(!help.contains(flag), "{}: {}", flag, help);
}
assert!(help.contains("--timeout") && help.contains("--at "));
}
#[test]
fn notify_needs_its_text_and_sends_it_as_the_message() {
let plan = plan_prompt(
&prompt_cli(&["notify", "Build finished", "--timeout", "5s"]),
|| Ok(String::new()),
false,
)
.unwrap();
assert_eq!(plan.element, zellij_utils::prompt::PromptElement::Notify);
assert_eq!(
plan.args
.get(zellij_utils::prompt::ARG_MESSAGE)
.map(|m| m.as_str()),
Some("Build finished")
);
assert!(!plan
.args
.contains_key(zellij_utils::prompt::ARG_NO_PANE_NAME));
assert!(!plan
.args
.contains_key(zellij_utils::prompt::ARG_NO_TAB_NAME));
let without_names = plan_prompt(
&prompt_cli(&["notify", "x", "--no-pane-name", "--no-tab-name"]),
|| Ok(String::new()),
false,
)
.unwrap();
for arg in [
zellij_utils::prompt::ARG_NO_PANE_NAME,
zellij_utils::prompt::ARG_NO_TAB_NAME,
] {
assert_eq!(
without_names.args.get(arg).map(|m| m.as_str()),
Some("true")
);
}
assert!(plan_prompt(&prompt_cli(&["notify"]), || Ok(String::new()), false).is_err());
}
#[test]
fn queued_prompt_lines_are_sent_together_but_pipe_lines_are_not() {
let mut queued: std::collections::VecDeque<Option<String>> =
vec![Some("a\n".to_owned()), Some("b\n".to_owned()), None]
.into_iter()
.collect();
assert_eq!(
crate::cli_client::next_payload(&mut queued, true),
Some(Some("a\nb\n".to_owned()))
);
assert_eq!(
crate::cli_client::next_payload(&mut queued, true),
Some(None)
);
let mut queued: std::collections::VecDeque<Option<String>> =
vec![Some("a\n".to_owned()), Some("b\n".to_owned())]
.into_iter()
.collect();
assert_eq!(
crate::cli_client::next_payload(&mut queued, false),
Some(Some("a\n".to_owned()))
);
}
+3
View File
@@ -7,3 +7,6 @@ mod teardown_tests;
#[cfg(test)]
mod ipc_pipe_length_tests;
#[cfg(test)]
mod cli_client_tests;
@@ -242,8 +242,11 @@ pub fn zellij_server_listener(
},
Some(ServerToClientMsg::ConfigFileUpdated) => {
if let Some(config_file_path) = &config_file_path {
if let Ok(new_config) = Config::from_path(&config_file_path, Some(config.clone())) {
if config_file_path.is_some() {
let mut new_config = config.clone();
let mut new_config_options = config_options.clone();
reload_config_from_disk(&mut new_config, &mut new_config_options, &config_file_path);
{
// Re-seed host-query cache for this client
// so OSC 10/11/4 replies follow the new theme.
for seed in build_host_query_seed_msgs(&new_config, &config_options) {
+1
View File
@@ -14,6 +14,7 @@ zellij-server = { path = "../zellij-server", version = "0.46.0" }
zellij-utils = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true }
crossbeam = { version = "0.8", default-features = false, features = ["crossbeam-channel", "std"] }
interprocess = { workspace = true }
insta = "1.48"
@@ -276,6 +276,7 @@ pub struct CellStyle {
pub italic: bool,
pub bold: bool,
pub foreground: Option<AnsiCode>,
pub background: Option<AnsiCode>,
}
#[derive(Clone, Debug)]
@@ -410,6 +411,7 @@ fn render_bytes(bytes: &[u8], win_size: Size) -> GridSnapshot {
italic: matches!(character_style.italic, Some(AnsiCode::On)),
bold: matches!(character_style.bold, Some(AnsiCode::On)),
foreground: character_style.foreground,
background: character_style.background,
});
let character_position = CursorPosition {
x: character_index,
@@ -51,6 +51,27 @@ pub struct FakeClientOsApi {
server_spawner: Arc<Mutex<Option<ServerSpawner>>>,
env: Arc<Mutex<HashMap<String, String>>>,
received_server_messages: Arc<Mutex<Vec<String>>>,
piped_stdin: Arc<Mutex<Option<crossbeam::channel::Receiver<Vec<u8>>>>>,
}
pub struct ChannelReader {
receiver: crossbeam::channel::Receiver<Vec<u8>>,
pending: Vec<u8>,
}
impl std::io::Read for ChannelReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pending.is_empty() {
match self.receiver.recv() {
Ok(bytes) => self.pending = bytes,
Err(_) => return Ok(0),
}
}
let count = buf.len().min(self.pending.len());
buf[..count].copy_from_slice(&self.pending[..count]);
self.pending.drain(..count);
Ok(count)
}
}
impl Clone for FakeClientOsApi {
@@ -66,6 +87,7 @@ impl Clone for FakeClientOsApi {
server_spawner: self.server_spawner.clone(),
env: self.env.clone(),
received_server_messages: self.received_server_messages.clone(),
piped_stdin: self.piped_stdin.clone(),
}
}
}
@@ -119,6 +141,7 @@ impl FakeClientOsApi {
server_spawner: Arc::new(Mutex::new(server_spawner)),
env: Arc::new(Mutex::new(env)),
received_server_messages: received_server_messages.clone(),
piped_stdin: Arc::new(Mutex::new(None)),
};
client_screen.set_host_terminal(HostTerminal::Basic, stdin_tx.clone());
let fake_client_handle = FakeClientHandle {
@@ -130,6 +153,11 @@ impl FakeClientOsApi {
};
(fake_client_os_api, fake_client_handle)
}
pub fn with_piped_stdin(self, receiver: crossbeam::channel::Receiver<Vec<u8>>) -> Self {
*self.piped_stdin.lock().unwrap() = Some(receiver);
self
}
}
impl ClientOsApi for FakeClientOsApi {
@@ -144,7 +172,16 @@ impl ClientOsApi for FakeClientOsApi {
self.client_screen.writer()
}
fn get_stdin_reader(&self) -> Box<dyn std::io::BufRead> {
Box::new(std::io::BufReader::new(std::io::empty()))
match self.piped_stdin.lock().unwrap().clone() {
Some(receiver) => Box::new(std::io::BufReader::new(ChannelReader {
receiver,
pending: vec![],
})),
None => Box::new(std::io::BufReader::new(std::io::empty())),
}
}
fn stdin_is_terminal(&self) -> bool {
self.piped_stdin.lock().unwrap().is_none()
}
fn update_session_name(&mut self, new_session_name: String) {
*self.session_name.lock().unwrap() = Some(new_session_name);
+15 -1
View File
@@ -3,7 +3,7 @@ use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use zellij_server::os_input_output::AsyncReader;
use zellij_server::os_input_output::{AsyncReader, PaneEnv};
use zellij_server::panes::PaneId;
use zellij_utils::input::command::{RunCommand, TerminalAction};
@@ -13,6 +13,7 @@ pub(crate) const FAKE_PID_BASE: u32 = 100_000;
pub(crate) struct FakePtyState {
pub terminal_action: Option<TerminalAction>,
pub pane_env: PaneEnv,
pub output_tx: Option<tokio::sync::mpsc::UnboundedSender<Vec<u8>>>,
pub stdin: Vec<u8>,
pub size: Option<(u16, u16)>,
@@ -105,6 +106,7 @@ impl SharedPtys {
terminal_id: u32,
terminal_action: Option<TerminalAction>,
quit_cb: Option<QuitCb>,
pane_env: PaneEnv,
) -> Box<dyn AsyncReader> {
let (output_tx, output_rx) = tokio::sync::mpsc::unbounded_channel();
self.mutate(|fake_pty_registry| {
@@ -112,6 +114,7 @@ impl SharedPtys {
terminal_id,
FakePtyState {
terminal_action,
pane_env,
output_tx: Some(output_tx),
stdin: Vec::new(),
size: None,
@@ -320,6 +323,17 @@ impl FakePtyHandle {
})
}
pub fn env_var(&self, name: &str) -> Option<String> {
let pane_env = self.shared_ptys.read(|fake_pty_registry| {
fake_pty_registry
.fake_pty_states
.get(&self.terminal_id)
.map(|fake_pty_state| fake_pty_state.pane_env.clone())
.unwrap_or_default()
});
zellij_server::os_input_output::env_value(&pane_env, name)
}
pub fn disable_echo(&self) {
self.shared_ptys.mutate(|fake_pty_registry| {
if let Some(fake_pty_state) =
@@ -3,7 +3,7 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use interprocess::local_socket::Stream as LocalSocketStream;
use zellij_server::os_input_output::{AsyncReader, ServerOsApi};
use zellij_server::os_input_output::{AsyncReader, PaneEnv, ServerOsApi};
use zellij_server::panes::PaneId;
use zellij_server::ClientId;
use zellij_utils::data::Palette;
@@ -91,6 +91,7 @@ impl ServerOsApi for FakeServerOsApi {
terminal_action: TerminalAction,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
_default_editor: Option<PathBuf>,
pane_env: &PaneEnv,
) -> Result<(u32, Box<dyn AsyncReader>, Option<u32>)> {
let terminal_id = self.shared_ptys.next_terminal_id();
let opened_file_contents = match &terminal_action {
@@ -102,9 +103,12 @@ impl ServerOsApi for FakeServerOsApi {
.cloned(),
_ => None,
};
let fake_async_reader =
self.shared_ptys
.register(terminal_id, Some(terminal_action), Some(quit_cb));
let fake_async_reader = self.shared_ptys.register(
terminal_id,
Some(terminal_action),
Some(quit_cb),
pane_env.clone(),
);
if let Some(contents) = opened_file_contents {
let contents_with_carriage_returns =
contents.replace("\r\n", "\n").replace('\n', "\r\n");
@@ -119,7 +123,9 @@ impl ServerOsApi for FakeServerOsApi {
}
fn reserve_terminal_id(&self) -> Result<u32> {
let terminal_id = self.shared_ptys.next_terminal_id();
let _ = self.shared_ptys.register(terminal_id, None, None);
let _ = self
.shared_ptys
.register(terminal_id, None, None, PaneEnv::new());
Ok(terminal_id)
}
fn write_to_tty_stdin(&self, terminal_id: u32, buf: &[u8]) -> Result<usize> {
@@ -207,6 +213,7 @@ impl ServerOsApi for FakeServerOsApi {
terminal_id: u32,
_run_command: RunCommand,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
_pane_env: &PaneEnv,
) -> Result<(Box<dyn AsyncReader>, Option<u32>)> {
let fake_async_reader = self.shared_ptys.rerun(terminal_id, quit_cb);
Ok((fake_async_reader, Some(FAKE_PID_BASE + terminal_id)))
+2 -2
View File
@@ -25,8 +25,8 @@ pub use nested::{
NestedDepthThreeHarness, NestedHarness,
};
pub use runner::{
assert_same_rendered_grid, normalized, BackgroundTestSession, TestClient, TestRunner,
TestSession,
assert_same_rendered_grid, normalized, BackgroundTestSession, CliCaller, CliOutputHandle,
CliResult, TestClient, TestRunner, TestSession,
};
pub use zellij_utils::data::LayoutInfo;
pub use zellij_utils::pane_size::Size;
+166 -6
View File
@@ -6,7 +6,7 @@ use std::time::Duration;
use zellij_client::os_input_output::SignalEvent;
use zellij_client::ClientInfo;
use zellij_utils::cli::{CliAction, CliArgs};
use zellij_utils::cli::{CliAction, CliArgs, Command};
use zellij_utils::data::{CommandOrPlugin, ConnectToSession, LayoutInfo};
use zellij_utils::input::actions::{Action, RunCommandAction};
use zellij_utils::input::options::Options;
@@ -30,6 +30,8 @@ pub struct TestRunner {
stdout_tap: Option<crossbeam::channel::Sender<Vec<u8>>>,
skip_concurrency_slot: bool,
host_terminal: Option<HostTerminal>,
cli_options: Option<Options>,
without_config_file: bool,
}
impl TestRunner {
@@ -43,15 +45,27 @@ impl TestRunner {
stdout_tap: None,
skip_concurrency_slot: false,
host_terminal: None,
cli_options: None,
without_config_file: false,
}
}
pub fn without_config_file(mut self) -> Self {
self.without_config_file = true;
self
}
pub fn with_config(mut self, extra_config_kdl: &str) -> Self {
self.extra_config_kdl.push('\n');
self.extra_config_kdl.push_str(extra_config_kdl);
self
}
pub fn with_cli_options(mut self, options: Options) -> Self {
self.cli_options = Some(options);
self
}
pub fn with_layout(mut self, layout: LayoutInfo) -> Self {
self.layout = Some(layout);
self
@@ -135,13 +149,27 @@ impl TestRunner {
) {
test_env::init();
let session_name = test_env::unique_session_name();
let config_path = test_env::write_config(&session_name, &self.extra_config_kdl);
let data_dir = test_env::init().join("data");
let cli_args = CliArgs {
config: Some(config_path),
data_dir: Some(data_dir),
..Default::default()
let cli_args = if self.without_config_file {
let mut options = test_env::default_test_options();
if let Some(cli_options) = self.cli_options.clone() {
options = options.merge(cli_options);
}
CliArgs {
config_dir: Some(test_env::empty_config_dir(&session_name)),
data_dir: Some(data_dir),
command: Some(Command::Options(options.into())),
..Default::default()
}
} else {
let config_path = test_env::write_config(&session_name, &self.extra_config_kdl);
CliArgs {
config: Some(config_path),
data_dir: Some(data_dir),
command: self.cli_options.clone().map(Command::Options),
..Default::default()
}
};
let (config, default_layout_info, config_options, _, _) =
Setup::from_cli_args(&cli_args).expect("failed to load harness config");
@@ -511,6 +539,65 @@ impl CliClientHandle {
}
}
pub struct CliOutputHandle {
thread: JoinHandle<i32>,
fake_client_handle: FakeClientHandle,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CliResult {
pub exit_code: i32,
pub stdout: String,
}
impl CliOutputHandle {
pub fn has_exited(&self) -> bool {
self.thread.is_finished()
}
pub fn stdout(&self) -> String {
String::from_utf8_lossy(&self.fake_client_handle.client_screen.raw_bytes()).into_owned()
}
pub fn wait_for_exit(self) -> CliResult {
let deadline = std::time::Instant::now() + crate::default_timeout();
while !self.thread.is_finished() {
if std::time::Instant::now() > deadline {
panic!(
"cli command did not exit in time; stdout so far: {:?}",
self.stdout()
);
}
std::thread::sleep(Duration::from_millis(5));
}
let stdout = self.stdout();
let exit_code = self.thread.join().expect("cli client thread panicked");
CliResult { exit_code, stdout }
}
}
pub struct CliCaller {
pub pane_id: Option<u32>,
pub stdin: Option<crossbeam::channel::Receiver<Vec<u8>>>,
}
impl CliCaller {
pub fn from_pane(pane_id: u32) -> Self {
CliCaller {
pane_id: Some(pane_id),
stdin: None,
}
}
pub fn with_stdin(mut self, stdin: crossbeam::channel::Receiver<Vec<u8>>) -> Self {
self.stdin = Some(stdin);
self
}
pub fn with_stdin_text(self, text: &str) -> Self {
let (sender, receiver) = crossbeam::channel::unbounded();
sender.send(text.as_bytes().to_vec()).unwrap();
drop(sender);
self.with_stdin(receiver)
}
}
impl TestSession {
pub fn session_name(&self) -> &str {
&self.session_name
@@ -579,6 +666,15 @@ impl TestSession {
self.fake_server_os_api.written_files()
}
pub fn config_file_path(&self) -> Option<std::path::PathBuf> {
self.cli_args.config.clone().or_else(|| {
self.cli_args
.config_dir
.as_ref()
.map(|config_dir| config_dir.join("config.kdl"))
})
}
pub fn attach_client(&self, size: Size) -> TestClient {
let (fake_client_os_api, fake_client_handle) = FakeClientOsApi::new(size, None);
let thread = spawn_client_thread(
@@ -636,6 +732,70 @@ impl TestSession {
.unwrap()
}
fn cli_os_api(&self, caller: CliCaller) -> (FakeClientOsApi, FakeClientHandle) {
let mut env = std::collections::HashMap::new();
if let Some(pane_id) = caller.pane_id {
env.insert("ZELLIJ_PANE_ID".to_owned(), pane_id.to_string());
}
let (fake_client_os_api, fake_client_handle) =
FakeClientOsApi::new_with_env(self.size, None, env);
let fake_client_os_api = match caller.stdin {
Some(stdin) => fake_client_os_api.with_piped_stdin(stdin),
None => fake_client_os_api,
};
(fake_client_os_api, fake_client_handle)
}
pub fn run_prompt(&self, args: &[&str], caller: CliCaller) -> CliOutputHandle {
let mut command_line = vec!["prompt"];
command_line.extend_from_slice(args);
let prompt_cli =
<zellij_utils::cli::PromptCli as clap::Parser>::try_parse_from(command_line)
.expect("invalid prompt arguments");
let (fake_client_os_api, fake_client_handle) = self.cli_os_api(caller);
let session_name = self.session_name.clone();
let thread = std::thread::Builder::new()
.name("in_process_prompt_client".to_string())
.spawn(move || {
zellij_client::cli_client::start_prompt_client(
Box::new(fake_client_os_api),
&session_name,
prompt_cli,
zellij_utils::prompt::PROMPT_PLUGIN_URL.to_owned(),
)
})
.unwrap();
CliOutputHandle {
thread,
fake_client_handle,
}
}
pub fn run_cli_pipe(&self, cli_action: CliAction, caller: CliCaller) -> CliOutputHandle {
let actions = Action::actions_from_cli(
cli_action,
Box::new(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"))),
Some(self.config.clone()),
)
.expect("failed to build cli actions");
let (fake_client_os_api, fake_client_handle) = self.cli_os_api(caller);
let session_name = self.session_name.clone();
let thread = std::thread::Builder::new()
.name("in_process_pipe_client".to_string())
.spawn(move || {
zellij_client::cli_client::start_cli_client(
Box::new(fake_client_os_api),
&session_name,
actions,
)
})
.unwrap();
CliOutputHandle {
thread,
fake_client_handle,
}
}
pub fn save_session(&self) {
self.run_cli_action(CliAction::SaveSession);
}
+14
View File
@@ -160,6 +160,20 @@ pane_frame_style "titles"
theme "default"
"#;
pub fn default_test_options() -> zellij_utils::input::options::Options {
zellij_utils::input::config::Config::from_kdl(DEFAULT_TEST_CONFIG, None)
.expect("the default test config is valid")
.options
}
pub fn empty_config_dir(session_name: &str) -> PathBuf {
let config_dir = init()
.join("config")
.join(format!("{}-config-dir", session_name));
std::fs::create_dir_all(&config_dir).unwrap();
config_dir
}
pub fn write_config(session_name: &str, extra_config_kdl: &str) -> PathBuf {
let test_root = init();
let config_path = test_root
@@ -0,0 +1,744 @@
#![cfg(unix)]
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, keys, start_zellij, GridSnapshot, TestRunner,
TestSession, TERMINAL_SIZE,
};
const ARROW_DOWN: &[u8] = b"\x1b[B";
const ARROW_UP: &[u8] = b"\x1b[A";
const ARROW_LEFT: &[u8] = b"\x1b[D";
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;
fn open_settings(zellij: &TestSession) {
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("settings screen opened", |grid_snapshot| {
grid_snapshot.contains("Configuration") && grid_snapshot.contains("unsaved change")
});
}
fn open_page(zellij: &TestSession, index: usize, marker: &str) {
for _ in 0..index {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("settings page shown", |grid_snapshot| {
grid_snapshot.contains(marker)
});
zellij.send_stdin(&keys::TAB);
}
fn open_keybindings(zellij: &TestSession) {
open_settings(zellij);
open_page(zellij, KEYS_PAGE, "Save as a preset");
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")
});
}
fn type_text(zellij: &TestSession, text: &str) {
zellij.send_stdin(text.as_bytes());
}
fn save_and_wait(zellij: &TestSession) {
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("config saved", |grid_snapshot| {
grid_snapshot.contains("Saved") && grid_snapshot.contains("0 unsaved changes")
});
}
fn close_settings(zellij: &TestSession) {
zellij.send_stdin(&keys::ESC);
zellij.wait_until("settings closed", |grid_snapshot| {
!grid_snapshot.contains("Configuration") && grid_snapshot.status_bar_appears()
});
}
fn capture_key(zellij: &TestSession, key: &[u8]) {
zellij.wait_until("ready to capture a key", |grid_snapshot| {
grid_snapshot.contains("press a key")
});
zellij.send_stdin(key);
}
fn choose_action(zellij: &TestSession, search: &str, action: &str) {
zellij.wait_until("action search shown", |grid_snapshot| {
grid_snapshot.contains("type to search")
});
type_text(zellij, search);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("action argument form shown", |grid_snapshot| {
grid_snapshot.contains("[ Apply ]") && grid_snapshot.contains("Action ")
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("action added", |grid_snapshot| {
grid_snapshot.contains(action) && grid_snapshot.contains("+ Add action")
});
}
fn press_done(zellij: &TestSession) {
zellij.send_stdin(ARROW_UP);
zellij.send_stdin(ARROW_LEFT);
zellij.send_stdin(&keys::ENTER);
}
fn choose_action_without_arguments(zellij: &TestSession, search: &str, action: &str) {
choose_action(zellij, search, action);
press_done(zellij);
}
#[test]
fn the_keys_page_shows_the_preset_above_the_keybindings() {
let mut zellij = TestRunner::new(zellij_integration_tests::Size {
cols: 120,
rows: 50,
})
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_settings(&zellij);
open_page(&zellij, KEYS_PAGE, "Save as a preset");
let grid_snapshot = zellij.wait_until("preset and keybindings on one page", |grid_snapshot| {
grid_snapshot.contains("Mark all") && grid_snapshot.contains("Primary key")
});
assert!(
grid_snapshot.row_of_line("Primary key").unwrap()
< grid_snapshot.row_of_line("Mark all").unwrap()
);
zellij.quit();
}
#[test]
fn up_from_the_keybindings_goes_back_to_the_preset_fields() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_keybindings(&zellij);
for _ in 0..4 {
zellij.send_stdin(ARROW_UP);
}
zellij.wait_until("the preset fields are focused again", |grid_snapshot| {
grid_snapshot.contains("<Space> - change") && grid_snapshot.contains("Primary key")
});
zellij.quit();
}
#[test]
fn a_key_rebound_in_normal_mode_works_live_and_saves_only_its_bind_line() {
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_keybindings(&zellij);
zellij.send_stdin(&keys::key('a'));
capture_key(&zellij, &keys::alt('y'));
choose_action_without_arguments(&zellij, "newtab", "NewTab");
zellij.wait_until("the new binding is listed as unsaved", |grid_snapshot| {
grid_snapshot.contains("Alt y") && grid_snapshot.contains("1 unsaved change ")
});
close_settings(&zellij);
zellij.send_stdin(&keys::alt('y'));
zellij.expect_pty_spawn();
zellij.wait_until("the rebound key 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 = "keybinds {\n normal {\n bind \"Alt y\" { NewTab; }\n }\n}\n";
assert!(saved.contains(added), "{}", saved);
assert_eq!(saved.replacen(added, "", 1), original);
zellij.quit();
}
#[test]
fn a_removed_preset_key_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_keybindings(&zellij);
zellij.send_stdin(&keys::key('/'));
type_text(&zellij, "alt n");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the preset key is found", |grid_snapshot| {
grid_snapshot.contains("Normal") && grid_snapshot.contains("Alt n")
});
zellij.send_stdin(DELETE);
let grid_snapshot = zellij.wait_until("removal is confirmed first", |grid_snapshot| {
grid_snapshot.contains("Delete Alt n from Normal mode?")
&& grid_snapshot.contains("Don't ask again")
});
let question_row = grid_snapshot
.row_of_line("Delete Alt n from Normal mode?")
.unwrap();
let toggle_row = grid_snapshot.row_of_line("Don't ask again").unwrap();
let buttons_row = grid_snapshot.row_of_line("Cancel").unwrap();
assert!(grid_snapshot.lines()[question_row - 2].contains("Confirm"));
assert!(question_row < toggle_row && toggle_row < buttons_row);
let question_line = grid_snapshot.lines()[question_row].clone();
let delete_column = question_line[..question_line.find("Delete Alt n").unwrap()]
.chars()
.count();
let key_columns = delete_column + 7..delete_column + 12;
let plain_color = grid_snapshot.cell_foreground(delete_column, question_row);
for column in key_columns {
let key_color = grid_snapshot.cell_foreground(column, question_row);
assert!(key_color.is_some() && key_color != plain_color);
assert_eq!(
key_color,
grid_snapshot.cell_foreground(delete_column + 7, question_row)
);
}
assert_eq!(
grid_snapshot.cell_foreground(delete_column + 13, question_row),
plain_color
);
assert!(grid_snapshot.lines()[buttons_row].contains("Delete"));
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the key is unbound", |grid_snapshot| {
grid_snapshot.contains("(unbound)") && grid_snapshot.contains("1 unsaved change ")
});
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
assert!(
saved.contains(" normal {\n unbind \"Alt n\"\n }\n"),
"{}",
saved
);
zellij.send_stdin(&keys::key('r'));
zellij.wait_until("the key 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 n"), "{}", reset);
assert!(reset.starts_with(original.trim_end()), "{}", reset);
zellij.quit();
}
#[test]
fn binding_a_key_that_is_already_bound_warns_first() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_keybindings(&zellij);
zellij.send_stdin(&keys::key('a'));
capture_key(&zellij, &keys::alt('n'));
zellij.wait_until("the conflict is reported", |grid_snapshot| {
grid_snapshot.contains("Key already bound")
&& grid_snapshot.contains("Alt n is already bound in Normal mode")
&& grid_snapshot.contains("Replace")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("the warning closed and capture goes on", |grid_snapshot| {
!grid_snapshot.contains("Key already bound") && grid_snapshot.contains("press a key")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("capture stopped", |grid_snapshot| {
grid_snapshot.contains("Enter captures a key")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("the form is left with nothing changed", |grid_snapshot| {
!grid_snapshot.contains("Add Key") && grid_snapshot.contains("0 unsaved changes")
});
zellij.quit();
}
#[test]
fn an_env_variable_and_a_plugin_alias_are_added_and_saved() {
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_settings(&zellij);
open_page(&zellij, PLUGINS_PAGE, "Plugin aliases");
zellij.wait_until("every list is shown on one page", |grid_snapshot| {
grid_snapshot.contains("zellij:about") && grid_snapshot.contains("<a> - add")
});
zellij.send_stdin(&keys::key('a'));
zellij.wait_until("alias form shown", |grid_snapshot| {
grid_snapshot.contains("Add Plugin Alias")
});
type_text(&zellij, "mine");
zellij.send_stdin(&keys::TAB);
type_text(&zellij, "zellij:strider");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("alias added", |grid_snapshot| {
grid_snapshot
.row_of_line(" mine ")
.map(|row| grid_snapshot.lines()[row].contains("zellij:strider"))
.unwrap_or(false)
&& grid_snapshot.contains("1 unsaved change ")
});
zellij.send_stdin(END);
zellij.send_stdin(&keys::key('a'));
zellij.wait_until("variable form shown", |grid_snapshot| {
grid_snapshot.contains("Add Variable")
});
type_text(&zellij, "EDITOR");
zellij.send_stdin(&keys::TAB);
type_text(&zellij, "vim");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("variable added", |grid_snapshot| {
grid_snapshot
.row_of_line("EDITOR")
.map(|row| grid_snapshot.lines()[row].contains("vim"))
.unwrap_or(false)
&& grid_snapshot.contains("2 unsaved changes")
});
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
let plugins = "plugins {\n mine location=\"zellij:strider\"\n}\n";
let env = "env {\n EDITOR \"vim\"\n}\n";
assert!(saved.contains(plugins), "{}", saved);
assert!(saved.contains(env), "{}", saved);
assert_eq!(
saved.replacen(plugins, "", 1).replacen(env, "", 1),
original
);
zellij.quit();
}
const ADDED_VARIABLE: &str = "ZJ_TEST_ADDED";
const REMOVED_VARIABLE: &str = "ZJ_TEST_REMOVED";
#[test]
fn an_env_variable_added_at_runtime_reaches_new_panes_but_not_the_server() {
let mut zellij = start_zellij();
let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
open_settings(&zellij);
open_page(&zellij, PLUGINS_PAGE, "Plugin aliases");
zellij.wait_until("every list is shown on one page", |grid_snapshot| {
grid_snapshot.contains("zellij:about") && grid_snapshot.contains("<a> - add")
});
zellij.send_stdin(END);
zellij.send_stdin(&keys::key('a'));
zellij.wait_until("variable form shown", |grid_snapshot| {
grid_snapshot.contains("Add Variable")
});
type_text(&zellij, ADDED_VARIABLE);
zellij.send_stdin(&keys::TAB);
type_text(&zellij, "added");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("variable added", |grid_snapshot| {
grid_snapshot.contains(ADDED_VARIABLE) && grid_snapshot.contains("1 unsaved change ")
});
close_settings(&zellij);
let new_terminal = zellij_integration_tests::split_right_and_wait_for_prompt(&zellij);
assert_eq!(
new_terminal.env_var(ADDED_VARIABLE).as_deref(),
Some("added")
);
assert_eq!(first_terminal.env_var(ADDED_VARIABLE), None);
assert!(std::env::var(ADDED_VARIABLE).is_err());
zellij.quit();
}
#[test]
fn an_env_variable_removed_at_runtime_is_not_set_in_new_panes() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(&format!("env {{\n {} \"start\"\n}}", REMOVED_VARIABLE))
.start();
let first_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
assert_eq!(
first_terminal.env_var(REMOVED_VARIABLE).as_deref(),
Some("start")
);
open_settings(&zellij);
open_page(&zellij, PLUGINS_PAGE, "Plugin aliases");
zellij.wait_until("every list is shown on one page", |grid_snapshot| {
grid_snapshot.contains("zellij:about") && grid_snapshot.contains("<a> - add")
});
zellij.send_stdin(END);
zellij.wait_until("the variable is listed", |grid_snapshot| {
grid_snapshot.contains(REMOVED_VARIABLE)
});
zellij.send_stdin(ARROW_UP);
zellij.wait_until("the variable is selected", |grid_snapshot| {
grid_snapshot.contains("<Del> - delete")
});
zellij.send_stdin(DELETE);
zellij.wait_until("deleting asks first", |grid_snapshot| {
grid_snapshot.contains("Don't ask again")
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the variable is removed", |grid_snapshot| {
!grid_snapshot.contains(REMOVED_VARIABLE) && grid_snapshot.contains("1 unsaved change ")
});
close_settings(&zellij);
let new_terminal = zellij_integration_tests::split_right_and_wait_for_prompt(&zellij);
assert_eq!(new_terminal.env_var(REMOVED_VARIABLE), None);
assert_eq!(
first_terminal.env_var(REMOVED_VARIABLE).as_deref(),
Some("start")
);
zellij.quit();
}
fn right_click(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(format!("\u{1b}[<2;{};{}M", column, line).as_bytes());
zellij.send_stdin(format!("\u{1b}[<2;{};{}m", column, line).as_bytes());
}
fn sgr_click(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(format!("\u{1b}[<0;{};{}M", column, line).as_bytes());
zellij.send_stdin(format!("\u{1b}[<0;{};{}m", column, line).as_bytes());
}
fn click_text(zellij: &TestSession, grid_snapshot: &GridSnapshot, needle: &str, nth: usize) {
let mut found = 0;
for (row, line) in grid_snapshot.lines().iter().enumerate() {
let mut search_from = 0;
while let Some(byte_index) = line[search_from..].find(needle) {
let byte_index = search_from + byte_index;
if found == nth {
let column = line[..byte_index].chars().count();
sgr_click(zellij, column + 2, row + 1);
return;
}
found += 1;
search_from = byte_index + needle.len();
}
}
panic!("{} (#{}) is not on screen", needle, nth);
}
fn open_menu_page(zellij: &TestSession) {
open_settings(zellij);
open_page(zellij, MENU_PAGE, "Pane menu");
zellij.wait_until("menu sections listed together", |grid_snapshot| {
grid_snapshot.contains("Pane menu") && grid_snapshot.contains("<a> - add")
});
}
#[test]
fn a_new_right_click_menu_item_shows_in_the_menu_with_its_shortcut() {
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();
open_menu_page(&zellij);
zellij.send_stdin(&keys::key('a'));
zellij.wait_until("menu item form shown", |grid_snapshot| {
grid_snapshot.contains("Add Item") && grid_snapshot.contains("Kind")
});
type_text(&zellij, "Hello");
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
choose_action(&zellij, "newtab", "NewTab");
press_done(&zellij);
zellij.wait_until("the item is listed with its shortcut", |grid_snapshot| {
grid_snapshot
.row_of_line("Hello")
.map(|row| grid_snapshot.lines()[row].contains("Ctrl t, n"))
.unwrap_or(false)
&& grid_snapshot.contains("1 unsaved change ")
});
close_settings(&zellij);
right_click(&zellij, 30, 10);
zellij.wait_until(
"the menu shows the new item and its shortcut",
|grid_snapshot| grid_snapshot.contains("Hello") && grid_snapshot.contains("Ctrl t, n"),
);
zellij.send_stdin(&keys::ESC);
zellij.wait_until("menu closed", |grid_snapshot| {
!grid_snapshot.contains("Hello") && grid_snapshot.status_bar_appears()
});
open_settings(&zellij);
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
assert!(!saved.contains("clear-defaults"), "{}", saved);
assert!(
saved.contains("item \"Hello\" after=\"New pane\" { NewTab; }"),
"{}",
saved
);
zellij.quit();
}
#[test]
fn menu_items_and_separators_are_edited_with_the_mouse() {
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();
open_menu_page(&zellij);
zellij.send_stdin(END);
let grid_snapshot = zellij.wait_until("the common items are shown", |grid_snapshot| {
grid_snapshot.contains("Common items") && grid_snapshot.contains("Detach")
});
click_text(&zellij, &grid_snapshot, "+ Add separator", 0);
zellij.wait_until("a separator was added", |grid_snapshot| {
grid_snapshot.contains("1 unsaved change ")
});
let grid_snapshot = zellij.wait_until("the item is shown", |grid_snapshot| {
grid_snapshot.contains("Detach")
});
click_text(&zellij, &grid_snapshot, "Detach", 0);
let grid_snapshot = zellij.wait_until("the item is selected", |grid_snapshot| {
grid_snapshot.contains("<Del> - delete")
});
click_text(&zellij, &grid_snapshot, "Detach", 0);
zellij.wait_until("the edit popup opened", |grid_snapshot| {
grid_snapshot.contains("Edit Item") && grid_snapshot.contains("[ Done ]")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("the edit popup closed", |grid_snapshot| {
!grid_snapshot.contains("Edit Item")
});
zellij.send_stdin(DELETE);
let grid_snapshot = zellij.wait_until("deleting asks first", |grid_snapshot| {
grid_snapshot.contains("Don't ask again") && grid_snapshot.contains("Cancel")
});
let row = grid_snapshot.row_of_line("Cancel").unwrap();
let line = grid_snapshot.lines()[row].clone();
let column = line[..line.find("Delete").unwrap()].chars().count();
sgr_click(&zellij, column + 2, row + 1);
zellij.wait_until("the item is gone", |grid_snapshot| {
!grid_snapshot.contains("Detach") && grid_snapshot.contains("Common items")
});
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
assert!(!saved.contains("clear-defaults"), "{}", saved);
assert!(saved.contains("remove \"Detach\""), "{}", saved);
assert!(saved.contains("separator"), "{}", saved);
zellij.quit();
}
fn column_of_text(grid_snapshot: &GridSnapshot, row_needle: &str, needle: &str) -> usize {
let row = grid_snapshot
.row_of_line(row_needle)
.unwrap_or_else(|| panic!("no row with {}:\n{}", row_needle, grid_snapshot.text));
let line = grid_snapshot.lines()[row].clone();
let byte_index = line
.find(needle)
.unwrap_or_else(|| panic!("{} is not on the row of {}: {}", needle, row_needle, line));
line[..byte_index].chars().count()
}
#[test]
fn menu_columns_line_up_and_rows_show_the_hover() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true\nadvanced_mouse_actions true\nmouse_hover_effects true")
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_menu_page(&zellij);
let grid_snapshot = zellij.wait_until("pane items listed", |grid_snapshot| {
grid_snapshot.contains("New pane") && grid_snapshot.contains("Fullscreen")
});
assert_eq!(
column_of_text(&grid_snapshot, "New pane", "Alt n"),
column_of_text(&grid_snapshot, "Fullscreen", "Ctrl p, f")
);
assert_eq!(
column_of_text(&grid_snapshot, "New pane", "NewPane"),
column_of_text(&grid_snapshot, "Fullscreen", "ToggleFocus")
);
assert!(
column_of_text(&grid_snapshot, "New pane", "NewPane")
< column_of_text(&grid_snapshot, "New pane", "Alt n")
);
let row = grid_snapshot.row_of_line("Fullscreen").unwrap();
let column = column_of_text(&grid_snapshot, "Fullscreen", "Fullscreen");
let before = format!("{:?}", grid_snapshot.cell_style(column, row).background);
zellij.send_stdin(format!("\u{1b}[<35;{};{}M", column + 2, row + 1).as_bytes());
zellij.wait_until("the hovered row is highlighted", |grid_snapshot| {
format!("{:?}", grid_snapshot.cell_style(column, row).background) != before
});
zellij.quit();
}
fn tab_background(grid_snapshot: &GridSnapshot) -> String {
let line = grid_snapshot.lines()[0].clone();
let byte_index = line.find("Tab #1").unwrap();
let column = line[..byte_index].chars().count();
format!("{:?}", grid_snapshot.cell_style(column, 0).background)
}
const THEME_CONFIG: &str = "theme \"mine\"
themes {
mine {
fg 250
bg 236
red 160
green 70
blue 32
yellow 178
magenta 133
orange 208
cyan 37
black 16
white 255
}
}
";
#[test]
fn a_colour_of_the_active_config_file_theme_applies_live_and_saves() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(THEME_CONFIG)
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
let config_file_path = zellij.config_file_path().unwrap();
let before = tab_background(&zellij.wait_until("tab bar drawn", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
}));
open_settings(&zellij);
open_page(&zellij, THEMES_PAGE, "+ New theme");
zellij.wait_until("themes listed with ours first", |grid_snapshot| {
grid_snapshot
.row_of_line(" mine ")
.map(|row| grid_snapshot.lines()[row].contains("active"))
.unwrap_or(false)
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("colour form shown", |grid_snapshot| {
grid_snapshot.contains("Colors of mine (active: changes preview live)")
});
for _ in 0..19 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("the ribbon background is focused", |grid_snapshot| {
grid_snapshot.contains("ribbon_selected.background")
});
zellij.send_stdin(END);
for _ in 0..12 {
zellij.send_stdin(BACKSPACE);
}
type_text(&zellij, "#010203");
zellij.wait_until("the new colour previews on the tab bar", |grid_snapshot| {
tab_background(grid_snapshot) != before
});
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("the colour is applied", |grid_snapshot| {
grid_snapshot.contains("Colors of mine applied")
});
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
assert!(saved.contains("ribbon_selected {"), "{}", saved);
assert!(saved.contains("background 1 2 3"), "{}", saved);
assert!(saved.contains("theme \"mine\""), "{}", saved);
zellij.quit();
}
#[test]
fn the_keybinding_search_finds_keys_of_every_mode_and_hides_the_dropdowns() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_settings(&zellij);
open_page(&zellij, KEYS_PAGE, "Save as a preset");
zellij.send_stdin(&keys::key('/'));
zellij.wait_until("the keybinding search is focused", |grid_snapshot| {
grid_snapshot.contains("search keys and actions") && grid_snapshot.contains("Filter")
});
type_text(&zellij, "newtab");
let grid_snapshot = zellij.wait_until("tab keys of several modes found", |grid_snapshot| {
grid_snapshot.contains("Tmux") && grid_snapshot.contains("NewTab")
});
assert!(
!grid_snapshot.contains("Filter ["),
"{}",
grid_snapshot.text
);
assert!(
grid_snapshot
.lines()
.iter()
.filter(|line| line.contains("NewTab"))
.all(|line| !line.contains("Switch Modes")),
"{}",
grid_snapshot.text
);
zellij.send_stdin(&keys::ESC);
zellij.wait_until("the search is cleared", |grid_snapshot| {
grid_snapshot.contains("Filter") && grid_snapshot.contains("Mark all")
});
zellij.quit();
}
#[test]
fn every_key_of_a_category_is_marked_and_deleted_together() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let config_file_path = zellij.config_file_path().unwrap();
open_keybindings(&zellij);
zellij.send_stdin(&keys::key('C'));
zellij.wait_until("the category is marked", |grid_snapshot| {
grid_snapshot.contains("✓") && grid_snapshot.contains("delete marked")
});
zellij.send_stdin(DELETE);
zellij.wait_until("deleting several keys asks first", |grid_snapshot| {
grid_snapshot.contains("keys?") && grid_snapshot.contains("Don't ask again")
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the keys are unbound", |grid_snapshot| {
grid_snapshot.contains("(unbound)") && grid_snapshot.contains("1 unsaved change ")
});
save_and_wait(&zellij);
let saved = std::fs::read_to_string(&config_file_path).unwrap();
assert!(saved.matches("unbind").count() > 1, "{}", saved);
zellij.quit();
}
#[test]
fn menu_items_move_with_shift_arrows_and_the_menu_search_finds_them() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_menu_page(&zellij);
let grid_snapshot = zellij.wait_until("pane items listed", |grid_snapshot| {
grid_snapshot.contains("New pane") && grid_snapshot.contains("New floating pane")
});
let new_pane_row = grid_snapshot.row_of_line("New pane ").unwrap();
let floating_row = grid_snapshot.row_of_line("New floating pane").unwrap();
assert!(new_pane_row < floating_row);
zellij.send_stdin(SHIFT_DOWN);
zellij.wait_until("the first item moved down", |grid_snapshot| {
match (
grid_snapshot.row_of_line("New pane "),
grid_snapshot.row_of_line("New floating pane"),
) {
(Some(new_pane), Some(floating)) => {
new_pane > floating && grid_snapshot.contains("1 unsaved change ")
},
_ => false,
}
});
zellij.send_stdin(&keys::key('/'));
type_text(&zellij, "detach");
zellij.wait_until("the search lists the matching item", |grid_snapshot| {
grid_snapshot.contains("Common items")
&& grid_snapshot.contains("Detach")
&& !grid_snapshot.contains("New floating pane")
});
zellij.quit();
}
@@ -0,0 +1,241 @@
#![cfg(unix)]
use std::path::Path;
use std::time::{Duration, Instant};
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, default_timeout, keys, start_zellij, TestRunner,
TestSession, PROMPT, TERMINAL_SIZE,
};
const ARROW_DOWN: &[u8] = b"\x1b[B";
fn open_settings(zellij: &TestSession) {
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("settings screen opened", |grid_snapshot| {
grid_snapshot.contains("Configuration") && grid_snapshot.contains("unsaved change")
});
}
fn hide_session_name_in_frames(zellij: &TestSession) {
zellij.send_stdin(&keys::key('/'));
zellij.send_stdin(b"hide session");
zellij.wait_until("search shows the setting", |grid_snapshot| {
grid_snapshot.contains("Hide session name")
});
zellij.send_stdin(&keys::ENTER);
zellij.send_stdin(&keys::SPACE);
zellij.wait_until("the change is unsaved", |grid_snapshot| {
grid_snapshot.contains("● unsaved") && grid_snapshot.contains("1 unsaved change ")
});
}
fn wait_for_file_containing(path: &Path, needle: &str) -> String {
let deadline = Instant::now() + default_timeout();
loop {
let contents = std::fs::read_to_string(path).unwrap_or_default();
if contents.contains(needle) {
return contents;
}
assert!(
Instant::now() < deadline,
"{} never contained {:?}:\n{}",
path.display(),
needle,
contents
);
std::thread::sleep(Duration::from_millis(20));
}
}
const HIDE_SESSION_NAME_BLOCK: &str =
"ui {\n pane_frames {\n hide_session_name true\n }\n}\n";
#[test]
fn saving_writes_only_the_changed_lines_and_keeps_comments() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("// a comment the user wrote\n")
.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_settings(&zellij);
hide_session_name_in_frames(&zellij);
zellij.send_stdin(&keys::ESC);
zellij.wait_until("search closed", |grid_snapshot| {
grid_snapshot.contains("<↓↑> - category")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(ARROW_DOWN);
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")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("both changes are unsaved", |grid_snapshot| {
grid_snapshot.contains("2 unsaved changes") && grid_snapshot.contains("UNLOCK")
});
zellij.send_stdin(&keys::ctrl('a'));
let grid_snapshot = zellij.wait_until("saved without a confirmation", |grid_snapshot| {
grid_snapshot.contains("Saved to")
});
assert!(!grid_snapshot.contains("Save settings?"));
let written = std::fs::read_to_string(&config_file_path).unwrap();
assert_eq!(
written,
format!(
"{}{}keybinds preset=\"unlock-first\"\n",
original, HIDE_SESSION_NAME_BLOCK
)
);
assert!(written.contains("// a comment the user wrote"));
zellij.wait_until("nothing is left unsaved", |grid_snapshot| {
grid_snapshot.contains("0 unsaved changes")
});
zellij.quit();
}
fn change_then_edit_the_file_outside(zellij: &TestSession) -> (std::path::PathBuf, String) {
let config_file_path = zellij.config_file_path().unwrap();
open_settings(zellij);
hide_session_name_in_frames(zellij);
let mut outside = std::fs::read_to_string(&config_file_path).unwrap();
outside.push_str("// edited in another program\n");
std::fs::write(&config_file_path, &outside).unwrap();
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("the outside change is reported", |grid_snapshot| {
grid_snapshot.contains("changed outside Zellij")
&& grid_snapshot.contains("Overwrite:")
&& grid_snapshot.contains("Reload:")
&& grid_snapshot.contains("Cancel")
});
(config_file_path, outside)
}
#[test]
fn overwrite_after_an_outside_edit_applies_the_changes_to_the_edited_file() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let (config_file_path, outside) = change_then_edit_the_file_outside(&zellij);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("saved", |grid_snapshot| {
grid_snapshot.contains("Saved to") && grid_snapshot.contains("0 unsaved changes")
});
let written = std::fs::read_to_string(&config_file_path).unwrap();
assert_eq!(written, format!("{}{}", outside, HIDE_SESSION_NAME_BLOCK));
zellij.quit();
}
#[test]
fn reload_after_an_outside_edit_keeps_the_changes_unsaved() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let (config_file_path, outside) = change_then_edit_the_file_outside(&zellij);
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("reloaded with the change kept", |grid_snapshot| {
grid_snapshot.contains("Reloaded the config file")
&& grid_snapshot.contains("1 unsaved change ")
});
assert_eq!(std::fs::read_to_string(&config_file_path).unwrap(), outside);
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("saved without asking again", |grid_snapshot| {
grid_snapshot.contains("Saved to") && grid_snapshot.contains("0 unsaved changes")
});
let written = std::fs::read_to_string(&config_file_path).unwrap();
assert_eq!(written, format!("{}{}", outside, HIDE_SESSION_NAME_BLOCK));
zellij.quit();
}
#[test]
fn cancel_after_an_outside_edit_writes_nothing() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let (config_file_path, outside) = change_then_edit_the_file_outside(&zellij);
zellij.send_stdin(&keys::ESC);
zellij.wait_until("not saved", |grid_snapshot| {
grid_snapshot.contains("Not saved")
&& grid_snapshot.contains("1 unsaved change ")
&& !grid_snapshot.contains("changed outside Zellij")
});
assert_eq!(std::fs::read_to_string(&config_file_path).unwrap(), outside);
zellij.quit();
}
#[test]
fn closing_the_settings_pane_during_a_theme_preview_restores_the_theme() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_settings(&zellij);
zellij.send_stdin(&keys::TAB);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("theme list opened", |grid_snapshot| {
grid_snapshot.contains("● default") && grid_snapshot.contains("ansi")
});
zellij.send_stdin(ARROW_DOWN);
zellij.wait_until("the highlighted theme is previewed", |grid_snapshot| {
grid_snapshot.contains("1 unsaved change ")
});
zellij.send_stdin(&keys::ctrl('p'));
zellij.send_stdin(&keys::key('x'));
zellij.wait_until("settings pane closed", |grid_snapshot| {
!grid_snapshot.contains("Configuration")
});
open_settings(&zellij);
zellij.wait_until(
"the original theme is back and nothing is unsaved",
|grid_snapshot| grid_snapshot.contains("0 unsaved changes"),
);
zellij.quit();
}
#[test]
fn the_first_run_wizard_saves_the_chosen_preset_into_the_new_config_file() {
let mut zellij = TestRunner::new(TERMINAL_SIZE).without_config_file().start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("the setup wizard opened", |grid_snapshot| {
grid_snapshot.contains("How would you like your keybindings to work?")
});
let config_file_path = zellij.config_file_path().unwrap();
let first_run_file = std::fs::read_to_string(&config_file_path).unwrap();
assert!(first_run_file.contains("//"), "{}", first_run_file);
assert!(!first_run_file.contains("keybinds preset="));
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("preset list opened", |grid_snapshot| {
grid_snapshot.contains("unlock-first")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("unlock-first applied", |grid_snapshot| {
grid_snapshot.contains("UNLOCK")
});
zellij.send_stdin(&keys::ctrl('a'));
let written = wait_for_file_containing(&config_file_path, "keybinds preset=\"unlock-first\"");
assert!(
written.starts_with(&first_run_file),
"the first-run file was not kept:\n{}",
written
);
assert!(!written.contains("AUTOGENERATED"), "{}", written);
zellij.wait_until("the wizard closed", |grid_snapshot| {
!grid_snapshot.contains("How would you like your keybindings to work?")
});
zellij.quit();
}
@@ -0,0 +1,583 @@
#![cfg(unix)]
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, col, keys, split_right_and_wait_for_prompt,
GridSnapshot, TestRunner, TestSession, PROMPT, TERMINAL_SIZE,
};
use zellij_utils::cli::CliAction;
const PANE_MENU_MARKER: &str = "Close pane";
const TAB_MENU_MARKER: &str = "Move tab right";
const COMMON_MENU_MARKER: &str = "Session manager";
const LAST_COMMON_MENU_ITEM: &str = "Detach";
fn start_zellij() -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true")
.start()
}
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 right_click(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(&sgr_press(column, line, 2));
zellij.send_stdin(&sgr_release(column, line, 2));
}
fn left_click(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(&sgr_press(column, line, 0));
zellij.send_stdin(&sgr_release(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 wait_for_pane_menu(zellij: &TestSession) -> GridSnapshot {
zellij.wait_until("pane context menu opened", |grid_snapshot| {
grid_snapshot.contains(PANE_MENU_MARKER) && grid_snapshot.contains(COMMON_MENU_MARKER)
})
}
fn wait_for_menu_to_close(zellij: &TestSession) -> GridSnapshot {
zellij.wait_until("context menu closed", |grid_snapshot| {
!grid_snapshot.contains(COMMON_MENU_MARKER)
&& !grid_snapshot.contains(LAST_COMMON_MENU_ITEM)
&& grid_snapshot.status_bar_appears()
})
}
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).unwrap();
left_click(zellij, column + 2, row + 1);
}
#[test]
fn right_click_on_a_pane_opens_the_pane_menu_and_esc_closes_it() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
right_click(&zellij, 30, 10);
let grid_snapshot = wait_for_pane_menu(&zellij);
assert!(grid_snapshot.contains("New floating pane"));
assert!(!grid_snapshot.contains(TAB_MENU_MARKER));
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.quit();
}
#[test]
fn right_click_on_a_tab_opens_the_tab_menu() {
let mut zellij = start_zellij();
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");
right_click(&zellij, tab_column + 2, 1);
let grid_snapshot = zellij.wait_until("tab context menu opened", |grid_snapshot| {
grid_snapshot.contains(TAB_MENU_MARKER) && grid_snapshot.contains(COMMON_MENU_MARKER)
});
assert!(!grid_snapshot.contains(PANE_MENU_MARKER));
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.quit();
}
#[test]
fn right_click_on_the_status_bar_opens_the_bar_menu() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
right_click(&zellij, 60, TERMINAL_SIZE.rows);
let grid_snapshot = zellij.wait_until("bar context menu opened", |grid_snapshot| {
grid_snapshot.contains(COMMON_MENU_MARKER) && grid_snapshot.contains("Detach")
});
assert!(!grid_snapshot.contains(PANE_MENU_MARKER));
assert!(!grid_snapshot.contains(TAB_MENU_MARKER));
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.quit();
}
#[test]
fn choosing_close_pane_closes_the_clicked_pane_and_not_the_focused_one() {
let mut zellij = start_zellij();
let left_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
left_terminal.output(b"LEFT-MARKER");
right_terminal.output(b"RIGHT-MARKER");
zellij.wait_until("both markers rendered", |grid_snapshot| {
grid_snapshot.contains("LEFT-MARKER") && grid_snapshot.contains("RIGHT-MARKER")
});
right_click(&zellij, 10, 12);
let grid_snapshot = wait_for_pane_menu(&zellij);
click_menu_item(&zellij, &grid_snapshot, PANE_MENU_MARKER);
zellij.wait_until(
"left pane closed while the right pane stays",
|grid_snapshot| {
!grid_snapshot.contains(COMMON_MENU_MARKER)
&& !grid_snapshot.contains("LEFT-MARKER")
&& grid_snapshot.contains("RIGHT-MARKER")
},
);
zellij.quit();
}
fn columns_of_name(grid_snapshot: &GridSnapshot, needle: &str) -> Vec<usize> {
(0..grid_snapshot.lines().len())
.filter_map(|row| column_of(grid_snapshot, row, needle))
.collect()
}
#[test]
fn choosing_rename_pane_renames_the_clicked_pane_and_not_the_focused_one() {
let mut zellij = start_zellij();
let left_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
left_terminal.output(b"LEFT-MARKER");
right_terminal.output(b"RIGHT-MARKER");
zellij.wait_until("both markers rendered", |grid_snapshot| {
grid_snapshot.contains("LEFT-MARKER") && grid_snapshot.contains("RIGHT-MARKER")
});
right_click(&zellij, 10, 12);
let grid_snapshot = wait_for_pane_menu(&zellij);
click_menu_item(&zellij, &grid_snapshot, "Rename pane");
zellij.wait_until("rename pane mode entered", |grid_snapshot| {
!grid_snapshot.contains(COMMON_MENU_MARKER) && grid_snapshot.contains("RENAMING PANE")
});
for character in "CLICKED".chars() {
zellij.send_stdin(&keys::key(character));
}
zellij.send_stdin(&keys::ENTER);
let grid_snapshot = zellij.wait_until("the clicked pane shows the new name", |grid_snapshot| {
!grid_snapshot.contains("RENAMING PANE") && grid_snapshot.contains("CLICKED")
});
let middle = (TERMINAL_SIZE.cols / 2) as usize;
let new_name_columns = columns_of_name(&grid_snapshot, "CLICKED");
assert!(
!new_name_columns.is_empty() && new_name_columns.iter().all(|column| *column < middle),
"the new name is only on the left (clicked) pane, found at columns {:?}",
new_name_columns
);
assert!(
columns_of_name(&grid_snapshot, "Pane #2")
.iter()
.any(|column| *column >= middle),
"the focused right pane keeps its name"
);
zellij.quit();
}
fn tab_names_in_tab_bar(grid_snapshot: &GridSnapshot) -> usize {
grid_snapshot
.lines()
.first()
.map(|line| line.matches("Tab #").count())
.unwrap_or(0)
}
#[test]
fn choosing_close_tab_closes_the_clicked_tab_and_not_the_active_one() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('t'));
zellij.send_stdin(&keys::key('n'));
let second_tab_terminal = zellij.expect_pty_spawn();
second_tab_terminal.output(b"SECOND-TAB-MARKER");
let grid_snapshot = zellij.wait_until("second tab is active", |grid_snapshot| {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("SECOND-TAB-MARKER")
&& tab_names_in_tab_bar(grid_snapshot) == 2
});
let first_tab_column = column_of(&grid_snapshot, 0, "Tab #1").expect("tab bar shows Tab #1");
right_click(&zellij, first_tab_column + 2, 1);
let grid_snapshot = zellij.wait_until("tab context menu opened", |grid_snapshot| {
grid_snapshot.contains(TAB_MENU_MARKER) && grid_snapshot.contains(COMMON_MENU_MARKER)
});
click_menu_item(&zellij, &grid_snapshot, "Close tab");
zellij.wait_until(
"first tab closed while the active second tab stays",
|grid_snapshot| {
!grid_snapshot.contains(COMMON_MENU_MARKER)
&& grid_snapshot.contains("SECOND-TAB-MARKER")
&& tab_names_in_tab_bar(grid_snapshot) == 1
},
);
zellij.quit();
}
#[test]
fn choosing_new_floating_pane_opens_a_floating_pane() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
right_click(&zellij, 30, 10);
let grid_snapshot = wait_for_pane_menu(&zellij);
click_menu_item(&zellij, &grid_snapshot, "New floating pane");
let floating_terminal = zellij.expect_pty_spawn();
floating_terminal.output(b"FLOAT-MARKER");
zellij.wait_until("floating pane opened", |grid_snapshot| {
!grid_snapshot.contains(COMMON_MENU_MARKER) && grid_snapshot.contains("FLOAT-MARKER")
});
zellij.quit();
}
#[test]
fn click_outside_the_menu_closes_it_and_does_nothing_else() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
split_right_and_wait_for_prompt(&zellij);
right_click(&zellij, 90, 12);
wait_for_pane_menu(&zellij);
left_click(&zellij, 10, 12);
wait_for_menu_to_close(&zellij);
zellij.wait_until("focus stayed on the right pane", |grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(62).row(2))
});
left_click(&zellij, 10, 12);
zellij.wait_until(
"a later click focuses the left pane as usual",
|grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.cursor_is_at(col(2).row(2))
},
);
zellij.quit();
}
#[test]
fn opening_the_menu_keeps_floating_panes_hidden_or_visible() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('p'));
zellij.send_stdin(&keys::key('w'));
let floating_terminal = zellij.expect_pty_spawn();
floating_terminal.output(b"FLOAT-MARKER");
zellij.wait_until("floating pane visible", |grid_snapshot| {
grid_snapshot.contains("FLOAT-MARKER")
});
right_click(&zellij, 5, 20);
let grid_snapshot = wait_for_pane_menu(&zellij);
assert!(grid_snapshot.contains("Pane #2"));
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.send_stdin(&keys::ctrl('p'));
zellij.send_stdin(&keys::key('w'));
zellij.wait_until("floating pane hidden", |grid_snapshot| {
!grid_snapshot.contains("FLOAT-MARKER") && grid_snapshot.status_bar_appears()
});
right_click(&zellij, 30, 10);
let grid_snapshot = wait_for_pane_menu(&zellij);
assert!(!grid_snapshot.contains("FLOAT-MARKER"));
zellij.send_stdin(&keys::ESC);
let grid_snapshot = wait_for_menu_to_close(&zellij);
assert!(!grid_snapshot.contains("FLOAT-MARKER"));
zellij.quit();
}
#[test]
fn a_second_attached_user_does_not_see_the_menu() {
let mut zellij = start_zellij();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("first terminal prompt rendered", |grid_snapshot| {
grid_snapshot.tab_bar_appears() && grid_snapshot.status_bar_appears()
});
let second_client = zellij.attach_client(TERMINAL_SIZE);
second_client.wait_until("second client loaded", |grid_snapshot| {
grid_snapshot.tab_bar_appears() && grid_snapshot.status_bar_appears()
});
right_click(&zellij, 30, 10);
wait_for_pane_menu(&zellij);
terminal.output(b"AFTER-MENU-MARKER");
zellij.wait_until("main client still shows the menu", |grid_snapshot| {
grid_snapshot.contains(PANE_MENU_MARKER) && grid_snapshot.contains("AFTER-MENU-MARKER")
});
let second_grid = second_client
.wait_until("second client rendered the output", |grid_snapshot| {
grid_snapshot.contains("AFTER-MENU-MARKER")
});
assert!(!second_grid.contains(PANE_MENU_MARKER));
assert!(!second_grid.contains(COMMON_MENU_MARKER));
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
second_client.quit();
zellij.quit();
}
#[test]
fn a_program_that_wants_the_mouse_still_receives_plain_right_clicks() {
let mut zellij = start_zellij();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
terminal.output(b"\x1b[?1000h\x1b[?1006h");
terminal.output(b"MOUSE-APP");
zellij.wait_until("program output rendered", |grid_snapshot| {
grid_snapshot.contains("MOUSE-APP")
});
right_click(&zellij, 30, 10);
terminal.wait_for_stdin("right click reported to the program", |stdin| {
String::from_utf8_lossy(stdin).contains("\u{1b}[<2;")
});
assert!(!zellij.snapshot().contains(COMMON_MENU_MARKER));
zellij.quit();
}
#[test]
fn the_default_menu_shows_the_keyboard_shortcuts_of_its_items() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
right_click(&zellij, 30, 10);
zellij.wait_until("pane menu shows its shortcuts", |grid_snapshot| {
grid_snapshot.contains(PANE_MENU_MARKER)
&& grid_snapshot.contains("Alt n")
&& grid_snapshot.contains("Ctrl p, f")
&& grid_snapshot.contains("Ctrl p, e")
&& grid_snapshot.contains("Ctrl p, c")
&& grid_snapshot.contains("Ctrl p, x")
&& grid_snapshot.contains("Ctrl o, d")
&& grid_snapshot.contains("Ctrl p, a")
});
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.quit();
}
#[test]
fn menu_shortcuts_follow_a_change_of_keybinding_preset() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("configuration plugin opened", |grid_snapshot| {
grid_snapshot.contains("Configuration")
});
zellij.send_stdin(b"\x1b[B");
zellij.send_stdin(b"\x1b[B");
zellij.wait_until("keys screen opened", |grid_snapshot| {
grid_snapshot.contains("Save as a preset") && grid_snapshot.contains("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("default")
});
zellij.send_stdin(b"\x1b[B");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("unlock-first preset applied", |grid_snapshot| {
grid_snapshot.contains("UNLOCK")
});
zellij.send_stdin(&keys::ctrl('c'));
zellij.wait_until("configuration plugin closed", |grid_snapshot| {
!grid_snapshot.contains("Configuration")
});
right_click(&zellij, 30, 10);
zellij.wait_until(
"pane menu shows the unlock-first shortcuts",
|grid_snapshot| {
grid_snapshot.contains(PANE_MENU_MARKER)
&& grid_snapshot.contains("p, f")
&& !grid_snapshot.contains("Ctrl p, f")
},
);
zellij.send_stdin(&keys::ESC);
zellij.wait_until("context menu closed", |grid_snapshot| {
!grid_snapshot.contains(COMMON_MENU_MARKER)
&& !grid_snapshot.contains(LAST_COMMON_MENU_ITEM)
});
zellij.quit();
}
#[test]
fn turning_the_menu_off_in_the_settings_stops_right_clicks_from_opening_it() {
let mut zellij = start_zellij();
let left_terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
let right_terminal = split_right_and_wait_for_prompt(&zellij);
right_terminal.output(b"\x1b[?1000h\x1b[?1006h");
right_click(&zellij, 30, 10);
wait_for_pane_menu(&zellij);
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("settings screen opened", |grid_snapshot| {
grid_snapshot.contains("Configuration") && grid_snapshot.contains("unsaved change")
});
zellij.send_stdin(&keys::key('/'));
zellij.send_stdin(b"right-click");
zellij.wait_until("search shows the setting", |grid_snapshot| {
grid_snapshot.contains("Right-click menu")
});
zellij.send_stdin(&keys::ENTER);
zellij.send_stdin(&keys::SPACE);
zellij.wait_until("setting changed", |grid_snapshot| {
grid_snapshot.contains("1 unsaved change ")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("search closed", |grid_snapshot| {
grid_snapshot.contains("Mouse and clipboard") && !grid_snapshot.contains(" match")
});
zellij.send_stdin(&keys::ESC);
zellij.wait_until("settings screen closed", |grid_snapshot| {
!grid_snapshot.contains("Configuration") && grid_snapshot.status_bar_appears()
});
right_click(&zellij, 30, 10);
right_click(&zellij, 90, 10);
right_terminal.wait_for_stdin("right click on the focused pane reached it", |stdin| {
String::from_utf8_lossy(stdin).contains("\u{1b}[<2;")
});
left_terminal.output(b"AFTER-CLICKS");
let grid_snapshot = zellij.wait_until("output after the clicks rendered", |grid_snapshot| {
grid_snapshot.contains("AFTER-CLICKS")
});
assert!(
!grid_snapshot.contains(COMMON_MENU_MARKER)
&& !grid_snapshot.contains(LAST_COMMON_MENU_ITEM)
);
zellij.quit();
}
#[test]
fn keybindings_work_while_the_menu_is_open() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
right_click(&zellij, 30, 10);
wait_for_pane_menu(&zellij);
zellij.send_stdin(&keys::ctrl('g'));
zellij.wait_until("interface locked with the menu open", |grid_snapshot| {
grid_snapshot.contains("LOCK")
&& !grid_snapshot.contains("PANE")
&& grid_snapshot.contains(COMMON_MENU_MARKER)
});
zellij.send_stdin(&keys::ctrl('g'));
zellij.wait_until("interface unlocked with the menu open", |grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.contains(COMMON_MENU_MARKER)
});
zellij.send_stdin(&keys::ctrl('p'));
zellij.wait_until("pane mode with the menu open", |grid_snapshot| {
grid_snapshot.contains("PANE")
&& !grid_snapshot.contains("LOCK")
&& grid_snapshot.contains(COMMON_MENU_MARKER)
});
zellij.send_stdin(&keys::ctrl('p'));
zellij.wait_until("back to normal mode with the menu open", |grid_snapshot| {
grid_snapshot.status_bar_appears() && grid_snapshot.contains(COMMON_MENU_MARKER)
});
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.quit();
}
#[test]
fn switching_tabs_with_keys_closes_the_menu() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('t'));
zellij.send_stdin(&keys::key('n'));
let second_terminal = zellij.expect_pty_spawn();
second_terminal.output(PROMPT);
zellij.wait_until("second tab opened", |grid_snapshot| {
grid_snapshot.contains("Tab #2") && grid_snapshot.status_bar_appears()
});
right_click(&zellij, 30, 10);
wait_for_pane_menu(&zellij);
zellij.send_stdin(&keys::ctrl('t'));
zellij.send_stdin(&keys::key('1'));
wait_for_menu_to_close(&zellij);
zellij.quit();
}
#[test]
fn detaching_with_keys_works_while_the_menu_is_open() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
right_click(&zellij, 30, 10);
wait_for_pane_menu(&zellij);
zellij.detach_main_client();
let reattached_client = zellij.attach_client(TERMINAL_SIZE);
reattached_client.wait_until("reattached without the menu", |grid_snapshot| {
grid_snapshot.status_bar_appears() && !grid_snapshot.contains(COMMON_MENU_MARKER)
});
reattached_client.quit();
zellij.quit();
}
#[test]
fn the_open_context_menu_cli_action_opens_the_menu_of_the_focused_pane() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.run_cli_action(CliAction::OpenContextMenu);
let grid_snapshot = wait_for_pane_menu(&zellij);
assert!(grid_snapshot.contains("New floating pane"));
assert!(!grid_snapshot.contains(TAB_MENU_MARKER));
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.quit();
}
#[test]
fn a_key_bound_to_open_context_menu_opens_the_menu_of_the_focused_pane() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(
r#"
keybinds {
normal {
bind "Alt m" { OpenContextMenu; }
}
}
"#,
)
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::alt('m'));
wait_for_pane_menu(&zellij);
zellij.send_stdin(&keys::ESC);
wait_for_menu_to_close(&zellij);
zellij.quit();
}
+542 -20
View File
@@ -2,36 +2,41 @@
use insta::assert_snapshot;
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, keys, normalized, start_zellij, GridSnapshot,
TestSession,
claim_first_terminal_and_wait_for_prompt, col, keys, normalized, start_zellij, GridSnapshot,
Size, TestRunner, TestSession, PROMPT, TERMINAL_SIZE,
};
use zellij_utils::input::options::Options;
const ARROW_DOWN: &[u8] = b"\x1b[B";
const ARROW_UP: &[u8] = b"\x1b[A";
fn open_presets_screen(zellij: &TestSession) {
fn open_keys_screen(zellij: &TestSession) {
zellij.wait_until("configuration plugin opened", |grid_snapshot| {
grid_snapshot.contains("Configuration")
});
zellij.send_stdin(&keys::TAB);
zellij.wait_until("presets screen opened", |grid_snapshot| {
grid_snapshot.contains("1. Default") && grid_snapshot.contains("2. Unlock First")
});
}
fn apply_selected_preset_and_close(
zellij: &TestSession,
times_to_move_down: usize,
applied_marker: &str,
) {
for _ in 0..times_to_move_down {
for _ in 0..2 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("keys screen opened", |grid_snapshot| {
grid_snapshot.contains("Save as a preset") && grid_snapshot.contains("Preset")
});
zellij.send_stdin(&keys::TAB);
}
fn choose_preset(zellij: &TestSession, arrow: &[u8], applied_marker: &str) {
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("preset list opened", |grid_snapshot| {
grid_snapshot.contains("unlock-first") && grid_snapshot.contains("default")
});
zellij.send_stdin(arrow);
zellij.send_stdin(&keys::ENTER);
let applied_marker = applied_marker.to_owned();
zellij.wait_until("preset applied to the running session", |grid_snapshot| {
grid_snapshot.contains(&applied_marker)
});
zellij.send_stdin(&keys::ctrl('c'));
}
fn close_configuration(zellij: &TestSession) {
zellij.send_stdin(&keys::ctrl('c'));
zellij.wait_until("configuration plugin closed", |grid_snapshot| {
!grid_snapshot.contains("Configuration")
@@ -58,8 +63,9 @@ fn switching_keybind_presets_updates_the_status_bar_without_a_new_tab() {
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
open_presets_screen(&zellij);
apply_selected_preset_and_close(&zellij, 2, "UNLOCK");
open_keys_screen(&zellij);
choose_preset(&zellij, ARROW_DOWN, "UNLOCK");
close_configuration(&zellij);
zellij.send_stdin(&keys::ctrl('g'));
let grid_snapshot = wait_for_unlock_first_status_bar(&zellij);
@@ -67,11 +73,527 @@ fn switching_keybind_presets_updates_the_status_bar_without_a_new_tab() {
zellij.send_stdin(&keys::key('o'));
zellij.send_stdin(&keys::key('c'));
open_presets_screen(&zellij);
apply_selected_preset_and_close(&zellij, 1, "Ctrl +");
open_keys_screen(&zellij);
choose_preset(&zellij, ARROW_UP, "Ctrl +");
close_configuration(&zellij);
let grid_snapshot = wait_for_default_status_bar(&zellij);
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
}
#[test]
fn saving_after_switching_to_unlock_first_writes_only_the_preset_attribute() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
open_keys_screen(&zellij);
choose_preset(&zellij, ARROW_DOWN, "UNLOCK");
zellij.wait_until("the keys page describes unlock-first", |grid_snapshot| {
grid_snapshot.contains("Ctrl g + p to enter PANE mode")
&& grid_snapshot.contains("1 unsaved change")
});
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("config saved", |grid_snapshot| {
grid_snapshot.contains("Saved to")
});
let config_file_path = zellij.config_file_path().unwrap();
let written = std::fs::read_to_string(&config_file_path).unwrap();
assert!(
written.contains("keybinds preset=\"unlock-first\""),
"{}",
written
);
assert!(!written.contains("bind \""), "{}", written);
assert!(!written.contains("keybinds clear-defaults"), "{}", written);
assert!(
!written
.lines()
.any(|line| line.trim_start().starts_with("default_mode")),
"{}",
written
);
close_configuration(&zellij);
zellij.send_stdin(&keys::ctrl('g'));
wait_for_unlock_first_status_bar(&zellij);
zellij.quit();
}
#[test]
fn an_old_config_that_clears_the_defaults_gets_exactly_its_own_keys() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(
r#"
keybinds clear-defaults=true {
normal {
bind "Ctrl y" { NewTab; }
}
shared {
bind "Ctrl q" { Quit; }
}
}
"#,
)
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("first terminal prompt rendered", |grid_snapshot| {
grid_snapshot.tab_bar_appears() && grid_snapshot.cursor_is_at(col(2).row(1))
});
zellij.send_stdin(&keys::ctrl('t'));
terminal.wait_for_stdin("ctrl t reaches the terminal instead of tab mode", |bytes| {
bytes.contains(&0x14)
});
zellij.send_stdin(&keys::ctrl('o'));
terminal.wait_for_stdin(
"ctrl o reaches the terminal instead of session mode",
|bytes| bytes.contains(&0x0f),
);
zellij.send_stdin(&keys::ctrl('y'));
let second_terminal = zellij.expect_pty_spawn();
second_terminal.output(PROMPT);
zellij.wait_until("a second tab opened", |grid_snapshot| {
grid_snapshot.contains("Tab #2")
});
zellij.quit();
}
#[test]
fn a_preset_given_on_the_command_line_applies_to_the_session_but_is_not_saved() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_cli_options(Options {
keybinds_preset: Some("unlock-first".to_owned()),
..Default::default()
})
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("unlock-first status bar in locked mode", |grid_snapshot| {
grid_snapshot.tab_bar_appears() && grid_snapshot.contains("UNLOCK")
});
zellij.send_stdin(&keys::ctrl('g'));
wait_for_unlock_first_status_bar(&zellij);
zellij.send_stdin(&keys::key('o'));
zellij.send_stdin(&keys::key('c'));
open_keys_screen(&zellij);
zellij.wait_until(
"the keys page names the command line preset",
|grid_snapshot| {
grid_snapshot.contains("command line")
&& grid_snapshot.contains("unlock-first")
&& grid_snapshot.contains("0 unsaved changes")
},
);
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("config saved", |grid_snapshot| {
grid_snapshot.contains("Saved to")
});
let written = std::fs::read_to_string(zellij.config_file_path().unwrap()).unwrap();
assert!(!written.contains("unlock-first"), "{}", written);
assert!(!written.contains("keybinds_preset"), "{}", written);
close_configuration(&zellij);
zellij.send_stdin(&keys::ctrl('g'));
wait_for_unlock_first_status_bar(&zellij);
zellij.quit();
}
#[test]
fn switching_away_from_custom_keybindings_also_drops_their_default_mode() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(
r#"
default_mode "locked"
keybinds clear-defaults=true {
locked {
bind "Ctrl y" {
LaunchOrFocusPlugin "configuration" {
floating true
move_to_focused_tab true
};
}
}
shared {
bind "Ctrl q" { Quit; }
}
}
"#,
)
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("first terminal prompt rendered", |grid_snapshot| {
grid_snapshot.tab_bar_appears() && grid_snapshot.cursor_is_at(col(2).row(1))
});
zellij.send_stdin(&keys::ctrl('y'));
zellij.wait_until("configuration plugin opened", |grid_snapshot| {
grid_snapshot.contains("Configuration")
});
for _ in 0..2 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("custom keybindings shown", |grid_snapshot| {
grid_snapshot.contains("Using custom keybinds block")
});
zellij.send_stdin(&keys::TAB);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("preset list opened", |grid_snapshot| {
grid_snapshot.contains("custom") && grid_snapshot.contains("default")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until(
"switch confirmation mentions the default mode",
|grid_snapshot| {
grid_snapshot.contains("Switch to a preset?") && grid_snapshot.contains("default mode")
},
);
zellij.send_stdin(b"\x1b[C");
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("default preset applied in normal mode", |grid_snapshot| {
grid_snapshot.contains("Ctrl p to enter PANE mode") && grid_snapshot.status_bar_appears()
});
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("config saved", |grid_snapshot| {
grid_snapshot.contains("Saved to")
});
let written = std::fs::read_to_string(zellij.config_file_path().unwrap()).unwrap();
assert!(
!written
.lines()
.any(|line| line.trim_start().starts_with("default_mode")),
"{}",
written
);
assert!(!written.contains("keybinds clear-defaults"), "{}", written);
zellij.quit();
}
fn row_containing(grid_snapshot: &GridSnapshot, needle: &str) -> String {
grid_snapshot
.lines()
.into_iter()
.find(|line| line.contains(needle))
.unwrap_or_default()
}
#[test]
fn tab_moves_through_the_leader_key_fields_before_leaving_the_keys_page() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
open_keys_screen(&zellij);
zellij.send_stdin(&keys::TAB);
zellij.send_stdin(&keys::TAB);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the primary key list opened", |grid_snapshot| {
grid_snapshot.contains("Ctrl Alt")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("primary equal to secondary is refused", |grid_snapshot| {
grid_snapshot.contains("must differ")
});
zellij.send_stdin(&keys::TAB);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the secondary key list opened", |grid_snapshot| {
grid_snapshot.contains("Ctrl Alt")
});
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the secondary key changed", |grid_snapshot| {
row_containing(grid_snapshot, "Secondary key").contains("Super")
&& grid_snapshot.contains("1 unsaved change")
});
zellij.send_stdin(&keys::TAB);
zellij.wait_until(
"the keybindings below the preset are focused",
|grid_snapshot| grid_snapshot.contains("search keys and actions"),
);
for _ in 0..4 {
zellij.send_stdin(&keys::TAB);
}
zellij.wait_until("focus went back to the categories", |grid_snapshot| {
grid_snapshot.contains("<↓↑> - category")
});
zellij.quit();
}
#[test]
fn the_keys_page_describes_the_preset_with_examples_using_the_current_leader_keys() {
let mut zellij = TestRunner::new(Size {
cols: 120,
rows: 40,
})
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("first terminal prompt rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
});
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
open_keys_screen(&zellij);
zellij.wait_until("the default preset examples", |grid_snapshot| {
grid_snapshot.contains("Ctrl p to enter PANE mode")
&& grid_snapshot.contains("Ctrl t to enter TAB mode")
});
let grid_snapshot = zellij.snapshot();
for needle in ["Ctrl p to enter"] {
let row = grid_snapshot.row_of_line(needle).unwrap();
let line = grid_snapshot.lines()[row].clone();
let start = line[..line.find(needle).unwrap()].chars().count();
let bold_cells = (start..start + needle.chars().count())
.filter(|column| grid_snapshot.char_is_bold(*column, row))
.count();
assert_eq!(bold_cells, 0, "{} is bold", needle);
}
choose_preset(&zellij, ARROW_DOWN, "UNLOCK");
zellij.wait_until("the unlock-first examples", |grid_snapshot| {
grid_snapshot.contains("Ctrl g + p to enter PANE mode")
});
zellij.quit();
}
fn left_click(zellij: &TestSession, column: usize, line: usize) {
zellij.send_stdin(format!("\u{1b}[<0;{};{}M", column, line).as_bytes());
zellij.send_stdin(format!("\u{1b}[<0;{};{}m", column, line).as_bytes());
}
#[test]
fn a_preset_from_the_keybinds_folder_links_to_its_file() {
let keybinds_dir = tempfile::tempdir().unwrap();
let preset_path = keybinds_dir.path().join("mine.kdl");
std::fs::write(
&preset_path,
"preset {\n description \"My own keys\"\n}\nkeybinds {\n locked {\n bind \"Ctrl g\" { SwitchToMode \"Normal\"; }\n }\n shared_except \"locked\" {\n bind \"Ctrl g\" { SwitchToMode \"Locked\"; }\n bind \"Ctrl o\" { SwitchToMode \"Session\"; }\n }\n session {\n bind \"c\" { LaunchOrFocusPlugin \"configuration\" { floating true; }; SwitchToMode \"Normal\"; }\n }\n shared {\n bind \"Ctrl q\" { Quit; }\n }\n}\n",
)
.unwrap();
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(&format!(
"mouse_mode true\nkeybinds_dir \"{}\"\nkeybinds preset=\"mine\"",
keybinds_dir.path().display()
))
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(PROMPT);
zellij.wait_until("first terminal prompt rendered", |grid_snapshot| {
grid_snapshot.tab_bar_appears() && grid_snapshot.cursor_is_at(col(2).row(1))
});
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("configuration plugin opened", |grid_snapshot| {
grid_snapshot.contains("Configuration")
});
for _ in 0..2 {
zellij.send_stdin(ARROW_DOWN);
}
let grid_snapshot = zellij.wait_until("the preset links to its file", |grid_snapshot| {
grid_snapshot.contains("mine.kdl") && grid_snapshot.contains("Preset")
});
assert!(
!grid_snapshot.contains("My own keys"),
"{}",
grid_snapshot.text
);
let row = grid_snapshot.row_of_line("mine.kdl").unwrap();
let line = grid_snapshot.lines()[row].clone();
let column = line[..line.find("mine.kdl").unwrap()].chars().count();
left_click(&zellij, column + 2, row + 1);
let editor = zellij.expect_pty_spawn();
let action = format!("{:?}", editor.terminal_action());
let folder_name = keybinds_dir.path().file_name().unwrap().to_str().unwrap();
assert!(
action.contains(&format!("{}/mine.kdl", folder_name)),
"{}",
action
);
zellij.quit();
}
#[test]
fn the_keys_page_fits_its_fields_at_the_default_size() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
open_keys_screen(&zellij);
zellij.wait_until("every field of the keys page is shown", |grid_snapshot| {
grid_snapshot.contains("Ctrl p to enter PANE mode")
&& grid_snapshot.contains("Secondary key")
&& grid_snapshot.contains("Save as a preset")
});
for _ in 0..3 {
zellij.send_stdin(&keys::TAB);
}
zellij.wait_until(
"the last leader key is focused without scrolling the page",
|grid_snapshot| {
grid_snapshot.contains("<Space> - change")
&& grid_snapshot.contains("Ctrl p to enter PANE mode")
},
);
zellij.quit();
}
#[test]
fn tab_past_the_fields_of_a_small_keys_page_reaches_the_keybindings_and_back() {
let mut zellij = TestRunner::new(Size {
cols: 120,
rows: 20,
})
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("configuration plugin opened", |grid_snapshot| {
grid_snapshot.contains("Configuration")
});
for _ in 0..2 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("the top of the keys page is shown", |grid_snapshot| {
grid_snapshot.contains("Ctrl p to enter PANE mode")
});
zellij.send_stdin(&keys::TAB);
for _ in 0..4 {
zellij.send_stdin(&keys::TAB);
}
zellij.wait_until(
"the keybindings below the preset are focused",
|grid_snapshot| grid_snapshot.contains("search keys and actions"),
);
for _ in 0..4 {
zellij.send_stdin(&keys::TAB);
}
zellij.wait_until(
"back at the top when focus leaves the page",
|grid_snapshot| {
grid_snapshot.contains("<↓↑> - category")
&& grid_snapshot.contains("Ctrl p to enter PANE mode")
},
);
zellij.quit();
}
#[test]
fn the_preset_folder_set_on_the_files_page_lists_its_presets_on_the_keys_page() {
let keybinds_dir = tempfile::tempdir().unwrap();
std::fs::write(
keybinds_dir.path().join("from-new-folder.kdl"),
"keybinds {\n}\n",
)
.unwrap();
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("configuration plugin opened", |grid_snapshot| {
grid_snapshot.contains("Configuration")
});
for _ in 0..12 {
zellij.send_stdin(ARROW_DOWN);
}
zellij.wait_until("files and folders shown", |grid_snapshot| {
grid_snapshot.contains("Keybinding preset folder")
});
zellij.send_stdin(&keys::TAB);
let mut focused = false;
for _ in 0..10 {
let grid_snapshot = zellij.snapshot();
if grid_snapshot.contains("Folder searched for keybinding presets") {
focused = true;
break;
}
zellij.send_stdin(ARROW_DOWN);
std::thread::sleep(std::time::Duration::from_millis(200));
}
assert!(focused, "{}", zellij.snapshot().text);
zellij.send_stdin(&keys::ENTER);
zellij.send_stdin(keybinds_dir.path().display().to_string().as_bytes());
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("the folder is applied", |grid_snapshot| {
grid_snapshot.contains("1 unsaved change") && grid_snapshot.contains("● unsaved")
});
zellij.send_stdin(b"\x1b[D");
for _ in 0..10 {
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(
"the preset list shows the new folder's preset",
|grid_snapshot| grid_snapshot.contains("from-new-folder"),
);
zellij.quit();
}
#[test]
fn a_command_line_preset_overrides_an_old_config_that_clears_the_defaults() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config(
r#"
keybinds clear-defaults=true {
normal {
bind "Ctrl y" { NewTab; }
}
}
"#,
)
.with_cli_options(Options {
keybinds_preset: Some("default".to_owned()),
..Default::default()
})
.start();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('p'));
zellij.wait_until("ctrl p enters pane mode from the preset", |grid_snapshot| {
grid_snapshot.contains("<p> PANE") && grid_snapshot.contains("Fullscreen")
});
assert!(!terminal.stdin_bytes().contains(&0x10));
zellij.quit();
}
#[test]
fn a_command_line_preset_overrides_the_config_default_mode() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("default_mode \"locked\"")
.with_cli_options(Options {
keybinds_preset: Some("default".to_owned()),
..Default::default()
})
.start();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
zellij.send_stdin(&keys::ctrl('p'));
zellij.wait_until("the session starts in normal mode", |grid_snapshot| {
grid_snapshot.contains("<p> PANE") && grid_snapshot.contains("Fullscreen")
});
assert!(!terminal.stdin_bytes().contains(&0x10));
zellij.quit();
}
@@ -0,0 +1,236 @@
#![cfg(unix)]
use zellij_integration_tests::{
keys, CliCaller, GridSnapshot, LayoutInfo, TestRunner, TestSession, TERMINAL_SIZE,
};
use zellij_utils::cli::CliAction;
const FIXTURE_PLUGIN_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm"
);
const PERMISSION_PROMPT: &str = "Allow? (y/n)";
const PROMPT_MARKER: &str = "<Esc> - cancel";
const DOWN: &[u8] = b"\x1b[B";
const RIGHT: &[u8] = b"\x1b[C";
const FIRST_CLIENT: &str = "1";
fn fixture_plugin_url() -> String {
format!("file:{}", FIXTURE_PLUGIN_PATH)
}
fn start_with_the_fixture_plugin() -> TestSession {
let layout = format!(
r#"
layout {{
pane size=1 borderless=true {{
plugin location="tab-bar"
}}
pane focus=true {{
plugin location="{}"
}}
pane
pane size=2 borderless=true {{
plugin location="status-bar"
}}
}}
"#,
fixture_plugin_url()
);
let zellij = TestRunner::new(TERMINAL_SIZE)
.with_layout(LayoutInfo::Stringified(layout))
.start();
let terminal = zellij.expect_pty_spawn();
terminal.output(b"$ ");
zellij.wait_until("the fixture plugin asks for permissions", |grid_snapshot| {
grid_snapshot.contains(PERMISSION_PROMPT)
});
zellij.send_stdin(b"y");
zellij.wait_until("the fixture plugin is running", |grid_snapshot| {
!grid_snapshot.contains(PERMISSION_PROMPT)
&& grid_snapshot.contains("Rows:")
&& grid_snapshot.status_bar_appears()
});
zellij
}
fn send_to_the_fixture(zellij: &TestSession, name: &str, args: &[(&str, &str)]) {
let args = if args.is_empty() {
None
} else {
let joined = args
.iter()
.map(|(key, value)| format!("{}={}", key, value))
.collect::<Vec<_>>()
.join(",");
Some(joined.parse().unwrap())
};
let pipe = CliAction::Pipe {
name: Some(name.to_owned()),
payload: None,
args,
plugin: Some(fixture_plugin_url()),
plugin_configuration: None,
force_launch_plugin: false,
skip_plugin_cache: false,
floating_plugin: None,
in_place_plugin: None,
plugin_cwd: None,
plugin_title: None,
popup: None,
popup_no_focus: false,
};
let caller = CliCaller {
pane_id: None,
stdin: None,
};
zellij.run_cli_pipe(pipe, caller).wait_for_exit();
}
fn ask(zellij: &TestSession, name: &str, text: &str) -> GridSnapshot {
send_to_the_fixture(zellij, name, &[]);
wait_for_prompt_showing(zellij, text)
}
fn wait_for_prompt_showing(zellij: &TestSession, text: &str) -> GridSnapshot {
zellij.wait_until(&format!("prompt showing {:?}", text), |grid_snapshot| {
grid_snapshot.contains(PROMPT_MARKER) && grid_snapshot.contains(text)
})
}
fn wait_for_result(zellij: &TestSession, result: &str) -> GridSnapshot {
let expected = format!("Prompt result 1: {}", result);
zellij.wait_until(
&format!("the plugin shows {:?}", expected),
|grid_snapshot| grid_snapshot.contains(&expected) && !grid_snapshot.contains(PROMPT_MARKER),
)
}
#[test]
fn confirm_yes_reaches_the_plugin() {
let mut zellij = start_with_the_fixture_plugin();
let grid_snapshot = ask(&zellij, "prompt_confirm", "Delete branch?");
assert!(grid_snapshot.contains("Delete") && grid_snapshot.contains("Keep"));
let row = grid_snapshot.row_of_line("Delete branch?").unwrap();
let line = grid_snapshot.lines()[row].clone();
let start = line[..line.find("Delete branch?").unwrap()].chars().count();
let plain_color = grid_snapshot.cell_foreground(start, row);
let word_color = grid_snapshot.cell_foreground(start + 7, row);
assert!(word_color.is_some() && word_color != plain_color);
for column in start + 7..start + 13 {
assert_eq!(grid_snapshot.cell_foreground(column, row), word_color);
}
assert_eq!(grid_snapshot.cell_foreground(start + 13, row), plain_color);
assert_eq!(line.matches("Delete branch?").count(), 1);
zellij.send_stdin(&keys::ENTER);
wait_for_result(&zellij, "Confirmed(true)");
zellij.quit();
}
#[test]
fn confirm_no_reaches_the_plugin() {
let mut zellij = start_with_the_fixture_plugin();
ask(&zellij, "prompt_confirm", "Delete branch?");
zellij.send_stdin(RIGHT);
zellij.send_stdin(&keys::ENTER);
wait_for_result(&zellij, "Confirmed(false)");
zellij.quit();
}
#[test]
fn esc_on_a_confirm_reaches_the_plugin_as_cancelled() {
let mut zellij = start_with_the_fixture_plugin();
ask(&zellij, "prompt_confirm", "Delete branch?");
zellij.send_stdin(&keys::ESC);
wait_for_result(&zellij, "Cancelled");
zellij.quit();
}
#[test]
fn choose_with_multi_returns_every_ticked_item() {
let mut zellij = start_with_the_fixture_plugin();
ask(&zellij, "prompt_choose_multi", "0 ticked");
zellij.send_stdin(&keys::SPACE);
zellij.send_stdin(DOWN);
zellij.send_stdin(DOWN);
zellij.send_stdin(&keys::SPACE);
zellij.wait_until("two items ticked", |grid_snapshot| {
grid_snapshot.contains("2 ticked")
});
zellij.send_stdin(&keys::ENTER);
wait_for_result(&zellij, r#"Answered(Choices(["alpha", "gamma"]))"#);
zellij.quit();
}
#[test]
fn a_form_returns_a_map_of_its_fields() {
let mut zellij = start_with_the_fixture_plugin();
ask(&zellij, "prompt_form", "Use CI");
zellij.send_stdin(&keys::ctrl('a'));
wait_for_result(
&zellij,
r#"Answered(Form({"ci": Bool(true), "name": Text("demo"), "port": Number(8080)}))"#,
);
zellij.quit();
}
#[test]
fn a_timeout_without_a_default_reaches_the_plugin() {
let mut zellij = start_with_the_fixture_plugin();
ask(&zellij, "prompt_input_timeout", "Name");
wait_for_result(&zellij, "TimedOut(None)");
zellij.quit();
}
#[test]
fn a_timeout_with_a_default_answers_the_default() {
let mut zellij = start_with_the_fixture_plugin();
send_to_the_fixture(&zellij, "prompt_input_timeout", &[("default", "fallback")]);
wait_for_prompt_showing(&zellij, "Name");
wait_for_result(&zellij, r#"TimedOut(Some(Text("fallback")))"#);
zellij.quit();
}
#[test]
fn an_invalid_request_is_answered_with_an_error_and_no_popup() {
let mut zellij = start_with_the_fixture_plugin();
send_to_the_fixture(&zellij, "prompt_invalid", &[]);
let grid_snapshot = wait_for_result(&zellij, "Error(");
assert!(grid_snapshot.contains("min is larger than max"));
zellij.quit();
}
#[test]
fn closing_the_calling_plugin_closes_its_prompt() {
let mut zellij = start_with_the_fixture_plugin();
ask(&zellij, "prompt_confirm", "Delete branch?");
send_to_the_fixture(&zellij, "close_self", &[]);
zellij.wait_until("the plugin and its prompt are gone", |grid_snapshot| {
!grid_snapshot.contains(PROMPT_MARKER)
&& !grid_snapshot.contains("Delete branch?")
&& grid_snapshot.status_bar_appears()
});
zellij.quit();
}
#[test]
fn a_second_attached_user_does_not_see_the_prompt() {
let mut zellij = start_with_the_fixture_plugin();
let second_client = zellij.attach_client(TERMINAL_SIZE);
second_client.wait_until("second client loaded", |grid_snapshot| {
grid_snapshot.tab_bar_appears() && grid_snapshot.status_bar_appears()
});
send_to_the_fixture(&zellij, "prompt_confirm", &[("only_client", FIRST_CLIENT)]);
wait_for_prompt_showing(&zellij, "Delete branch?");
let second_grid = second_client.wait_until("second client rendered", |grid_snapshot| {
grid_snapshot.status_bar_appears()
});
assert!(!second_grid.contains("Delete branch?"));
assert!(!second_grid.contains(PROMPT_MARKER));
zellij.send_stdin(&keys::ENTER);
wait_for_result(&zellij, "Confirmed(true)");
let second_grid = second_client.snapshot();
assert!(!second_grid.contains("Prompt result 1"));
second_client.quit();
zellij.quit();
}
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
#![cfg(unix)]
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, keys, start_zellij, GridSnapshot, TestRunner,
TestSession, TERMINAL_SIZE,
};
const ARROW_LEFT: &[u8] = b"\x1b[D";
const ARROW_RIGHT: &[u8] = b"\x1b[C";
const ARROW_DOWN: &[u8] = b"\x1b[B";
fn open_settings(zellij: &TestSession) -> GridSnapshot {
zellij.send_stdin(&keys::ctrl('o'));
zellij.send_stdin(&keys::key('c'));
zellij.wait_until("settings screen opened", |grid_snapshot| {
grid_snapshot.contains("Configuration") && grid_snapshot.contains("unsaved change")
})
}
fn close_search(zellij: &TestSession) {
zellij.send_stdin(&keys::ESC);
zellij.wait_until("search closed and categories shown", |grid_snapshot| {
grid_snapshot.contains("Mouse and clipboard") && !grid_snapshot.contains(" match")
});
}
fn close_settings(zellij: &TestSession) {
zellij.send_stdin(&keys::ESC);
zellij.wait_until("settings screen closed", |grid_snapshot| {
!grid_snapshot.contains("Configuration")
});
}
fn focus_setting_by_search(zellij: &TestSession, query: &str, setting_name: &str) {
zellij.send_stdin(&keys::key('/'));
zellij.send_stdin(query.as_bytes());
let setting_name = setting_name.to_owned();
zellij.wait_until("search shows the setting", |grid_snapshot| {
grid_snapshot.contains(&setting_name)
});
zellij.send_stdin(&keys::ENTER);
}
fn tab_bar_shows_session_name(grid_snapshot: &GridSnapshot, session_name: &str) -> bool {
grid_snapshot
.lines()
.first()
.map(|line| line.contains(session_name))
.unwrap_or(false)
}
fn tiled_pane_has_full_frame(grid_snapshot: &GridSnapshot) -> bool {
grid_snapshot
.lines()
.get(10)
.map(|line| line.starts_with('│'))
.unwrap_or(false)
}
fn toggle_hide_session_name(zellij: &TestSession) {
focus_setting_by_search(zellij, "hide session", "Hide session name");
zellij.send_stdin(&keys::SPACE);
}
#[test]
fn changing_a_toggle_and_a_dropdown_reaches_the_session() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let session_name = zellij.session_name().to_owned();
open_settings(&zellij);
zellij.wait_until("tab bar shows the session name", |grid_snapshot| {
tab_bar_shows_session_name(grid_snapshot, &session_name)
&& !tiled_pane_has_full_frame(grid_snapshot)
});
toggle_hide_session_name(&zellij);
zellij.wait_until("session name hidden in the tab bar", |grid_snapshot| {
!tab_bar_shows_session_name(grid_snapshot, &session_name)
&& grid_snapshot.contains("● unsaved")
});
focus_setting_by_search(&zellij, "frame style", "Pane frame style");
zellij.send_stdin(ARROW_LEFT);
zellij.wait_until("tiled pane drawn with a full frame", |grid_snapshot| {
tiled_pane_has_full_frame(grid_snapshot) && grid_snapshot.contains("2 unsaved changes")
});
zellij.quit();
}
#[test]
fn the_unsaved_marker_is_still_shown_after_closing_and_reopening_the_screen() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_settings(&zellij);
toggle_hide_session_name(&zellij);
zellij.wait_until("unsaved marker shown", |grid_snapshot| {
grid_snapshot.contains("● unsaved") && grid_snapshot.contains("1 unsaved change ")
});
close_search(&zellij);
close_settings(&zellij);
open_settings(&zellij);
zellij.send_stdin(ARROW_DOWN);
zellij.send_stdin(&keys::TAB);
zellij.wait_until(
"unsaved marker shown again after reopening",
|grid_snapshot| {
grid_snapshot.contains("1 unsaved change ")
&& grid_snapshot.contains("Hide session name")
&& grid_snapshot.contains("● unsaved")
},
);
zellij.quit();
}
#[test]
fn a_restart_only_setting_is_written_to_the_file_on_save() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let config_file_path = zellij
.config_file_path()
.expect("the test session has a config file");
let backup_file_path = config_file_path.with_file_name(format!(
"{}.bak",
config_file_path.file_name().unwrap().to_string_lossy()
));
assert!(!backup_file_path.exists());
open_settings(&zellij);
focus_setting_by_search(&zellij, "scrollback lines", "Scrollback lines");
zellij.send_stdin(ARROW_RIGHT);
zellij.wait_until("restart-only change is pending", |grid_snapshot| {
grid_snapshot.contains("11000")
&& grid_snapshot.contains("● unsaved ⟳ restart")
&& grid_snapshot.contains("1 unsaved change ")
});
zellij.send_stdin(&keys::ctrl('a'));
zellij.wait_until("save reported with a restart note", |grid_snapshot| {
grid_snapshot.contains("Restart Zellij to apply: Scrollback lines")
});
let written = std::fs::read_to_string(&config_file_path).unwrap();
assert!(
written.contains("scroll_buffer_size 11000"),
"config file was not updated: {}",
written
);
assert!(backup_file_path.exists(), "no backup file was created");
let backup = std::fs::read_to_string(&backup_file_path).unwrap();
assert!(!backup.contains("scroll_buffer_size 11000"));
zellij.quit();
}
#[test]
fn ctrl_r_reverts_all_unsaved_changes() {
let mut zellij = start_zellij();
claim_first_terminal_and_wait_for_prompt(&zellij);
let session_name = zellij.session_name().to_owned();
open_settings(&zellij);
toggle_hide_session_name(&zellij);
zellij.wait_until("change applied", |grid_snapshot| {
!tab_bar_shows_session_name(grid_snapshot, &session_name)
&& grid_snapshot.contains("1 unsaved change ")
});
zellij.send_stdin(&keys::ctrl('r'));
zellij.wait_until("revert confirm dialog shown", |grid_snapshot| {
grid_snapshot.contains("Revert all?")
});
zellij.send_stdin(&keys::ENTER);
zellij.wait_until("change reverted", |grid_snapshot| {
tab_bar_shows_session_name(grid_snapshot, &session_name)
&& grid_snapshot.contains("0 unsaved changes")
&& !grid_snapshot.contains("● unsaved")
});
zellij.quit();
}
fn sgr_motion(column: usize, line: usize) -> Vec<u8> {
format!("\u{1b}[<35;{};{}M", column, line).into_bytes()
}
#[test]
fn the_unsaved_marker_in_the_side_menu_takes_the_hover_background() {
let mut zellij = TestRunner::new(TERMINAL_SIZE)
.with_config("mouse_mode true\nadvanced_mouse_actions true\nmouse_hover_effects true")
.start();
claim_first_terminal_and_wait_for_prompt(&zellij);
open_settings(&zellij);
toggle_hide_session_name(&zellij);
zellij.wait_until("unsaved marker shown", |grid_snapshot| {
grid_snapshot.contains("1 unsaved change ")
});
close_search(&zellij);
let grid_snapshot = zellij.wait_until("menu marker shown", |grid_snapshot| {
grid_snapshot.contains("Pane frames and borders ●")
});
let row = grid_snapshot
.row_of_line("Pane frames and borders ●")
.unwrap();
let line = grid_snapshot.lines()[row].clone();
let title_column = line[..line.find("Pane frames").unwrap()].chars().count();
let marker_column = line[..line.find('●').unwrap()].chars().count();
let background_before_hover = grid_snapshot.cell_style(title_column, row).background;
zellij.send_stdin(&sgr_motion(title_column + 2, row + 1));
zellij.wait_until(
"the hovered row and its marker share a background",
|grid_snapshot| {
let title_background = grid_snapshot.cell_style(title_column, row).background;
let marker_background = grid_snapshot.cell_style(marker_column, row).background;
title_background != background_before_hover && marker_background == title_background
},
);
zellij.quit();
}
+2559 -188
View File
File diff suppressed because it is too large Load Diff
+28 -9
View File
@@ -73,9 +73,18 @@ fn find_executable(candidate: &std::path::Path) -> Option<PathBuf> {
None
}
pub type PaneEnv = BTreeMap<String, Option<String>>;
pub fn env_value(pane_env: &PaneEnv, name: &str) -> Option<String> {
match pane_env.get(name) {
Some(value) => value.clone(),
None => env::var(name).ok(),
}
}
/// Resolve a command to its absolute path, searching the working directory,
/// then PATH (and PATHEXT on Windows).
pub(crate) fn resolve_command(cmd: &RunCommand) -> Option<PathBuf> {
pub(crate) fn resolve_command(cmd: &RunCommand, pane_env: &PaneEnv) -> Option<PathBuf> {
let command = &cmd.command;
match cmd.cwd.as_ref() {
Some(cwd) => {
@@ -89,7 +98,11 @@ pub(crate) fn resolve_command(cmd: &RunCommand) -> Option<PathBuf> {
}
},
}
if let Some(paths) = env::var_os("PATH") {
let paths = match pane_env.get("PATH") {
Some(value) => value.clone().map(std::ffi::OsString::from),
None => env::var_os("PATH"),
};
if let Some(paths) = paths {
for path in env::split_paths(&paths) {
if let Some(resolved) = find_executable(&path.join(command)) {
return Some(resolved);
@@ -100,8 +113,8 @@ pub(crate) fn resolve_command(cmd: &RunCommand) -> Option<PathBuf> {
}
#[cfg(not(windows))]
pub(crate) fn command_exists(cmd: &RunCommand) -> bool {
resolve_command(cmd).is_some()
pub(crate) fn command_exists(cmd: &RunCommand, pane_env: &PaneEnv) -> bool {
resolve_command(cmd, pane_env).is_some()
}
// this is a utility method to separate the arguments from a pathbuf before we turn it into a
@@ -137,6 +150,7 @@ fn separate_command_arguments(command: &mut PathBuf, args: &mut Vec<String>) {
fn build_command(
terminal_action: TerminalAction,
default_editor: Option<PathBuf>,
pane_env: &PaneEnv,
) -> (RunCommand, Option<RunCommand>) {
let mut failover_cmd_args = None;
let cmd = match terminal_action {
@@ -148,8 +162,9 @@ fn build_command(
}
let mut command = default_editor.unwrap_or_else(|| {
PathBuf::from(
env::var("EDITOR")
.unwrap_or_else(|_| env::var("VISUAL").unwrap_or_else(|_| "vi".into())),
env_value(pane_env, "EDITOR")
.or_else(|| env_value(pane_env, "VISUAL"))
.unwrap_or_else(|| "vi".into()),
)
});
@@ -365,6 +380,7 @@ pub trait ServerOsApi: Send + Sync {
terminal_action: TerminalAction,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
default_editor: Option<PathBuf>,
pane_env: &PaneEnv,
) -> Result<(u32, Box<dyn AsyncReader>, Option<u32>)>;
// reserves a terminal id without actually opening a terminal
fn reserve_terminal_id(&self) -> Result<u32> {
@@ -422,6 +438,7 @@ pub trait ServerOsApi: Send + Sync {
terminal_id: u32,
run_command: RunCommand,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
pane_env: &PaneEnv,
) -> Result<(Box<dyn AsyncReader>, Option<u32>)>;
fn clear_terminal_id(&self, terminal_id: u32) -> Result<()>;
fn cache_resizes(&mut self) {}
@@ -449,6 +466,7 @@ impl ServerOsApi for ServerOsInputOutput {
terminal_action: TerminalAction,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
default_editor: Option<PathBuf>,
pane_env: &PaneEnv,
) -> Result<(u32, Box<dyn AsyncReader>, Option<u32>)> {
let err_context = || "failed to spawn terminal".to_string();
@@ -459,11 +477,11 @@ impl ServerOsApi for ServerOsInputOutput {
self.pty_backend.reserve_terminal_id(terminal_id);
let (cmd, failover_cmd) = build_command(terminal_action, default_editor);
let (cmd, failover_cmd) = build_command(terminal_action, default_editor, pane_env);
let (async_reader, child_fd) = self
.pty_backend
.spawn_terminal(cmd, failover_cmd, quit_cb, terminal_id)
.spawn_terminal(cmd, failover_cmd, quit_cb, terminal_id, pane_env)
.with_context(err_context)?;
Ok((terminal_id, async_reader, Some(child_fd as u32)))
@@ -713,10 +731,11 @@ impl ServerOsApi for ServerOsInputOutput {
terminal_id: u32,
run_command: RunCommand,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
pane_env: &PaneEnv,
) -> Result<(Box<dyn AsyncReader>, Option<u32>)> {
let (async_reader, child_fd) =
self.pty_backend
.spawn_terminal(run_command, None, quit_cb, terminal_id)?;
.spawn_terminal(run_command, None, quit_cb, terminal_id, pane_env)?;
Ok((async_reader, Some(child_fd as u32)))
}
fn clear_terminal_id(&self, terminal_id: u32) -> Result<()> {
+22 -7
View File
@@ -1,4 +1,4 @@
use crate::os_input_output::{command_exists, AsyncReader};
use crate::os_input_output::{command_exists, AsyncReader, PaneEnv};
use crate::panes::PaneId;
use nix::{
@@ -189,6 +189,7 @@ fn handle_openpty(
cmd: RunCommand,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
terminal_id: u32,
pane_env: &PaneEnv,
) -> Result<(RawFd, RawFd)> {
let err_context = |cmd: &RunCommand| {
format!(
@@ -201,7 +202,7 @@ fn handle_openpty(
let pid_primary = open_pty_res.master.into_raw_fd();
let pid_secondary = open_pty_res.slave.into_raw_fd();
if !command_exists(&cmd) {
if !command_exists(&cmd, pane_env) {
return Err(ZellijError::CommandNotFound {
terminal_id,
command: cmd.command.to_string_lossy().to_string(),
@@ -222,6 +223,12 @@ fn handle_openpty(
);
}
}
for (name, value) in pane_env {
match value {
Some(value) => command.env(name, value),
None => command.env_remove(name),
};
}
command
.args(&cmd.args)
.env("ZELLIJ_PANE_ID", &format!("{}", terminal_id))
@@ -257,18 +264,24 @@ fn handle_terminal(
orig_termios: Option<termios::Termios>,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
terminal_id: u32,
pane_env: &PaneEnv,
) -> Result<(RawFd, RawFd)> {
let err_context = || "failed to spawn child terminal".to_string();
// Create a pipe to allow the child the communicate the shell's pid to its
// parent.
match openpty(None, &orig_termios) {
Ok(open_pty_res) => handle_openpty(open_pty_res, cmd, quit_cb, terminal_id),
Ok(open_pty_res) => handle_openpty(open_pty_res, cmd, quit_cb, terminal_id, pane_env),
Err(e) => match failover_cmd {
Some(failover_cmd) => {
handle_terminal(failover_cmd, None, orig_termios, quit_cb, terminal_id)
.with_context(err_context)
},
Some(failover_cmd) => handle_terminal(
failover_cmd,
None,
orig_termios,
quit_cb,
terminal_id,
pane_env,
)
.with_context(err_context),
None => Err::<(i32, i32), _>(e)
.context("failed to start pty")
.with_context(err_context)
@@ -324,6 +337,7 @@ impl UnixPtyBackend {
failover_cmd: Option<RunCommand>,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
terminal_id: u32,
pane_env: &PaneEnv,
) -> Result<(Box<dyn AsyncReader>, RawFd)> {
let orig_termios = self
.orig_termios
@@ -336,6 +350,7 @@ impl UnixPtyBackend {
orig_termios.clone(),
quit_cb,
terminal_id,
pane_env,
)?;
self.terminal_id_to_raw_fd
.lock()
+21 -12
View File
@@ -1,4 +1,4 @@
use crate::os_input_output::{resolve_command, AsyncReader};
use crate::os_input_output::{resolve_command, AsyncReader, PaneEnv};
use crate::panes::PaneId;
use std::{
@@ -202,12 +202,18 @@ fn build_command_line(cmd: &RunCommand) -> Vec<u16> {
/// Build a UTF-16 environment block (each entry `KEY=VALUE\0`, terminated by
/// an extra `\0`) from the current process environment, adding
/// `ZELLIJ_PANE_ID`.
fn build_environment_block(terminal_id: u32) -> Vec<u16> {
let mut block: Vec<u16> = Vec::new();
for (key, value) in std::env::vars() {
if key == "ZELLIJ_PANE_ID" {
continue;
fn build_environment_block(terminal_id: u32, pane_env: &PaneEnv) -> Vec<u16> {
let mut variables: BTreeMap<String, String> = std::env::vars()
.filter(|(key, _)| !key.eq_ignore_ascii_case("ZELLIJ_PANE_ID"))
.collect();
for (key, value) in pane_env {
variables.retain(|existing, _| !existing.eq_ignore_ascii_case(key));
if let Some(value) = value {
variables.insert(key.clone(), value.clone());
}
}
let mut block: Vec<u16> = Vec::new();
for (key, value) in variables {
let entry = format!("{}={}", key, value);
block.extend(OsStr::new(&entry).encode_wide());
block.push(0);
@@ -297,6 +303,7 @@ fn spawn_child_process(
hpcon: HPCON,
cmd: &RunCommand,
terminal_id: u32,
pane_env: &PaneEnv,
) -> io::Result<(HANDLE, HANDLE, u32)> {
// --- proc thread attribute list ---
let mut attr_size: usize = 0;
@@ -338,7 +345,7 @@ fn spawn_child_process(
// --- command line & environment ---
let mut cmd_line = build_command_line(cmd);
let env_block = build_environment_block(terminal_id);
let env_block = build_environment_block(terminal_id, pane_env);
let cwd: Option<Vec<u16>> = cmd.cwd.as_ref().and_then(|p| {
if p.exists() && p.is_dir() {
@@ -419,6 +426,7 @@ impl WindowsPtyBackend {
cmd: RunCommand,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
terminal_id: u32,
pane_env: &PaneEnv,
) -> Result<(Box<dyn AsyncReader>, u32)> {
let err_context = |c: &RunCommand| {
format!(
@@ -464,7 +472,7 @@ impl WindowsPtyBackend {
// 5. Spawn child process
let (process_handle, thread_handle, child_pid) =
match spawn_child_process(hpcon, &cmd, terminal_id) {
match spawn_child_process(hpcon, &cmd, terminal_id, pane_env) {
Ok(r) => r,
Err(e) => {
unsafe {
@@ -522,15 +530,16 @@ impl WindowsPtyBackend {
failover_cmd: Option<RunCommand>,
quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
terminal_id: u32,
pane_env: &PaneEnv,
) -> Result<(Box<dyn AsyncReader>, u32)> {
if let Some(resolved) = resolve_command(&cmd) {
if let Some(resolved) = resolve_command(&cmd, pane_env) {
cmd.command = resolved;
return self.do_spawn(cmd, quit_cb, terminal_id);
return self.do_spawn(cmd, quit_cb, terminal_id, pane_env);
}
if let Some(mut failover) = failover_cmd {
if let Some(resolved) = resolve_command(&failover) {
if let Some(resolved) = resolve_command(&failover, pane_env) {
failover.command = resolved;
return self.do_spawn(failover, quit_cb, terminal_id);
return self.do_spawn(failover, quit_cb, terminal_id, pane_env);
}
}
Err(ZellijError::CommandNotFound {
+70 -4
View File
@@ -655,6 +655,7 @@ pub struct Output {
sixel_host_capabilities: Rc<RefCell<HashMap<ClientId, bool>>>,
character_cell_size: Rc<RefCell<Option<SizeInPixels>>>,
floating_panes_stack: Option<FloatingPanesStack>,
popup_covers: HashMap<ClientId, Vec<PaneGeom>>,
styled_underlines: bool,
osc8_hyperlinks: bool,
pane_render_report: PaneRenderReport,
@@ -698,12 +699,45 @@ impl Output {
self.client_character_chunks.insert(*client_id, vec![]);
}
}
pub fn set_popup_cover(&mut self, client_id: ClientId, popup_geoms: Vec<PaneGeom>) {
if popup_geoms.is_empty() {
self.popup_covers.remove(&client_id);
} else {
self.popup_covers.insert(client_id, popup_geoms);
}
}
fn popup_cover_for(
&self,
client_id: ClientId,
z_index: Option<usize>,
) -> Option<FloatingPanesStack> {
let covers = self.popup_covers.get(&client_id)?;
let first_covering_layer = match z_index {
Some(z_index) if z_index >= crate::tab::POPUP_Z_INDEX => {
z_index - crate::tab::POPUP_Z_INDEX + 1
},
_ => 0,
};
let layers: Vec<PaneGeom> = covers.iter().skip(first_covering_layer).copied().collect();
if layers.is_empty() {
None
} else {
Some(FloatingPanesStack { layers })
}
}
pub fn add_character_chunks_to_client(
&mut self,
client_id: ClientId,
mut character_chunks: Vec<CharacterChunk>,
z_index: Option<usize>,
) -> Result<()> {
if let Some(popup_cover) = self.popup_cover_for(client_id, z_index) {
character_chunks = popup_cover
.visible_character_chunks(character_chunks, None)
.with_context(|| {
format!("failed to hide chunks under popup of client {}", client_id)
})?;
}
if let Some(client_character_chunks) = self.client_character_chunks.get_mut(&client_id) {
if let Some(floating_panes_stack) = &self.floating_panes_stack {
let mut visible_character_chunks = floating_panes_stack
@@ -793,10 +827,17 @@ impl Output {
pub fn add_sixel_image_chunks_to_client(
&mut self,
client_id: ClientId,
sixel_image_chunks: Vec<SixelImageChunk>,
mut sixel_image_chunks: Vec<SixelImageChunk>,
z_index: Option<usize>,
) {
if let Some(character_cell_size) = *self.character_cell_size.borrow() {
if let Some(popup_cover) = self.popup_cover_for(client_id, z_index) {
sixel_image_chunks = popup_cover.visible_sixel_image_chunks(
sixel_image_chunks,
None,
&character_cell_size,
);
}
let mut sixel_chunks = if let Some(floating_panes_stack) = &self.floating_panes_stack {
floating_panes_stack.visible_sixel_image_chunks(
sixel_image_chunks,
@@ -827,8 +868,16 @@ impl Output {
sixel_image_chunks
};
for client_id in client_ids {
let mut client_sixel_chunks = match self.popup_cover_for(client_id, z_index) {
Some(popup_cover) => popup_cover.visible_sixel_image_chunks(
sixel_chunks.clone(),
None,
&character_cell_size,
),
None => sixel_chunks.clone(),
};
let entry = self.sixel_chunks.entry(client_id).or_insert_with(Vec::new);
entry.append(&mut sixel_chunks.clone());
entry.append(&mut client_sixel_chunks);
}
}
}
@@ -843,7 +892,7 @@ impl Output {
.entry(client_id)
.or_insert_with(HashSet::new)
.insert(pane_id);
let mut kitty_chunks = match (
let kitty_chunks = match (
*self.character_cell_size.borrow(),
&self.floating_panes_stack,
) {
@@ -851,6 +900,15 @@ impl Output {
.visible_kitty_image_chunks(kitty_image_chunks, z_index, &character_cell_size),
_ => kitty_image_chunks,
};
let mut kitty_chunks = match (
*self.character_cell_size.borrow(),
self.popup_cover_for(client_id, z_index),
) {
(Some(character_cell_size), Some(popup_cover)) => {
popup_cover.visible_kitty_image_chunks(kitty_chunks, None, &character_cell_size)
},
_ => kitty_chunks,
};
self.client_kitty_chunks
.entry(client_id)
.or_insert_with(HashMap::new)
@@ -878,12 +936,20 @@ impl Output {
.entry(client_id)
.or_insert_with(HashSet::new)
.insert(pane_id);
let mut client_kitty_chunks = match (
*self.character_cell_size.borrow(),
self.popup_cover_for(client_id, z_index),
) {
(Some(character_cell_size), Some(popup_cover)) => popup_cover
.visible_kitty_image_chunks(kitty_chunks.clone(), None, &character_cell_size),
_ => kitty_chunks.clone(),
};
self.client_kitty_chunks
.entry(client_id)
.or_insert_with(HashMap::new)
.entry(pane_id)
.or_insert_with(Vec::new)
.append(&mut kitty_chunks.clone());
.append(&mut client_kitty_chunks);
}
}
pub fn set_kitty_visible_panes(&mut self, client_id: ClientId, pane_ids: HashSet<PaneId>) {
+19 -5
View File
@@ -5795,6 +5795,7 @@ pub struct Row {
pub columns: VecDeque<TerminalCharacter>,
pub is_canonical: bool,
width: Option<usize>,
only_single_width: bool,
pub bg_color: Option<AnsiCode>,
osc133_markers: Vec<Osc133Marker>,
}
@@ -5828,6 +5829,7 @@ impl Row {
columns: VecDeque::new(),
is_canonical: false,
width: None,
only_single_width: false,
bg_color: None,
osc133_markers: vec![],
}
@@ -5837,6 +5839,7 @@ impl Row {
columns,
is_canonical: false,
width: None,
only_single_width: false,
bg_color: None,
osc133_markers: vec![],
}
@@ -5870,10 +5873,14 @@ impl Row {
self.width.unwrap()
} else {
let mut width = 0;
let mut only_single_width = true;
for terminal_character in &self.columns {
width += terminal_character.width();
let character_width = terminal_character.width();
width += character_width;
only_single_width &= character_width == 1;
}
self.width = Some(width);
self.only_single_width = only_single_width;
width
}
}
@@ -5937,6 +5944,7 @@ impl Row {
Ordering::Equal => {
// this is unwrapped because this always happens after self.width_cached()
*self.width.as_mut().unwrap() += terminal_character.width();
self.only_single_width &= terminal_character.width() == 1;
// adding the character at the end of the current line
self.columns.push_back(terminal_character);
},
@@ -5958,8 +5966,11 @@ impl Row {
Ordering::Greater => {
// adding the character in the middle of the line
// we replace the character at its position
let (absolute_x_index, position_inside_character) =
self.absolute_character_index_and_position_in_char(x);
let (absolute_x_index, position_inside_character) = if self.only_single_width {
(x, 0)
} else {
self.absolute_character_index_and_position_in_char(x)
};
let character_width = terminal_character.width();
let overwrite_start = x.saturating_sub(position_inside_character);
let overwrite_end =
@@ -5986,6 +5997,7 @@ impl Row {
.insert(position_to_remove, EMPTY_TERMINAL_CHARACTER);
}
}
self.width = None;
},
Ordering::Less => {
// the replaced character is wider than the current character
@@ -6000,10 +6012,12 @@ impl Row {
self.columns
.insert(absolute_x_index + 1, EMPTY_TERMINAL_CHARACTER);
}
self.width = None;
},
Ordering::Equal => {
self.only_single_width &= character_width == 1;
},
_ => {},
}
self.width = None;
},
}
}
+23 -2
View File
@@ -22,7 +22,7 @@ use std::rc::Rc;
use vte;
use zellij_utils::data::PaneContents;
use zellij_utils::data::{
BareKey, BorderStyleOverride, KeyWithModifier, PermissionStatus, PermissionType,
BareKey, BorderStyleOverride, KeyModifier, KeyWithModifier, PermissionStatus, PermissionType,
PluginPermission,
};
use zellij_utils::pane_size::{Offset, SizeInPixels};
@@ -112,6 +112,7 @@ pub(crate) struct PluginPane {
should_be_suppressed: bool,
text_being_pasted: Option<Vec<u8>>,
supports_mouse_selection: bool,
mouse_modifiers: BTreeSet<KeyModifier>,
}
impl PluginPane {
@@ -172,6 +173,7 @@ impl PluginPane {
should_be_suppressed: false,
text_being_pasted: None,
supports_mouse_selection: false,
mouse_modifiers: BTreeSet::new(),
};
for client_id in currently_connected_clients {
plugin.handle_plugin_bytes(client_id, initial_loading_message.as_bytes().to_vec());
@@ -641,18 +643,28 @@ impl Pane for PluginPane {
grid.set_selection_options(osc133_command_selection, word_separators);
}
}
fn set_mouse_modifiers(&mut self, modifiers: BTreeSet<KeyModifier>) {
self.mouse_modifiers = modifiers;
}
fn start_selection(&mut self, start: &Position, client_id: ClientId) {
let modifiers = std::mem::take(&mut self.mouse_modifiers);
if self.supports_mouse_selection {
if let Some(grid) = self.grids.get_mut(&client_id) {
grid.start_selection(start);
self.set_should_render(true);
}
} else {
let click = Mouse::LeftClick(start.line.0, start.column.0);
let event = if modifiers.is_empty() {
Event::Mouse(click)
} else {
Event::MouseWithModifiers(click, modifiers)
};
self.send_plugin_instructions
.send(PluginInstruction::Update(vec![(
Some(self.pid),
Some(client_id),
Event::Mouse(Mouse::LeftClick(start.line.0, start.column.0)),
event,
)]))
.unwrap();
}
@@ -886,6 +898,15 @@ impl Pane for PluginPane {
}
false
}
fn position_is_on_pin_button(&self, position: &Position, client_id: ClientId) -> bool {
if self.position_is_on_frame(position) {
let relative_position = self.relative_position(position);
if let Some(client_frame) = self.frame.get(&client_id) {
return client_frame.clicked_on_pinned(relative_position);
}
}
false
}
fn intercept_mouse_event_on_frame(&mut self, event: &MouseEvent, client_id: ClientId) -> bool {
if self.position_is_on_frame(&event.position) {
let relative_position = self.relative_position(&event.position);
+9
View File
@@ -1280,6 +1280,15 @@ impl Pane for TerminalPane {
}
false
}
fn position_is_on_pin_button(&self, position: &Position, client_id: ClientId) -> bool {
if self.position_is_on_frame(position) {
let relative_position = self.relative_position(position);
if let Some(client_frame) = self.frame.get(&client_id) {
return client_frame.clicked_on_pinned(relative_position);
}
}
false
}
fn intercept_mouse_event_on_frame(&mut self, event: &MouseEvent, client_id: ClientId) -> bool {
if self.position_is_on_frame(&event.position) {
let relative_position = self.relative_position(&event.position);
+413
View File
@@ -6816,6 +6816,309 @@ fn ui_component_flag_prefixes_parse_order_independently() {
}
}
fn widget_lines(grid: &Grid) -> Vec<String> {
grid.as_character_lines()
.iter()
.map(|line| {
line.iter()
.map(|c| c.character)
.collect::<String>()
.trim_end()
.to_owned()
})
.collect()
}
fn styled_line(grid: &Grid, line_index: usize) -> String {
grid.as_character_lines()
.get(line_index)
.map(|line| {
line.iter()
.map(|c| format!("{}{}", *c.styles, c.character))
.collect::<String>()
})
.unwrap_or_default()
}
fn styles_at(
grid: &Grid,
line_index: usize,
column: usize,
) -> crate::panes::terminal_character::CharacterStyles {
*grid.as_character_lines()[line_index][column].styles
}
const GRAY_BACKGROUND: &str = "\u{1b}[48;5;238m";
const LIGHT_GRAY_BACKGROUND: &str = "\u{1b}[48;5;245m";
const GREEN_BACKGROUND: &str = "\u{1b}[48;5;154m";
const GREEN_FOREGROUND: &str = "\u{1b}[38;5;154m";
const RED_FOREGROUND: &str = "\u{1b}[38;5;124m";
#[test]
fn button_ui_component_renders_each_look_in_grays() {
use crate::panes::terminal_character::AnsiCode;
let label = serialize_text_bytes("Save");
let normal = render_ui_component("button", &format!("1/1/8/1;;{}", label));
assert_eq!(widget_lines(&normal)[1], " [ Save ]");
assert!(!styled_line(&normal, 1).contains(GRAY_BACKGROUND));
let hovered = render_ui_component("button", &format!("1/1/8/1;h;{}", label));
assert!(styled_line(&hovered, 1).contains(GRAY_BACKGROUND));
let focused = render_ui_component("button", &format!("1/1/8/1;f;{}", label));
assert!(styled_line(&focused, 1).contains(GRAY_BACKGROUND));
assert!(styled_line(&focused, 1).contains(GREEN_FOREGROUND));
assert_eq!(styles_at(&focused, 1, 3).bold, Some(AnsiCode::On));
let pressed = render_ui_component("button", &format!("1/1/8/1;p;{}", label));
assert!(styled_line(&pressed, 1).contains(LIGHT_GRAY_BACKGROUND));
let disabled = render_ui_component("button", &format!("1/1/8/1;d;{}", label));
assert_eq!(styles_at(&disabled, 1, 3).italic, Some(AnsiCode::On));
assert_eq!(styles_at(&disabled, 1, 3).dim, Some(AnsiCode::On));
for grid in [&normal, &hovered, &focused, &pressed, &disabled] {
assert!(!styled_line(grid, 1).contains(GREEN_BACKGROUND));
}
}
#[test]
fn toggle_ui_component_renders_on_off_hover_and_dim_disabled() {
use crate::panes::terminal_character::AnsiCode;
let label = serialize_text_bytes("Wrap");
let on = render_ui_component("toggle", &format!("0/0/10/1;f,on,lw=5;{}", label));
assert_eq!(widget_lines(&on)[0], "Wrap [on ]");
assert!(!styled_line(&on, 0).contains(GRAY_BACKGROUND));
assert!(styled_line(&on, 0).contains(GREEN_FOREGROUND));
let off = render_ui_component("toggle", &format!("0/0/10/1;lw=5;{}", label));
assert_eq!(widget_lines(&off)[0], "Wrap [off]");
assert!(!styled_line(&off, 0).contains(GRAY_BACKGROUND));
let hovered = render_ui_component("toggle", &format!("0/0/10/1;h,lw=5;{}", label));
assert!(styled_line(&hovered, 0).contains(GRAY_BACKGROUND));
let disabled = render_ui_component("toggle", &format!("0/0/10/1;d,on,lw=5;{}", label));
assert_eq!(widget_lines(&disabled)[0], "Wrap [on ]");
assert_eq!(styles_at(&disabled, 0, 6).italic, Some(AnsiCode::On));
assert_eq!(styles_at(&disabled, 0, 6).dim, Some(AnsiCode::On));
assert!(!styled_line(&disabled, 0).contains(GRAY_BACKGROUND));
}
#[test]
fn dropdown_ui_component_pads_the_field_and_truncates_long_values() {
let grid = render_ui_component(
"dropdown",
&format!(
"0/0/20/1;f,lw=5;{};{}",
serialize_text_bytes("Mode"),
serialize_text_bytes("normal")
),
);
assert_eq!(widget_lines(&grid)[0], "Mode [ normal ▾ ]");
assert!(styled_line(&grid, 0).contains(GREEN_FOREGROUND));
assert!(styled_line(&grid, 0).contains(GRAY_BACKGROUND));
let long = render_ui_component(
"dropdown",
&format!(
"0/0/16/1;lw=0;;{}",
serialize_text_bytes("a very long value indeed")
),
);
assert_eq!(widget_lines(&long)[0], "[ a very lo… ▾ ]");
assert!(!styled_line(&long, 0).contains(GRAY_BACKGROUND));
let hovered = render_ui_component(
"dropdown",
&format!("0/0/16/1;h,lw=0;;{}", serialize_text_bytes("x")),
);
assert!(styled_line(&hovered, 0).contains(GRAY_BACKGROUND));
}
#[test]
fn menu_ui_component_renders_border_separator_shortcut_and_gray_highlight() {
use crate::panes::terminal_character::AnsiCode;
let rows = format!(
"{}:{};-;d{};m{}",
serialize_text_bytes("Copy"),
serialize_text_bytes("^C"),
serialize_text_bytes("Delete"),
serialize_text_bytes("Rename")
);
let grid = render_ui_component("menu", &format!("2/1/16/6;b,f,hl=0;{}", rows));
let lines = widget_lines(&grid);
assert_eq!(lines[1], " ┌──────────────┐");
assert_eq!(lines[2], " │ Copy ^C │");
assert_eq!(lines[3], " ├──────────────┤");
assert_eq!(lines[4], " │ Delete │");
assert_eq!(lines[5], " │ ● Rename │");
assert_eq!(lines[6], " └──────────────┘");
assert!(styled_line(&grid, 2).contains(GRAY_BACKGROUND));
assert!(!styled_line(&grid, 5).contains(GRAY_BACKGROUND));
for line in 1..7 {
assert!(!styled_line(&grid, line).contains(GREEN_BACKGROUND));
}
assert_eq!(styles_at(&grid, 4, 6).italic, Some(AnsiCode::On));
assert_eq!(styles_at(&grid, 4, 6).dim, Some(AnsiCode::On));
}
#[test]
fn menu_ui_component_shows_hidden_row_counts_instead_of_a_bar() {
let rows = ["B", "C"]
.iter()
.map(|r| serialize_text_bytes(r))
.collect::<Vec<_>>()
.join(";");
let bordered = render_ui_component("menu", &format!("0/0/14/4;b,above=1,below=6;{}", rows));
let lines = widget_lines(&bordered);
assert_eq!(lines[0], "┌─ ↑ [+1] ───┐");
assert_eq!(lines[1], "│ B │");
assert_eq!(lines[3], "└─ ↓ [+6] ───┘");
assert!(lines.iter().all(|line| !line.contains('┃')));
let borderless = render_ui_component("menu", &format!("0/0/12/4;ind,above=0,below=5;{}", rows));
let lines = widget_lines(&borderless);
assert_eq!(lines[0], "");
assert_eq!(lines[1], " B");
assert_eq!(lines[3], " ↓ [+5]");
}
#[test]
fn text_input_ui_component_renders_cursor_placeholder_and_error() {
use crate::panes::terminal_character::AnsiCode;
let grid = render_ui_component(
"text_input",
&format!(
"0/0/14/2;f,err,lw=0,cur=1;;{};{};",
serialize_text_bytes("12a"),
serialize_text_bytes("digits only")
),
);
let lines = widget_lines(&grid);
assert_eq!(lines[0], "[ 12a ]");
assert_eq!(lines[1], "✗ digits only");
assert_eq!(styles_at(&grid, 0, 3).reverse, Some(AnsiCode::On));
assert!(styled_line(&grid, 0).contains(RED_FOREGROUND));
assert!(styled_line(&grid, 1).contains(RED_FOREGROUND));
let placeholder = render_ui_component(
"text_input",
&format!(
"0/0/20/1;ph,lw=5;{};{};;",
serialize_text_bytes("Name"),
serialize_text_bytes("type here")
),
);
assert_eq!(widget_lines(&placeholder)[0], "Name [ type here ]");
assert_eq!(styles_at(&placeholder, 0, 7).italic, Some(AnsiCode::On));
let search = render_ui_component(
"text_input",
&format!(
"0/0/24/1;h,lw=0;;{};;{}",
serialize_text_bytes("ab"),
serialize_text_bytes("3 matches")
),
);
assert_eq!(widget_lines(&search)[0], "[ ab 3 matches ]");
assert!(styled_line(&search, 0).contains(GRAY_BACKGROUND));
}
#[test]
fn stepper_ui_component_renders_arrows_and_value() {
use crate::panes::terminal_character::AnsiCode;
let grid = render_ui_component(
"stepper",
&format!(
"0/0/15/1;f,lmin,lw=6;{};{}",
serialize_text_bytes("Width"),
serialize_text_bytes("10000")
),
);
assert_eq!(widget_lines(&grid)[0], "Width ‹ 10000 ›");
assert_eq!(styles_at(&grid, 0, 6).dim, Some(AnsiCode::On));
assert_eq!(styles_at(&grid, 0, 14).bold, Some(AnsiCode::On));
let hovered = render_ui_component(
"stepper",
&format!("0/0/9/1;h,hinc,lw=0;;{}", serialize_text_bytes("10000")),
);
assert!(styled_line(&hovered, 0).contains(GRAY_BACKGROUND));
assert_eq!(styles_at(&hovered, 0, 8).bold, Some(AnsiCode::On));
}
#[test]
fn scroll_indicator_ui_component_renders_arrow_counts() {
let grid = render_ui_component("scroll_indicator", "3/0/10/4;r,above=4,below=0");
let lines = widget_lines(&grid);
assert_eq!(lines[0], " ↑ [+4]");
assert_eq!(lines[1], "");
assert_eq!(lines[3], "");
let both = render_ui_component("scroll_indicator", "0/0/10/3;above=0,below=12,hd");
let lines = widget_lines(&both);
assert_eq!(lines[0], "");
assert_eq!(lines[2], " ↓ [+12]");
assert!(styled_line(&both, 2).contains(GRAY_BACKGROUND));
}
#[test]
fn scroll_indicator_ui_component_renders_a_bar_with_counts_to_its_right() {
let grid = render_ui_component(
"scroll_indicator",
"10/0/9/4;bar,f,tot=8,off=4,above=4,below=0,hu",
);
let lines = widget_lines(&grid);
assert_eq!(lines[0], " │ ↑ [+4]");
assert_eq!(lines[1], " │");
assert_eq!(lines[2], " ┃");
assert_eq!(lines[3], " ┃");
assert!(styled_line(&grid, 0).contains(GRAY_BACKGROUND));
assert!(styled_line(&grid, 2).contains(GREEN_FOREGROUND));
}
#[test]
fn side_menu_ui_component_renders_gray_selection_hover_and_indicators() {
let items = format!(
"{};{}",
serialize_text_bytes("General"),
serialize_text_bytes("Theme")
);
let grid = render_ui_component("side_menu", &format!("0/0/10/3;sel=1,hov=0;{}", items));
let lines = widget_lines(&grid);
assert_eq!(lines[0], " General │");
assert_eq!(lines[1], " Theme │");
assert_eq!(lines[2], " │");
assert!(styled_line(&grid, 1).contains(GRAY_BACKGROUND));
assert!(styled_line(&grid, 0).contains(GRAY_BACKGROUND));
assert!(!styled_line(&grid, 1).contains(GREEN_BACKGROUND));
let scrolled = render_ui_component(
"side_menu",
&format!("0/0/10/4;f,ind,above=2,below=1;{}", items),
);
let lines = widget_lines(&scrolled);
assert_eq!(lines[0], " ↑ [+2] │");
assert_eq!(lines[1], " General │");
assert_eq!(lines[3], " ↓ [+1] │");
}
#[test]
fn dialog_ui_component_renders_an_opaque_box_with_bracketed_buttons() {
let fields = [
serialize_text_bytes("Quit"),
serialize_text_bytes("Yes"),
serialize_text_bytes("No"),
serialize_text_bytes("Really quit?"),
]
.join(";");
let grid = render_ui_component(
"dialog",
&format!("0/0/20/6;sel=1,nb=2,bx=3,hb=0;{}", fields),
);
let lines = widget_lines(&grid);
assert_eq!(lines[0], "╭─ Quit ───────────╮");
assert_eq!(lines[1], "│ │");
assert_eq!(lines[2], "│ Really quit? │");
assert_eq!(lines[3], "│ │");
assert_eq!(lines[4], "│ [ Yes ] [ No ] │");
assert_eq!(lines[5], "╰──────────────────╯");
assert!(styled_line(&grid, 4).contains(GRAY_BACKGROUND));
assert!(!styled_line(&grid, 4).contains(GREEN_BACKGROUND));
assert!(styled_line(&grid, 0).contains(GREEN_FOREGROUND));
}
#[test]
fn widget_ui_components_require_coordinates() {
let grid = render_ui_component("button", &format!(";{}", serialize_text_bytes("Save")));
assert!(widget_lines(&grid).iter().all(|line| line.is_empty()));
}
use crate::panes::kitty_graphics::{InterceptorResult, KittyApcInterceptor, KittyHostSupport};
use crate::panes::sixel::PixelRect;
@@ -9676,3 +9979,113 @@ fn shrinking_keeps_cursor_on_its_own_line_when_it_sits_past_the_content() {
assert_eq!(rendered_row(&grid, 1), "X prompt text here okay");
assert_eq!(grid.viewport.len(), 2);
}
fn row_of(text: &str) -> super::super::Row {
super::super::Row::from_columns(
text.chars()
.map(crate::panes::terminal_character::TerminalCharacter::new)
.collect(),
)
}
fn assert_cached_width_is_fresh(row: &mut super::super::Row) {
let fresh: usize = row.columns.iter().map(|character| character.width()).sum();
assert_eq!(row.width_cached(), fresh);
assert_eq!(row.width(), fresh);
}
#[test]
fn same_width_overwrite_keeps_the_row_width_cache() {
use crate::panes::terminal_character::TerminalCharacter;
let mut row = row_of("abcdef");
assert_eq!(row.width_cached(), 6);
row.add_character_at(TerminalCharacter::new('X'), 2);
assert!(row.width.is_some());
assert_eq!(row_text(&row), "abXdef");
assert_cached_width_is_fresh(&mut row);
}
#[test]
fn same_width_wide_overwrite_keeps_the_row_width_cache() {
use crate::panes::terminal_character::TerminalCharacter;
let mut row = row_of("a中b");
assert_eq!(row.width_cached(), 4);
row.add_character_at(TerminalCharacter::new('文'), 1);
assert!(row.width.is_some());
assert_eq!(row_text(&row), "a文b");
assert_cached_width_is_fresh(&mut row);
}
#[test]
fn wider_overwrite_invalidates_the_row_width_cache() {
use crate::panes::terminal_character::TerminalCharacter;
let mut row = row_of("abcdef");
assert_eq!(row.width_cached(), 6);
row.add_character_at(TerminalCharacter::new('中'), 1);
assert!(row.width.is_none());
assert_eq!(row_text(&row), "a中def");
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 6);
}
#[test]
fn narrower_overwrite_invalidates_the_row_width_cache() {
use crate::panes::terminal_character::TerminalCharacter;
let mut row = row_of("a中b");
assert_eq!(row.width_cached(), 4);
row.add_character_at(TerminalCharacter::new('x'), 1);
assert!(row.width.is_none());
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 4);
let mut row = row_of("a中b");
row.width_cached();
row.add_character_at(TerminalCharacter::new('y'), 2);
assert!(row.width.is_none());
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 4);
}
#[test]
fn appending_wide_and_zero_width_characters_keeps_the_width_correct() {
use crate::panes::terminal_character::TerminalCharacter;
let mut row = row_of("ab");
assert_eq!(row.width_cached(), 2);
row.add_character_at(TerminalCharacter::new('中'), 2);
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 4);
let zero_width = TerminalCharacter::new('\u{0301}');
assert_eq!(zero_width.width(), 0);
row.add_character_at(zero_width, 4);
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 4);
row.add_character_at(TerminalCharacter::new('c'), 4);
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 5);
}
#[test]
fn overwriting_next_to_a_zero_width_character_keeps_the_width_correct() {
use crate::panes::terminal_character::TerminalCharacter;
let mut row = row_of("ab");
row.columns.push_back(TerminalCharacter::new('\u{0301}'));
row.columns.push_back(TerminalCharacter::new('c'));
assert_eq!(row.width_cached(), 3);
row.add_character_at(TerminalCharacter::new('Z'), 2);
assert!(row.width.is_some());
assert_eq!(row.columns[3].character, 'Z');
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 3);
row.add_character_at(TerminalCharacter::new('中'), 0);
assert_cached_width_is_fresh(&mut row);
}
#[test]
fn writing_past_the_end_pads_and_keeps_the_width_correct() {
use crate::panes::terminal_character::TerminalCharacter;
let mut row = row_of("a中");
assert_eq!(row.width_cached(), 3);
row.add_character_at(TerminalCharacter::new('z'), 6);
assert!(row.width.is_none());
assert_cached_width_is_fresh(&mut row);
assert_eq!(row.width_cached(), 7);
}
+324 -7
View File
@@ -3,6 +3,7 @@ mod pipes;
mod plugin_loader;
mod plugin_map;
mod plugin_worker;
mod prompt_requests;
mod shared;
mod wasm_bridge;
mod watch_filesystem;
@@ -26,14 +27,17 @@ use crate::{pty::PtyInstruction, thread_bus::Bus, ClientId, ServerInstruction, S
use zellij_utils::data::PaneRenderReport;
use zellij_utils::input::layout::TabLayoutInfo;
pub use prompt_requests::{PopupRequest, PromptCaller};
pub use wasm_bridge::PluginRenderAsset;
use wasm_bridge::WasmBridge;
use wasm_bridge::{PipePopupRoute, WasmBridge};
use crate::tab::{PopupKind, PopupPlacement};
use zellij_utils::{
data::{
ClientInfo, CommandOrPlugin, Event, EventType, FloatingPaneCoordinates, InputMode,
LayoutInfo, LayoutWithError, MessageToPlugin, PermissionStatus, PermissionType,
PipeMessage, PipeSource, WebServerStatus,
KeybindPresetInfo, KeybindPresetWithError, LayoutInfo, LayoutWithError, MessageToPlugin,
PermissionStatus, PermissionType, PipeMessage, PipePopupPlacement, PipeSource, SettingKey,
WebServerStatus,
},
errors::{prelude::*, ContextType, PluginContext},
input::{
@@ -43,6 +47,7 @@ use zellij_utils::{
plugins::PluginAliases,
},
pane_size::Size,
prompt::{PromptRequest, PromptResult},
session_serialization,
};
@@ -160,6 +165,33 @@ pub enum PluginInstruction {
cwd: Option<PathBuf>,
skip_cache: bool,
cli_client_id: ClientId,
caller_pane_id: Option<PaneId>,
popup: Option<PipePopupPlacement>,
popup_focused: bool,
},
SetCliPipeExitCode {
pipe_id: String,
exit_code: i32,
plugin_id: PluginId,
},
PipePopupFailed {
pipe_id: String,
error: String,
},
PromptRequest {
caller: PromptCaller,
owner_client_id: ClientId,
caller_pane_id: Option<PaneId>,
request: PromptRequest,
},
PromptReplied {
prompt_plugin_id: PluginId,
request_id: u64,
result: PromptResult,
},
PromptPopupFailed {
caller: PromptCaller,
error: String,
},
KeybindPipe {
name: String,
@@ -195,6 +227,9 @@ pub enum PluginInstruction {
FailedToWriteConfigToDisk {
file_path: Option<PathBuf>,
},
ConfigWasWrittenToDisk,
ConfigChangesDropped(ClientId, Vec<SettingKey>),
ConfigFileChangedSinceRead(ClientId),
WatchFilesystem,
ListClientsToPlugin(SessionLayoutMetadata, PluginId, ClientId),
ChangePluginHostDir(PathBuf, PluginId, ClientId),
@@ -208,6 +243,7 @@ pub enum PluginInstruction {
cli_client_id: Option<ClientId>,
},
LayoutListUpdate(Vec<LayoutInfo>, Vec<LayoutWithError>),
KeybindPresetListUpdate(Vec<KeybindPresetInfo>, Vec<KeybindPresetWithError>),
RequestStateUpdateForPlugin(PluginId),
UpdateSessionSaveTime(u64), // u64 = milliseconds since UNIX epoch
GetLastSessionSaveTime {
@@ -222,6 +258,18 @@ pub enum PluginInstruction {
matched_string: String,
context: BTreeMap<String, String>,
},
LoadPopup {
run_plugin_or_alias: RunPluginOrAlias,
tab_id: usize,
tab_index: usize,
client_id: ClientId,
placement: PopupPlacement,
kind: PopupKind,
anchor_pane: Option<PaneId>,
size: Size,
initial_events: Vec<Event>,
pipe: Option<(PopupRequest, BTreeMap<String, String>)>,
},
Exit,
}
@@ -230,6 +278,7 @@ impl From<&PluginInstruction> for PluginContext {
match *plugin_instruction {
PluginInstruction::Load(..) => PluginContext::Load,
PluginInstruction::LoadBackgroundPlugin(..) => PluginContext::LoadBackgroundPlugin,
PluginInstruction::LoadPopup { .. } => PluginContext::LoadPopup,
PluginInstruction::Update(..) => PluginContext::Update,
PluginInstruction::Unload(..) => PluginContext::Unload,
PluginInstruction::Reload(..) => PluginContext::Reload,
@@ -262,6 +311,11 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::ListClientsMetadata(..) => PluginContext::ListClientsMetadata,
PluginInstruction::LogLayoutToHd(..) => PluginContext::LogLayoutToHd,
PluginInstruction::CliPipe { .. } => PluginContext::CliPipe,
PluginInstruction::SetCliPipeExitCode { .. } => PluginContext::SetCliPipeExitCode,
PluginInstruction::PipePopupFailed { .. } => PluginContext::PipePopupFailed,
PluginInstruction::PromptRequest { .. } => PluginContext::PromptRequest,
PluginInstruction::PromptReplied { .. } => PluginContext::PromptReplied,
PluginInstruction::PromptPopupFailed { .. } => PluginContext::PromptPopupFailed,
PluginInstruction::CachePluginEvents { .. } => PluginContext::CachePluginEvents,
PluginInstruction::MessageFromPlugin { .. } => PluginContext::MessageFromPlugin,
PluginInstruction::UnblockCliPipes { .. } => PluginContext::UnblockCliPipes,
@@ -272,6 +326,11 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::FailedToWriteConfigToDisk { .. } => {
PluginContext::FailedToWriteConfigToDisk
},
PluginInstruction::ConfigWasWrittenToDisk => PluginContext::ConfigWasWrittenToDisk,
PluginInstruction::ConfigChangesDropped(..) => PluginContext::ConfigChangesDropped,
PluginInstruction::ConfigFileChangedSinceRead(..) => {
PluginContext::ConfigFileChangedSinceRead
},
PluginInstruction::ListClientsToPlugin(..) => PluginContext::ListClientsToPlugin,
PluginInstruction::ChangePluginHostDir(..) => PluginContext::ChangePluginHostDir,
PluginInstruction::WebServerStarted(..) => PluginContext::WebServerStarted,
@@ -279,6 +338,9 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::PaneRenderReport(..) => PluginContext::PaneRenderReport,
PluginInstruction::UserInput { .. } => PluginContext::UserInput,
PluginInstruction::LayoutListUpdate(..) => PluginContext::LayoutListUpdate,
PluginInstruction::KeybindPresetListUpdate(..) => {
PluginContext::KeybindPresetListUpdate
},
PluginInstruction::RequestStateUpdateForPlugin(..) => {
PluginContext::RequestStateUpdateForPlugin
},
@@ -309,7 +371,7 @@ pub(crate) fn plugin_thread_main(
plugin_aliases: PluginAliases,
default_mode: InputMode,
default_keybinds: SharedKeybinds,
background_plugins: HashSet<RunPluginOrAlias>,
background_plugins: Vec<RunPluginOrAlias>,
// the client id that started the session,
// we need it here because the thread's own list of connected clients might not yet be updated
// on session start when we need to load the background plugins, and so we must have an
@@ -417,6 +479,93 @@ pub(crate) fn plugin_thread_main(
},
}
},
PluginInstruction::LoadPopup {
mut run_plugin_or_alias,
tab_id,
tab_index,
client_id,
placement,
kind,
anchor_pane,
size,
initial_events,
pipe,
} => {
run_plugin_or_alias.populate_run_plugin_if_needed(&plugin_aliases);
let run_plugin = run_plugin_or_alias.get_run_plugin().or_else(|| {
RunPlugin::from_url(&format!(
"zellij:{}",
run_plugin_or_alias.location_string()
))
.ok()
});
let skip_cache = false;
match wasm_bridge.load_plugin(
&run_plugin,
Some(tab_index),
size,
None,
skip_cache,
Some(client_id),
) {
Ok((plugin_id, client_id)) => {
drop(bus.senders.send_to_screen(ScreenInstruction::AddPopup {
plugin_id,
client_id,
tab_id,
run_plugin_or_alias,
placement,
kind,
width: size.cols,
height: size.rows,
anchor_pane,
}));
if !initial_events.is_empty() {
wasm_bridge.update_plugins(
initial_events
.into_iter()
.map(|event| (Some(plugin_id), Some(client_id), event))
.collect(),
shutdown_send.clone(),
)?;
}
if let Some((popup_request, caller_args)) = pipe {
let pipe_messages = match popup_request {
PopupRequest::Cli(pipe_id) => wasm_bridge.attach_pipe_popup(
&pipe_id,
plugin_id,
client_id,
caller_args,
),
PopupRequest::Prompt(caller) => wasm_bridge.attach_prompt_popup(
caller,
plugin_id,
client_id,
caller_args,
),
};
wasm_bridge.pipe_messages(
pipe_messages,
shutdown_send.clone(),
None,
)?;
}
},
Err(e) => {
log::error!("Failed to load popup plugin: {e}");
let error = format!("Failed to load the prompt plugin: {}", e);
match pipe {
Some((PopupRequest::Cli(pipe_id), _)) => {
wasm_bridge.fail_pipe_popup(&pipe_id, error);
},
Some((PopupRequest::Prompt(caller), _)) => {
wasm_bridge.fail_prompt_popup(caller, error);
},
None => {},
}
},
}
},
PluginInstruction::LoadBackgroundPlugin(run_plugin_or_alias, client_id) => {
load_background_plugin(
run_plugin_or_alias,
@@ -953,7 +1102,73 @@ pub(crate) fn plugin_thread_main(
cwd,
skip_cache,
cli_client_id,
caller_pane_id,
popup,
popup_focused,
} => {
if wasm_bridge.cli_pipe_is_finished(&pipe_id) {
continue;
}
let mut args = args;
if let Some(args) = args.as_mut() {
zellij_utils::prompt::strip_caller_args(args);
}
if let (Some(placement), Some(plugin_url)) = (popup, plugin.as_ref()) {
let pipe_message = PipeMessage::new(
PipeSource::Cli(pipe_id.clone()),
name,
&payload,
&args,
true,
);
match wasm_bridge.route_pipe_popup_message(&pipe_id, pipe_message) {
PipePopupRoute::Queued => {},
PipePopupRoute::Deliver(plugin_id, client_id, pipe_message) => {
wasm_bridge.pipe_messages(
vec![(Some(plugin_id), Some(client_id), pipe_message)],
shutdown_send.clone(),
None,
)?;
},
PipePopupRoute::New(pipe_message) => {
match RunPluginOrAlias::from_url(
plugin_url,
&configuration,
Some(&plugin_aliases),
cwd.clone(),
) {
Ok(run_plugin_or_alias) => {
wasm_bridge.start_pipe_popup(
&pipe_id,
pipe_message,
cli_client_id,
);
drop(bus.senders.send_to_screen(
ScreenInstruction::OpenPipePopup {
pipe_id,
run_plugin_or_alias,
caller_pane_id,
placement,
focused: popup_focused,
},
));
},
Err(e) => {
wasm_bridge.start_pipe_popup(
&pipe_id,
pipe_message,
cli_client_id,
);
wasm_bridge.fail_pipe_popup(
&pipe_id,
format!("Failed to find plugin {}: {}", plugin_url, e),
);
},
}
},
}
continue;
}
let should_float = floating.unwrap_or(true);
let mut pipe_messages = vec![];
let floating_pane_coordinates = None; // TODO: do we want to allow this?
@@ -993,7 +1208,81 @@ pub(crate) fn plugin_thread_main(
);
},
}
let has_recipients = !pipe_messages.is_empty();
wasm_bridge.pipe_messages(pipe_messages, shutdown_send.clone(), None)?;
if !has_recipients && !wasm_bridge.cli_pipe_is_pending(&pipe_id) {
let _ = bus
.senders
.send_to_server(ServerInstruction::UnblockCliPipeInput(pipe_id, None));
}
},
PluginInstruction::SetCliPipeExitCode {
pipe_id,
exit_code,
plugin_id,
} => {
wasm_bridge.set_cli_pipe_exit_code(&pipe_id, exit_code, plugin_id);
},
PluginInstruction::PipePopupFailed { pipe_id, error } => {
wasm_bridge.fail_pipe_popup(&pipe_id, error);
},
PluginInstruction::PromptRequest {
caller,
owner_client_id,
caller_pane_id,
request,
} => {
let (name, args, payload) = request.to_pipe_message();
let pipe_message = PipeMessage::new(
PipeSource::PromptRequest {
caller_plugin_id: caller.plugin_id,
request_id: caller.request_id,
},
name,
&payload,
&Some(args),
true,
);
let plugin_url = if plugin_aliases
.aliases
.contains_key(zellij_utils::prompt::PROMPT_PLUGIN_ALIAS)
{
zellij_utils::prompt::PROMPT_PLUGIN_ALIAS
} else {
zellij_utils::prompt::PROMPT_PLUGIN_URL
};
wasm_bridge.start_prompt_popup(caller, pipe_message, request.is_notice());
match RunPluginOrAlias::from_url(plugin_url, &None, Some(&plugin_aliases), None) {
Ok(run_plugin_or_alias) => {
drop(
bus.senders
.send_to_screen(ScreenInstruction::OpenPromptPopup {
caller,
owner_client_id,
caller_pane_id,
run_plugin_or_alias,
placement: request.placement,
focused: request.focused,
}),
);
},
Err(e) => {
wasm_bridge.fail_prompt_popup(
caller,
format!("Failed to find the prompt plugin: {}", e),
);
},
}
},
PluginInstruction::PromptReplied {
prompt_plugin_id,
request_id,
result,
} => {
wasm_bridge.prompt_replied(prompt_plugin_id, request_id, result);
},
PluginInstruction::PromptPopupFailed { caller, error } => {
wasm_bridge.fail_prompt_popup(caller, error);
},
PluginInstruction::KeybindPipe {
name,
@@ -1159,11 +1448,14 @@ pub(crate) fn plugin_thread_main(
wasm_bridge.pipe_messages(pipe_messages, shutdown_send.clone(), None)?;
},
PluginInstruction::UnblockCliPipes(pipes_to_unblock) => {
let pipes_to_unblock = wasm_bridge.update_cli_pipe_state(pipes_to_unblock);
for pipe_name in pipes_to_unblock {
let pipes_to_release = wasm_bridge.update_cli_pipe_state(pipes_to_unblock);
for release in pipes_to_release {
let _ = bus
.senders
.send_to_server(ServerInstruction::UnblockCliPipeInput(pipe_name))
.send_to_server(ServerInstruction::UnblockCliPipeInput(
release.pipe_id,
release.exit_code,
))
.context("failed to unblock input pipe");
}
},
@@ -1197,6 +1489,28 @@ pub(crate) fn plugin_thread_main(
.update_plugins(updates, shutdown_send.clone())
.non_fatal();
},
PluginInstruction::ConfigWasWrittenToDisk => {
let updates = vec![(None, None, Event::ConfigWasWrittenToDisk)];
wasm_bridge
.update_plugins(updates, shutdown_send.clone())
.non_fatal();
},
PluginInstruction::ConfigChangesDropped(client_id, dropped_settings) => {
let updates = vec![(
None,
Some(client_id),
Event::ConfigChangesDropped(dropped_settings),
)];
wasm_bridge
.update_plugins(updates, shutdown_send.clone())
.non_fatal();
},
PluginInstruction::ConfigFileChangedSinceRead(client_id) => {
let updates = vec![(None, Some(client_id), Event::ConfigFileChangedSinceRead)];
wasm_bridge
.update_plugins(updates, shutdown_send.clone())
.non_fatal();
},
PluginInstruction::WatchFilesystem => {
wasm_bridge.start_fs_watcher_if_not_started();
},
@@ -1252,6 +1566,9 @@ pub(crate) fn plugin_thread_main(
PluginInstruction::LayoutListUpdate(layouts, errors) => {
wasm_bridge.update_available_layouts(layouts, errors);
},
PluginInstruction::KeybindPresetListUpdate(presets, errors) => {
wasm_bridge.update_available_keybind_presets(presets, errors);
},
PluginInstruction::RequestStateUpdateForPlugin(plugin_id) => {
wasm_bridge.state_update_for_plugin(plugin_id);
},
+236 -5
View File
@@ -11,19 +11,113 @@ use zellij_utils::errors::prelude::*;
use crate::{thread_bus::ThreadSenders, ClientId};
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum PipeStateChange {
NoChange,
Block,
Unblock,
Crashed,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PipeRelease {
pub pipe_id: String,
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, Default)]
pub struct PendingPipes {
pipes: HashMap<String, PendingPipeInfo>,
exit_codes: HashMap<String, (i32, PluginId)>,
finished: HashSet<String>,
}
impl PendingPipes {
pub fn set_exit_code(&mut self, pipe_id: &str, exit_code: i32, plugin_id: PluginId) {
if self.finished.contains(pipe_id) {
return;
}
self.exit_codes
.insert(pipe_id.to_owned(), (exit_code, plugin_id));
}
pub fn is_finished(&self, pipe_id: &str) -> bool {
self.finished.contains(pipe_id)
}
pub fn is_pending(&self, pipe_id: &str) -> bool {
self.pipes.contains_key(pipe_id)
}
fn release(&mut self, pipe_id: String, is_final: bool) -> PipeRelease {
let exit_code = if is_final {
self.exit_codes
.remove(&pipe_id)
.map(|(exit_code, _)| exit_code)
} else {
None
};
if exit_code.is_some() {
self.pipes.remove(&pipe_id);
self.finished.insert(pipe_id.clone());
}
PipeRelease { pipe_id, exit_code }
}
pub fn release_with_code(&mut self, pipe_id: &str, exit_code: i32) -> PipeRelease {
self.exit_codes.remove(pipe_id);
self.pipes.remove(pipe_id);
self.finished.insert(pipe_id.to_owned());
PipeRelease {
pipe_id: pipe_id.to_owned(),
exit_code: Some(exit_code),
}
}
pub fn update_pipe_state_change(
&mut self,
cli_pipe_name: &str,
pipe_state_change: PipeStateChange,
plugin_id: &PluginId,
client_id: &ClientId,
) -> Vec<PipeRelease> {
if self.finished.contains(cli_pipe_name) {
return vec![];
}
let is_final = matches!(
pipe_state_change,
PipeStateChange::Unblock | PipeStateChange::Crashed
);
let has_exit_code = self.exit_codes.contains_key(cli_pipe_name);
if pipe_state_change == PipeStateChange::Crashed && has_exit_code {
return vec![self.release(cli_pipe_name.to_owned(), true)];
}
self.legacy_update_pipe_state_change(cli_pipe_name, pipe_state_change, plugin_id, client_id)
.into_iter()
.map(|pipe_name| self.release(pipe_name, is_final))
.collect()
}
pub fn unload_plugin(&mut self, plugin_id: &PluginId) -> Vec<PipeRelease> {
let owned_pipes: Vec<String> = self
.exit_codes
.iter()
.filter(|(_, (_, owner))| owner == plugin_id)
.map(|(pipe_id, _)| pipe_id.clone())
.collect();
let mut releases: Vec<PipeRelease> = owned_pipes
.into_iter()
.map(|pipe_id| self.release(pipe_id, true))
.collect();
for pipe_name in self.legacy_unload_plugin(plugin_id) {
releases.push(self.release(pipe_name, false));
}
releases
}
pub fn unload_plugin_client(
&mut self,
plugin_id: &PluginId,
client_id: &ClientId,
) -> Vec<PipeRelease> {
self.legacy_unload_plugin_client(plugin_id, client_id)
.into_iter()
.map(|pipe_name| self.release(pipe_name, false))
.collect()
}
pub fn mark_being_processed(
&mut self,
pipe_id: &str,
@@ -42,7 +136,7 @@ impl PendingPipes {
}
}
// returns a list of pipes that are no longer pending and should be unblocked
pub fn update_pipe_state_change(
fn legacy_update_pipe_state_change(
&mut self,
cli_pipe_name: &str,
pipe_state_change: PipeStateChange,
@@ -69,7 +163,7 @@ impl PendingPipes {
pipe_names_to_unblock
}
// returns a list of pipes that are no longer pending and should be unblocked
pub fn unload_plugin(&mut self, plugin_id: &PluginId) -> Vec<String> {
fn legacy_unload_plugin(&mut self, plugin_id: &PluginId) -> Vec<String> {
let mut pipe_names_to_unblock = vec![];
for (pipe_name, pending_pipe_info) in self.pipes.iter_mut() {
let should_unblock_this_pipe = pending_pipe_info.unload_plugin(plugin_id);
@@ -82,7 +176,7 @@ impl PendingPipes {
}
pipe_names_to_unblock
}
pub fn unload_plugin_client(
fn legacy_unload_plugin_client(
&mut self,
plugin_id: &PluginId,
client_id: &ClientId,
@@ -105,6 +199,7 @@ impl PendingPipes {
#[derive(Debug, Clone, Default)]
pub struct PendingPipeInfo {
is_explicitly_blocked: bool,
blocked_by: Option<PluginId>,
currently_being_processed_by: HashSet<(PluginId, ClientId)>,
}
@@ -131,9 +226,11 @@ impl PendingPipeInfo {
match pipe_state_change {
PipeStateChange::Block => {
self.is_explicitly_blocked = true;
self.blocked_by = Some(*plugin_id);
},
PipeStateChange::Unblock => {
self.is_explicitly_blocked = false;
self.blocked_by = None;
},
_ => {},
};
@@ -147,6 +244,10 @@ impl PendingPipeInfo {
pub fn unload_plugin(&mut self, plugin_id_to_unload: &PluginId) -> bool {
self.currently_being_processed_by
.retain(|(plugin_id, _)| plugin_id != plugin_id_to_unload);
if self.blocked_by == Some(*plugin_id_to_unload) {
self.is_explicitly_blocked = false;
self.blocked_by = None;
}
if self.currently_being_processed_by.is_empty() && !self.is_explicitly_blocked {
true
} else {
@@ -196,7 +297,7 @@ fn release_pipe_of_crashed_plugin(
) {
if let PipeSource::Cli(pipe_id) = &pipe_message.source {
let mut pipe_state_changes = HashMap::new();
pipe_state_changes.insert(pipe_id.to_owned(), PipeStateChange::NoChange);
pipe_state_changes.insert(pipe_id.to_owned(), PipeStateChange::Crashed);
let plugin_render_asset =
PluginRenderAsset::new(plugin_id, client_id, vec![]).with_pipes(pipe_state_changes);
let _ = senders
@@ -321,3 +422,133 @@ pub fn pipes_to_block_or_unblock(
}
pipe_state_changes
}
#[cfg(test)]
mod tests {
use super::*;
const PLUGIN: PluginId = 7;
const CLIENT: ClientId = 1;
fn pending_pipe(pipes: &mut PendingPipes, pipe_id: &str) {
pipes.mark_being_processed(pipe_id, &PLUGIN, &CLIENT);
}
#[test]
fn a_release_without_a_stored_exit_code_carries_none() {
let mut pipes = PendingPipes::default();
pending_pipe(&mut pipes, "p");
let releases =
pipes.update_pipe_state_change("p", PipeStateChange::NoChange, &PLUGIN, &CLIENT);
assert_eq!(
releases,
vec![PipeRelease {
pipe_id: "p".to_owned(),
exit_code: None
}]
);
assert!(!pipes.is_finished("p"));
}
#[test]
fn the_stored_exit_code_is_sent_when_the_plugin_unblocks_the_pipe() {
let mut pipes = PendingPipes::default();
pending_pipe(&mut pipes, "p");
pipes.update_pipe_state_change("p", PipeStateChange::Block, &PLUGIN, &CLIENT);
pipes.set_exit_code("p", 0, PLUGIN);
let releases =
pipes.update_pipe_state_change("p", PipeStateChange::Unblock, &PLUGIN, &CLIENT);
assert_eq!(
releases,
vec![PipeRelease {
pipe_id: "p".to_owned(),
exit_code: Some(0)
}]
);
assert!(pipes.is_finished("p"));
}
#[test]
fn an_automatic_release_after_a_message_is_not_final_even_with_an_exit_code() {
let mut pipes = PendingPipes::default();
pending_pipe(&mut pipes, "p");
pipes.set_exit_code("p", 1, PLUGIN);
let releases =
pipes.update_pipe_state_change("p", PipeStateChange::NoChange, &PLUGIN, &CLIENT);
assert_eq!(releases[0].exit_code, None);
assert!(!pipes.is_finished("p"));
}
#[test]
fn an_explicit_unblock_while_no_message_is_pending_still_releases_with_the_code() {
let mut pipes = PendingPipes::default();
pipes.set_exit_code("p", 0, PLUGIN);
let releases =
pipes.update_pipe_state_change("p", PipeStateChange::Unblock, &PLUGIN, &CLIENT);
assert_eq!(releases[0].exit_code, Some(0));
}
#[test]
fn the_stored_exit_code_is_sent_when_the_plugin_closes() {
let mut pipes = PendingPipes::default();
pending_pipe(&mut pipes, "p");
pipes.update_pipe_state_change("p", PipeStateChange::Block, &PLUGIN, &CLIENT);
pipes.set_exit_code("p", 1, PLUGIN);
let releases = pipes.unload_plugin(&PLUGIN);
assert_eq!(
releases,
vec![PipeRelease {
pipe_id: "p".to_owned(),
exit_code: Some(1)
}]
);
}
#[test]
fn the_stored_exit_code_is_sent_when_the_plugin_closes_between_messages() {
let mut pipes = PendingPipes::default();
pipes.set_exit_code("p", 1, PLUGIN);
let releases = pipes.unload_plugin(&PLUGIN);
assert_eq!(releases[0].exit_code, Some(1));
assert!(pipes.is_finished("p"));
}
#[test]
fn the_stored_exit_code_is_sent_when_the_plugin_crashes() {
let mut pipes = PendingPipes::default();
pending_pipe(&mut pipes, "p");
pipes.update_pipe_state_change("p", PipeStateChange::Block, &PLUGIN, &CLIENT);
pipes.set_exit_code("p", 1, PLUGIN);
pending_pipe(&mut pipes, "p");
let releases =
pipes.update_pipe_state_change("p", PipeStateChange::Crashed, &PLUGIN, &CLIENT);
assert_eq!(releases[0].exit_code, Some(1));
}
#[test]
fn a_closing_plugin_releases_a_pipe_it_blocked_without_an_exit_code() {
let mut pipes = PendingPipes::default();
pending_pipe(&mut pipes, "p");
pipes.update_pipe_state_change("p", PipeStateChange::Block, &PLUGIN, &CLIENT);
let releases = pipes.unload_plugin(&PLUGIN);
assert_eq!(
releases,
vec![PipeRelease {
pipe_id: "p".to_owned(),
exit_code: None
}]
);
}
#[test]
fn a_finished_pipe_ignores_later_changes_and_exit_codes() {
let mut pipes = PendingPipes::default();
pipes.set_exit_code("p", 0, PLUGIN);
pipes.update_pipe_state_change("p", PipeStateChange::Unblock, &PLUGIN, &CLIENT);
pipes.set_exit_code("p", 1, PLUGIN);
assert!(pipes
.update_pipe_state_change("p", PipeStateChange::Unblock, &PLUGIN, &CLIENT)
.is_empty());
assert!(pipes.unload_plugin(&PLUGIN).is_empty());
}
}
@@ -0,0 +1,332 @@
use super::PluginId;
use crate::ClientId;
use std::collections::{BTreeMap, HashMap};
use zellij_utils::data::{Event, PipeMessage};
use zellij_utils::prompt::PromptResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PromptCaller {
pub plugin_id: PluginId,
pub client_id: ClientId,
pub request_id: u64,
}
impl PromptCaller {
pub fn result_event(
&self,
result: PromptResult,
) -> (Option<PluginId>, Option<ClientId>, Event) {
(
Some(self.plugin_id),
Some(self.client_id),
Event::PromptResult(self.request_id, result),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PopupRequest {
Cli(String),
Prompt(PromptCaller),
}
#[derive(Debug, Clone, PartialEq)]
pub enum PromptAction {
Deliver(PromptCaller, PromptResult),
Send(PluginId, ClientId, PipeMessage),
Close(PluginId),
}
#[derive(Debug)]
enum PromptState {
Opening(PipeMessage),
Open(PluginId),
}
#[derive(Debug)]
struct PendingPrompt {
state: PromptState,
is_notice: bool,
}
#[derive(Debug, Default)]
pub struct PromptRequests {
pending: HashMap<PromptCaller, PendingPrompt>,
}
impl PromptRequests {
pub fn start(&mut self, caller: PromptCaller, message: PipeMessage, is_notice: bool) {
self.pending.insert(
caller,
PendingPrompt {
state: PromptState::Opening(message),
is_notice,
},
);
}
pub fn attach(
&mut self,
caller: PromptCaller,
prompt_plugin_id: PluginId,
prompt_client_id: ClientId,
caller_args: BTreeMap<String, String>,
) -> Vec<PromptAction> {
let Some(pending) = self.pending.get_mut(&caller) else {
return vec![PromptAction::Close(prompt_plugin_id)];
};
match std::mem::replace(&mut pending.state, PromptState::Open(prompt_plugin_id)) {
PromptState::Opening(mut message) => {
message.args.extend(caller_args);
vec![PromptAction::Send(
prompt_plugin_id,
prompt_client_id,
message,
)]
},
PromptState::Open(previous_plugin_id) => {
pending.state = PromptState::Open(previous_plugin_id);
vec![PromptAction::Close(prompt_plugin_id)]
},
}
}
pub fn fail(&mut self, caller: PromptCaller, error: String) -> Vec<PromptAction> {
match self.pending.remove(&caller) {
Some(_) => vec![PromptAction::Deliver(caller, PromptResult::Error(error))],
None => vec![],
}
}
pub fn reply(
&mut self,
prompt_plugin_id: PluginId,
request_id: u64,
result: PromptResult,
) -> Vec<PromptAction> {
let caller = self.pending.iter().find_map(|(caller, pending)| {
let answered_by_this_prompt = matches!(
pending.state,
PromptState::Open(plugin_id) if plugin_id == prompt_plugin_id
);
(answered_by_this_prompt && caller.request_id == request_id).then_some(*caller)
});
match caller {
Some(caller) => {
self.pending.remove(&caller);
vec![PromptAction::Deliver(caller, result)]
},
None => vec![],
}
}
pub fn plugin_unloaded(&mut self, plugin_id: PluginId) -> Vec<PromptAction> {
let mut actions = self.prompt_closed(plugin_id);
actions.append(&mut self.callers_gone(|caller| caller.plugin_id == plugin_id));
actions
}
pub fn caller_reloaded(&mut self, plugin_id: PluginId) -> Vec<PromptAction> {
self.callers_gone(|caller| caller.plugin_id == plugin_id)
}
pub fn caller_instance_gone(
&mut self,
plugin_id: PluginId,
client_id: ClientId,
) -> Vec<PromptAction> {
self.callers_gone(|caller| caller.plugin_id == plugin_id && caller.client_id == client_id)
}
#[cfg(test)]
pub fn is_waiting_for(&self, caller: &PromptCaller) -> bool {
self.pending.contains_key(caller)
}
fn prompt_closed(&mut self, prompt_plugin_id: PluginId) -> Vec<PromptAction> {
let callers: Vec<PromptCaller> = self
.pending
.iter()
.filter(|(_, pending)| {
matches!(pending.state, PromptState::Open(plugin_id) if plugin_id == prompt_plugin_id)
})
.map(|(caller, _)| *caller)
.collect();
let mut actions = vec![];
for caller in callers {
if let Some(pending) = self.pending.remove(&caller) {
if !pending.is_notice {
actions.push(PromptAction::Deliver(caller, PromptResult::Cancelled));
}
}
}
actions
}
fn callers_gone(&mut self, is_gone: impl Fn(&PromptCaller) -> bool) -> Vec<PromptAction> {
let callers: Vec<PromptCaller> = self
.pending
.keys()
.filter(|caller| is_gone(caller))
.copied()
.collect();
let mut actions = vec![];
for caller in callers {
if let Some(PendingPrompt {
state: PromptState::Open(prompt_plugin_id),
..
}) = self.pending.remove(&caller)
{
actions.push(PromptAction::Close(prompt_plugin_id));
}
}
actions
}
}
#[cfg(test)]
mod tests {
use super::*;
use zellij_utils::data::PipeSource;
use zellij_utils::prompt::PromptValue;
fn caller(plugin_id: PluginId, client_id: ClientId, request_id: u64) -> PromptCaller {
PromptCaller {
plugin_id,
client_id,
request_id,
}
}
fn message(caller: PromptCaller) -> PipeMessage {
PipeMessage::new(
PipeSource::PromptRequest {
caller_plugin_id: caller.plugin_id,
request_id: caller.request_id,
},
"confirm",
&None,
&None,
true,
)
}
fn opened(requests: &mut PromptRequests, caller: PromptCaller, prompt_plugin_id: PluginId) {
requests.start(caller, message(caller), false);
let mut caller_args = BTreeMap::new();
caller_args.insert("_caller_pane_title".to_owned(), "fixture".to_owned());
let actions = requests.attach(caller, prompt_plugin_id, caller.client_id, caller_args);
match &actions[..] {
[PromptAction::Send(plugin_id, client_id, message)] => {
assert_eq!(*plugin_id, prompt_plugin_id);
assert_eq!(*client_id, caller.client_id);
assert_eq!(
message.args.get("_caller_pane_title").map(|s| s.as_str()),
Some("fixture")
);
},
other => panic!("unexpected {:?}", other),
}
}
#[test]
fn the_answer_reaches_only_the_caller_that_asked() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
let same_plugin_other_user = caller(1, 2, 7);
opened(&mut requests, asker, 10);
opened(&mut requests, same_plugin_other_user, 11);
let answer = PromptResult::Confirmed(true);
assert_eq!(
requests.reply(10, 7, answer.clone()),
vec![PromptAction::Deliver(asker, answer.clone())]
);
assert_eq!(asker.result_event(answer.clone()).0, Some(1));
assert_eq!(asker.result_event(answer).1, Some(1));
assert!(requests.is_waiting_for(&same_plugin_other_user));
assert!(requests.reply(10, 7, PromptResult::Cancelled).is_empty());
}
#[test]
fn a_reply_from_another_plugin_or_for_another_request_is_ignored() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
opened(&mut requests, asker, 10);
assert!(requests.reply(99, 7, PromptResult::Cancelled).is_empty());
assert!(requests.reply(10, 8, PromptResult::Cancelled).is_empty());
assert!(requests.is_waiting_for(&asker));
}
#[test]
fn the_caller_closing_closes_its_prompt_and_sends_nothing() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
let other = caller(2, 1, 1);
opened(&mut requests, asker, 10);
opened(&mut requests, other, 11);
assert_eq!(requests.plugin_unloaded(1), vec![PromptAction::Close(10)]);
assert!(requests.plugin_unloaded(10).is_empty());
assert!(requests.is_waiting_for(&other));
}
#[test]
fn the_caller_closing_while_the_prompt_opens_closes_it_once_it_arrives() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
requests.start(asker, message(asker), false);
assert!(requests.plugin_unloaded(1).is_empty());
assert_eq!(
requests.attach(asker, 10, 1, BTreeMap::new()),
vec![PromptAction::Close(10)]
);
}
#[test]
fn a_reloaded_caller_or_a_detached_caller_instance_closes_its_prompts() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
let other_user = caller(1, 2, 7);
opened(&mut requests, asker, 10);
opened(&mut requests, other_user, 11);
assert_eq!(
requests.caller_instance_gone(1, 2),
vec![PromptAction::Close(11)]
);
assert_eq!(requests.caller_reloaded(1), vec![PromptAction::Close(10)]);
}
#[test]
fn the_prompt_closing_without_an_answer_sends_cancelled() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
opened(&mut requests, asker, 10);
assert_eq!(
requests.plugin_unloaded(10),
vec![PromptAction::Deliver(asker, PromptResult::Cancelled)]
);
assert!(!requests.is_waiting_for(&asker));
}
#[test]
fn a_notice_closing_sends_nothing_and_a_click_is_delivered() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
requests.start(asker, message(asker), true);
requests.attach(asker, 10, 1, BTreeMap::new());
assert!(requests.plugin_unloaded(10).is_empty());
let clicked = caller(1, 1, 8);
requests.start(clicked, message(clicked), true);
requests.attach(clicked, 11, 1, BTreeMap::new());
let answer = PromptResult::Answered(PromptValue::Bool(true));
assert_eq!(
requests.reply(11, 8, answer.clone()),
vec![PromptAction::Deliver(clicked, answer)]
);
}
#[test]
fn a_prompt_that_cannot_open_reports_an_error() {
let mut requests = PromptRequests::default();
let asker = caller(1, 1, 7);
requests.start(asker, message(asker), false);
assert_eq!(
requests.fail(asker, "no user".to_owned()),
vec![PromptAction::Deliver(
asker,
PromptResult::Error("no user".to_owned())
)]
);
assert!(requests.fail(asker, "again".to_owned()).is_empty());
}
}
+3 -3
View File
@@ -6,7 +6,8 @@ use crate::plugins::plugin_map::{PluginMap, RunningPlugin, SharedSlot, Subscript
use crate::plugins::plugin_worker::MessageToWorker;
use crate::plugins::wasm_bridge::{
check_event_permission, handle_plugin_crash, handle_plugin_loading_failure,
handle_plugin_successful_loading, LoadingContext, PluginCache, PluginRenderAsset,
handle_plugin_successful_loading, subscription_event_type, LoadingContext, PluginCache,
PluginRenderAsset,
};
use crate::plugins::zellij_exports::{wasi_read_bytes, wasi_read_string, wasi_write_object};
use crate::plugins::PluginInstruction;
@@ -16,7 +17,6 @@ use crate::{thread_bus::ThreadSenders, ClientId, SharedKeybinds};
use prost::Message;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use wasmi::Engine;
use zellij_utils::data::KeybindsVec;
@@ -446,7 +446,7 @@ pub fn apply_events_job(
let mut render = RenderResponse::Nothing;
let strip_keybinds = subscriptions.contains(&EventType::InitialKeybinds);
for (mut event, context, mut keybinds) in events {
let Ok(event_type) = EventType::from_str(&event.to_string()) else {
let Ok(event_type) = subscription_event_type(&event) else {
continue;
};
if let Event::ModeUpdate(mode_info) = &mut event {
@@ -0,0 +1,77 @@
use super::{is_hover, subscription_event_type, HoverGenerations};
use std::collections::BTreeSet;
use zellij_utils::data::{BareKey, Event, EventType, KeyModifier, KeyWithModifier, Mouse};
fn shift() -> BTreeSet<KeyModifier> {
let mut modifiers = BTreeSet::new();
modifiers.insert(KeyModifier::Shift);
modifiers
}
#[test]
fn a_newer_hover_supersedes_an_older_one_for_the_same_plugin_and_client() {
let mut generations = HoverGenerations::default();
let first = generations.queue(1, 1);
assert!(!generations.is_superseded(1, 1, first));
let second = generations.queue(1, 1);
assert!(generations.is_superseded(1, 1, first));
assert!(!generations.is_superseded(1, 1, second));
}
#[test]
fn hovers_of_other_plugins_or_clients_do_not_supersede_each_other() {
let mut generations = HoverGenerations::default();
let plugin_one = generations.queue(1, 1);
let plugin_two = generations.queue(2, 1);
let other_client = generations.queue(1, 2);
generations.queue(2, 1);
generations.queue(1, 2);
assert!(!generations.is_superseded(1, 1, plugin_one));
assert!(generations.is_superseded(2, 1, plugin_two));
assert!(generations.is_superseded(1, 2, other_client));
}
#[test]
fn a_generation_never_queued_is_not_superseded() {
let generations = HoverGenerations::default();
assert!(!generations.is_superseded(7, 3, 1));
}
#[test]
fn only_hover_events_are_merged() {
assert!(is_hover(&Event::Mouse(Mouse::Hover(1, 2))));
assert!(is_hover(&Event::MouseWithModifiers(
Mouse::Hover(1, 2),
shift()
)));
assert!(!is_hover(&Event::Mouse(Mouse::LeftClick(1, 2))));
assert!(!is_hover(&Event::Mouse(Mouse::Release(1, 2))));
assert!(!is_hover(&Event::Mouse(Mouse::ScrollDown(1))));
assert!(!is_hover(&Event::MouseWithModifiers(
Mouse::LeftClick(1, 2),
shift()
)));
assert!(!is_hover(&Event::Key(KeyWithModifier::new(BareKey::Enter))));
assert!(!is_hover(&Event::Timer(1.0)));
}
#[test]
fn a_mouse_event_with_modifiers_matches_a_mouse_subscription() {
assert_eq!(
subscription_event_type(&Event::MouseWithModifiers(Mouse::LeftClick(1, 2), shift()))
.unwrap(),
EventType::Mouse
);
assert_eq!(
subscription_event_type(&Event::Mouse(Mouse::LeftClick(1, 2))).unwrap(),
EventType::Mouse
);
assert_eq!(
subscription_event_type(&Event::Key(KeyWithModifier::new(BareKey::Enter))).unwrap(),
EventType::Key
);
assert_eq!(
subscription_event_type(&Event::Timer(1.0)).unwrap(),
EventType::Timer
);
}
+30 -13
View File
@@ -346,10 +346,8 @@ pub(super) fn create_plugin_thread(
plugin_aliases.aliases.insert(
"fixture_plugin_for_tests".to_owned(),
RunPlugin::from_url(&format!(
"file:{}/../target/e2e-data/plugins/fixture-plugin-for-tests.wasm",
std::env::var_os("CARGO_MANIFEST_DIR")
.unwrap()
.to_string_lossy()
"file:{}",
e2e_plugin_path("fixture-plugin-for-tests").display()
))
.unwrap(),
);
@@ -671,15 +669,19 @@ fn create_plugin_thread_with_background_jobs_receiver(
)
}
pub(crate) fn e2e_plugin_path(name: &str) -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.filter(|target_dir| !target_dir.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../target"))
.join("e2e-data/plugins")
.join(format!("{}.wasm", name))
}
lazy_static! {
static ref PLUGIN_FIXTURE: String = format!(
// to populate this file, make sure to run the build-e2e CI job
// (or compile the fixture plugin and copy the resulting .wasm blob to the below location)
"{}/../target/e2e-data/plugins/fixture-plugin-for-tests.wasm",
std::env::var_os("CARGO_MANIFEST_DIR")
.unwrap()
.to_string_lossy()
);
static ref PLUGIN_FIXTURE: String = e2e_plugin_path("fixture-plugin-for-tests")
.display()
.to_string();
}
#[test]
@@ -6684,6 +6686,9 @@ pub fn unblock_input_plugin_command() {
cwd: None,
skip_cache: false,
cli_client_id: client_id,
caller_pane_id: None,
popup: None,
popup_focused: true,
});
screen_thread.join().unwrap(); // this might take a while if the cache is cold
teardown();
@@ -6772,6 +6777,9 @@ pub fn block_input_plugin_command() {
cwd: None,
skip_cache: false,
cli_client_id: client_id,
caller_pane_id: None,
popup: None,
popup_focused: true,
});
screen_thread.join().unwrap(); // this might take a while if the cache is cold
teardown();
@@ -6866,6 +6874,9 @@ pub fn pipe_output_plugin_command() {
cwd: None,
skip_cache: false,
cli_client_id: client_id,
caller_pane_id: None,
popup: None,
popup_focused: true,
});
std::thread::sleep(std::time::Duration::from_millis(500));
teardown();
@@ -6953,6 +6964,9 @@ pub fn pipe_message_to_plugin_plugin_command() {
cwd: None,
skip_cache: false,
cli_client_id: client_id,
caller_pane_id: None,
popup: None,
popup_focused: true,
});
std::thread::sleep(std::time::Duration::from_millis(500));
teardown();
@@ -13286,10 +13300,13 @@ pub fn cli_pipe_is_released_when_plugin_panics_while_handling_it() {
cwd: None,
skip_cache: false,
cli_client_id: client_id,
caller_pane_id: None,
popup: None,
popup_focused: true,
});
std::thread::sleep(std::time::Duration::from_millis(1000));
let unblocked = received_server_instruction.lock().unwrap().iter().any(
|i| matches!(i, ServerInstruction::UnblockCliPipeInput(pipe_name) if pipe_name == "input_pipe_id"),
|i| matches!(i, ServerInstruction::UnblockCliPipeInput(pipe_name, _) if pipe_name == "input_pipe_id"),
);
teardown();
let _ = server_thread.join();
@@ -17,13 +17,7 @@ use zellij_utils::pane_size::Size;
type Renders = HashMap<(PluginId, ClientId), String>;
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(format!(
"{}/../target/e2e-data/plugins/{}.wasm",
std::env::var_os("CARGO_MANIFEST_DIR")
.unwrap()
.to_string_lossy(),
name
))
super::plugin_tests::e2e_plugin_path(name)
}
fn shared_fixture(configuration: &[(&str, &str)]) -> RunPluginOrAlias {
+370 -51
View File
@@ -1,18 +1,21 @@
use super::{PinnedExecutor, PluginId, PluginInstruction};
use crate::global_async_runtime::get_tokio_runtime;
use crate::plugins::pipes::{
apply_pipe_message_to_plugin, pipes_to_block_or_unblock, PendingPipes, PipeStateChange,
apply_pipe_message_to_plugin, pipes_to_block_or_unblock, PendingPipes, PipeRelease,
PipeStateChange,
};
use crate::plugins::plugin_loader::PluginLoader;
use crate::plugins::plugin_map::{
AtomicEvent, PluginEnv, PluginMap, PluginMetadata, RemovedPluginAssets, RunningPlugin,
SharedSlot, Subscriptions,
};
use crate::plugins::prompt_requests::{PromptAction, PromptCaller, PromptRequests};
use crate::plugins::shared::{
add_slot_job, apply_events_job, apply_pipes_job, client_job, host_settings_job,
remove_slot_job, resize_job, start_instance_job, visibility_job, wasm_module_exports_function,
SharedKey, SHARED_MARKER_EXPORT, SHARED_PIPE_CLIENT,
};
use zellij_utils::prompt::PromptResult;
use crate::plugins::plugin_worker::MessageToWorker;
use crate::plugins::shared::SharedEventBatchEntry;
@@ -33,9 +36,9 @@ use url::Url;
use wasmi::{Engine, Module};
use zellij_utils::consts::{ZELLIJ_CACHE_DIR, ZELLIJ_SESSION_CACHE_DIR, ZELLIJ_TMP_DIR};
use zellij_utils::data::{
FloatingPaneCoordinates, HostTerminalThemeMode, InputMode, KeybindsVec, LayoutInfo,
LayoutWithError, PaneContents, PaneRenderReport, PermissionStatus, PermissionType, PipeMessage,
PipeSource,
FloatingPaneCoordinates, HostTerminalThemeMode, InputMode, KeybindPresetInfo,
KeybindPresetWithError, KeybindsVec, LayoutInfo, LayoutWithError, PaneContents,
PaneRenderReport, PermissionStatus, PermissionType, PipeMessage, PipeSource,
};
use zellij_utils::downloader::Downloader;
use zellij_utils::input::permission::PermissionCache;
@@ -74,8 +77,31 @@ fn make_plugin_url_path_safe(url: String) -> String {
#[derive(Debug, Clone)]
pub enum EventOrPipeMessage {
Event(Event),
PipeMessage(PipeMessage),
Event(Event, Option<ClientId>),
PipeMessage(PipeMessage, Option<ClientId>),
}
pub const PIPE_POPUP_CLOSED_EXIT_CODE: i32 = 1;
pub const PIPE_POPUP_ERROR_EXIT_CODE: i32 = 2;
#[derive(Debug, Clone)]
enum PipePopup {
Opening {
queued: Vec<PipeMessage>,
cli_client_id: ClientId,
},
Open {
plugin_id: PluginId,
client_id: ClientId,
caller_args: BTreeMap<String, String>,
},
}
#[derive(Debug, Clone)]
pub enum PipePopupRoute {
New(PipeMessage),
Queued,
Deliver(PluginId, ClientId, PipeMessage),
}
#[derive(Debug, Clone, Default)]
@@ -194,6 +220,7 @@ fn shared_event_context(
let slot_id = match event {
Event::Key(..)
| Event::Mouse(..)
| Event::MouseWithModifiers(..)
| Event::PastedText(..)
| Event::Visible(..)
| Event::PermissionRequestResult(..)
@@ -207,7 +234,39 @@ fn shared_event_context(
EventContext { slot_id, client_id }
}
#[derive(Debug, Default)]
pub(crate) struct HoverGenerations {
latest: HashMap<(PluginId, ClientId), u64>,
}
impl HoverGenerations {
pub fn queue(&mut self, plugin_id: PluginId, client_id: ClientId) -> u64 {
let generation = self.latest.entry((plugin_id, client_id)).or_insert(0);
*generation += 1;
*generation
}
pub fn is_superseded(&self, plugin_id: PluginId, client_id: ClientId, generation: u64) -> bool {
self.latest
.get(&(plugin_id, client_id))
.map(|latest| *latest != generation)
.unwrap_or(false)
}
}
pub(crate) fn subscription_event_type(
event: &Event,
) -> Result<EventType, <EventType as FromStr>::Err> {
EventType::from_str(&event.to_string()).map(|event_type| {
if event_type == EventType::MouseWithModifiers {
EventType::Mouse
} else {
event_type
}
})
}
pub struct WasmBridge {
latest_hovers: Arc<Mutex<HoverGenerations>>,
connected_clients: Arc<Mutex<Vec<ClientId>>>,
senders: ThreadSenders,
plugin_dir: PathBuf,
@@ -232,9 +291,13 @@ pub struct WasmBridge {
cached_plugin_map:
HashMap<RunPluginLocation, HashMap<PluginUserConfiguration, Vec<(PluginId, ClientId)>>>,
pending_pipes: PendingPipes,
pipe_popups: HashMap<String, PipePopup>,
prompt_requests: PromptRequests,
layout_dir: Option<PathBuf>,
available_layouts: Vec<LayoutInfo>,
available_layout_errors: Vec<LayoutWithError>,
available_keybind_presets: Vec<KeybindPresetInfo>,
available_keybind_preset_errors: Vec<KeybindPresetWithError>,
default_mode: InputMode,
default_keybinds: SharedKeybinds,
keybinds: HashMap<ClientId, SharedKeybinds>,
@@ -300,15 +363,20 @@ impl WasmBridge {
default_shell,
cached_plugin_map: HashMap::new(),
pending_pipes: Default::default(),
pipe_popups: HashMap::new(),
prompt_requests: PromptRequests::default(),
layout_dir,
available_layouts,
available_layout_errors,
available_keybind_presets: vec![],
available_keybind_preset_errors: vec![],
default_mode,
default_keybinds,
keybinds: HashMap::new(),
base_modes: HashMap::new(),
downloader,
previous_pane_render_report: None,
latest_hovers: Arc::new(Mutex::new(HoverGenerations::default())),
last_host_terminal_theme_mode: None,
last_session_save_time: Arc::new(Mutex::new(None)),
shared_instances: HashMap::new(),
@@ -661,13 +729,11 @@ impl WasmBridge {
// Main thread cleanup
self.cached_plugin_map.clear();
let mut pipes_to_unblock = self.pending_pipes.unload_plugin(&pid);
for pipe_name in pipes_to_unblock.drain(..) {
let _ = self
.senders
.send_to_server(ServerInstruction::UnblockCliPipeInput(pipe_name))
.context("failed to unblock input pipe");
}
let pipes_to_release = self.pending_pipes.unload_plugin(&pid);
self.send_pipe_releases(pipes_to_release);
self.forget_pipe_popups_of_plugin(pid);
let prompt_actions = self.prompt_requests.plugin_unloaded(pid);
self.apply_prompt_actions(prompt_actions);
let plugin_list = self.plugin_map.lock().unwrap().list_plugins();
let _ = self
.senders
@@ -676,6 +742,8 @@ impl WasmBridge {
Ok(())
}
pub fn reload_plugin_with_id(&mut self, plugin_id: u32) -> Result<()> {
let prompt_actions = self.prompt_requests.caller_reloaded(plugin_id);
self.apply_prompt_actions(prompt_actions);
if let Some(instance_id) = self.shared_instance_of(plugin_id) {
self.reload_shared_instance(instance_id);
return Ok(());
@@ -1048,11 +1116,22 @@ impl WasmBridge {
for (plugin_id, client_id, running_plugin, subscriptions) in &plugins_to_update {
let subs = subscriptions.lock().unwrap().clone();
// FIXME: This is very janky... Maybe I should write my own macro for Event -> EventType?
if let Ok(event_type) = EventType::from_str(&event.to_string()) {
if let Ok(event_type) = subscription_event_type(&event) {
if (subs.contains(&event_type)
|| event_type == EventType::PermissionRequestResult)
&& Self::message_is_directed_at_plugin(pid, cid, plugin_id, client_id)
{
let hover_generation = if is_hover(&event) {
Some(
self.latest_hovers
.lock()
.unwrap()
.queue(*plugin_id, *client_id),
)
} else {
None
};
let latest_hovers = self.latest_hovers.clone();
// Execute directly on pinned thread (no async I/O needed for event processing)
plugin_executor.execute_for_plugin(*plugin_id, {
let plugin_id = *plugin_id;
@@ -1067,6 +1146,15 @@ impl WasmBridge {
_plugin_cache,
_engine| {
let _s = _s; // guard to allow the task to complete before cleanup/shutdown
if let Some(generation) = hover_generation {
let superseded = latest_hovers
.lock()
.unwrap()
.is_superseded(plugin_id, client_id, generation);
if superseded {
return;
}
}
let mut running_plugin = running_plugin.lock().unwrap();
let mut plugin_render_assets = vec![];
match apply_event_to_plugin(
@@ -1106,10 +1194,10 @@ impl WasmBridge {
// loop once more to update the cached events for the pending plugins (probably currently
// being loaded, we'll send them these events when they load)
for (pid, _cid, event) in updates.drain(..) {
for (pid, cid, event) in updates.drain(..) {
for (plugin_id, cached_events) in self.cached_events_for_pending_plugins.iter_mut() {
if pid.is_none() || pid.as_ref() == Some(plugin_id) {
cached_events.push(EventOrPipeMessage::Event(event.clone()));
cached_events.push(EventOrPipeMessage::Event(event.clone(), cid));
}
}
}
@@ -1382,7 +1470,10 @@ impl WasmBridge {
.collect();
for (plugin_id, cached_events) in self.cached_events_for_pending_plugins.iter_mut() {
if message_pid.is_none() || message_pid.as_ref() == Some(plugin_id) {
cached_events.push(EventOrPipeMessage::PipeMessage(pipe_message.clone()));
cached_events.push(EventOrPipeMessage::PipeMessage(
pipe_message.clone(),
message_cid,
));
if let PipeSource::Cli(pipe_id) = &pipe_message.source {
for client_id in &all_connected_clients {
if Self::message_is_directed_at_plugin(
@@ -1510,15 +1601,14 @@ impl WasmBridge {
}
});
let mut pipes_to_unblock = self
let pipes_to_release = self
.pending_pipes
.unload_plugin_client(&plugin_id, &client_id);
for pipe_name in pipes_to_unblock.drain(..) {
let _ = self
.senders
.send_to_server(ServerInstruction::UnblockCliPipeInput(pipe_name))
.context("failed to unblock input pipe");
}
self.send_pipe_releases(pipes_to_release);
let prompt_actions = self
.prompt_requests
.caller_instance_gone(plugin_id, client_id);
self.apply_prompt_actions(prompt_actions);
}
self.cached_plugin_map.clear();
}
@@ -1823,8 +1913,14 @@ impl WasmBridge {
let _s = _s; // guard to allow the task to complete before cleanup/shutdown
for event_or_pipe_message in events_or_pipe_messages {
match event_or_pipe_message {
EventOrPipeMessage::Event(event) => {
match EventType::from_str(&event.to_string())
EventOrPipeMessage::Event(event, target_client_id) => {
if target_client_id
.map(|target| target != client_id)
.unwrap_or(false)
{
continue;
}
match subscription_event_type(&event)
.with_context(err_context)
{
Ok(event_type) => {
@@ -1860,7 +1956,16 @@ impl WasmBridge {
},
}
},
EventOrPipeMessage::PipeMessage(pipe_message) => {
EventOrPipeMessage::PipeMessage(
pipe_message,
target_client_id,
) => {
if target_client_id
.map(|target| target != client_id)
.unwrap_or(false)
{
continue;
}
let mut running_plugin = running_plugin.lock().unwrap();
let mut plugin_render_assets = vec![];
@@ -2230,13 +2335,13 @@ impl WasmBridge {
pub fn update_cli_pipe_state(
&mut self,
pipe_state_changes: Vec<PluginRenderAsset>,
) -> Vec<String> {
let mut pipe_names_to_unblock = vec![];
) -> Vec<PipeRelease> {
let mut pipes_to_release = vec![];
for pipe_state_change in pipe_state_changes {
let client_id = pipe_state_change.client_id;
let plugin_id = pipe_state_change.plugin_id;
for (cli_pipe_name, pipe_state_change) in pipe_state_change.cli_pipes {
pipe_names_to_unblock.append(&mut self.pending_pipes.update_pipe_state_change(
pipes_to_release.append(&mut self.pending_pipes.update_pipe_state_change(
&cli_pipe_name,
pipe_state_change,
&plugin_id,
@@ -2244,14 +2349,186 @@ impl WasmBridge {
));
}
}
let pipe_names_to_unblock =
pipe_names_to_unblock
.into_iter()
.fold(HashSet::new(), |mut acc, p| {
acc.insert(p);
acc
});
pipe_names_to_unblock.into_iter().collect()
let mut seen = HashSet::new();
pipes_to_release.retain(|release| seen.insert(release.clone()));
pipes_to_release
}
pub fn set_cli_pipe_exit_code(&mut self, pipe_id: &str, exit_code: i32, plugin_id: PluginId) {
self.pending_pipes
.set_exit_code(pipe_id, exit_code, plugin_id);
}
pub fn cli_pipe_is_finished(&self, pipe_id: &str) -> bool {
self.pending_pipes.is_finished(pipe_id)
}
pub fn cli_pipe_is_pending(&self, pipe_id: &str) -> bool {
self.pending_pipes.is_pending(pipe_id)
}
fn send_pipe_releases(&self, releases: Vec<PipeRelease>) {
for release in releases {
let _ = self
.senders
.send_to_server(ServerInstruction::UnblockCliPipeInput(
release.pipe_id,
release.exit_code,
))
.context("failed to unblock input pipe");
}
}
pub fn route_pipe_popup_message(
&mut self,
pipe_id: &str,
pipe_message: PipeMessage,
) -> PipePopupRoute {
match self.pipe_popups.get_mut(pipe_id) {
Some(PipePopup::Opening { queued, .. }) => {
queued.push(pipe_message);
PipePopupRoute::Queued
},
Some(PipePopup::Open {
plugin_id,
client_id,
caller_args,
}) => {
let mut pipe_message = pipe_message;
pipe_message.args.extend(caller_args.clone());
PipePopupRoute::Deliver(*plugin_id, *client_id, pipe_message)
},
None => PipePopupRoute::New(pipe_message),
}
}
pub fn start_pipe_popup(
&mut self,
pipe_id: &str,
pipe_message: PipeMessage,
cli_client_id: ClientId,
) {
self.pipe_popups.insert(
pipe_id.to_owned(),
PipePopup::Opening {
queued: vec![pipe_message],
cli_client_id,
},
);
}
pub fn attach_pipe_popup(
&mut self,
pipe_id: &str,
plugin_id: PluginId,
client_id: ClientId,
caller_args: BTreeMap<String, String>,
) -> Vec<(Option<PluginId>, Option<ClientId>, PipeMessage)> {
let queued = match self.pipe_popups.remove(pipe_id) {
Some(PipePopup::Opening { queued, .. }) => queued,
_ => vec![],
};
self.pipe_popups.insert(
pipe_id.to_owned(),
PipePopup::Open {
plugin_id,
client_id,
caller_args: caller_args.clone(),
},
);
self.pending_pipes
.set_exit_code(pipe_id, PIPE_POPUP_CLOSED_EXIT_CODE, plugin_id);
queued
.into_iter()
.map(|mut pipe_message| {
pipe_message.args.extend(caller_args.clone());
(Some(plugin_id), Some(client_id), pipe_message)
})
.collect()
}
pub fn fail_pipe_popup(&mut self, pipe_id: &str, error: String) {
let cli_client_id = match self.pipe_popups.remove(pipe_id) {
Some(PipePopup::Opening { cli_client_id, .. }) => Some(cli_client_id),
_ => None,
};
if let Some(cli_client_id) = cli_client_id {
let _ = self.senders.send_to_server(ServerInstruction::LogError(
vec![error],
cli_client_id,
None,
));
}
let release = self
.pending_pipes
.release_with_code(pipe_id, PIPE_POPUP_ERROR_EXIT_CODE);
self.send_pipe_releases(vec![release]);
}
pub fn start_prompt_popup(
&mut self,
caller: PromptCaller,
pipe_message: PipeMessage,
is_notice: bool,
) {
self.prompt_requests.start(caller, pipe_message, is_notice);
}
pub fn attach_prompt_popup(
&mut self,
caller: PromptCaller,
plugin_id: PluginId,
client_id: ClientId,
caller_args: BTreeMap<String, String>,
) -> Vec<(Option<PluginId>, Option<ClientId>, PipeMessage)> {
let actions = self
.prompt_requests
.attach(caller, plugin_id, client_id, caller_args);
self.apply_prompt_actions(actions)
}
pub fn fail_prompt_popup(&mut self, caller: PromptCaller, error: String) {
let actions = self.prompt_requests.fail(caller, error);
self.apply_prompt_actions(actions);
}
pub fn prompt_replied(
&mut self,
prompt_plugin_id: PluginId,
request_id: u64,
result: PromptResult,
) {
let actions = self
.prompt_requests
.reply(prompt_plugin_id, request_id, result);
self.apply_prompt_actions(actions);
}
fn apply_prompt_actions(
&mut self,
actions: Vec<PromptAction>,
) -> Vec<(Option<PluginId>, Option<ClientId>, PipeMessage)> {
let mut pipe_messages = vec![];
for action in actions {
match action {
PromptAction::Deliver(caller, result) => {
let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![
caller.result_event(result)
]));
},
PromptAction::Send(plugin_id, client_id, pipe_message) => {
pipe_messages.push((Some(plugin_id), Some(client_id), pipe_message));
},
PromptAction::Close(plugin_id) => {
let _ = self.senders.send_to_screen(ScreenInstruction::ClosePane(
PaneId::Plugin(plugin_id),
None,
None,
None,
));
let _ = self
.senders
.send_to_plugin(PluginInstruction::Unload(plugin_id));
},
}
}
pipe_messages
}
fn forget_pipe_popups_of_plugin(&mut self, plugin_id: PluginId) {
self.pipe_popups.retain(|_, pipe_popup| match pipe_popup {
PipePopup::Open {
plugin_id: popup_plugin_id,
..
} => *popup_plugin_id != plugin_id,
PipePopup::Opening { .. } => true,
});
}
fn message_is_directed_at_plugin(
message_pid: Option<PluginId>,
@@ -2294,15 +2571,42 @@ impl WasmBridge {
)]));
}
}
pub fn update_available_keybind_presets(
&mut self,
presets: Vec<KeybindPresetInfo>,
errors: Vec<KeybindPresetWithError>,
) {
if self.available_keybind_presets != presets
|| self.available_keybind_preset_errors != errors
{
self.available_keybind_presets = presets.clone();
self.available_keybind_preset_errors = errors.clone();
let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![(
None,
None,
Event::AvailableKeybindPresets(presets, errors),
)]));
}
}
pub fn state_update_for_plugin(&self, plugin_id: PluginId) {
let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![(
Some(plugin_id),
None,
Event::AvailableLayoutInfo(
self.available_layouts.clone(),
self.available_layout_errors.clone(),
let _ = self.senders.send_to_plugin(PluginInstruction::Update(vec![
(
Some(plugin_id),
None,
Event::AvailableLayoutInfo(
self.available_layouts.clone(),
self.available_layout_errors.clone(),
),
),
)]));
(
Some(plugin_id),
None,
Event::AvailableKeybindPresets(
self.available_keybind_presets.clone(),
self.available_keybind_preset_errors.clone(),
),
),
]));
}
pub fn shared_instance_ids(&self) -> Vec<PluginId> {
self.shared_instances.keys().copied().collect()
@@ -2526,12 +2830,10 @@ impl WasmBridge {
remove_slot_job(senders, plugin_map, instance_id, slot_id, true);
},
);
let mut pipes_to_unblock = self.pending_pipes.unload_plugin(&instance_id);
for pipe_name in pipes_to_unblock.drain(..) {
let _ = self
.senders
.send_to_server(ServerInstruction::UnblockCliPipeInput(pipe_name));
}
let pipes_to_release = self.pending_pipes.unload_plugin(&instance_id);
self.send_pipe_releases(pipes_to_release);
let prompt_actions = self.prompt_requests.plugin_unloaded(instance_id);
self.apply_prompt_actions(prompt_actions);
} else {
self.plugin_executor.execute_for_plugin(
instance_id,
@@ -3023,6 +3325,7 @@ pub fn check_event_permission(
| Event::CwdChanged(..)
| Event::CommandChanged(..)
| Event::AvailableLayoutInfo(..)
| Event::AvailableKeybindPresets(..)
| Event::PluginConfigurationChanged(..)
| Event::HighlightClicked { .. }
| Event::SoftKeyboardVisibilityChanged(..)
@@ -3030,7 +3333,11 @@ pub fn check_event_permission(
| Event::ActivePaneScroll(..)
| Event::NestedSessionModeUpdate { .. }
| Event::NestedSessionEnded { .. }
| Event::ContextMenu(..)
| Event::InputReceived => PermissionType::ReadApplicationState,
Event::ConfigChangesDropped(..) | Event::ConfigFileChangedSinceRead => {
PermissionType::Reconfigure
},
Event::WebServerStatus(..) => PermissionType::StartWebServer,
Event::PaneRenderReport(..) => PermissionType::ReadPaneContents,
Event::UserAction(..) => PermissionType::InterceptInput,
@@ -3211,3 +3518,15 @@ fn change_host_dir_of_running_plugin(
plugin_env.plugin_cwd = new_host_dir.clone();
Ok(())
}
pub(crate) fn is_hover(event: &Event) -> bool {
matches!(
event,
Event::Mouse(zellij_utils::data::Mouse::Hover(..))
| Event::MouseWithModifiers(zellij_utils::data::Mouse::Hover(..), _)
)
}
#[cfg(test)]
#[path = "unit/hover_merge_tests.rs"]
mod hover_merge_tests;
+587 -34
View File
@@ -3,7 +3,7 @@ use crate::background_jobs::BackgroundJob;
use crate::global_async_runtime::get_tokio_runtime;
use crate::plugins::plugin_map::PluginEnv;
use crate::plugins::wasm_bridge::handle_plugin_crash;
use crate::plugins::PluginId;
use crate::plugins::{PluginId, PromptCaller};
use crate::pty::{ClientTabIndexOrPaneId, PtyInstruction};
use crate::route::{route_action, wait_for_action_completion, NotificationEnd};
use crate::ClientId;
@@ -24,13 +24,13 @@ use wasmi::{Caller, Linker};
use zellij_utils::consts::ipc_connect;
use zellij_utils::data::{
BorderStyleOverride, BreakPanesToNewTabResponse, BreakPanesToTabWithIdResponse,
BreakPanesToTabWithIndexResponse, CommandType, ConnectToSession, DeleteAllDeadSessionsResponse,
DeleteDeadSessionResponse, DeleteLayoutResponse, EditLayoutResponse, Event,
FloatingPaneCoordinates, FocusOrCreateTabResponse, GetFocusedPaneInfoResponse,
GetPaneCwdResponse, GetPanePidResponse, GetPaneRunningCommandResponse, HttpVerb,
KeyWithModifier, KillSessionsResponse, LayoutInfo, LayoutMetadata, LayoutParsingError,
MessageToPlugin, NewPanePlacement, NewTabResponse, NewTabUnfocusedResponse,
NewTiledPaneInTabResponse, OpenCommandPaneBackgroundResponse,
BreakPanesToTabWithIndexResponse, CommandType, ConfigSnapshot, ConnectToSession,
ContextMenuTarget, DeleteAllDeadSessionsResponse, DeleteDeadSessionResponse,
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,
@@ -41,7 +41,8 @@ use zellij_utils::data::{
OpenTerminalFloatingResponse, OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse,
OpenTerminalNearPluginResponse, OpenTerminalPaneInPlaceOfPaneIdResponse, OpenTerminalResponse,
OriginatingPlugin, PaneFrameStyle, PaneScrollbackResponse, PermissionStatus, PermissionType,
PluginPermission, RegexHighlight, RenameLayoutResponse, SaveLayoutResponse, TabMetadata,
PluginPermission, PopupOptions, RegexHighlight, RenameLayoutResponse, SaveLayoutResponse,
SettingKey, TabMetadata,
};
use zellij_utils::home::default_layout_dir;
use zellij_utils::input::permission::PermissionCache;
@@ -70,7 +71,7 @@ use zellij_utils::{
actions::Action,
command::{OpenFilePayload, RunCommand, RunCommandAction, TerminalAction},
config::ConfigError,
layout::{Layout, RunPluginOrAlias, TabLayoutInfo},
layout::{Layout, RunPluginLocation, RunPluginOrAlias, TabLayoutInfo},
},
plugin_api::{
event::{
@@ -82,18 +83,18 @@ use zellij_utils::{
dump_layout_response, dump_session_layout_response, hide_floating_panes_response,
parse_layout_response, save_session_response, show_floating_panes_response,
ProtobufBreakPanesToNewTabResponse, ProtobufBreakPanesToTabWithIdResponse,
ProtobufBreakPanesToTabWithIndexResponse, ProtobufDeleteAllDeadSessionsResponse,
ProtobufDeleteDeadSessionResponse, ProtobufDeleteLayoutResponse,
ProtobufDumpLayoutResponse, ProtobufDumpSessionLayoutResponse,
ProtobufEditLayoutResponse, ProtobufFocusOrCreateTabResponse,
ProtobufGenerateRandomNameResponse, ProtobufGetFocusedPaneInfoResponse,
ProtobufGetLayoutDirResponse, ProtobufGetPaneCwdResponse, ProtobufGetPaneInfoResponse,
ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse,
ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetSessionListResponse,
ProtobufGetTabInfoResponse, ProtobufHideFloatingPanesResponse,
ProtobufKillSessionsResponse, ProtobufNewTabResponse, ProtobufNewTabUnfocusedResponse,
ProtobufNewTabsResponse, ProtobufNewTiledPaneInTabResponse,
ProtobufOpenCommandPaneBackgroundResponse,
ProtobufBreakPanesToTabWithIndexResponse, ProtobufCopyKeybindPresetResponse,
ProtobufDeleteAllDeadSessionsResponse, ProtobufDeleteDeadSessionResponse,
ProtobufDeleteLayoutResponse, ProtobufDumpLayoutResponse,
ProtobufDumpSessionLayoutResponse, ProtobufEditLayoutResponse,
ProtobufFocusOrCreateTabResponse, ProtobufGenerateRandomNameResponse,
ProtobufGetFocusedPaneInfoResponse, ProtobufGetLayoutDirResponse,
ProtobufGetPaneCwdResponse, ProtobufGetPaneInfoResponse, ProtobufGetPanePidResponse,
ProtobufGetPaneRunningCommandResponse, ProtobufGetSessionEnvironmentVariablesResponse,
ProtobufGetSessionListResponse, ProtobufGetTabInfoResponse,
ProtobufHideFloatingPanesResponse, ProtobufKillSessionsResponse,
ProtobufNewTabResponse, ProtobufNewTabUnfocusedResponse, ProtobufNewTabsResponse,
ProtobufNewTiledPaneInTabResponse, ProtobufOpenCommandPaneBackgroundResponse,
ProtobufOpenCommandPaneFloatingNearPluginResponse,
ProtobufOpenCommandPaneFloatingResponse,
ProtobufOpenCommandPaneInPlaceOfPaneIdResponse,
@@ -108,12 +109,14 @@ use zellij_utils::{
ProtobufOpenTerminalInPlaceOfPluginResponse, ProtobufOpenTerminalInPlaceResponse,
ProtobufOpenTerminalNearPluginResponse,
ProtobufOpenTerminalPaneInPlaceOfPaneIdResponse, ProtobufOpenTerminalResponse,
ProtobufParseLayoutResponse, ProtobufPluginCommand, ProtobufRenameLayoutResponse,
ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse,
ProtobufParseLayoutResponse, ProtobufPluginCommand, ProtobufReadConfigResponse,
ProtobufRenameLayoutResponse, ProtobufSaveLayoutResponse, ProtobufSaveSessionResponse,
ProtobufShowFloatingPanesResponse, ProtobufSlotCommandResponse,
ProtobufWriteThemeFileResponse,
},
plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion},
},
prompt::{PromptRequest, PromptResult},
};
#[cfg(feature = "web_server_capability")]
@@ -309,6 +312,113 @@ fn close_slot(env: &PluginEnv, slot_id: PluginId) {
write_slot_command_response(env, result);
}
fn open_context_menu(env: &PluginEnv, target: ContextMenuTarget, line: usize, column: usize) {
env.senders
.send_to_screen(ScreenInstruction::OpenContextMenuFromPlugin {
plugin_id: self_pane_id(env),
client_id: acting_client(env),
target,
line,
column,
})
.with_context(|| format!("failed to open context menu"))
.non_fatal();
}
fn open_plugin_popup(
env: &PluginEnv,
plugin_url: String,
configuration: BTreeMap<String, String>,
line: usize,
column: usize,
width: usize,
height: usize,
options: PopupOptions,
) {
let run_plugin_or_alias = match RunPluginOrAlias::from_url(
&plugin_url,
&Some(configuration),
None,
Some(env.plugin_cwd.clone()),
) {
Ok(run_plugin_or_alias) => run_plugin_or_alias,
Err(e) => {
log::error!("Failed to open popup: {}", e);
return;
},
};
env.senders
.send_to_screen(ScreenInstruction::OpenPluginPopup {
requesting_plugin_id: self_pane_id(env),
client_id: acting_client(env),
run_plugin_or_alias,
line,
column,
width,
height,
options,
})
.with_context(|| format!("failed to open popup"))
.non_fatal();
}
fn run_context_menu_item(env: &PluginEnv, index: usize) {
let client_id = acting_client(env);
let plugin_id = env.plugin_id;
let (response_sender, response_receiver) = crossbeam::channel::bounded(1);
if env
.senders
.send_to_screen(ScreenInstruction::GetContextMenuItemActions {
plugin_id,
client_id,
index,
response_channel: response_sender,
})
.is_err()
{
log::error!("Failed to request context menu item {}", index);
return;
}
let actions = match response_receiver.recv_timeout(Duration::from_secs(5)) {
Ok(actions) => actions,
Err(e) => {
log::error!("Failed to get context menu item {}: {:?}", index, e);
return;
},
};
let senders = env.senders.clone();
let default_shell = env.default_shell.clone();
let default_mode = env.default_mode.clone();
thread::spawn(move || {
for action in actions {
if let Err(e) = route_action(
action,
client_id,
None,
Some(PaneId::Plugin(plugin_id)),
senders.clone(),
default_shell.clone(),
None,
default_mode,
None,
) {
log::error!("Failed to run context menu action: {:?}", e);
}
}
});
}
fn set_popup_size(env: &PluginEnv, width: usize, height: usize) {
env.senders
.send_to_screen(ScreenInstruction::SetPopupSize {
plugin_id: env.plugin_id,
width,
height,
})
.with_context(|| format!("failed to set popup size"))
.non_fatal();
}
pub fn zellij_exports(linker: &mut Linker<PluginEnv>) {
linker
.func_wrap("zellij", "host_run_plugin_command", host_run_plugin_command)
@@ -344,6 +454,65 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
show_slot(env, slot_id, should_float_if_hidden)
},
PluginCommand::CloseSlot(slot_id) => close_slot(env, slot_id),
PluginCommand::OpenContextMenu {
target,
line,
column,
} => open_context_menu(env, target, line, column),
PluginCommand::OpenPluginPopup {
plugin_url,
configuration,
line,
column,
width,
height,
options,
} => open_plugin_popup(
env,
plugin_url,
configuration,
line,
column,
width,
height,
options,
),
PluginCommand::SetPopupSize { width, height } => {
set_popup_size(env, width, height)
},
PluginCommand::RunContextMenuItem(index) => {
run_context_menu_item(env, index)
},
PluginCommand::ReadConfig => read_config(env),
PluginCommand::RevertConfig(key) => revert_config(env, key),
PluginCommand::UnsetConfigSetting(key) => unset_config_setting(env, key),
PluginCommand::SaveConfig => save_config(env),
PluginCommand::OverwriteConfigFile => overwrite_config_file(env),
PluginCommand::ReloadConfigFile => reload_config_file(env),
PluginCommand::ReplaceConfigBlocks(blocks) => replace_config_blocks(env, blocks),
PluginCommand::ResetKeys {
keys,
write_config_to_disk,
} => reset_keys(env, keys, write_config_to_disk),
PluginCommand::WriteThemeFile {
name,
copy_from,
colours,
} => write_theme_file(env, name, copy_from, colours),
PluginCommand::DeleteThemeFile { name } => delete_theme_file(env, name),
PluginCommand::SaveKeybindsAsPreset { new_name } => {
save_keybinds_as_preset(env, new_name)
},
PluginCommand::Prompt {
request_id,
request,
} => prompt(env, request_id, request),
PluginCommand::ReplyToPrompt { request_id, result } => {
reply_to_prompt(env, request_id, result)
},
PluginCommand::CopyKeybindPreset { preset, new_name } => {
copy_keybind_preset(env, preset, new_name)
},
PluginCommand::Subscribe(event_list) => subscribe(env, event_list)?,
PluginCommand::Unsubscribe(event_list) => unsubscribe(env, event_list)?,
PluginCommand::SetSelectable(selectable) => set_selectable(env, selectable),
@@ -639,6 +808,9 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
PluginCommand::CliPipeOutput(pipe_name, output) => {
cli_pipe_output(env, pipe_name, output)?
},
PluginCommand::SetCliPipeExitCode(pipe_name, exit_code) => {
set_cli_pipe_exit_code(env, pipe_name, exit_code)?
},
PluginCommand::MessageToPlugin(message) => message_to_plugin(env, message)?,
PluginCommand::DisconnectOtherClients => disconnect_other_clients(env),
PluginCommand::KillSessions(session_list) => kill_sessions(session_list),
@@ -1029,6 +1201,16 @@ fn cli_pipe_output(env: &PluginEnv, pipe_name: String, output: String) -> Result
.context("failed to send pipe output")
}
fn set_cli_pipe_exit_code(env: &PluginEnv, pipe_name: String, exit_code: i32) -> Result<()> {
env.senders
.send_to_plugin(PluginInstruction::SetCliPipeExitCode {
pipe_id: pipe_name,
exit_code,
plugin_id: env.plugin_id,
})
.context("failed to set pipe exit code")
}
fn message_to_plugin(env: &PluginEnv, mut message_to_plugin: MessageToPlugin) -> Result<()> {
if message_to_plugin.plugin_url.as_ref().map(|s| s.as_str()) == Some("zellij:OWN_URL") {
message_to_plugin.plugin_url = Some(env.plugin.location.display());
@@ -2893,6 +3075,60 @@ fn show_pane_with_id(
}
}
fn prompt(env: &PluginEnv, request_id: u64, request: PromptRequest) {
let caller = PromptCaller {
plugin_id: env.plugin_id,
client_id: env.client_id,
request_id,
};
let instruction = prompt_instruction(caller, acting_client(env), self_pane_id(env), request);
env.senders
.send_to_plugin(instruction)
.with_context(|| format!("failed to send a prompt request"))
.non_fatal();
}
fn prompt_instruction(
caller: PromptCaller,
owner_client_id: ClientId,
caller_pane_plugin_id: PluginId,
request: PromptRequest,
) -> PluginInstruction {
match request.check() {
Ok(()) => PluginInstruction::PromptRequest {
caller,
owner_client_id,
caller_pane_id: Some(PaneId::Plugin(caller_pane_plugin_id)),
request,
},
Err(error) => {
PluginInstruction::Update(vec![caller.result_event(PromptResult::Error(error))])
},
}
}
fn may_reply_to_prompt(location: &RunPluginLocation) -> bool {
matches!(location, RunPluginLocation::Zellij(tag) if tag.to_string() == zellij_utils::prompt::PROMPT_PLUGIN_ALIAS)
}
fn reply_to_prompt(env: &PluginEnv, request_id: u64, result: PromptResult) {
if !may_reply_to_prompt(&env.plugin.location) {
log::warn!(
"Ignoring a prompt reply from {}: only the prompt plugin can answer prompts",
env.plugin.location.display()
);
return;
}
env.senders
.send_to_plugin(PluginInstruction::PromptReplied {
prompt_plugin_id: env.plugin_id,
request_id,
result,
})
.with_context(|| format!("failed to send a prompt reply"))
.non_fatal();
}
fn close_self(env: &PluginEnv) {
env.senders
.send_to_screen(ScreenInstruction::ClosePane(
@@ -2922,6 +3158,215 @@ fn reconfigure(env: &PluginEnv, new_config: String, write_config_to_disk: bool)
Ok(())
}
fn read_config(env: &PluginEnv) {
let client_id = acting_client(env);
let (response_sender, response_receiver) = crossbeam::channel::bounded(1);
let snapshot = match env.senders.send_to_server(ServerInstruction::ReadConfig {
client_id,
response_channel: response_sender,
}) {
Ok(()) => response_receiver
.recv_timeout(Duration::from_secs(5))
.unwrap_or_else(|e| {
log::error!("Failed to read config: {:?}", e);
ConfigSnapshot::default()
}),
Err(e) => {
log::error!("Failed to request config: {:?}", e);
ConfigSnapshot::default()
},
};
let response: ProtobufReadConfigResponse = snapshot.into();
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to send config to plugin {}", env.name()))
.non_fatal();
}
fn save_keybinds_as_preset(env: &PluginEnv, new_name: String) {
use zellij_utils::plugin_api::plugin_command::copy_keybind_preset_response::Result as CopyResult;
let client_id = acting_client(env);
let (response_sender, response_receiver) = crossbeam::channel::bounded(1);
let result = match env
.senders
.send_to_server(ServerInstruction::SaveKeybindsAsPreset {
client_id,
new_name,
response_channel: response_sender,
}) {
Ok(()) => response_receiver
.recv_timeout(Duration::from_secs(5))
.unwrap_or_else(|e| Err(format!("Failed to save the preset: {:?}", e))),
Err(e) => Err(format!("Failed to save the preset: {:?}", e)),
};
let response = ProtobufCopyKeybindPresetResponse {
result: Some(match result {
Ok(new_name) => CopyResult::NewName(new_name),
Err(error) => CopyResult::Error(error),
}),
};
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to send save result to plugin {}", env.name()))
.non_fatal();
}
fn write_theme_file(
env: &PluginEnv,
name: String,
copy_from: Option<String>,
colours: Vec<String>,
) {
use zellij_utils::plugin_api::plugin_command::write_theme_file_response::Result as WriteResult;
let client_id = acting_client(env);
let (response_sender, response_receiver) = crossbeam::channel::bounded(1);
let result = match env
.senders
.send_to_server(ServerInstruction::WriteThemeFile {
client_id,
name,
copy_from,
colours,
response_channel: response_sender,
}) {
Ok(()) => response_receiver
.recv_timeout(Duration::from_secs(5))
.unwrap_or_else(|e| Err(format!("Failed to write the theme file: {:?}", e))),
Err(e) => Err(format!("Failed to write the theme file: {:?}", e)),
};
let response = ProtobufWriteThemeFileResponse {
result: Some(match result {
Ok(path) => WriteResult::Path(path),
Err(error) => WriteResult::Error(error),
}),
};
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to send theme file result to plugin {}", env.name()))
.non_fatal();
}
fn delete_theme_file(env: &PluginEnv, name: String) {
use zellij_utils::plugin_api::plugin_command::write_theme_file_response::Result as WriteResult;
let client_id = acting_client(env);
let (response_sender, response_receiver) = crossbeam::channel::bounded(1);
let result = match env
.senders
.send_to_server(ServerInstruction::DeleteThemeFile {
client_id,
name,
response_channel: response_sender,
}) {
Ok(()) => response_receiver
.recv_timeout(Duration::from_secs(5))
.unwrap_or_else(|e| Err(format!("Failed to delete the theme: {:?}", e))),
Err(e) => Err(format!("Failed to delete the theme: {:?}", e)),
};
let response = ProtobufWriteThemeFileResponse {
result: Some(match result {
Ok(path) => WriteResult::Path(path),
Err(error) => WriteResult::Error(error),
}),
};
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| {
format!(
"failed to send theme deletion result to plugin {}",
env.name()
)
})
.non_fatal();
}
fn copy_keybind_preset(env: &PluginEnv, preset: String, new_name: String) {
use zellij_utils::plugin_api::plugin_command::copy_keybind_preset_response::Result as CopyResult;
let client_id = acting_client(env);
let (response_sender, response_receiver) = crossbeam::channel::bounded(1);
let result = match env
.senders
.send_to_server(ServerInstruction::CopyKeybindPreset {
client_id,
preset,
new_name,
response_channel: response_sender,
}) {
Ok(()) => response_receiver
.recv_timeout(Duration::from_secs(5))
.unwrap_or_else(|e| Err(format!("Failed to copy the preset: {:?}", e))),
Err(e) => Err(format!("Failed to copy the preset: {:?}", e)),
};
let response = ProtobufCopyKeybindPresetResponse {
result: Some(match result {
Ok(new_name) => CopyResult::NewName(new_name),
Err(error) => CopyResult::Error(error),
}),
};
wasi_write_object(env, &response.encode_to_vec())
.with_context(|| format!("failed to send copy result to plugin {}", env.name()))
.non_fatal();
}
fn revert_config(env: &PluginEnv, key: Option<SettingKey>) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::RevertConfig { client_id, key })
.with_context(|| "Failed to revert config")
.non_fatal();
}
fn unset_config_setting(env: &PluginEnv, key: SettingKey) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::UnsetConfigSetting { client_id, key })
.with_context(|| "Failed to unset config setting")
.non_fatal();
}
fn save_config(env: &PluginEnv) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::SaveConfig { client_id })
.with_context(|| "Failed to save config")
.non_fatal();
}
fn overwrite_config_file(env: &PluginEnv) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::OverwriteConfigFile { client_id })
.with_context(|| "Failed to overwrite the config file")
.non_fatal();
}
fn reload_config_file(env: &PluginEnv) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::ReloadConfigFile { client_id })
.with_context(|| "Failed to reload the config file")
.non_fatal();
}
fn replace_config_blocks(env: &PluginEnv, config: String) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::ReplaceConfigBlocks { client_id, config })
.with_context(|| "Failed to replace config blocks")
.non_fatal();
}
fn reset_keys(
env: &PluginEnv,
keys: Vec<(InputMode, KeyWithModifier)>,
write_config_to_disk: bool,
) {
let client_id = acting_client(env);
env.senders
.send_to_server(ServerInstruction::ResetKeys {
client_id,
keys,
write_config_to_disk,
})
.with_context(|| "Failed to reset keys")
.non_fatal();
}
fn rebind_keys(
env: &PluginEnv,
keys_to_rebind: Vec<(InputMode, KeyWithModifier, Vec<Action>)>,
@@ -5741,6 +6186,21 @@ fn check_command_permission(
// there's no use to deny them anything
return (PermissionStatus::Granted, None);
}
let permission = match required_permission(command) {
Some(permission) => permission,
None => return (PermissionStatus::Granted, None),
};
if let Some(permissions) = plugin_env.permissions.lock().unwrap().as_ref() {
if permissions.contains(&permission) {
return (PermissionStatus::Granted, None);
}
}
(PermissionStatus::Denied, Some(permission))
}
fn required_permission(command: &PluginCommand) -> Option<PermissionType> {
let permission = match command {
PluginCommand::OpenFile(..)
| PluginCommand::OpenFileFloating(..)
@@ -5890,10 +6350,18 @@ fn check_command_permission(
| PluginCommand::HideFloatingPanes { .. }
| PluginCommand::SetPaneRegexHighlights(..)
| PluginCommand::ClearPaneHighlights(..)
| PluginCommand::SetSoftKeyboard(..) => PermissionType::ChangeApplicationState,
| PluginCommand::SetSoftKeyboard(..)
| PluginCommand::OpenContextMenu { .. }
| PluginCommand::OpenPluginPopup { .. }
| PluginCommand::SetPopupSize { .. } => PermissionType::ChangeApplicationState,
PluginCommand::Prompt { .. } | PluginCommand::ReplyToPrompt { .. } => {
PermissionType::OpenTerminalsOrPlugins
},
PluginCommand::RunContextMenuItem(..) => PermissionType::RunActionsAsUser,
PluginCommand::UnblockCliPipeInput(..)
| PluginCommand::BlockCliPipeInput(..)
| PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes,
| PluginCommand::CliPipeOutput(..)
| PluginCommand::SetCliPipeExitCode(..) => PermissionType::ReadCliPipes,
PluginCommand::MessageToPlugin(..) => PermissionType::MessageAndLaunchOtherPlugins,
PluginCommand::ListClients
| PluginCommand::DumpSessionLayout { .. }
@@ -5911,9 +6379,20 @@ fn check_command_permission(
| PluginCommand::GetTabInfo(..)
| PluginCommand::GetNestedSessionKeybinds(..)
| PluginCommand::GetSessionList => PermissionType::ReadApplicationState,
PluginCommand::RebindKeys { .. } | PluginCommand::Reconfigure(..) => {
PermissionType::Reconfigure
},
PluginCommand::RebindKeys { .. }
| PluginCommand::Reconfigure(..)
| PluginCommand::ReadConfig
| PluginCommand::RevertConfig(..)
| PluginCommand::UnsetConfigSetting(..)
| PluginCommand::CopyKeybindPreset { .. }
| PluginCommand::SaveKeybindsAsPreset { .. }
| PluginCommand::WriteThemeFile { .. }
| PluginCommand::DeleteThemeFile { .. }
| PluginCommand::SaveConfig
| PluginCommand::OverwriteConfigFile
| PluginCommand::ReloadConfigFile
| PluginCommand::ReplaceConfigBlocks(..)
| PluginCommand::ResetKeys { .. } => PermissionType::Reconfigure,
PluginCommand::ChangeHostFolder(..) | PluginCommand::ListWindowsVolumes => {
PermissionType::FullHdAccess
},
@@ -5937,14 +6416,88 @@ fn check_command_permission(
},
PluginCommand::OpenCommandPaneInNewTab(..) => PermissionType::RunCommands,
PluginCommand::OpenEditorPaneInNewTab(..) => PermissionType::OpenFiles,
_ => return (PermissionStatus::Granted, None),
_ => return None,
};
Some(permission)
}
if let Some(permissions) = plugin_env.permissions.lock().unwrap().as_ref() {
if permissions.contains(&permission) {
return (PermissionStatus::Granted, None);
#[cfg(test)]
mod prompt_tests {
use super::*;
use std::path::PathBuf;
use zellij_utils::data::Event;
use zellij_utils::data::PluginTag;
fn caller() -> PromptCaller {
PromptCaller {
plugin_id: 3,
client_id: 2,
request_id: 9,
}
}
(PermissionStatus::Denied, Some(permission))
#[test]
fn a_valid_request_opens_a_popup_for_the_acting_user_over_the_caller() {
let request = PromptRequest::confirm("Go?");
match prompt_instruction(caller(), 5, 4, request.clone()) {
PluginInstruction::PromptRequest {
caller: instruction_caller,
owner_client_id,
caller_pane_id,
request: instruction_request,
} => {
assert_eq!(instruction_caller, caller());
assert_eq!(owner_client_id, 5);
assert_eq!(caller_pane_id, Some(PaneId::Plugin(4)));
assert_eq!(instruction_request, request);
},
other => panic!("unexpected {:?}", other),
}
}
#[test]
fn an_invalid_request_answers_the_caller_with_an_error_and_opens_nothing() {
let request = PromptRequest::number("Port").min(5).max(1);
match prompt_instruction(caller(), 5, 4, request) {
PluginInstruction::Update(updates) => {
assert_eq!(updates.len(), 1);
let (plugin_id, client_id, event) = &updates[0];
assert_eq!((*plugin_id, *client_id), (Some(3), Some(2)));
assert!(matches!(
event,
Event::PromptResult(9, PromptResult::Error(message)) if message.contains("min")
));
},
other => panic!("unexpected {:?}", other),
}
}
#[test]
fn prompting_needs_permission_to_open_plugins_and_only_the_prompt_plugin_may_reply() {
let request = PluginCommand::Prompt {
request_id: 1,
request: PromptRequest::confirm("x"),
};
assert_eq!(
required_permission(&request),
Some(PermissionType::OpenTerminalsOrPlugins)
);
let reply = PluginCommand::ReplyToPrompt {
request_id: 1,
result: PromptResult::Cancelled,
};
assert_eq!(
required_permission(&reply),
Some(PermissionType::OpenTerminalsOrPlugins)
);
assert!(may_reply_to_prompt(&RunPluginLocation::Zellij(
PluginTag::new("prompt")
)));
assert!(!may_reply_to_prompt(&RunPluginLocation::Zellij(
PluginTag::new("configuration")
)));
assert!(!may_reply_to_prompt(&RunPluginLocation::File(
PathBuf::from("/tmp/prompt.wasm")
)));
}
}
+42 -15
View File
@@ -1,7 +1,7 @@
use crate::background_jobs::write_session_state_to_disk;
use crate::background_jobs::BackgroundJob;
use crate::global_async_runtime::get_tokio_runtime as async_runtime;
use crate::os_input_output::{AsyncReader, NullAsyncReader};
use crate::os_input_output::{env_value, AsyncReader, NullAsyncReader, PaneEnv};
use crate::route::NotificationEnd;
use crate::terminal_bytes::TerminalBytes;
use crate::{
@@ -139,6 +139,7 @@ pub enum PtyInstruction {
client_id: ClientId,
default_editor: Option<PathBuf>,
post_command_discovery_hook: Option<String>,
pane_env: PaneEnv,
},
ListClientsToPlugin(SessionLayoutMetadata, PluginId, ClientId),
ReportPluginCwd(PluginId, PathBuf),
@@ -208,6 +209,7 @@ pub(crate) struct Pty {
task_handles: HashMap<u32, JoinHandle<()>>, // terminal_id to join-handle
default_editor: Option<PathBuf>,
post_command_discovery_hook: Option<String>,
pane_env: PaneEnv,
plugin_cwds: HashMap<u32, PathBuf>, // plugin_id -> cwd
terminal_cwds: HashMap<u32, PathBuf>, // terminal_id -> cwd
pane_activity_flags: HashMap<u32, std::sync::Arc<std::sync::atomic::AtomicBool>>,
@@ -660,7 +662,7 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> {
// this otherwise (also look for a place that turns get_default_shell into a
// RunCommand, we might have done this before)
let run_command = RunCommand {
command: shell.unwrap_or_else(|| get_default_shell()),
command: shell.unwrap_or_else(|| default_shell_from(&pty.pane_env)),
hold_on_close: false,
hold_on_start: false,
cwd: working_dir,
@@ -879,9 +881,10 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> {
PtyInstruction::Reconfigure {
default_editor,
post_command_discovery_hook,
pane_env,
client_id: _,
} => {
pty.reconfigure(default_editor, post_command_discovery_hook);
pty.reconfigure(default_editor, post_command_discovery_hook, pane_env);
},
PtyInstruction::SendSigintToPaneId(pane_id) => {
pty.send_sigint_to_pane(pane_id);
@@ -928,6 +931,7 @@ impl Pty {
debug_to_file: bool,
default_editor: Option<PathBuf>,
post_command_discovery_hook: Option<String>,
pane_env: PaneEnv,
) -> Self {
Pty {
active_panes: HashMap::new(),
@@ -938,6 +942,7 @@ impl Pty {
default_editor,
originating_plugins: HashMap::new(),
post_command_discovery_hook,
pane_env,
plugin_cwds: HashMap::new(),
terminal_cwds: HashMap::new(),
pane_activity_flags: HashMap::new(),
@@ -972,7 +977,7 @@ impl Pty {
default_shell
},
None => {
let shell = get_default_shell();
let shell = default_shell_from(&self.pane_env);
TerminalAction::RunCommand(RunCommand {
args: vec![],
command: shell,
@@ -1145,7 +1150,12 @@ impl Pty {
.as_mut()
.context("no OS I/O interface found")
.and_then(|os_input| {
os_input.spawn_terminal(terminal_action, quit_cb, self.default_editor.clone())
os_input.spawn_terminal(
terminal_action,
quit_cb,
self.default_editor.clone(),
&self.pane_env,
)
})
.with_context(err_context)?;
let activity_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
@@ -1697,7 +1707,7 @@ impl Pty {
.as_mut()
.context("no OS I/O interface found")
.with_context(err_context)?
.spawn_terminal(cmd, quit_cb, self.default_editor.clone())
.spawn_terminal(cmd, quit_cb, self.default_editor.clone(), &self.pane_env)
.with_context(err_context)
{
Ok((terminal_id, reader, child_pid)) => {
@@ -1732,7 +1742,7 @@ impl Pty {
.as_mut()
.context("no OS I/O interface found")
.with_context(err_context)?
.spawn_terminal(shell, quit_cb, self.default_editor.clone())
.spawn_terminal(shell, quit_cb, self.default_editor.clone(), &self.pane_env)
.with_context(err_context)
{
Ok((terminal_id, reader, child_pid)) => {
@@ -1766,6 +1776,7 @@ impl Pty {
)),
quit_cb,
self.default_editor.clone(),
&self.pane_env,
)
.with_context(err_context)
{
@@ -1792,7 +1803,12 @@ impl Pty {
.as_mut()
.context("no OS I/O interface found")
.with_context(err_context)?
.spawn_terminal(default_shell.clone(), quit_cb, self.default_editor.clone())
.spawn_terminal(
default_shell.clone(),
quit_cb,
self.default_editor.clone(),
&self.pane_env,
)
.with_context(err_context)
{
Ok((terminal_id, reader, child_pid)) => {
@@ -1923,7 +1939,12 @@ impl Pty {
.as_mut()
.context("no OS I/O interface found")
.and_then(|os_input| {
os_input.re_run_command_in_terminal(id, run_command, quit_cb)
os_input.re_run_command_in_terminal(
id,
run_command,
quit_cb,
&self.pane_env,
)
})
.with_context(err_context)?;
let activity_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
@@ -2007,7 +2028,7 @@ impl Pty {
terminal_ids_to_cwds.insert(terminal_id, cwd.clone());
}
}
session_layout_metadata.update_default_shell(get_default_shell());
session_layout_metadata.update_default_shell(default_shell_from(&self.pane_env));
session_layout_metadata.update_terminal_commands(terminal_ids_to_commands);
session_layout_metadata.update_terminal_cwds(terminal_ids_to_cwds);
session_layout_metadata.update_default_editor(&self.default_editor);
@@ -2211,9 +2232,11 @@ impl Pty {
&mut self,
default_editor: Option<PathBuf>,
post_command_discovery_hook: Option<String>,
pane_env: PaneEnv,
) {
self.default_editor = default_editor;
self.post_command_discovery_hook = post_command_discovery_hook;
self.pane_env = pane_env;
}
pub fn notify_cwd_from_osc7(&mut self, terminal_id: u32, path: PathBuf) {
@@ -2415,20 +2438,24 @@ fn send_command_not_found_to_screen(
Ok(())
}
#[cfg(not(windows))]
pub fn get_default_shell() -> PathBuf {
PathBuf::from(std::env::var("SHELL").unwrap_or_else(|_| {
default_shell_from(&PaneEnv::new())
}
#[cfg(not(windows))]
pub fn default_shell_from(pane_env: &PaneEnv) -> PathBuf {
PathBuf::from(env_value(pane_env, "SHELL").unwrap_or_else(|| {
log::warn!("Cannot read SHELL env, falling back to use /bin/sh");
"/bin/sh".to_string()
}))
}
#[cfg(windows)]
pub fn get_default_shell() -> PathBuf {
if let Ok(shell) = std::env::var("SHELL") {
pub fn default_shell_from(pane_env: &PaneEnv) -> PathBuf {
if let Some(shell) = env_value(pane_env, "SHELL") {
return PathBuf::from(shell);
}
PathBuf::from(std::env::var("COMSPEC").unwrap_or_else(|_| {
PathBuf::from(env_value(pane_env, "COMSPEC").unwrap_or_else(|| {
log::warn!("Cannot read SHELL or COMSPEC env, falling back to use cmd.exe");
"cmd.exe".to_string()
}))
+137 -26
View File
@@ -35,6 +35,10 @@ use zellij_utils::{
use crate::ClientId;
mod popup_keys;
pub(crate) use popup_keys::PopupKeyAction;
pub use popup_keys::PopupScroll;
const ACTION_COMPLETION_TIMEOUT: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
@@ -1171,6 +1175,21 @@ pub(crate) fn route_action(
))
.with_context(err_context)?;
},
Action::StartRenameTabByTabId { id } => {
senders
.send_to_screen(ScreenInstruction::StartRenameTabWithTabId(
id as usize,
client_id,
))
.with_context(err_context)?;
senders
.send_to_server(ServerInstruction::ChangeMode(
client_id,
InputMode::RenameTab,
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
},
Action::RenameTabById { id, name } => {
senders
.send_to_screen(ScreenInstruction::RenameTabWithId(
@@ -1234,6 +1253,22 @@ pub(crate) fn route_action(
))))
.with_context(err_context)?;
},
Action::DismissInfoPopups => {
senders
.send_to_screen(ScreenInstruction::DismissInfoPopups(
client_id,
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
},
Action::OpenContextMenu => {
senders
.send_to_screen(ScreenInstruction::OpenContextMenu(
client_id,
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
},
Action::SwitchSession {
name,
tab_position,
@@ -1695,6 +1730,8 @@ pub(crate) fn route_action(
skip_cache,
cwd,
pane_title,
popup,
popup_no_focus,
..
} => {
drop(completion_tx); // releasing pipes is handled by the plugins, so we don't want
@@ -1730,6 +1767,9 @@ pub(crate) fn route_action(
pane_title,
skip_cache,
cli_client_id: cli_client_id.unwrap_or(client_id),
caller_pane_id: pane_id,
popup,
popup_focused: !popup_no_focus,
})
.with_context(err_context)?;
} else {
@@ -2237,6 +2277,30 @@ pub(crate) fn route_action(
))
.with_context(err_context)?;
},
Action::TogglePaneInGroupByPaneId { pane_id } => {
senders
.send_to_screen(ScreenInstruction::TogglePaneIdInGroup(
pane_id.into(),
client_id,
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
},
Action::StartRenamePaneByPaneId { pane_id } => {
senders
.send_to_screen(ScreenInstruction::StartRenamePaneWithPaneId(
pane_id.into(),
client_id,
))
.with_context(err_context)?;
senders
.send_to_server(ServerInstruction::ChangeMode(
client_id,
InputMode::RenamePane,
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
},
}
let result = wait_for_action_completion(completion_rx, &action_name, wait_forever);
if let Some(error_message) = &result.error_message {
@@ -2436,44 +2500,91 @@ pub(crate) fn route_thread_main(
// see the doc comment on `route_action` for why this matters.
let dispatch_inputs =
session_data.read().unwrap().as_ref().and_then(|s| {
let in_passthrough =
let in_key_passthrough =
s.key_passthrough_clients.contains_key(&client_id);
if in_passthrough {
return Some((
s.senders.clone(),
s.default_shell.clone(),
s.session_configuration
.get_client_default_input_mode(&client_id),
vec![Action::Write {
key_with_modifier: Some(key),
bytes: raw_bytes,
is_kitty_keyboard_protocol,
}],
));
}
let has_focused_popup = s.popup_clients.contains(&client_id);
let (kb, im, dim) =
s.get_client_keybinds_and_mode(&client_id)?;
let actions: Vec<Action> = kb
.get_actions_for_key_in_mode_or_default_action(
im,
&key,
raw_bytes,
dim,
is_kitty_keyboard_protocol,
);
let key_actions = popup_keys::key_dispatch(
kb,
im,
dim,
&key,
raw_bytes,
is_kitty_keyboard_protocol,
in_key_passthrough,
has_focused_popup,
);
Some((
s.senders.clone(),
s.default_shell.clone(),
s.session_configuration
.get_client_default_input_mode(&client_id),
actions,
key_actions,
))
});
if let Some((senders, default_shell, client_input_mode, actions)) =
if let Some((senders, default_shell, client_input_mode, key_actions)) =
dispatch_inputs
{
for action in actions {
// Send user input to plugin thread for logging
for key_action in key_actions {
let action = match key_action {
PopupKeyAction::Route(action) => action,
PopupKeyAction::ClosePopup => {
let _ = senders.send_to_screen(
ScreenInstruction::CloseTopPopup(client_id),
);
continue;
},
PopupKeyAction::ScrollPopup(scroll) => {
let _ = senders.send_to_screen(
ScreenInstruction::ScrollPopup(client_id, scroll),
);
continue;
},
PopupKeyAction::KeyToPopup {
key: popup_key,
raw_bytes: popup_raw_bytes,
is_kitty_keyboard_protocol: popup_key_is_kitty,
} => {
let _ = senders.send_to_plugin(
PluginInstruction::UserInput {
client_id,
action: Action::Write {
key_with_modifier: Some(popup_key.clone()),
bytes: popup_raw_bytes.clone(),
is_kitty_keyboard_protocol:
popup_key_is_kitty,
},
terminal_id: None,
cli_client_id: None,
},
);
let _ = senders.send_to_plugin(
PluginInstruction::Update(vec![(
None,
Some(client_id),
Event::InputReceived,
)]),
);
let (completion_tx, completion_rx) =
oneshot::channel();
let _ = senders.send_to_screen(
ScreenInstruction::KeyToPopup(
popup_key,
popup_raw_bytes,
popup_key_is_kitty,
client_id,
Some(NotificationEnd::new(completion_tx)),
),
);
let _ = wait_for_action_completion(
completion_rx,
"KeyToPopup",
false,
);
continue;
},
};
let _ = senders.send_to_plugin(PluginInstruction::UserInput {
client_id,
action: action.clone(),
+545
View File
@@ -0,0 +1,545 @@
use zellij_utils::data::{BareKey, InputMode, KeyWithModifier};
use zellij_utils::input::actions::Action;
use zellij_utils::input::keybinds::Keybinds;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopupScroll {
Up(usize),
Down(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PopupActionRule {
ToPopup,
ScrollPopup(PopupScroll),
ClosePopup,
Underneath,
Ignore,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum PopupKeyAction {
Route(Action),
KeyToPopup {
key: KeyWithModifier,
raw_bytes: Vec<u8>,
is_kitty_keyboard_protocol: bool,
},
ScrollPopup(PopupScroll),
ClosePopup,
}
const POPUP_HALF_PAGE_LINES: usize = 5;
const POPUP_PAGE_LINES: usize = 10;
const POPUP_EDGE_LINES: usize = 1000;
fn is_text_entry_mode(input_mode: &InputMode) -> bool {
matches!(
input_mode,
InputMode::RenamePane | InputMode::RenameTab | InputMode::EnterSearch
)
}
pub(crate) fn popup_action_rule(action: &Action) -> PopupActionRule {
use PopupActionRule::*;
match action {
Action::Write { .. } | Action::WriteChars { .. } => ToPopup,
Action::ScrollUp | Action::ScrollUpAt { .. } => ScrollPopup(PopupScroll::Up(1)),
Action::ScrollDown | Action::ScrollDownAt { .. } => ScrollPopup(PopupScroll::Down(1)),
Action::HalfPageScrollUp => ScrollPopup(PopupScroll::Up(POPUP_HALF_PAGE_LINES)),
Action::HalfPageScrollDown => ScrollPopup(PopupScroll::Down(POPUP_HALF_PAGE_LINES)),
Action::PageScrollUp => ScrollPopup(PopupScroll::Up(POPUP_PAGE_LINES)),
Action::PageScrollDown => ScrollPopup(PopupScroll::Down(POPUP_PAGE_LINES)),
Action::ScrollToTop => ScrollPopup(PopupScroll::Up(POPUP_EDGE_LINES)),
Action::ScrollToBottom => ScrollPopup(PopupScroll::Down(POPUP_EDGE_LINES)),
Action::CloseFocus => ClosePopup,
Action::SearchInput { .. }
| Action::Search { .. }
| Action::SearchToggleOption { .. }
| Action::EditScrollback { .. }
| Action::ScrollToPreviousPrompt
| Action::ScrollToNextPrompt
| Action::SelectCommandAtScrollPosition
| Action::CopyLastCommandOutput
| Action::ClearScreen
| Action::Copy
| Action::Paste { pane_id: None, .. }
| Action::DumpScreen { pane_id: None, .. }
| Action::ToggleFocusFullscreen
| Action::ToggleFocusNoUiFullscreen
| Action::TogglePaneEmbedOrFloating
| Action::TogglePanePinned
| Action::Resize { .. }
| Action::MovePane { .. }
| Action::MovePaneBackwards
| Action::MoveTab { .. }
| Action::BreakPane
| Action::BreakPaneRight
| Action::BreakPaneLeft
| Action::PaneNameInput { .. }
| Action::UndoRenamePane
| Action::TabNameInput { .. }
| Action::UndoRenameTab
| Action::TogglePaneInGroup
| Action::ToggleGroupMarking => Ignore,
Action::SwitchToMode { input_mode } | Action::SwitchModeForAllClients { input_mode }
if is_text_entry_mode(input_mode) =>
{
Ignore
},
Action::SkipConfirm { action } => popup_action_rule(action),
_ => Underneath,
}
}
fn key_to_popup(
key: &KeyWithModifier,
raw_bytes: Vec<u8>,
is_kitty_keyboard_protocol: bool,
) -> PopupKeyAction {
PopupKeyAction::KeyToPopup {
key: key.clone(),
raw_bytes,
is_kitty_keyboard_protocol,
}
}
fn always_reaches_popup(key: &KeyWithModifier) -> bool {
key.has_no_modifiers() && matches!(key.bare_key, BareKey::Esc | BareKey::Enter)
}
pub(crate) fn key_actions_with_popup_open(
actions: Vec<Action>,
key: &KeyWithModifier,
raw_bytes: Vec<u8>,
is_kitty_keyboard_protocol: bool,
) -> Vec<PopupKeyAction> {
let mut key_sent_to_popup = false;
actions
.into_iter()
.filter_map(|action| match popup_action_rule(&action) {
PopupActionRule::ToPopup => {
if key_sent_to_popup {
None
} else {
key_sent_to_popup = true;
Some(key_to_popup(
key,
raw_bytes.clone(),
is_kitty_keyboard_protocol,
))
}
},
PopupActionRule::Underneath => Some(PopupKeyAction::Route(action)),
PopupActionRule::ScrollPopup(scroll) => Some(PopupKeyAction::ScrollPopup(scroll)),
PopupActionRule::ClosePopup => Some(PopupKeyAction::ClosePopup),
PopupActionRule::Ignore => None,
})
.collect()
}
pub(crate) fn actions_for_key(
keybinds: &Keybinds,
current_mode: &InputMode,
default_mode: InputMode,
key: &KeyWithModifier,
raw_bytes: Vec<u8>,
is_kitty_keyboard_protocol: bool,
in_key_passthrough: bool,
) -> Vec<Action> {
if in_key_passthrough {
return vec![Action::Write {
key_with_modifier: Some(key.clone()),
bytes: raw_bytes,
is_kitty_keyboard_protocol,
}];
}
keybinds.get_actions_for_key_in_mode_or_default_action(
current_mode,
key,
raw_bytes,
default_mode,
is_kitty_keyboard_protocol,
)
}
pub(crate) fn key_dispatch(
keybinds: &Keybinds,
current_mode: &InputMode,
default_mode: InputMode,
key: &KeyWithModifier,
raw_bytes: Vec<u8>,
is_kitty_keyboard_protocol: bool,
in_key_passthrough: bool,
has_focused_popup: bool,
) -> Vec<PopupKeyAction> {
if has_focused_popup && (in_key_passthrough || always_reaches_popup(key)) {
return vec![key_to_popup(key, raw_bytes, is_kitty_keyboard_protocol)];
}
let actions = actions_for_key(
keybinds,
current_mode,
default_mode,
key,
raw_bytes.clone(),
is_kitty_keyboard_protocol,
in_key_passthrough,
);
if has_focused_popup {
key_actions_with_popup_open(actions, key, raw_bytes, is_kitty_keyboard_protocol)
} else {
actions.into_iter().map(PopupKeyAction::Route).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn key(c: char) -> KeyWithModifier {
KeyWithModifier::new(BareKey::Char(c))
}
fn ctrl(c: char) -> KeyWithModifier {
KeyWithModifier::new(BareKey::Char(c)).with_ctrl_modifier()
}
fn switch_to(input_mode: InputMode) -> Action {
Action::SwitchToMode { input_mode }
}
fn keybinds() -> Keybinds {
let mut normal = HashMap::new();
normal.insert(ctrl('g'), vec![switch_to(InputMode::Locked)]);
normal.insert(ctrl('p'), vec![switch_to(InputMode::Pane)]);
normal.insert(ctrl('d'), vec![Action::Detach]);
let mut locked = HashMap::new();
locked.insert(ctrl('g'), vec![switch_to(InputMode::Normal)]);
let mut pane = HashMap::new();
pane.insert(
key('f'),
vec![Action::ToggleFocusFullscreen, switch_to(InputMode::Normal)],
);
pane.insert(
key('x'),
vec![Action::CloseFocus, switch_to(InputMode::Normal)],
);
pane.insert(
key('c'),
vec![
switch_to(InputMode::RenamePane),
Action::PaneNameInput { input: vec![0] },
],
);
pane.insert(
key('h'),
vec![Action::MoveFocus {
direction: zellij_utils::data::Direction::Left,
}],
);
let mut map = HashMap::new();
map.insert(InputMode::Normal, normal);
map.insert(InputMode::Locked, locked);
map.insert(InputMode::Pane, pane);
Keybinds(map)
}
fn dispatch(mode: InputMode, key: KeyWithModifier) -> Vec<PopupKeyAction> {
key_dispatch(
&keybinds(),
&mode,
InputMode::Normal,
&key,
vec![],
false,
false,
true,
)
}
fn write(key: KeyWithModifier) -> PopupKeyAction {
PopupKeyAction::Route(Action::Write {
key_with_modifier: Some(key),
bytes: vec![],
is_kitty_keyboard_protocol: false,
})
}
fn to_popup(key: KeyWithModifier) -> PopupKeyAction {
PopupKeyAction::KeyToPopup {
key,
raw_bytes: vec![],
is_kitty_keyboard_protocol: false,
}
}
fn keybinds_with_esc_and_enter_bound() -> Keybinds {
let esc = KeyWithModifier::new(BareKey::Esc);
let enter = KeyWithModifier::new(BareKey::Enter);
let alt_enter = KeyWithModifier::new(BareKey::Enter).with_alt_modifier();
let mut keybinds = keybinds();
let normal = keybinds.0.get_mut(&InputMode::Normal).unwrap();
normal.insert(esc.clone(), vec![switch_to(InputMode::Locked)]);
normal.insert(enter.clone(), vec![switch_to(InputMode::Locked)]);
normal.insert(alt_enter, vec![Action::Detach]);
let pane = keybinds.0.get_mut(&InputMode::Pane).unwrap();
pane.insert(esc, vec![switch_to(InputMode::Normal)]);
pane.insert(enter, vec![switch_to(InputMode::Normal)]);
keybinds
}
fn dispatch_with(
keybinds: &Keybinds,
mode: InputMode,
key: KeyWithModifier,
has_focused_popup: bool,
) -> Vec<PopupKeyAction> {
key_dispatch(
keybinds,
&mode,
InputMode::Normal,
&key,
vec![],
false,
false,
has_focused_popup,
)
}
#[test]
fn bare_esc_and_enter_always_reach_the_popup_even_when_bound() {
let keybinds = keybinds_with_esc_and_enter_bound();
let esc = KeyWithModifier::new(BareKey::Esc);
let enter = KeyWithModifier::new(BareKey::Enter);
for mode in [InputMode::Normal, InputMode::Pane] {
assert_eq!(
dispatch_with(&keybinds, mode, esc.clone(), true),
vec![to_popup(esc.clone())],
"{:?}",
mode
);
assert_eq!(
dispatch_with(&keybinds, mode, enter.clone(), true),
vec![to_popup(enter.clone())],
"{:?}",
mode
);
}
}
#[test]
fn modified_enter_and_other_bound_keys_keep_their_binding_while_a_popup_is_open() {
let keybinds = keybinds_with_esc_and_enter_bound();
let alt_enter = KeyWithModifier::new(BareKey::Enter).with_alt_modifier();
assert_eq!(
dispatch_with(&keybinds, InputMode::Normal, alt_enter, true),
vec![PopupKeyAction::Route(Action::Detach)]
);
assert_eq!(
dispatch_with(&keybinds, InputMode::Normal, ctrl('p'), true),
vec![PopupKeyAction::Route(switch_to(InputMode::Pane))]
);
}
#[test]
fn bound_esc_and_enter_run_their_action_without_a_popup() {
let keybinds = keybinds_with_esc_and_enter_bound();
let esc = KeyWithModifier::new(BareKey::Esc);
let enter = KeyWithModifier::new(BareKey::Enter);
assert_eq!(
dispatch_with(&keybinds, InputMode::Pane, esc, false),
vec![PopupKeyAction::Route(switch_to(InputMode::Normal))]
);
assert_eq!(
dispatch_with(&keybinds, InputMode::Normal, enter, false),
vec![PopupKeyAction::Route(switch_to(InputMode::Locked))]
);
}
#[test]
fn a_key_bound_to_several_writes_reaches_the_popup_once() {
let mut keybinds = keybinds();
keybinds.0.get_mut(&InputMode::Normal).unwrap().insert(
ctrl('w'),
vec![
Action::WriteChars { chars: "a".into() },
Action::WriteChars { chars: "b".into() },
],
);
assert_eq!(
dispatch_with(&keybinds, InputMode::Normal, ctrl('w'), true),
vec![to_popup(ctrl('w'))]
);
}
#[test]
fn bound_keys_run_their_action_while_a_popup_is_open() {
assert_eq!(
dispatch(InputMode::Normal, ctrl('g')),
vec![PopupKeyAction::Route(switch_to(InputMode::Locked))]
);
assert_eq!(
dispatch(InputMode::Normal, ctrl('d')),
vec![PopupKeyAction::Route(Action::Detach)]
);
assert_eq!(
dispatch(InputMode::Pane, key('h')),
vec![PopupKeyAction::Route(Action::MoveFocus {
direction: zellij_utils::data::Direction::Left
})]
);
}
#[test]
fn unbound_keys_in_the_base_mode_reach_the_popup() {
assert_eq!(
dispatch(InputMode::Normal, key('a')),
vec![to_popup(key('a'))]
);
let esc = KeyWithModifier::new(BareKey::Esc);
assert_eq!(
dispatch(InputMode::Normal, esc.clone()),
vec![to_popup(esc)]
);
}
#[test]
fn locked_mode_sends_every_key_but_the_unlock_key_to_the_popup() {
assert_eq!(
dispatch(InputMode::Locked, ctrl('p')),
vec![to_popup(ctrl('p'))]
);
assert_eq!(
dispatch(InputMode::Locked, key('x')),
vec![to_popup(key('x'))]
);
assert_eq!(
dispatch(InputMode::Locked, ctrl('g')),
vec![PopupKeyAction::Route(switch_to(InputMode::Normal))]
);
}
#[test]
fn unbound_keys_in_a_non_base_mode_do_not_reach_the_popup() {
assert_eq!(
dispatch(InputMode::Pane, key('q')),
vec![PopupKeyAction::Route(Action::NoOp)]
);
}
#[test]
fn close_pane_closes_the_popup_and_pane_only_actions_are_dropped() {
assert_eq!(
dispatch(InputMode::Pane, key('x')),
vec![
PopupKeyAction::ClosePopup,
PopupKeyAction::Route(switch_to(InputMode::Normal))
]
);
assert_eq!(
dispatch(InputMode::Pane, key('f')),
vec![PopupKeyAction::Route(switch_to(InputMode::Normal))]
);
assert_eq!(dispatch(InputMode::Pane, key('c')), vec![]);
}
#[test]
fn key_passthrough_sends_bound_keys_to_the_popup() {
let actions = key_dispatch(
&keybinds(),
&InputMode::Normal,
InputMode::Normal,
&ctrl('g'),
vec![],
false,
true,
true,
);
assert_eq!(actions, vec![to_popup(ctrl('g'))]);
let actions = key_dispatch(
&keybinds(),
&InputMode::Normal,
InputMode::Normal,
&ctrl('g'),
vec![],
false,
true,
false,
);
assert_eq!(actions, vec![write(ctrl('g'))]);
}
#[test]
fn without_a_popup_every_action_is_routed_as_is() {
let actions = key_dispatch(
&keybinds(),
&InputMode::Pane,
InputMode::Normal,
&key('f'),
vec![],
false,
false,
false,
);
assert_eq!(
actions,
vec![
PopupKeyAction::Route(Action::ToggleFocusFullscreen),
PopupKeyAction::Route(switch_to(InputMode::Normal))
]
);
}
#[test]
fn action_rules_while_a_popup_is_open() {
use PopupActionRule::*;
let cases = vec![
(Action::WriteChars { chars: "a".into() }, ToPopup),
(Action::ScrollUp, ScrollPopup(PopupScroll::Up(1))),
(
Action::PageScrollDown,
ScrollPopup(PopupScroll::Down(POPUP_PAGE_LINES)),
),
(Action::SearchInput { input: vec![1] }, Ignore),
(Action::CloseFocus, ClosePopup),
(Action::GoToNextTab, Underneath),
(
Action::NewPane {
direction: None,
pane_name: None,
start_suppressed: false,
},
Underneath,
),
(Action::Detach, Underneath),
(Action::Quit, Underneath),
(Action::FocusNextPane, Underneath),
(switch_to(InputMode::Pane), Underneath),
(switch_to(InputMode::RenameTab), Ignore),
(switch_to(InputMode::EnterSearch), Ignore),
(Action::ToggleFocusFullscreen, Ignore),
(Action::TogglePaneEmbedOrFloating, Ignore),
(Action::TogglePanePinned, Ignore),
(
Action::Resize {
resize: zellij_utils::data::Resize::Increase,
direction: None,
},
Ignore,
),
(Action::MovePane { direction: None }, Ignore),
(Action::TogglePaneInGroup, Ignore),
(Action::TabNameInput { input: vec![0] }, Ignore),
(
Action::SkipConfirm {
action: Box::new(Action::CloseFocus),
},
ClosePopup,
),
];
for (action, rule) in cases {
assert_eq!(popup_action_rule(&action), rule, "{:?}", action);
}
}
}
+481 -9
View File
@@ -40,20 +40,24 @@ use std::time::{Duration, Instant};
use crate::route::NotificationEnd;
use crate::SharedKeybinds;
mod popups;
use log::{debug, warn};
use zellij_utils::data::{
BorderStyle, BorderStyleOverride, CommandOrPlugin, Direction, EventType,
FloatingPaneCoordinates, GetFocusedPaneInfoResponse, HostTerminalThemeMode, KeyWithModifier,
KeybindsVec, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse,
NestedSessionEndReason, NestedSessionKeybinds, NestedSessionKeybindsError,
NestedSessionKeybindsResponse, NewPanePlacement, PaneContents, PaneInfo, PaneListEntry,
PaneManifest, PaneRenderReport, PaneScrollbackResponse, PluginPermission, RegexHighlight,
Resize, ResizeStrategy, SessionInfo, Styling, TabInfo, ThemeHue, WebSharing,
BorderStyle, BorderStyleOverride, CommandOrPlugin, ContextMenuContext, ContextMenuEntry,
ContextMenuTarget, Direction, EventType, FloatingPaneCoordinates, GetFocusedPaneInfoResponse,
HostTerminalThemeMode, KeyWithModifier, KeybindsVec, LayoutInfo, LayoutWithError,
ListPanesResponse, ListTabsResponse, NestedSessionEndReason, NestedSessionKeybinds,
NestedSessionKeybindsError, NestedSessionKeybindsResponse, NewPanePlacement, PaneContents,
PaneInfo, PaneListEntry, PaneManifest, PaneRenderReport, PaneScrollbackResponse,
PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo,
ThemeHue, WebSharing,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::actions::Action;
use zellij_utils::input::command::RunCommand;
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::options::{
@@ -397,6 +401,13 @@ pub enum ScreenInstruction {
Option<NotificationEnd>,
), // bool ->
// is_kitty_keyboard_protocol
KeyToPopup(
KeyWithModifier,
Vec<u8>,
bool,
ClientId,
Option<NotificationEnd>,
),
Resize(ClientId, ResizeStrategy, Option<NotificationEnd>),
SwitchFocus(ClientId, Option<NotificationEnd>),
FocusNextPane(ClientId, Option<NotificationEnd>),
@@ -839,6 +850,7 @@ pub enum ScreenInstruction {
visual_bell: bool,
focus_follows_mouse: bool,
mouse_click_through: bool,
context_menu_enabled: bool,
osc133_command_selection: bool,
word_separators: String,
host_notification_protocol: HostNotificationProtocol,
@@ -920,6 +932,68 @@ pub enum ScreenInstruction {
SetMouseSelectionSupport(PaneId, bool),
InterceptKeyPresses(PluginId, ClientId),
ClearKeyPressesIntercepts(ClientId),
TogglePaneIdInGroup(PaneId, ClientId, Option<NotificationEnd>),
StartRenamePaneWithPaneId(PaneId, ClientId),
StartRenameTabWithTabId(usize, ClientId),
OpenContextMenuFromPlugin {
plugin_id: u32,
client_id: ClientId,
target: ContextMenuTarget,
line: usize,
column: usize,
},
OpenPluginPopup {
requesting_plugin_id: u32,
client_id: ClientId,
run_plugin_or_alias: RunPluginOrAlias,
line: usize,
column: usize,
width: usize,
height: usize,
options: zellij_utils::data::PopupOptions,
},
AddPopup {
plugin_id: u32,
client_id: ClientId,
tab_id: usize,
run_plugin_or_alias: RunPluginOrAlias,
placement: crate::tab::PopupPlacement,
kind: crate::tab::PopupKind,
width: usize,
height: usize,
anchor_pane: Option<PaneId>,
},
OpenPipePopup {
pipe_id: String,
run_plugin_or_alias: RunPluginOrAlias,
caller_pane_id: Option<PaneId>,
placement: zellij_utils::data::PipePopupPlacement,
focused: bool,
},
OpenPromptPopup {
caller: crate::plugins::PromptCaller,
owner_client_id: ClientId,
caller_pane_id: Option<PaneId>,
run_plugin_or_alias: RunPluginOrAlias,
placement: Option<zellij_utils::prompt::PromptPlacement>,
focused: bool,
},
CloseTopPopup(ClientId),
ScrollPopup(ClientId, crate::route::PopupScroll),
DismissInfoPopups(ClientId, Option<NotificationEnd>),
OpenContextMenu(ClientId, Option<NotificationEnd>),
SetPopupSize {
plugin_id: u32,
width: usize,
height: usize,
},
UpdateContextMenuConfig(ClientId, ContextMenuConfig),
GetContextMenuItemActions {
plugin_id: u32,
client_id: ClientId,
index: usize,
response_channel: crossbeam::channel::Sender<Vec<Action>>,
},
ReplacePaneWithExistingPane(PaneId, PaneId, bool, Option<NotificationEnd>), // bool -> suppress_replaced_pane
AddWatcherClient(ClientId, Size),
RemoveWatcherClient(ClientId),
@@ -1008,6 +1082,7 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenContext::AreFloatingPanesVisible
},
ScreenInstruction::WriteCharacter(..) => ScreenContext::WriteCharacter,
ScreenInstruction::KeyToPopup(..) => ScreenContext::KeyToPopup,
ScreenInstruction::Resize(.., strategy, _) => match strategy {
ResizeStrategy {
resize: Resize::Increase,
@@ -1305,6 +1380,31 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::ClearKeyPressesIntercepts(..) => {
ScreenContext::ClearKeyPressesIntercepts
},
ScreenInstruction::TogglePaneIdInGroup(..) => ScreenContext::TogglePaneIdInGroup,
ScreenInstruction::StartRenamePaneWithPaneId(..) => {
ScreenContext::StartRenamePaneWithPaneId
},
ScreenInstruction::StartRenameTabWithTabId(..) => {
ScreenContext::StartRenameTabWithTabId
},
ScreenInstruction::OpenContextMenuFromPlugin { .. } => {
ScreenContext::OpenContextMenuFromPlugin
},
ScreenInstruction::OpenPluginPopup { .. } => ScreenContext::OpenPluginPopup,
ScreenInstruction::AddPopup { .. } => ScreenContext::AddPopup,
ScreenInstruction::OpenPipePopup { .. } => ScreenContext::OpenPipePopup,
ScreenInstruction::OpenPromptPopup { .. } => ScreenContext::OpenPromptPopup,
ScreenInstruction::SetPopupSize { .. } => ScreenContext::SetPopupSize,
ScreenInstruction::CloseTopPopup(..) => ScreenContext::CloseTopPopup,
ScreenInstruction::ScrollPopup(..) => ScreenContext::ScrollPopup,
ScreenInstruction::DismissInfoPopups(..) => ScreenContext::DismissInfoPopups,
ScreenInstruction::OpenContextMenu(..) => ScreenContext::OpenContextMenu,
ScreenInstruction::UpdateContextMenuConfig(..) => {
ScreenContext::UpdateContextMenuConfig
},
ScreenInstruction::GetContextMenuItemActions { .. } => {
ScreenContext::GetContextMenuItemActions
},
ScreenInstruction::ReplacePaneWithExistingPane(..) => {
ScreenContext::ReplacePaneWithExistingPane
},
@@ -1609,6 +1709,13 @@ pub(crate) struct Screen {
default_mode_info: ModeInfo, // TODO: restructure ModeInfo to prevent this duplication
default_keybinds: SharedKeybinds,
client_keybinds: BTreeMap<ClientId, SharedKeybinds>,
default_context_menu_config: ContextMenuConfig,
context_menu_configs: HashMap<ClientId, ContextMenuConfig>,
open_context_menus: HashMap<ClientId, (ContextMenuContext, Vec<ContextMenuEntry>)>,
last_client_input: HashMap<ClientId, std::time::Instant>,
last_mouse_positions: HashMap<ClientId, Position>,
rename_pane_targets: HashMap<ClientId, PaneId>,
rename_tab_targets: HashMap<ClientId, usize>,
style: Style,
pane_frame_style: PaneFrameStyle,
auto_layout: bool,
@@ -1648,6 +1755,7 @@ pub(crate) struct Screen {
visual_bell: bool,
focus_follows_mouse: bool,
mouse_click_through: bool,
context_menu_enabled: bool,
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
// the below are the configured values - the ones that will be set if and when the web server
// is brought online
@@ -1843,6 +1951,13 @@ impl Screen {
mode_info: BTreeMap::new(),
default_keybinds: Arc::new(std::mem::take(&mut mode_info.keybinds)),
client_keybinds: BTreeMap::new(),
default_context_menu_config: ContextMenuConfig::default(),
context_menu_configs: HashMap::new(),
open_context_menus: HashMap::new(),
last_client_input: HashMap::new(),
last_mouse_positions: HashMap::new(),
rename_pane_targets: HashMap::new(),
rename_tab_targets: HashMap::new(),
default_mode_info: mode_info,
pane_frame_style,
auto_layout,
@@ -1879,6 +1994,7 @@ impl Screen {
visual_bell,
focus_follows_mouse,
mouse_click_through,
context_menu_enabled: true,
web_server_ip,
web_server_port,
render_blocker: RenderBlocker::new(100),
@@ -2129,6 +2245,15 @@ impl Screen {
}
fn update_client_tab_focus(&mut self, client_id: ClientId, new_tab_index: usize) {
match self.active_tab_ids.get(&client_id).copied() {
Some(old_tab_index) if old_tab_index != new_tab_index => {
self.move_popups_to_new_tab(client_id, old_tab_index, new_tab_index);
},
None => {
self.close_popup_for_client(client_id);
},
_ => {},
}
match self.active_tab_ids.remove(&client_id) {
Some(old_active_index) => {
self.active_tab_ids.insert(client_id, new_tab_index);
@@ -2363,6 +2488,8 @@ impl Screen {
let err_context = || format!("failed to close tab at index {tab_id:?}");
let mut tab_to_close = self.tabs.remove(&tab_id).with_context(err_context)?;
let popups_of_closed_tab = tab_to_close.drain_popups();
self.close_popups_of_closed_tab(popups_of_closed_tab);
let mut pane_ids = tab_to_close.get_all_pane_ids();
// here we extract the suppressed panes (these are background panes that don't care which
@@ -5412,6 +5539,7 @@ impl Screen {
tab.update_sixel_host_support(aggregate);
}
tab.update_selection_options(self.osc133_command_selection, self.word_separators.clone());
tab.update_context_menu_enabled(self.context_menu_enabled);
self.tabs.insert(tab_id, tab);
Ok(())
}
@@ -5701,6 +5829,12 @@ impl Screen {
self.highest_client_id_seen = self.highest_client_id_seen.max(client_id);
self.set_client_dimmed(client_id, false, None);
self.close_popup_for_client(client_id);
self.rename_pane_targets.remove(&client_id);
self.rename_tab_targets.remove(&client_id);
self.context_menu_configs.remove(&client_id);
self.last_client_input.remove(&client_id);
self.last_mouse_positions.remove(&client_id);
let passthrough_panes: Vec<PaneId> = self
.nested_guest_choices
.iter()
@@ -6454,9 +6588,93 @@ impl Screen {
self.cached_layout_errors = errors;
}
pub fn start_rename_pane_with_pane_id(&mut self, pane_id: PaneId, client_id: ClientId) {
let already_focused = self.get_active_pane_id(&client_id) == Some(pane_id);
if !already_focused {
self.focus_pane_with_id(pane_id, true, false, client_id)
.non_fatal();
}
let mut found = false;
for tab in self.tabs.values_mut() {
if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
pane.store_pane_name();
pane.update_name("\0");
pane.set_should_render(true);
found = true;
break;
}
}
if found {
self.rename_pane_targets.insert(client_id, pane_id);
self.log_and_report_session_state().non_fatal();
}
}
pub fn start_rename_tab_with_tab_id(&mut self, tab_id: usize, client_id: ClientId) {
if let Some(tab) = self.tabs.get_mut(&tab_id) {
tab.prev_name = tab.name.clone();
tab.name = String::new();
self.rename_tab_targets.insert(client_id, tab_id);
self.log_and_report_session_state().non_fatal();
}
}
fn update_rename_target_pane_name(&mut self, buf: Vec<u8>, client_id: ClientId) -> bool {
let Some(pane_id) = self.rename_pane_targets.get(&client_id).copied() else {
return false;
};
let Ok(s) = str::from_utf8(&buf) else {
return true;
};
let to_update = match s {
"\0" | "\u{007F}" | "\u{0008}" => s.to_owned(),
_ => clean_string_from_control_and_linebreak(s).to_string(),
};
for tab in self.tabs.values_mut() {
if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
pane.update_name(&to_update);
pane.set_should_render(true);
break;
}
}
true
}
fn undo_rename_of_target_pane(&mut self, client_id: ClientId) -> bool {
let Some(pane_id) = self.rename_pane_targets.get(&client_id).copied() else {
return false;
};
for tab in self.tabs.values_mut() {
if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
pane.load_pane_name();
pane.set_should_render(true);
break;
}
}
true
}
pub fn update_active_tab_name(&mut self, buf: Vec<u8>, client_id: ClientId) -> Result<()> {
let err_context =
|| format!("failed to update active tabs name for client id: {client_id:?}");
if let Some(tab_id) = self.rename_tab_targets.get(&client_id).copied() {
let s = str::from_utf8(&buf)
.with_context(|| format!("failed to construct tab name from buf: {buf:?}"))
.with_context(err_context)?;
if let Some(tab) = self.tabs.get_mut(&tab_id) {
match s {
"\0" => {
tab.name = String::new();
},
"\u{007F}" | "\u{0008}" => {
tab.name.pop();
},
c => {
tab.name
.push_str(&clean_string_from_control_and_linebreak(c));
},
}
}
return self
.log_and_report_session_state()
.with_context(err_context);
}
let client_id = if self.get_active_tab(client_id).is_ok() {
Some(client_id)
@@ -6499,6 +6717,16 @@ impl Screen {
}
pub fn undo_active_rename_tab(&mut self, client_id: ClientId) -> Result<()> {
let err_context = || format!("failed to undo active tab rename for client {}", client_id);
if let Some(tab_id) = self.rename_tab_targets.get(&client_id).copied() {
if let Some(tab) = self.tabs.get_mut(&tab_id) {
if tab.name != tab.prev_name {
tab.name = tab.prev_name.clone();
}
}
return self
.log_and_report_session_state()
.context("failed to undo renaming of target tab");
}
let client_id = if self.get_active_tab(client_id).is_ok() {
Some(client_id)
@@ -6711,13 +6939,24 @@ impl Screen {
active_tab!(self, client_id, |tab: &mut Tab| tab.clear_search(client_id));
}
if mode_info.mode == InputMode::RenameTab {
if mode_info.mode != InputMode::RenameTab {
self.rename_tab_targets.remove(&client_id);
}
if mode_info.mode != InputMode::RenamePane {
self.rename_pane_targets.remove(&client_id);
}
if mode_info.mode == InputMode::RenameTab
&& !self.rename_tab_targets.contains_key(&client_id)
{
if let Ok(active_tab) = self.get_active_tab_mut(client_id) {
active_tab.prev_name = active_tab.name.clone();
}
}
if mode_info.mode == InputMode::RenamePane {
if mode_info.mode == InputMode::RenamePane
&& !self.rename_pane_targets.contains_key(&client_id)
{
if let Ok(active_tab) = self.get_active_tab_mut(client_id) {
if let Some(active_pane) =
active_tab.get_active_pane_or_floating_pane_mut(client_id)
@@ -6885,6 +7124,11 @@ impl Screen {
if let Some(active_tab_id) = self.active_tab_ids.get(&client_id) {
if let Some(tab) = self.tabs.get(active_tab_id) {
plugin_ids.extend(tab.get_plugin_ids_including_suppressed());
for popup_plugin_id in tab.popup_plugin_ids_for_client(client_id) {
if !plugin_ids.contains(&popup_plugin_id) {
plugin_ids.push(popup_plugin_id);
}
}
}
}
// Background plugins subscribed to this event type
@@ -8273,6 +8517,13 @@ impl Screen {
false
}
pub fn handle_mouse_event(&mut self, event: MouseEvent, client_id: ClientId) {
self.record_mouse_position(client_id, event.position);
if event.event_type == MouseEventType::Press {
self.record_client_input(client_id);
}
if self.handle_popup_mouse_event(&event, client_id) {
return;
}
let is_bare_motion = event.event_type == MouseEventType::Motion
&& !event.left
&& !event.right
@@ -8309,6 +8560,9 @@ impl Screen {
should_render = true;
}
}
if let Some(request) = mouse_effect.open_context_menu {
self.open_context_menu_for_pane(request, client_id);
}
if mouse_effect.state_changed {
if !is_bare_motion {
let _ = self.log_and_report_session_state();
@@ -8549,6 +8803,9 @@ impl Screen {
pid: u32,
loading_indication: LoadingIndication,
) -> bool {
if loading_indication.is_error() && self.popup_plugin_failed(pid) {
return true;
}
let all_tabs = self.get_tabs_mut();
let mut found_plugin = false;
for tab in all_tabs.values_mut() {
@@ -9214,6 +9471,8 @@ pub(crate) fn screen_thread_main(
nested_session_handling,
);
screen.default_keybinds = default_keybinds;
screen.default_context_menu_config = config.context_menu.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;
screen.paste_buffer_read_enabled = dangerously_enable_paste_buffer_read;
@@ -9252,15 +9511,29 @@ pub(crate) fn screen_thread_main(
.insert(PaneId::Terminal(pid), Instant::now());
let all_tabs = screen.get_tabs_mut();
let mut vte_bytes = Some(vte_bytes);
let mut title_changed = false;
for tab in all_tabs.values_mut() {
if tab.has_terminal_pid(pid) {
if let Some(bytes) = vte_bytes.take() {
let title_before = tab
.get_pane_with_id(PaneId::Terminal(pid))
.map(|pane| pane.current_title());
tab.handle_pty_bytes(pid, bytes)
.context("failed to process pty bytes")?;
let title_after = tab
.get_pane_with_id(PaneId::Terminal(pid))
.map(|pane| pane.current_title());
title_changed = title_before != title_after;
}
break;
}
}
if title_changed {
screen
.generate_and_report_pane_state()
.map(|_| ())
.non_fatal();
}
if let Some(vte_bytes) = vte_bytes {
if !screen.pane_will_never_be_created(&PaneId::Terminal(pid)) {
pending_events_waiting_for_pane
@@ -9600,6 +9873,39 @@ pub(crate) fn screen_thread_main(
} => {
screen.are_floating_panes_visible_in_tab(client_id, tab_id, completion)?;
},
ScreenInstruction::KeyToPopup(
key_with_modifier,
raw_bytes,
is_kitty_keyboard_protocol,
client_id,
completion,
) => {
screen.record_client_input(client_id);
if screen.send_key_to_popup(
client_id,
key_with_modifier.clone(),
raw_bytes.clone(),
is_kitty_keyboard_protocol,
) {
drop(completion);
screen.render(None)?;
} else {
let _ = screen
.bus
.senders
.send_to_screen(ScreenInstruction::ClearScroll(client_id));
let _ = screen
.bus
.senders
.send_to_screen(ScreenInstruction::WriteCharacter(
Some(key_with_modifier),
raw_bytes,
is_kitty_keyboard_protocol,
client_id,
completion,
));
}
},
ScreenInstruction::WriteCharacter(
key_with_modifier,
raw_bytes,
@@ -9608,6 +9914,7 @@ pub(crate) fn screen_thread_main(
_completion_tx, // the action ends here, dropping this will release anything
// waiting for it
) => {
screen.record_client_input(client_id);
if let Some(plugin_id) = keybind_intercepts.get(&client_id) {
if let Some(key_with_modifier) = key_with_modifier {
let _ = screen
@@ -9631,6 +9938,15 @@ pub(crate) fn screen_thread_main(
state_changed = true;
}
},
Some(InputMode::RenamePane)
if screen.rename_pane_targets.contains_key(&client_id) =>
{
if !(raw_bytes == BRACKETED_PASTE_BEGIN || raw_bytes == BRACKETED_PASTE_END)
{
screen.update_rename_target_pane_name(raw_bytes, client_id);
state_changed = true;
}
},
_ => {
active_tab_and_connected_client_id!(
screen,
@@ -10589,6 +10905,7 @@ pub(crate) fn screen_thread_main(
if let (Some(old), Some(new)) = (old_pane_id, new_pane_id) {
screen.report_key_passthrough_state(client_id, old, new);
}
screen.close_popups_with_missing_anchor();
screen.sync_scroll_mode_on_focus(client_id)?;
screen.render(None)?;
screen.log_and_report_session_state()?;
@@ -10656,6 +10973,12 @@ pub(crate) fn screen_thread_main(
// waiting for it
exit_status,
) => {
if let PaneId::Plugin(plugin_id) = id {
if screen.close_popup_with_plugin_id(plugin_id) {
screen.render(None)?;
continue;
}
}
match client_id {
Some(client_id) => {
active_tab!(screen, client_id, |tab: &mut Tab| tab.close_pane(
@@ -10686,6 +11009,7 @@ pub(crate) fn screen_thread_main(
}
screen.clear_nested_guest(id);
screen.close_popups_with_missing_anchor();
// Clean up PTY-side resources (async reader task, child PID mapping,
// terminal_id_to_raw_fd entry). This is needed because the natural
@@ -10731,6 +11055,11 @@ pub(crate) fn screen_thread_main(
_completion_tx, // the action ends here, dropping this will release anything
// waiting for it
) => {
if screen.update_rename_target_pane_name(c.clone(), client_id) {
screen.render(None)?;
screen.log_and_report_session_state()?;
continue;
}
active_tab_and_connected_client_id!(
screen,
client_id,
@@ -10744,6 +11073,10 @@ pub(crate) fn screen_thread_main(
_completion_tx, // the action ends here, dropping this will release anything
// waiting for it
) => {
if screen.undo_rename_of_target_pane(client_id) {
screen.render(None)?;
continue;
}
active_tab_and_connected_client_id!(
screen,
client_id,
@@ -12784,12 +13117,14 @@ pub(crate) fn screen_thread_main(
visual_bell,
focus_follows_mouse,
mouse_click_through,
context_menu_enabled,
osc133_command_selection,
word_separators,
host_notification_protocol,
nested_session_handling,
dangerously_enable_paste_buffer_read,
} => {
screen.update_context_menu_enabled(context_menu_enabled);
screen.host_theme_dark_styling = host_theme_dark;
screen.host_theme_light_styling = host_theme_light;
screen
@@ -13401,6 +13736,143 @@ pub(crate) fn screen_thread_main(
ScreenInstruction::InterceptKeyPresses(plugin_id, client_id) => {
keybind_intercepts.insert(client_id, plugin_id);
},
ScreenInstruction::TogglePaneIdInGroup(pane_id, client_id, _completion_tx) => {
screen.toggle_pane_id_in_group(pane_id, &client_id);
screen.log_and_report_session_state().non_fatal();
screen.render(None)?;
},
ScreenInstruction::StartRenamePaneWithPaneId(pane_id, client_id) => {
screen.start_rename_pane_with_pane_id(pane_id, client_id);
screen.render(None)?;
},
ScreenInstruction::StartRenameTabWithTabId(tab_id, client_id) => {
screen.start_rename_tab_with_tab_id(tab_id, client_id);
screen.render(None)?;
},
ScreenInstruction::OpenContextMenuFromPlugin {
plugin_id,
client_id,
target,
line,
column,
} => {
screen.open_context_menu_from_plugin(plugin_id, client_id, target, line, column);
},
ScreenInstruction::OpenPluginPopup {
requesting_plugin_id,
client_id,
run_plugin_or_alias,
line,
column,
width,
height,
options,
} => {
screen.open_plugin_popup_for_plugin(
requesting_plugin_id,
client_id,
run_plugin_or_alias,
line,
column,
width,
height,
options,
);
},
ScreenInstruction::AddPopup {
plugin_id,
client_id,
tab_id,
run_plugin_or_alias,
placement,
kind,
width,
height,
anchor_pane,
} => {
screen.add_popup(
plugin_id,
client_id,
tab_id,
run_plugin_or_alias,
placement,
kind,
width,
height,
anchor_pane,
)?;
if let Some(loading_indication) = plugin_loading_message_cache.remove(&plugin_id) {
screen.update_plugin_loading_stage(plugin_id, loading_indication);
screen.render(None)?;
}
},
ScreenInstruction::OpenPipePopup {
pipe_id,
run_plugin_or_alias,
caller_pane_id,
placement,
focused,
} => {
screen.open_pipe_popup(
pipe_id,
run_plugin_or_alias,
caller_pane_id,
placement,
focused,
);
},
ScreenInstruction::OpenPromptPopup {
caller,
owner_client_id,
caller_pane_id,
run_plugin_or_alias,
placement,
focused,
} => {
screen.open_prompt_popup(
caller,
owner_client_id,
caller_pane_id,
run_plugin_or_alias,
placement,
focused,
);
},
ScreenInstruction::CloseTopPopup(client_id) => {
if screen.close_top_popup_for_client(client_id) {
screen.render(None)?;
}
},
ScreenInstruction::ScrollPopup(client_id, scroll) => {
screen.scroll_popup(client_id, scroll);
},
ScreenInstruction::DismissInfoPopups(client_id, _completion_tx) => {
if screen.dismiss_info_popups(client_id) {
screen.render(None)?;
}
},
ScreenInstruction::OpenContextMenu(client_id, _completion_tx) => {
screen.open_context_menu_for_focused_pane(client_id);
},
ScreenInstruction::SetPopupSize {
plugin_id,
width,
height,
} => {
screen.set_popup_size(plugin_id, width, height)?;
},
ScreenInstruction::UpdateContextMenuConfig(client_id, context_menu_config) => {
screen.update_context_menu_config(client_id, context_menu_config);
},
ScreenInstruction::GetContextMenuItemActions {
plugin_id,
client_id,
index,
response_channel,
} => {
let actions = screen.context_menu_item_actions(plugin_id, client_id, index);
let _ = response_channel.send(actions);
},
ScreenInstruction::ClearKeyPressesIntercepts(client_id) => {
keybind_intercepts.remove(&client_id);
},
+995
View File
@@ -0,0 +1,995 @@
use super::Screen;
use crate::panes::PaneId;
use crate::plugins::{PluginInstruction, PopupRequest, PromptCaller};
use crate::route::PopupScroll;
use crate::tab::{ContextMenuRequest, PopupKind, PopupMouseOutcome, PopupPlacement};
use crate::{ClientId, ServerInstruction};
use std::collections::BTreeMap;
use std::time::Instant;
use zellij_utils::data::{
ContextMenuContext, ContextMenuEntry, ContextMenuKind, ContextMenuTarget, Event, InputMode,
KeybindsVec, PipePopupPlacement, PopupCorner, PopupOptions,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::actions::Action;
use zellij_utils::input::context_menu::context_menu_shortcut;
use zellij_utils::input::context_menu::ContextMenuConfig;
use zellij_utils::input::layout::RunPluginOrAlias;
use zellij_utils::input::mouse::MouseEvent;
use zellij_utils::pane_size::{Size, Viewport};
use zellij_utils::plugin_api::event::ProtobufContextMenuAction;
use zellij_utils::position::Position;
use zellij_utils::prompt::PromptPlacement;
pub const CONTEXT_MENU_PLUGIN_ALIAS: &str = "context-menu";
pub const PIPE_POPUP_INITIAL_COLS: usize = 50;
pub const PIPE_POPUP_INITIAL_ROWS: usize = 8;
pub fn estimated_context_menu_size(
entries: &[ContextMenuEntry],
keybinds: &KeybindsVec,
base_mode: InputMode,
) -> (usize, usize) {
let widest_item = entries
.iter()
.filter_map(|entry| match entry {
ContextMenuEntry::Item { label, actions } => {
let shortcut = context_menu_shortcut(keybinds, base_mode, actions)
.map(|shortcut| shortcut.chars().count() + 1)
.unwrap_or(0);
Some(label.chars().count() + 4 + shortcut)
},
ContextMenuEntry::Separator => None,
})
.max()
.unwrap_or(0);
let width = (widest_item + 3).max(16);
let height = entries.len() + 2;
(width, height)
}
pub fn entries_available_to_plugins(entries: Vec<ContextMenuEntry>) -> Vec<ContextMenuEntry> {
entries
.into_iter()
.filter(|entry| match entry {
ContextMenuEntry::Separator => true,
ContextMenuEntry::Item { actions, .. } => actions
.iter()
.all(|action| ProtobufContextMenuAction::try_from(action.clone()).is_ok()),
})
.collect()
}
impl Screen {
pub fn update_context_menu_enabled(&mut self, context_menu_enabled: bool) {
self.context_menu_enabled = context_menu_enabled;
for tab in self.tabs.values_mut() {
tab.update_context_menu_enabled(context_menu_enabled);
}
}
pub fn update_context_menu_config(
&mut self,
client_id: ClientId,
context_menu_config: ContextMenuConfig,
) {
self.context_menu_configs
.insert(client_id, context_menu_config);
}
fn context_menu_config_for_client(&self, client_id: ClientId) -> &ContextMenuConfig {
self.context_menu_configs
.get(&client_id)
.unwrap_or(&self.default_context_menu_config)
}
pub fn handle_popup_mouse_event(&mut self, event: &MouseEvent, client_id: ClientId) -> bool {
let outcome = match self.get_active_tab_mut(client_id) {
Ok(tab) => tab.handle_popup_mouse_event(event, client_id),
Err(_) => None,
};
match outcome {
Some(PopupMouseOutcome::CloseRequested) => {
self.close_top_popup_for_client(client_id);
self.render(None).non_fatal();
true
},
Some(PopupMouseOutcome::Consumed) => true,
None => false,
}
}
fn client_has_focused_popup(&self, client_id: ClientId) -> bool {
self.tabs
.values()
.any(|tab| tab.has_focused_popup_for_client(client_id))
}
fn report_popup_state(&self, client_id: ClientId) {
let _ = self
.bus
.senders
.send_to_server(ServerInstruction::PopupStateChanged(
client_id,
self.client_has_focused_popup(client_id),
));
}
fn unload_popups(&self, plugin_ids: &[u32]) {
for plugin_id in plugin_ids {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(*plugin_id));
}
}
pub fn scroll_popup(&mut self, client_id: ClientId, scroll: PopupScroll) -> bool {
let Ok(tab) = self.get_active_tab(client_id) else {
return false;
};
match scroll {
PopupScroll::Up(lines) => tab.scroll_top_popup(client_id, true, lines),
PopupScroll::Down(lines) => tab.scroll_top_popup(client_id, false, lines),
}
}
pub fn dismiss_info_popups(&mut self, client_id: ClientId) -> bool {
let mut closed_plugin_ids = vec![];
for tab in self.tabs.values_mut() {
closed_plugin_ids.append(&mut tab.close_info_popups(client_id));
}
self.unload_popups(&closed_plugin_ids);
!closed_plugin_ids.is_empty()
}
pub fn close_popups_with_missing_anchor(&mut self) -> bool {
let mut closed = vec![];
for tab in self.tabs.values_mut() {
closed.append(&mut tab.close_popups_with_missing_anchor());
}
if closed.is_empty() {
return false;
}
let mut clients = vec![];
for (client_id, plugin_id) in closed {
self.unload_popups(&[plugin_id]);
if !clients.contains(&client_id) {
clients.push(client_id);
}
}
for client_id in clients {
self.forget_context_menu_unless_open(client_id);
self.report_popup_state(client_id);
}
true
}
fn forget_context_menu_unless_open(&mut self, client_id: ClientId) {
let menu_still_open = self
.open_context_menus
.get(&client_id)
.map(|_| {
self.tabs.values().any(|tab| {
tab.popup_plugin_id(client_id)
.and_then(|plugin_id| tab.popup_kind_of_plugin(plugin_id))
== Some(PopupKind::Menu)
})
})
.unwrap_or(false);
if !menu_still_open {
self.open_context_menus.remove(&client_id);
}
}
pub fn close_top_popup_for_client(&mut self, client_id: ClientId) -> bool {
let closed = match self.get_active_tab_mut(client_id) {
Ok(tab) => tab.close_top_popup(client_id),
Err(_) => None,
};
match closed {
Some(plugin_id) => {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(plugin_id));
self.forget_context_menu_unless_open(client_id);
self.report_popup_state(client_id);
true
},
None => false,
}
}
pub fn record_client_input(&mut self, client_id: ClientId) {
self.last_client_input.insert(client_id, Instant::now());
}
pub fn record_mouse_position(&mut self, client_id: ClientId, position: Position) {
self.last_mouse_positions.insert(client_id, position);
}
pub fn popup_plugin_failed(&mut self, plugin_id: u32) -> bool {
if self.close_popup_with_plugin_id(plugin_id) {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(plugin_id));
true
} else {
false
}
}
pub fn context_menu_item_actions(
&self,
plugin_id: u32,
client_id: ClientId,
index: usize,
) -> Vec<Action> {
let popup_belongs_to_client = self
.get_active_tab(client_id)
.ok()
.and_then(|tab| tab.popup_plugin_id(client_id))
== Some(plugin_id);
if !popup_belongs_to_client {
return vec![];
}
let Some((context, entries)) = self.open_context_menus.get(&client_id) else {
return vec![];
};
match entries.get(index) {
Some(ContextMenuEntry::Item { actions, .. }) => actions
.iter()
.cloned()
.filter_map(|action| {
action.into_action(context.target_pane_id(), context.target_tab_id())
})
.collect(),
_ => vec![],
}
}
pub fn move_popups_to_new_tab(
&mut self,
client_id: ClientId,
old_tab_id: usize,
new_tab_id: usize,
) {
self.open_context_menus.remove(&client_id);
let mut closed_plugin_ids = vec![];
for tab in self.tabs.values_mut() {
closed_plugin_ids.append(&mut tab.close_focused_popups(client_id));
}
self.unload_popups(&closed_plugin_ids);
let info_popups = self
.tabs
.get_mut(&old_tab_id)
.map(|tab| tab.take_info_popups(client_id))
.unwrap_or_default();
if let Some(tab) = self.tabs.get_mut(&new_tab_id) {
tab.adopt_popups(client_id, info_popups);
} else {
let plugin_ids: Vec<u32> = info_popups
.iter()
.filter_map(|popup| match popup.pane.pid() {
PaneId::Plugin(plugin_id) => Some(plugin_id),
PaneId::Terminal(_) => None,
})
.collect();
self.unload_popups(&plugin_ids);
}
if !closed_plugin_ids.is_empty() {
self.report_popup_state(client_id);
}
}
pub fn close_popup_for_client(&mut self, client_id: ClientId) -> bool {
self.open_context_menus.remove(&client_id);
let mut closed_plugin_ids = vec![];
for tab in self.tabs.values_mut() {
closed_plugin_ids.append(&mut tab.close_popup(client_id));
}
let closed_any = !closed_plugin_ids.is_empty();
for plugin_id in closed_plugin_ids {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(plugin_id));
}
if closed_any {
self.report_popup_state(client_id);
}
closed_any
}
fn close_menu_popups_for_client(&mut self, client_id: ClientId) {
self.open_context_menus.remove(&client_id);
let mut closed_plugin_ids = vec![];
for tab in self.tabs.values_mut() {
closed_plugin_ids.append(&mut tab.close_menu_popups(client_id));
}
let closed_any = !closed_plugin_ids.is_empty();
for plugin_id in closed_plugin_ids {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(plugin_id));
}
if closed_any {
self.report_popup_state(client_id);
}
}
pub fn close_popup_with_plugin_id(&mut self, plugin_id: u32) -> bool {
let mut closed_for_client = None;
for tab in self.tabs.values_mut() {
if let Some(client_id) = tab.close_popup_with_plugin_id(plugin_id) {
closed_for_client = Some(client_id);
break;
}
}
match closed_for_client {
Some(client_id) => {
self.forget_context_menu_unless_open(client_id);
self.report_popup_state(client_id);
true
},
None => false,
}
}
pub fn close_popups_of_closed_tab(&mut self, popups: Vec<(ClientId, u32)>) {
let mut clients = vec![];
for (client_id, plugin_id) in popups {
self.open_context_menus.remove(&client_id);
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(plugin_id));
if !clients.contains(&client_id) {
clients.push(client_id);
}
}
for client_id in clients {
self.report_popup_state(client_id);
}
}
pub fn send_key_to_popup(
&mut self,
client_id: ClientId,
key_with_modifier: zellij_utils::data::KeyWithModifier,
raw_bytes: Vec<u8>,
is_kitty_keyboard_protocol: bool,
) -> bool {
match self.get_active_tab_mut(client_id) {
Ok(tab) => tab.send_key_to_popup(
client_id,
key_with_modifier,
raw_bytes,
is_kitty_keyboard_protocol,
),
Err(_) => false,
}
}
pub fn open_context_menu_for_pane(&mut self, request: ContextMenuRequest, client_id: ClientId) {
let tab_count = self.tabs.len();
let Ok(tab) = self.get_active_tab(client_id) else {
return;
};
let context = ContextMenuContext {
kind: request.kind,
pane_id: Some(request.pane_id.into()),
pane_is_floating: request.is_floating,
tab_index: Some(tab.position),
tab_id: Some(tab.id),
tab_count,
line: request.position.line.0.max(0) as usize,
column: request.position.column.0,
client_id,
};
self.open_context_menu(context);
}
pub fn open_context_menu_for_focused_pane(&mut self, client_id: ClientId) {
let client_id = match self.get_active_tab(client_id) {
Ok(_) => client_id,
Err(_) => match self.get_first_client_id() {
Some(first_client_id) => first_client_id,
None => return,
},
};
let Ok(tab) = self.get_active_tab(client_id) else {
return;
};
let Some(pane) = tab.get_active_pane(client_id) else {
return;
};
let request = ContextMenuRequest {
kind: ContextMenuKind::Pane,
pane_id: pane.pid(),
is_floating: tab.are_floating_panes_visible(),
position: Position::new(pane.get_content_y() as i32, pane.get_content_x() as u16),
};
self.open_context_menu_for_pane(request, client_id);
}
pub fn open_context_menu_from_plugin(
&mut self,
plugin_id: u32,
client_id: ClientId,
target: ContextMenuTarget,
line: usize,
column: usize,
) {
let (line, column) = self.plugin_relative_to_screen(plugin_id, client_id, line, column);
let tab_count = self.tabs.len();
let (kind, tab_index, tab_id) = match target {
ContextMenuTarget::Tab(tab_index) => {
match self.tabs.values().find(|tab| tab.position == tab_index) {
Some(tab) => (ContextMenuKind::Tab, Some(tab.position), Some(tab.id)),
None => return,
}
},
ContextMenuTarget::Bar => match self.get_active_tab(client_id) {
Ok(tab) => (ContextMenuKind::Bar, Some(tab.position), Some(tab.id)),
Err(_) => return,
},
};
let context = ContextMenuContext {
kind,
pane_id: None,
pane_is_floating: false,
tab_index,
tab_id,
tab_count,
line,
column,
client_id,
};
self.open_context_menu(context);
}
fn plugin_relative_to_screen(
&self,
plugin_id: u32,
client_id: ClientId,
line: usize,
column: usize,
) -> (usize, usize) {
let pane_offset = self
.get_active_tab(client_id)
.ok()
.and_then(|tab| tab.get_pane_with_id(PaneId::Plugin(plugin_id)))
.or_else(|| {
self.tabs
.values()
.find_map(|tab| tab.get_pane_with_id(PaneId::Plugin(plugin_id)))
})
.map(|pane| (pane.get_content_y(), pane.get_content_x()));
match pane_offset {
Some((y, x)) => (line + y, column + x),
None => (line, column),
}
}
fn open_context_menu(&mut self, context: ContextMenuContext) {
let client_id = context.client_id;
let entries = entries_available_to_plugins(
self.context_menu_config_for_client(client_id)
.entries_for(context.kind),
);
if entries.is_empty() {
return;
}
let keybinds = self.keybinds_for_client(client_id);
let mut mode_info = self
.mode_info
.get(&client_id)
.unwrap_or(&self.default_mode_info)
.clone();
let base_mode = mode_info.base_mode.unwrap_or(self.default_mode_info.mode);
mode_info.base_mode = Some(base_mode);
let (width, height) = estimated_context_menu_size(&entries, &keybinds, base_mode);
let anchor = Position::new(context.line as i32, context.column as u16);
let run_plugin_or_alias =
match RunPluginOrAlias::from_url(CONTEXT_MENU_PLUGIN_ALIAS, &None, None, None) {
Ok(run_plugin_or_alias) => run_plugin_or_alias,
Err(e) => {
log::error!("Failed to open context menu: {}", e);
return;
},
};
let anchor_pane = context.pane_id.map(PaneId::from);
self.request_popup(
client_id,
run_plugin_or_alias,
PopupPlacement::At(anchor),
PopupKind::Menu,
anchor_pane,
width,
height,
vec![
Event::ModeUpdate(mode_info),
Event::ContextMenu(context.clone(), entries.clone()),
],
);
self.open_context_menus
.insert(client_id, (context, entries));
}
pub fn open_plugin_popup_for_plugin(
&mut self,
requesting_plugin_id: u32,
client_id: ClientId,
run_plugin_or_alias: RunPluginOrAlias,
line: usize,
column: usize,
width: usize,
height: usize,
options: PopupOptions,
) {
let (line, column) =
self.plugin_relative_to_screen(requesting_plugin_id, client_id, line, column);
let placement = match options.corner {
Some(corner) => PopupPlacement::Corner(corner),
None => PopupPlacement::At(Position::new(line as i32, column as u16)),
};
let kind = if options.focused {
PopupKind::Menu
} else {
PopupKind::Info
};
self.request_popup(
client_id,
run_plugin_or_alias,
placement,
kind,
None,
width,
height,
vec![],
);
}
fn request_popup(
&mut self,
client_id: ClientId,
run_plugin_or_alias: RunPluginOrAlias,
placement: PopupPlacement,
kind: PopupKind,
anchor_pane: Option<PaneId>,
width: usize,
height: usize,
initial_events: Vec<Event>,
) {
if kind == PopupKind::Menu {
self.close_menu_popups_for_client(client_id);
}
let Ok(tab) = self.get_active_tab(client_id) else {
return;
};
let (tab_id, tab_index) = (tab.id, tab.position);
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::LoadPopup {
run_plugin_or_alias,
tab_id,
tab_index,
client_id,
placement,
kind,
anchor_pane,
size: Size {
rows: height,
cols: width,
},
initial_events,
pipe: None,
});
}
fn most_recently_active(&self, candidates: impl Iterator<Item = ClientId>) -> Option<ClientId> {
candidates.max_by_key(|client_id| {
(
self.last_client_input.get(client_id).copied(),
std::cmp::Reverse(*client_id),
)
})
}
pub fn pipe_popup_owner(
&self,
caller_pane_id: Option<PaneId>,
) -> Option<(ClientId, usize, Option<PaneId>)> {
let caller_tab_id = caller_pane_id.and_then(|pane_id| {
self.tabs
.iter()
.find(|(_, tab)| tab.get_pane_with_id(pane_id).is_some())
.map(|(tab_id, _)| *tab_id)
});
let connected: Vec<ClientId> = self.active_tab_ids.keys().copied().collect();
if let (Some(pane_id), Some(tab_id)) = (caller_pane_id, caller_tab_id) {
let focused_on_caller = connected.iter().copied().filter(|client_id| {
self.active_tab_ids.get(client_id) == Some(&tab_id)
&& self
.tabs
.get(&tab_id)
.and_then(|tab| tab.get_active_pane_id(*client_id))
== Some(pane_id)
});
if let Some(client_id) = self.most_recently_active(focused_on_caller) {
return Some((client_id, tab_id, Some(pane_id)));
}
let in_caller_tab = connected
.iter()
.copied()
.filter(|client_id| self.active_tab_ids.get(client_id) == Some(&tab_id));
if let Some(client_id) = self.most_recently_active(in_caller_tab) {
return Some((client_id, tab_id, Some(pane_id)));
}
}
let client_id = self.most_recently_active(connected.iter().copied())?;
let tab_id = *self.active_tab_ids.get(&client_id)?;
Some((client_id, tab_id, None))
}
pub fn open_pipe_popup(
&mut self,
pipe_id: String,
run_plugin_or_alias: RunPluginOrAlias,
caller_pane_id: Option<PaneId>,
placement: PipePopupPlacement,
focused: bool,
) {
let Some((client_id, tab_id, pane_in_tab)) = self.pipe_popup_owner(caller_pane_id) else {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::PipePopupFailed {
pipe_id,
error: "No user is attached to this session to answer the prompt".to_owned(),
});
return;
};
self.load_answer_popup(
PopupRequest::Cli(pipe_id),
run_plugin_or_alias,
client_id,
tab_id,
pane_in_tab,
caller_pane_id,
placement,
focused,
);
}
pub fn open_prompt_popup(
&mut self,
caller: PromptCaller,
owner_client_id: ClientId,
caller_pane_id: Option<PaneId>,
run_plugin_or_alias: RunPluginOrAlias,
placement: Option<PromptPlacement>,
focused: bool,
) {
let tab_id = match self.active_tab_ids.get(&owner_client_id) {
Some(tab_id) if self.tabs.contains_key(tab_id) => *tab_id,
_ => {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::PromptPopupFailed {
caller,
error: "The user this prompt is for is not attached".to_owned(),
});
return;
},
};
let (pipe_placement, explicit_pane) = match placement {
None => (PipePopupPlacement::Pane, None),
Some(PromptPlacement::Pane(pane_id)) => {
(PipePopupPlacement::Pane, Some(PaneId::from(pane_id)))
},
Some(PromptPlacement::Center) => (PipePopupPlacement::Center, None),
Some(PromptPlacement::Mouse) => (PipePopupPlacement::Mouse, None),
Some(PromptPlacement::Cursor) => (PipePopupPlacement::Cursor, None),
Some(PromptPlacement::Corner(corner)) => (
match corner {
PopupCorner::TopLeft => PipePopupPlacement::TopLeft,
PopupCorner::TopRight => PipePopupPlacement::TopRight,
PopupCorner::BottomLeft => PipePopupPlacement::BottomLeft,
PopupCorner::BottomRight => PipePopupPlacement::BottomRight,
},
None,
),
};
let caller_pane_id = caller_pane_id.filter(|pane_id| {
self.tabs
.values()
.any(|tab| tab.get_pane_with_id(*pane_id).is_some())
});
let placement_pane = self.tabs.get(&tab_id).and_then(|tab| {
explicit_pane
.or(caller_pane_id)
.filter(|pane_id| tab.get_pane_with_id(*pane_id).is_some())
.or_else(|| tab.get_active_pane_id(owner_client_id))
});
self.load_answer_popup(
PopupRequest::Prompt(caller),
run_plugin_or_alias,
owner_client_id,
tab_id,
placement_pane,
caller_pane_id,
pipe_placement,
focused,
);
}
fn load_answer_popup(
&mut self,
popup_request: PopupRequest,
run_plugin_or_alias: RunPluginOrAlias,
client_id: ClientId,
tab_id: usize,
pane_in_tab: Option<PaneId>,
caller_pane_id: Option<PaneId>,
placement: PipePopupPlacement,
focused: bool,
) {
let Some(tab) = self.tabs.get(&tab_id) else {
return;
};
let bounds = tab.popup_bounds();
let caller_pane = caller_pane_id.and_then(|pane_id| {
self.tabs
.values()
.find_map(|tab| tab.get_pane_with_id(pane_id))
});
let caller_title = caller_pane
.map(|pane| pane.current_title())
.unwrap_or_default();
let caller_tab_name = caller_pane_id.and_then(|pane_id| {
self.tabs
.values()
.find(|tab| tab.get_pane_with_id(pane_id).is_some())
.filter(|caller_tab| caller_tab.id != tab_id)
.map(|caller_tab| caller_tab.name.clone())
});
let pane_area = pane_in_tab
.and_then(|pane_id| tab.get_pane_with_id(pane_id))
.map(|pane| {
let geom = pane.current_geom();
Viewport {
x: geom.x,
y: geom.y,
rows: geom.rows.as_usize(),
cols: geom.cols.as_usize(),
}
});
let focused_area = tab
.get_active_pane_id(client_id)
.and_then(|pane_id| tab.get_pane_with_id(pane_id))
.map(|pane| {
let geom = pane.current_geom();
Viewport {
x: geom.x,
y: geom.y,
rows: geom.rows.as_usize(),
cols: geom.cols.as_usize(),
}
})
.unwrap_or(bounds);
let cursor_position = tab
.get_active_terminal_cursor_position(client_id)
.map(|(x, y, _)| Position::new((y + 1) as i32, x as u16));
let at_cursor = cursor_position
.map(PopupPlacement::At)
.unwrap_or(PopupPlacement::CenteredIn(focused_area));
let popup_placement = match placement {
PipePopupPlacement::Pane => PopupPlacement::CenteredIn(pane_area.unwrap_or(bounds)),
PipePopupPlacement::Center => PopupPlacement::CenteredIn(bounds),
PipePopupPlacement::Mouse => match self.last_mouse_positions.get(&client_id) {
Some(position) => PopupPlacement::At(*position),
None => at_cursor,
},
PipePopupPlacement::Cursor => at_cursor,
PipePopupPlacement::TopLeft
| PipePopupPlacement::TopRight
| PipePopupPlacement::BottomLeft
| PipePopupPlacement::BottomRight => match placement.corner() {
Some(corner) => PopupPlacement::Corner(corner),
None => PopupPlacement::CenteredIn(bounds),
},
};
let kind = if focused {
PopupKind::Prompt
} else {
PopupKind::Info
};
let mut caller_args = BTreeMap::new();
if let Some(pane_id) = caller_pane_id {
let pane_id: zellij_utils::data::PaneId = pane_id.into();
caller_args.insert(
zellij_utils::prompt::CALLER_PANE_ID_ARG.to_owned(),
pane_id.to_string(),
);
}
caller_args.insert(
zellij_utils::prompt::CALLER_PANE_TITLE_ARG.to_owned(),
caller_title,
);
if let Some(caller_tab_name) = caller_tab_name {
caller_args.insert(
zellij_utils::prompt::CALLER_TAB_NAME_ARG.to_owned(),
caller_tab_name,
);
}
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::LoadPopup {
run_plugin_or_alias,
tab_id,
tab_index: tab.position,
client_id,
placement: popup_placement,
kind,
anchor_pane: if focused { pane_in_tab } else { None },
size: Size {
rows: PIPE_POPUP_INITIAL_ROWS,
cols: PIPE_POPUP_INITIAL_COLS,
},
initial_events: vec![],
pipe: Some((popup_request, caller_args)),
});
}
pub fn add_popup(
&mut self,
plugin_id: u32,
client_id: ClientId,
tab_id: usize,
run_plugin_or_alias: RunPluginOrAlias,
placement: PopupPlacement,
kind: PopupKind,
width: usize,
height: usize,
anchor_pane: Option<PaneId>,
) -> Result<()> {
let anchor_still_exists = anchor_pane
.map(|pane_id| {
self.tabs
.get(&tab_id)
.map(|tab| tab.get_pane_with_id(pane_id).is_some())
.unwrap_or(false)
})
.unwrap_or(true);
let tab_id = match self.active_tab_ids.get(&client_id) {
Some(current_tab_id) if kind == PopupKind::Info => *current_tab_id,
_ => tab_id,
};
let client_is_still_on_tab =
anchor_still_exists && self.active_tab_ids.get(&client_id) == Some(&tab_id);
if !client_is_still_on_tab {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(plugin_id));
return Ok(());
}
let title = run_plugin_or_alias.location_string();
let invoked_with = Some(zellij_utils::input::layout::Run::Plugin(
run_plugin_or_alias,
));
let replaced = match self.tabs.get_mut(&tab_id) {
Some(tab) => {
let replaced = tab.open_popup(
client_id,
plugin_id,
placement,
kind,
width,
height,
invoked_with,
title,
)?;
tab.set_popup_anchor(plugin_id, anchor_pane);
replaced
},
None => return Ok(()),
};
for replaced_plugin_id in replaced {
let _ = self
.bus
.senders
.send_to_plugin(PluginInstruction::Unload(replaced_plugin_id));
}
self.report_popup_state(client_id);
self.render(None)
}
pub fn set_popup_size(&mut self, plugin_id: u32, width: usize, height: usize) -> Result<()> {
for tab in self.tabs.values_mut() {
if tab.has_popup_plugin(plugin_id) {
tab.resize_popup(plugin_id, width, height);
break;
}
}
self.render(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use zellij_utils::data::{BareKey, ClickedPaneAction, ContextMenuAction, KeyWithModifier};
#[test]
fn items_for_the_clicked_pane_or_tab_and_with_explicit_ids_reach_the_plugin() {
use zellij_utils::data::{ClickedTabAction, Direction};
let entries = vec![
ContextMenuEntry::item(
"Close pane",
vec![ContextMenuAction::ClickedPane(
ClickedPaneAction::CloseFocus,
)],
),
ContextMenuEntry::item(
"Move tab left",
vec![ContextMenuAction::ClickedTab(ClickedTabAction::Move(
Direction::Left,
))],
),
ContextMenuEntry::Separator,
ContextMenuEntry::item(
"Close pane 3",
vec![Action::CloseFocusByPaneId {
pane_id: zellij_utils::data::PaneId::Terminal(3),
}
.into()],
),
ContextMenuEntry::item("Detach", vec![Action::Detach.into()]),
];
assert_eq!(entries_available_to_plugins(entries.clone()), entries);
}
#[test]
fn items_that_cannot_reach_the_plugin_are_left_out() {
use zellij_utils::data::{ClickedTabAction, Direction};
let entries = vec![
ContextMenuEntry::item(
"Move tab up",
vec![ContextMenuAction::ClickedTab(ClickedTabAction::Move(
Direction::Up,
))],
),
ContextMenuEntry::item("Detach", vec![Action::Detach.into()]),
];
assert_eq!(
entries_available_to_plugins(entries),
vec![ContextMenuEntry::item(
"Detach",
vec![Action::Detach.into()]
)]
);
}
#[test]
fn the_estimated_menu_width_leaves_room_for_the_shortcuts() {
let entries = vec![
ContextMenuEntry::Item {
label: "Close pane".to_owned(),
actions: vec![ContextMenuAction::ClickedPane(
ClickedPaneAction::CloseFocus,
)],
},
ContextMenuEntry::Separator,
ContextMenuEntry::Item {
label: "Detach".to_owned(),
actions: vec![Action::Detach.into()],
},
];
let keybinds: KeybindsVec = vec![
(
InputMode::Normal,
vec![(
KeyWithModifier::new(BareKey::Char('p')).with_ctrl_modifier(),
vec![Action::SwitchToMode {
input_mode: InputMode::Pane,
}],
)],
),
(
InputMode::Pane,
vec![(
KeyWithModifier::new(BareKey::Char('x')),
vec![
Action::CloseFocus,
Action::SwitchToMode {
input_mode: InputMode::Normal,
},
],
)],
),
];
let (without_keys, height) =
estimated_context_menu_size(&entries, &vec![], InputMode::Normal);
let (with_keys, _) = estimated_context_menu_size(&entries, &keybinds, InputMode::Normal);
assert_eq!(height, 5);
assert_eq!(without_keys, 17);
assert_eq!(
with_keys,
"Close pane".len() + 4 + "Ctrl p, x".len() + 1 + 3
);
}
}

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