mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-11 22:50:07 +00:00
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:
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,6 +62,7 @@ windows-sys = { workspace = true, features = [
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
clap = { workspace = true }
|
||||
insta = "1.48"
|
||||
rcgen = "0.14"
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()))
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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<()> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user