Compare commits
18 commits
pre-1.8-me
...
local/yoda
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b10e98ab1 | |||
| a035b47e98 | |||
| db9ee02d8d | |||
| 52b37cc2f8 | |||
| 2414aaece3 | |||
| 81dc7d3eeb | |||
| 083e09f97e | |||
|
|
9129277bed | ||
|
|
78eaba9db1 | ||
|
|
d25c2108c0 | ||
|
|
120b4fdb12 | ||
|
|
4cd8f8d605 | ||
|
|
1d9e5784d2 | ||
|
|
327febcc92 | ||
| 99842bde03 | |||
| 5fc081e257 | |||
|
|
4f0f0b6e4e | ||
|
|
7945b9ffc6 |
23 changed files with 2422 additions and 750 deletions
662
Cargo.lock
generated
662
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "cosmic-term"
|
||||
version = "1.7.0"
|
||||
version = "1.9.0"
|
||||
authors = ["Jeremy Soller <jeremy@system76.com>"]
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-only"
|
||||
|
|
@ -18,6 +18,9 @@ ron = "0.11"
|
|||
serde = { version = "1", features = ["serde_derive"] }
|
||||
shlex = "1"
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||
# Notifications de bureau (OSC 9 / 777) et enveloppe du PTY (yoda)
|
||||
polling = "3.11"
|
||||
zbus = "5.19"
|
||||
# HTTP client for Modjo AI integration
|
||||
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
|
|
|||
12
debian/changelog
vendored
12
debian/changelog
vendored
|
|
@ -1,3 +1,15 @@
|
|||
cosmic-term (1.9.0) noble; urgency=medium
|
||||
|
||||
* Epoch 1.9.0 version update
|
||||
|
||||
-- Michael Murphy <michael@mmurphy.dev> Mon, 14 Sep 2026 19:50:47 +0200
|
||||
|
||||
cosmic-term (1.8.0) noble; urgency=medium
|
||||
|
||||
* Epoch 1.8.0 version update
|
||||
|
||||
-- Jeremy Soller <jeremy@system76.com> Wed, 02 Sep 2026 08:15:55 -0600
|
||||
|
||||
cosmic-term (1.7.0) noble; urgency=medium
|
||||
|
||||
* Epoch 1.7.0 version update
|
||||
|
|
|
|||
6
debian/source/options
vendored
6
debian/source/options
vendored
|
|
@ -1,4 +1,6 @@
|
|||
tar-ignore=.github
|
||||
tar-ignore=.vscode
|
||||
tar-ignore=target/.rustc_info.json
|
||||
tar-ignore=target/CACHEDIR.TAG
|
||||
tar-ignore=target/debug
|
||||
tar-ignore=target/release
|
||||
tar-ignore=vendor
|
||||
tar-ignore=target
|
||||
|
|
@ -59,13 +59,17 @@ use-bright-bold = Make bold text brighter
|
|||
splits = Splits
|
||||
focus-follow-mouse = Typing focus follows mouse
|
||||
show-pane-borders = Show pane borders
|
||||
unfocused-pane-dim = Dim unfocused panes
|
||||
|
||||
### Advanced
|
||||
advanced = Advanced
|
||||
show-headerbar = Show header
|
||||
window-header = Window header
|
||||
show-header-description = Reveal the header from the right-click menu
|
||||
tab-new-inherit-working-directory = New tabs and windows use current directory
|
||||
tab-new-inherit-working-directory-description = Open new tabs and windows in the active tab's working directory
|
||||
copy-on-select-clipboard = Copy selection to clipboard
|
||||
copy-on-select-clipboard-description = In addition to the primary selection (middle click), like copy-on-select = clipboard in Ghostty
|
||||
|
||||
### Keyboard shortcuts
|
||||
add-another-keybinding = Add another keybinding
|
||||
|
|
@ -138,6 +142,10 @@ save-output = Save output as text...
|
|||
monitor-activity = Monitor for activity
|
||||
monitor-silence = Monitor for silence
|
||||
monitor-process-finished = Monitor for process finishing
|
||||
read-only = Read-only
|
||||
|
||||
## Notifications
|
||||
notification-show = Show tab
|
||||
|
||||
## Open
|
||||
open-link = Open Link
|
||||
|
|
@ -152,7 +160,7 @@ next-tab = Next tab
|
|||
previous-tab = Previous tab
|
||||
split-horizontal = Split horizontal
|
||||
split-vertical = Split vertical
|
||||
pane-toggle-maximize = Toggle maximized
|
||||
pane-toggle-maximize = Maximize or restore pane
|
||||
menu-color-schemes = Color schemes...
|
||||
menu-settings = Settings...
|
||||
menu-about = About COSMIC Terminal...
|
||||
|
|
|
|||
0
i18n/fil/cosmic_term.ftl
Normal file
0
i18n/fil/cosmic_term.ftl
Normal file
|
|
@ -108,6 +108,10 @@ save-output = Enregistrer la sortie comme texte...
|
|||
monitor-activity = Surveiller l'activité
|
||||
monitor-silence = Surveiller le silence
|
||||
monitor-process-finished = Surveiller la fin du processus
|
||||
read-only = Lecture seule
|
||||
|
||||
## Notifications
|
||||
notification-show = Afficher l'onglet
|
||||
|
||||
## View
|
||||
|
||||
|
|
@ -168,4 +172,7 @@ comment = Émulateur de terminal pour le bureau COSMIC
|
|||
keywords = Commande;Shell;Terminal;CLI;
|
||||
tab-new-inherit-working-directory = Les nouveaux onglets et fenêtres utilisent le répertoire actuel
|
||||
tab-new-inherit-working-directory-description = Ouvrir les nouveaux onglets et fenêtres dans le répertoire de travail de l'onglet actif
|
||||
copy-on-select-clipboard = Copier la sélection dans le presse-papiers
|
||||
copy-on-select-clipboard-description = En plus de la sélection primaire (clic du milieu), comme copy-on-select = clipboard dans Ghostty
|
||||
show-pane-borders = Afficher les bordures des panneaux
|
||||
unfocused-pane-dim = Atténuation des panneaux inactifs
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ repository = Repositori
|
|||
support = Dukungan
|
||||
color-schemes = Skema warna
|
||||
cosmic-terminal = Terminal COSMIC
|
||||
rename = Ganti nama
|
||||
rename = Ganti Nama
|
||||
export = Ekspor
|
||||
delete = Hapus
|
||||
import = Impor
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
cancel = Sefsex
|
||||
cancel = Semmet
|
||||
name = Isem
|
||||
delete = kkes
|
||||
rename = Snifel isem
|
||||
quit = Tuffɣa
|
||||
support = Tallalt
|
||||
quit = Ffeɣ
|
||||
support = Tallelt
|
||||
repository = Asarsay
|
||||
settings = Iɣewwaṛen
|
||||
appearance = Timeẓri
|
||||
|
|
@ -14,7 +14,7 @@ paste = Senteḍ
|
|||
select-all = Fren akk
|
||||
view = Wali
|
||||
menu-settings = Iɣewwaṛen…
|
||||
match-desktop = Amṣada d tnarit
|
||||
match-desktop = Semṣada akked tnarit
|
||||
file = Afaylu
|
||||
new-window = Asfaylu amaynut
|
||||
edit = Ẓreg
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ light = Ronî
|
|||
advanced = Pêşketî
|
||||
support = Piştgirî
|
||||
default-font = Cûrenivîs
|
||||
paste = Pêve bike
|
||||
paste = Pê ve bike
|
||||
menu-settings = Sazkarî...
|
||||
command-line = Rêza fermanê
|
||||
view = Dîtin
|
||||
|
|
@ -100,7 +100,7 @@ focus-pane-left = Balê bikişîne ser destgeha çepê
|
|||
focus-pane-right = Balê bikişîne ser destgeha rastê
|
||||
focus-pane-up = Balê bikişîne ser destgeha jorîn
|
||||
password-manager = Rêveberê borînpeyvê
|
||||
paste-primary = A sereke pêve bike
|
||||
paste-primary = A sereke pê ve bike
|
||||
reset-to-default = Vegere rewşa berdest
|
||||
shortcut-capture-hint = Kombînasyona kilîtan bitikîne
|
||||
shortcut-replace-body = { $binding } berê ji bo { $existing } hatiye destnîşankirin. Tu dixwazî bi { $new_action } re biguherînî?
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
settings = Paramètres
|
||||
settings = Configuracion
|
||||
appearance = Aparéncia
|
||||
theme = Tèma
|
||||
match-desktop = Assortir al burèu
|
||||
dark = Fosc
|
||||
dark = Escur
|
||||
light = Clar
|
||||
file = Fichièr
|
||||
name = Nom
|
||||
repository = Depaus
|
||||
cancel = Anullar
|
||||
menu-settings = Configuracion...
|
||||
quit = Quitar
|
||||
delete = Suprimir
|
||||
replace = Remplaçar
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
cosmic-terminal = Terminal COSMIC
|
||||
comment = Emulator terminala dla pulpitu COSMIC
|
||||
comment = Emulator terminala dla środowiska COSMIC
|
||||
keywords = Cli;WierszPoleceń;Powłoka;Terminal;
|
||||
new-terminal = Nowy terminal
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ advanced = Zaawansowane
|
|||
show-headerbar = Pokaż pasek nagłówka
|
||||
show-header-description = Ukazuje pasek nagłówka z menu wybieranego prawym przyciskiem myszki
|
||||
# Find
|
||||
find-placeholder = Szukaj…
|
||||
find-placeholder = Wyszukaj…
|
||||
find-previous = Znajdź poprzedni
|
||||
find-next = Znajdź następny
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ edit = Edytuj
|
|||
copy = Kopiuj
|
||||
paste = Wklej
|
||||
select-all = Zaznacz wszystko
|
||||
find = Szukaj
|
||||
find = Wyszukaj
|
||||
clear-scrollback = Wyczyść przewijanie
|
||||
|
||||
## View
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ settings = การตั้งค่า
|
|||
appearance = ลักษณะ
|
||||
theme = ธีม
|
||||
match-desktop = ใช้ตามธีมหน้าจอหลัก
|
||||
dark = ธีมมืด
|
||||
light = ธีมสว่าง
|
||||
dark = มืด
|
||||
light = สว่าง
|
||||
syntax-dark = โทนสีมืด
|
||||
syntax-light = โทนสีสว่าง
|
||||
default-zoom-step = ระยะซูม
|
||||
|
|
@ -109,3 +109,8 @@ pane-toggle-maximize = ขยายให้ใหญ่สุด
|
|||
menu-color-schemes = โทนสี...
|
||||
menu-settings = การตั้งค่า...
|
||||
menu-about = เกี่ยวกับ เทอร์มินัล COSMIC...
|
||||
cancel = ยกเลิก
|
||||
reset-to-default = รีเซ็ตเป็นค่าเริ่มต้น
|
||||
type-to-search = พิมพ์เพื่อค้นหา
|
||||
add-another-keybinding = เพิ่มคีย์ลัดเพิ่มเติม
|
||||
replace = แทนที่
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ new-terminal = 新建終端機
|
|||
## Color schemes
|
||||
|
||||
color-schemes = 配色方案
|
||||
rename = 重命名
|
||||
rename = 重新命名
|
||||
export = 匯出
|
||||
delete = 刪除
|
||||
import = 匯入
|
||||
|
|
|
|||
|
|
@ -232,17 +232,33 @@ pub struct Config {
|
|||
pub profiles: BTreeMap<ProfileId, Profile>,
|
||||
pub show_headerbar: bool,
|
||||
pub show_pane_borders: bool,
|
||||
/// [yoda] Voile posé sur les panneaux non focalisés, en pourcentage
|
||||
/// (`unfocused-split-opacity` de Ghostty : 0,7 d'opacité = 30 % de voile).
|
||||
#[serde(default = "default_unfocused_pane_dim")]
|
||||
pub unfocused_pane_dim: u8,
|
||||
pub use_bright_bold: bool,
|
||||
pub syntax_theme_dark: String,
|
||||
pub syntax_theme_light: String,
|
||||
pub focus_follow_mouse: bool,
|
||||
#[serde(default)]
|
||||
pub tab_new_inherit_working_directory: bool,
|
||||
/// [yoda] Copier aussi la sélection dans le presse-papiers, et pas
|
||||
/// seulement dans la sélection primaire (`copy-on-select = clipboard` de
|
||||
/// Ghostty).
|
||||
#[serde(default)]
|
||||
pub copy_on_select_clipboard: bool,
|
||||
pub default_profile: Option<ProfileId>,
|
||||
#[serde(default)]
|
||||
pub shortcuts_custom: Shortcuts,
|
||||
}
|
||||
|
||||
/// Voile maximal des panneaux non focalisés : Ghostty borne leur opacité à 0,15.
|
||||
pub const UNFOCUSED_PANE_DIM_MAX: u8 = 85;
|
||||
|
||||
fn default_unfocused_pane_dim() -> u8 {
|
||||
30
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -253,6 +269,7 @@ impl Default for Config {
|
|||
dim_font_weight: Weight::NORMAL.0,
|
||||
focus_follow_mouse: false,
|
||||
tab_new_inherit_working_directory: false,
|
||||
copy_on_select_clipboard: false,
|
||||
font_name: "Noto Sans Mono".to_string(),
|
||||
font_size: 14,
|
||||
font_size_zoom_step_mul_100: 100,
|
||||
|
|
@ -262,6 +279,7 @@ impl Default for Config {
|
|||
profiles: BTreeMap::new(),
|
||||
show_headerbar: true,
|
||||
show_pane_borders: false,
|
||||
unfocused_pane_dim: default_unfocused_pane_dim(),
|
||||
syntax_theme_dark: COSMIC_THEME_DARK.to_string(),
|
||||
syntax_theme_light: COSMIC_THEME_LIGHT.to_string(),
|
||||
use_bright_bold: false,
|
||||
|
|
@ -343,6 +361,12 @@ impl Config {
|
|||
Metrics::new(font_size, line_height)
|
||||
}
|
||||
|
||||
/// Opacité du voile des panneaux non focalisés ; `None` si désactivé.
|
||||
pub fn unfocused_pane_dim_ratio(&self) -> Option<f32> {
|
||||
let dim = self.unfocused_pane_dim.min(UNFOCUSED_PANE_DIM_MAX);
|
||||
(dim > 0).then(|| f32::from(dim) / 100.0)
|
||||
}
|
||||
|
||||
pub fn opacity_ratio(&self) -> f32 {
|
||||
f32::from(self.opacity) / 100.0
|
||||
}
|
||||
|
|
@ -404,3 +428,22 @@ impl Config {
|
|||
})[&self.font_stretch]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Config, UNFOCUSED_PANE_DIM_MAX};
|
||||
|
||||
#[test]
|
||||
fn unfocused_pane_dim_ratio() {
|
||||
let mut config = Config::default();
|
||||
// Défaut de Ghostty : opacité 0,7, soit 30 % de voile
|
||||
assert_eq!(config.unfocused_pane_dim_ratio(), Some(0.3));
|
||||
config.unfocused_pane_dim = 0;
|
||||
assert_eq!(config.unfocused_pane_dim_ratio(), None);
|
||||
config.unfocused_pane_dim = 100;
|
||||
assert_eq!(
|
||||
config.unfocused_pane_dim_ratio(),
|
||||
Some(f32::from(UNFOCUSED_PANE_DIM_MAX) / 100.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
185
src/desktop_notify.rs
Normal file
185
src/desktop_notify.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
//! Notifications de bureau des OSC 9 / 777 (yoda).
|
||||
//!
|
||||
//! On parle directement à `org.freedesktop.Notifications` plutôt que de passer
|
||||
//! par notify-rust : au clic, cosmic-notifications émet `ActivationToken` puis
|
||||
//! `ActionInvoked`, et c'est ce jeton xdg-activation qui permet de ramener la
|
||||
//! fenêtre au premier plan sur le bon onglet.
|
||||
//!
|
||||
//! Une notification par onglet au plus : la suivante remplace la précédente
|
||||
//! (`replaces_id`).
|
||||
|
||||
use cosmic::{
|
||||
Application,
|
||||
iced::{
|
||||
Subscription,
|
||||
futures::{SinkExt, channel::mpsc::Sender},
|
||||
stream,
|
||||
},
|
||||
widget::{pane_grid, segmented_button},
|
||||
};
|
||||
use std::{any::TypeId, collections::HashMap};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::fl;
|
||||
|
||||
/// Onglet à l'origine d'une notification.
|
||||
pub type TabKey = (pane_grid::Pane, segmented_button::Entity);
|
||||
|
||||
/// Demande envoyée par l'application à la tâche D-Bus.
|
||||
#[derive(Debug)]
|
||||
pub enum Request {
|
||||
Show {
|
||||
tab: TabKey,
|
||||
summary: String,
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Événement renvoyé par la tâche D-Bus à l'application.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Event {
|
||||
/// Canal prêt : l'application peut envoyer ses demandes.
|
||||
Ready(mpsc::UnboundedSender<Request>),
|
||||
/// L'utilisateur a cliqué la notification de cet onglet.
|
||||
Activated { tab: TabKey, token: Option<String> },
|
||||
}
|
||||
|
||||
#[zbus::proxy(
|
||||
interface = "org.freedesktop.Notifications",
|
||||
default_service = "org.freedesktop.Notifications",
|
||||
default_path = "/org/freedesktop/Notifications"
|
||||
)]
|
||||
trait Notifications {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn notify(
|
||||
&self,
|
||||
app_name: &str,
|
||||
replaces_id: u32,
|
||||
app_icon: &str,
|
||||
summary: &str,
|
||||
body: &str,
|
||||
actions: &[&str],
|
||||
hints: HashMap<&str, zbus::zvariant::Value<'_>>,
|
||||
expire_timeout: i32,
|
||||
) -> zbus::Result<u32>;
|
||||
|
||||
#[zbus(signal)]
|
||||
fn action_invoked(&self, id: u32, action_key: String) -> zbus::Result<()>;
|
||||
|
||||
#[zbus(signal)]
|
||||
fn activation_token(&self, id: u32, activation_token: String) -> zbus::Result<()>;
|
||||
|
||||
#[zbus(signal)]
|
||||
fn notification_closed(&self, id: u32, reason: u32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
struct DesktopNotifySubscription;
|
||||
|
||||
pub fn subscription() -> Subscription<Event> {
|
||||
Subscription::run_with(TypeId::of::<DesktopNotifySubscription>(), |_| {
|
||||
stream::channel(16, |mut output: Sender<Event>| async move {
|
||||
let (request_tx, request_rx) = mpsc::unbounded_channel();
|
||||
if output.send(Event::Ready(request_tx)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = run(request_rx, output).await {
|
||||
log::warn!("desktop notifications unavailable: {err}");
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut request_rx: mpsc::UnboundedReceiver<Request>,
|
||||
mut output: Sender<Event>,
|
||||
) -> zbus::Result<()> {
|
||||
use cosmic::iced::futures::StreamExt;
|
||||
|
||||
let connection = zbus::Connection::session().await?;
|
||||
let proxy = NotificationsProxy::new(&connection).await?;
|
||||
let mut tokens = proxy.receive_activation_token().await?;
|
||||
let mut invoked = proxy.receive_action_invoked().await?;
|
||||
let mut closed = proxy.receive_notification_closed().await?;
|
||||
|
||||
let app_name = fl!("cosmic-terminal");
|
||||
let show_label = fl!("notification-show");
|
||||
let mut tab_by_id: HashMap<u32, TabKey> = HashMap::new();
|
||||
let mut id_by_tab: HashMap<TabKey, u32> = HashMap::new();
|
||||
let mut pending_tokens: HashMap<u32, String> = HashMap::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
request = request_rx.recv() => {
|
||||
let Some(Request::Show { tab, summary, body }) = request else {
|
||||
return Ok(());
|
||||
};
|
||||
let replaces_id = id_by_tab.get(&tab).copied().unwrap_or(0);
|
||||
let body = neutralize_markup(&body);
|
||||
let hints = HashMap::from([
|
||||
("desktop-entry", zbus::zvariant::Value::from(crate::App::APP_ID)),
|
||||
("urgency", zbus::zvariant::Value::U8(1)),
|
||||
]);
|
||||
match proxy
|
||||
.notify(
|
||||
&app_name,
|
||||
replaces_id,
|
||||
crate::App::APP_ID,
|
||||
&summary,
|
||||
&body,
|
||||
&["default", &show_label],
|
||||
hints,
|
||||
-1,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(id) => {
|
||||
if replaces_id != 0 && replaces_id != id {
|
||||
tab_by_id.remove(&replaces_id);
|
||||
}
|
||||
tab_by_id.insert(id, tab);
|
||||
id_by_tab.insert(tab, id);
|
||||
}
|
||||
Err(err) => log::warn!("failed to send desktop notification: {err}"),
|
||||
}
|
||||
}
|
||||
Some(signal) = tokens.next() => {
|
||||
if let Ok(args) = signal.args()
|
||||
&& tab_by_id.contains_key(args.id())
|
||||
{
|
||||
pending_tokens.insert(*args.id(), args.activation_token().clone());
|
||||
}
|
||||
}
|
||||
Some(signal) = invoked.next() => {
|
||||
if let Ok(args) = signal.args()
|
||||
&& let Some(tab) = tab_by_id.get(args.id()).copied()
|
||||
{
|
||||
let token = pending_tokens.remove(args.id());
|
||||
if output.send(Event::Activated { tab, token }).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(signal) = closed.next() => {
|
||||
if let Ok(args) = signal.args()
|
||||
&& let Some(tab) = tab_by_id.remove(args.id())
|
||||
{
|
||||
if id_by_tab.get(&tab) == Some(args.id()) {
|
||||
id_by_tab.remove(&tab);
|
||||
}
|
||||
pending_tokens.remove(args.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Le corps d'une notification est du balisage (`body-markup`). Échapper `<`
|
||||
/// en `<` serait conforme à la spécification, mais cosmic-notifications ne
|
||||
/// décode pas les entités (il afficherait `<`), et un `<` brut y avale le
|
||||
/// texte qui suit. Les chevrons deviennent donc des guillemets simples
|
||||
/// d'aspect voisin, lisibles sur tous les serveurs.
|
||||
fn neutralize_markup(body: &str) -> String {
|
||||
body.replace('<', "‹").replace('>', "›")
|
||||
}
|
||||
697
src/kitty_keyboard.rs
Normal file
697
src/kitty_keyboard.rs
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
//! Protocole clavier kitty (yoda).
|
||||
//!
|
||||
//! Avec `kitty_keyboard` actif, alacritty_terminal tient la pile des modes
|
||||
//! (`CSI > n u`, `CSI < n u`, `CSI = n ; m u`) et répond à la requête
|
||||
//! `CSI ? u`. Il n'encode en revanche aucune touche : c'est le rôle du frontal,
|
||||
//! donc de ce module. Il ne sert que quand l'application au premier plan a
|
||||
//! demandé le protocole ; sinon l'encodage legacy de `terminal_box` s'applique
|
||||
//! seul.
|
||||
//!
|
||||
//! C'est ce qui donne Shift+Entrée (`CSI 13;2u`) à Claude Code, qui interroge
|
||||
//! le terminal avec `CSI ? u` au démarrage.
|
||||
//!
|
||||
//! Spécification : <https://sw.kovidgoyal.net/kitty/keyboard-protocol/>. La
|
||||
//! découpe suit l'encodeur du frontal Alacritty (`alacritty/src/input/keyboard.rs`).
|
||||
//!
|
||||
//! Limites : les drapeaux 2 (types d'événements), 4 (touches alternatives) et
|
||||
//! 16 (texte associé) ne sont honorés qu'avec le drapeau 1 (désambiguïsation)
|
||||
//! ou 8 (toutes les touches), ce que font toutes les applications connues.
|
||||
//! La touche de la disposition de base (3e champ du drapeau 4) n'est pas
|
||||
//! envoyée, et Caps Lock / Num Lock ne figurent pas dans les modificateurs
|
||||
//! (iced ne les expose pas).
|
||||
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use cosmic::iced::keyboard::{Key, Location, Modifiers, key::Named};
|
||||
|
||||
/// Nature de l'événement clavier.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum KeyAction {
|
||||
Press,
|
||||
Repeat,
|
||||
Release,
|
||||
}
|
||||
|
||||
/// Événement clavier tel que livré par iced.
|
||||
pub struct KeyInput<'a> {
|
||||
/// Touche sans aucun modificateur (`key_without_modifiers` de winit).
|
||||
pub key: &'a Key,
|
||||
/// Touche avec Shift (et AltGr) appliqués, mais pas Ctrl (`logical_key`).
|
||||
pub modified_key: &'a Key,
|
||||
pub location: Location,
|
||||
pub modifiers: Modifiers,
|
||||
/// Texte produit, tous modificateurs appliqués.
|
||||
pub text: Option<&'a str>,
|
||||
pub action: KeyAction,
|
||||
}
|
||||
|
||||
const SHIFT: u8 = 1;
|
||||
const ALT: u8 = 2;
|
||||
const CTRL: u8 = 4;
|
||||
const SUPER: u8 = 8;
|
||||
|
||||
/// Le protocole kitty pilote-t-il l'encodage des touches ?
|
||||
pub fn active(mode: TermMode) -> bool {
|
||||
mode.intersects(TermMode::DISAMBIGUATE_ESC_CODES | TermMode::REPORT_ALL_KEYS_AS_ESC)
|
||||
}
|
||||
|
||||
/// Octets à envoyer au PTY quand [`active`] est vrai. Vide : rien à envoyer.
|
||||
pub fn encode(input: &KeyInput, mode: TermMode) -> Vec<u8> {
|
||||
let all = mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC);
|
||||
let mods = modifier_bits(input);
|
||||
let named = match input.modified_key {
|
||||
Key::Named(named) => Some(*named),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if input.action == KeyAction::Release {
|
||||
if !mode.contains(TermMode::REPORT_EVENT_TYPES) {
|
||||
return Vec::new();
|
||||
}
|
||||
// Pas de relâchement d'Entrée, Tab et Retour arrière hors « toutes les
|
||||
// touches » : le shell qui reprend la main après un plantage les
|
||||
// recevrait.
|
||||
if !all && matches!(named, Some(Named::Enter | Named::Tab | Named::Backspace)) {
|
||||
return Vec::new();
|
||||
}
|
||||
return build_sequence(input, mode, mods).unwrap_or_default();
|
||||
}
|
||||
|
||||
// Sans modificateur, Entrée, Tab et Retour arrière gardent leurs octets
|
||||
// legacy (spécification : un `reset` doit rester tapable). L'Entrée du
|
||||
// pavé numérique, elle, a son code dédié.
|
||||
if !all && mods == 0 && input.location != Location::Numpad {
|
||||
match named {
|
||||
Some(Named::Enter) => return b"\r".to_vec(),
|
||||
Some(Named::Tab) => return b"\t".to_vec(),
|
||||
Some(Named::Backspace) => return b"\x7f".to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let text = printable_text(input);
|
||||
let as_sequence = all
|
||||
|| text.is_none()
|
||||
|| named == Some(Named::Escape)
|
||||
|| (mods != 0 && mods != SHIFT)
|
||||
|| (mods == SHIFT && matches!(named, Some(Named::Enter | Named::Tab | Named::Backspace)));
|
||||
|
||||
if as_sequence {
|
||||
build_sequence(input, mode, mods).unwrap_or_default()
|
||||
} else {
|
||||
text.unwrap_or_default().as_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Texte imprimable produit par la touche, s'il y en a un.
|
||||
fn printable_text<'a>(input: &KeyInput<'a>) -> Option<&'a str> {
|
||||
input
|
||||
.text
|
||||
.filter(|text| !text.is_empty() && !text.chars().any(char::is_control))
|
||||
}
|
||||
|
||||
fn modifier_bits(input: &KeyInput) -> u8 {
|
||||
let mut bits = 0;
|
||||
if input.modifiers.shift() {
|
||||
bits |= SHIFT;
|
||||
}
|
||||
if input.modifiers.alt() {
|
||||
bits |= ALT;
|
||||
}
|
||||
if input.modifiers.control() {
|
||||
bits |= CTRL;
|
||||
}
|
||||
if input.modifiers.logo() {
|
||||
bits |= SUPER;
|
||||
}
|
||||
// Pour une touche de modification, l'état rapporté est celui qui suit
|
||||
// l'événement : le bit est posé à l'appui, retiré au relâchement.
|
||||
let own = match input.modified_key {
|
||||
Key::Named(Named::Shift) => SHIFT,
|
||||
Key::Named(Named::Alt) => ALT,
|
||||
Key::Named(Named::Control) => CTRL,
|
||||
Key::Named(Named::Super) => SUPER,
|
||||
_ => 0,
|
||||
};
|
||||
if input.action == KeyAction::Release {
|
||||
bits & !own
|
||||
} else {
|
||||
bits | own
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminateur d'une séquence : `u` pour les codes kitty, sinon le final
|
||||
/// legacy (`A`, `~`…) que le protocole conserve pour les touches de fonction.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Final {
|
||||
Kitty,
|
||||
Legacy(char),
|
||||
}
|
||||
|
||||
fn build_sequence(input: &KeyInput, mode: TermMode, mods: u8) -> Option<Vec<u8>> {
|
||||
let event_type = mode.contains(TermMode::REPORT_EVENT_TYPES)
|
||||
&& matches!(input.action, KeyAction::Repeat | KeyAction::Release);
|
||||
let associated_text = printable_text(input).filter(|_| {
|
||||
mode.contains(TermMode::REPORT_ASSOCIATED_TEXT) && input.action != KeyAction::Release
|
||||
});
|
||||
// Le paramètre par défaut vaut 1 : `CSI A` plutôt que `CSI 1 A`.
|
||||
let explicit = mods != 0 || event_type || associated_text.is_some();
|
||||
|
||||
let (payload, terminator) = numpad(input)
|
||||
.or_else(|| functional(input, explicit))
|
||||
.or_else(|| special(input, mode))
|
||||
.or_else(|| textual(input, mode, associated_text.is_some()))?;
|
||||
|
||||
let mut sequence = format!("\x1b[{payload}");
|
||||
if explicit {
|
||||
sequence.push_str(&format!(";{}", mods + 1));
|
||||
}
|
||||
if event_type {
|
||||
let kind = match input.action {
|
||||
KeyAction::Press => '1',
|
||||
KeyAction::Repeat => '2',
|
||||
KeyAction::Release => '3',
|
||||
};
|
||||
sequence.push(':');
|
||||
sequence.push(kind);
|
||||
}
|
||||
if let Some(text) = associated_text {
|
||||
let codepoints: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();
|
||||
sequence.push(';');
|
||||
sequence.push_str(&codepoints.join(":"));
|
||||
}
|
||||
sequence.push(match terminator {
|
||||
Final::Kitty => 'u',
|
||||
Final::Legacy(c) => c,
|
||||
});
|
||||
Some(sequence.into_bytes())
|
||||
}
|
||||
|
||||
/// Pavé numérique : codes kitty dédiés (57399 et suivants).
|
||||
fn numpad(input: &KeyInput) -> Option<(String, Final)> {
|
||||
if input.location != Location::Numpad {
|
||||
return None;
|
||||
}
|
||||
let code = match input.modified_key {
|
||||
Key::Character(c) => match c.as_str() {
|
||||
"0" => 57399,
|
||||
"1" => 57400,
|
||||
"2" => 57401,
|
||||
"3" => 57402,
|
||||
"4" => 57403,
|
||||
"5" => 57404,
|
||||
"6" => 57405,
|
||||
"7" => 57406,
|
||||
"8" => 57407,
|
||||
"9" => 57408,
|
||||
"." | "," => 57409,
|
||||
"/" => 57410,
|
||||
"*" => 57411,
|
||||
"-" => 57412,
|
||||
"+" => 57413,
|
||||
"=" => 57415,
|
||||
_ => return None,
|
||||
},
|
||||
Key::Named(named) => match named {
|
||||
Named::Enter => 57414,
|
||||
Named::ArrowLeft => 57417,
|
||||
Named::ArrowRight => 57418,
|
||||
Named::ArrowUp => 57419,
|
||||
Named::ArrowDown => 57420,
|
||||
Named::PageUp => 57421,
|
||||
Named::PageDown => 57422,
|
||||
Named::Home => 57423,
|
||||
Named::End => 57424,
|
||||
Named::Insert => 57425,
|
||||
Named::Delete => 57426,
|
||||
Named::Clear => 57427,
|
||||
_ => return None,
|
||||
},
|
||||
Key::Unidentified => return None,
|
||||
};
|
||||
Some((code.to_string(), Final::Kitty))
|
||||
}
|
||||
|
||||
/// Touches de fonction : finals legacy conservés par le protocole, sauf F3
|
||||
/// (`CSI R` serait pris pour un rapport de position du curseur) et F13+.
|
||||
fn functional(input: &KeyInput, explicit: bool) -> Option<(String, Final)> {
|
||||
let Key::Named(named) = input.modified_key else {
|
||||
return None;
|
||||
};
|
||||
let one = if explicit { "1" } else { "" };
|
||||
let (payload, terminator) = match named {
|
||||
Named::ArrowUp => (one, Final::Legacy('A')),
|
||||
Named::ArrowDown => (one, Final::Legacy('B')),
|
||||
Named::ArrowRight => (one, Final::Legacy('C')),
|
||||
Named::ArrowLeft => (one, Final::Legacy('D')),
|
||||
Named::End => (one, Final::Legacy('F')),
|
||||
Named::Home => (one, Final::Legacy('H')),
|
||||
Named::F1 => (one, Final::Legacy('P')),
|
||||
Named::F2 => (one, Final::Legacy('Q')),
|
||||
Named::F3 => ("13", Final::Legacy('~')),
|
||||
Named::F4 => (one, Final::Legacy('S')),
|
||||
Named::Insert => ("2", Final::Legacy('~')),
|
||||
Named::Delete => ("3", Final::Legacy('~')),
|
||||
Named::PageUp => ("5", Final::Legacy('~')),
|
||||
Named::PageDown => ("6", Final::Legacy('~')),
|
||||
Named::F5 => ("15", Final::Legacy('~')),
|
||||
Named::F6 => ("17", Final::Legacy('~')),
|
||||
Named::F7 => ("18", Final::Legacy('~')),
|
||||
Named::F8 => ("19", Final::Legacy('~')),
|
||||
Named::F9 => ("20", Final::Legacy('~')),
|
||||
Named::F10 => ("21", Final::Legacy('~')),
|
||||
Named::F11 => ("23", Final::Legacy('~')),
|
||||
Named::F12 => ("24", Final::Legacy('~')),
|
||||
_ => {
|
||||
let code = match named {
|
||||
Named::F13 => 57376,
|
||||
Named::F14 => 57377,
|
||||
Named::F15 => 57378,
|
||||
Named::F16 => 57379,
|
||||
Named::F17 => 57380,
|
||||
Named::F18 => 57381,
|
||||
Named::F19 => 57382,
|
||||
Named::F20 => 57383,
|
||||
Named::F21 => 57384,
|
||||
Named::F22 => 57385,
|
||||
Named::F23 => 57386,
|
||||
Named::F24 => 57387,
|
||||
Named::CapsLock => 57358,
|
||||
Named::ScrollLock => 57359,
|
||||
Named::NumLock => 57360,
|
||||
Named::PrintScreen => 57361,
|
||||
Named::Pause => 57362,
|
||||
Named::ContextMenu => 57363,
|
||||
Named::MediaPlay => 57428,
|
||||
Named::MediaPause => 57429,
|
||||
Named::MediaPlayPause => 57430,
|
||||
Named::MediaStop => 57432,
|
||||
Named::MediaFastForward => 57433,
|
||||
Named::MediaRewind => 57434,
|
||||
Named::MediaTrackNext => 57435,
|
||||
Named::MediaTrackPrevious => 57436,
|
||||
Named::MediaRecord => 57437,
|
||||
Named::AudioVolumeDown => 57438,
|
||||
Named::AudioVolumeUp => 57439,
|
||||
Named::AudioVolumeMute => 57440,
|
||||
_ => return None,
|
||||
};
|
||||
return Some((code.to_string(), Final::Kitty));
|
||||
}
|
||||
};
|
||||
Some((payload.to_string(), terminator))
|
||||
}
|
||||
|
||||
/// Touches de contrôle (Entrée, Tab, Échap, Retour arrière, Espace) et, avec
|
||||
/// « toutes les touches », les touches de modification elles-mêmes.
|
||||
fn special(input: &KeyInput, mode: TermMode) -> Option<(String, Final)> {
|
||||
let code = match input.modified_key {
|
||||
Key::Named(Named::Enter) => 13,
|
||||
Key::Named(Named::Tab) => 9,
|
||||
Key::Named(Named::Escape) => 27,
|
||||
Key::Named(Named::Backspace) => 127,
|
||||
Key::Character(c) if c == " " => 32,
|
||||
Key::Named(modifier) if mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) => {
|
||||
let right = input.location == Location::Right;
|
||||
match (modifier, right) {
|
||||
(Named::Shift, false) => 57441,
|
||||
(Named::Control, false) => 57442,
|
||||
(Named::Alt, false) => 57443,
|
||||
(Named::Super, false) => 57444,
|
||||
(Named::Hyper, false) => 57445,
|
||||
(Named::Meta, false) => 57446,
|
||||
(Named::Shift, true) => 57447,
|
||||
(Named::Control, true) => 57448,
|
||||
(Named::Alt, true) => 57449,
|
||||
(Named::Super, true) => 57450,
|
||||
(Named::Hyper, true) => 57451,
|
||||
(Named::Meta, true) => 57452,
|
||||
(Named::AltGraph, _) => 57453,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some((code.to_string(), Final::Kitty))
|
||||
}
|
||||
|
||||
/// Touches de caractère : code de la touche non décalée, suivi de la touche
|
||||
/// décalée si le drapeau 4 le demande (`CSI 97:65;6u` pour Ctrl+Shift+A).
|
||||
fn textual(input: &KeyInput, mode: TermMode, has_text: bool) -> Option<(String, Final)> {
|
||||
let Key::Character(shifted) = input.modified_key else {
|
||||
return None;
|
||||
};
|
||||
let mut chars = shifted.chars();
|
||||
let (Some(shifted_char), None) = (chars.next(), chars.next()) else {
|
||||
// Plusieurs caractères pour une touche : seul « toutes les touches »
|
||||
// avec texte associé sait le dire, par le code 0.
|
||||
return (mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) && has_text)
|
||||
.then(|| ("0".to_string(), Final::Kitty));
|
||||
};
|
||||
|
||||
let unshifted_char = if input.modifiers.shift() {
|
||||
shifted_char.to_lowercase().next().unwrap_or(shifted_char)
|
||||
} else {
|
||||
shifted_char
|
||||
};
|
||||
let mut code = u32::from(unshifted_char);
|
||||
let alternate = u32::from(shifted_char);
|
||||
// `!` n'a pas de minuscule : la touche de base est celle sans modificateur
|
||||
// (`1` en QWERTY ; `&` pour le `1` de l'AZERTY).
|
||||
if code == alternate
|
||||
&& let Key::Character(base) = input.key
|
||||
&& let Some(base_char) = base.chars().next()
|
||||
{
|
||||
code = u32::from(base_char);
|
||||
}
|
||||
|
||||
let payload = if mode.contains(TermMode::REPORT_ALTERNATE_KEYS) && alternate != code {
|
||||
format!("{code}:{alternate}")
|
||||
} else {
|
||||
code.to_string()
|
||||
};
|
||||
Some((payload, Final::Kitty))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DISAMBIGUATE: TermMode = TermMode::DISAMBIGUATE_ESC_CODES;
|
||||
|
||||
fn ch(c: &str) -> Key {
|
||||
Key::Character(c.into())
|
||||
}
|
||||
|
||||
fn named(n: Named) -> Key {
|
||||
Key::Named(n)
|
||||
}
|
||||
|
||||
fn press(
|
||||
key: Key,
|
||||
modified: Key,
|
||||
modifiers: Modifiers,
|
||||
text: Option<&str>,
|
||||
mode: TermMode,
|
||||
) -> String {
|
||||
event(key, modified, modifiers, text, KeyAction::Press, mode)
|
||||
}
|
||||
|
||||
fn event(
|
||||
key: Key,
|
||||
modified: Key,
|
||||
modifiers: Modifiers,
|
||||
text: Option<&str>,
|
||||
action: KeyAction,
|
||||
mode: TermMode,
|
||||
) -> String {
|
||||
let input = KeyInput {
|
||||
key: &key,
|
||||
modified_key: &modified,
|
||||
location: Location::Standard,
|
||||
modifiers,
|
||||
text,
|
||||
action,
|
||||
};
|
||||
String::from_utf8(encode(&input, mode)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_for_claude_code() {
|
||||
let enter = || named(Named::Enter);
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::SHIFT, Some("\r"), DISAMBIGUATE),
|
||||
"\x1b[13;2u"
|
||||
);
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::CTRL, Some("\r"), DISAMBIGUATE),
|
||||
"\x1b[13;5u"
|
||||
);
|
||||
// Entrée seule reste `\r`
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::empty(), Some("\r"), DISAMBIGUATE),
|
||||
"\r"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_tab_backspace() {
|
||||
let esc = || named(Named::Escape);
|
||||
assert_eq!(
|
||||
press(esc(), esc(), Modifiers::empty(), Some("\x1b"), DISAMBIGUATE),
|
||||
"\x1b[27u"
|
||||
);
|
||||
let tab = || named(Named::Tab);
|
||||
assert_eq!(
|
||||
press(tab(), tab(), Modifiers::empty(), Some("\t"), DISAMBIGUATE),
|
||||
"\t"
|
||||
);
|
||||
assert_eq!(
|
||||
press(tab(), tab(), Modifiers::SHIFT, Some("\t"), DISAMBIGUATE),
|
||||
"\x1b[9;2u"
|
||||
);
|
||||
let bs = || named(Named::Backspace);
|
||||
assert_eq!(
|
||||
press(bs(), bs(), Modifiers::empty(), Some("\x08"), DISAMBIGUATE),
|
||||
"\x7f"
|
||||
);
|
||||
assert_eq!(
|
||||
press(bs(), bs(), Modifiers::CTRL, Some("\x08"), DISAMBIGUATE),
|
||||
"\x1b[127;5u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keys() {
|
||||
// Texte simple ou décalé : envoyé tel quel
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("a"), Modifiers::empty(), Some("a"), DISAMBIGUATE),
|
||||
"a"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("A"), Modifiers::SHIFT, Some("A"), DISAMBIGUATE),
|
||||
"A"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch("é"), ch("é"), Modifiers::empty(), Some("é"), DISAMBIGUATE),
|
||||
"é"
|
||||
);
|
||||
// Ctrl et Alt : séquences
|
||||
assert_eq!(
|
||||
press(ch("c"), ch("c"), Modifiers::CTRL, Some("\x03"), DISAMBIGUATE),
|
||||
"\x1b[99;5u"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("a"), Modifiers::ALT, Some("a"), DISAMBIGUATE),
|
||||
"\x1b[97;3u"
|
||||
);
|
||||
assert_eq!(
|
||||
press(ch(" "), ch(" "), Modifiers::CTRL, Some("\0"), DISAMBIGUATE),
|
||||
"\x1b[32;5u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_keys() {
|
||||
let mode = DISAMBIGUATE | TermMode::REPORT_ALTERNATE_KEYS;
|
||||
assert_eq!(
|
||||
press(
|
||||
ch("a"),
|
||||
ch("A"),
|
||||
Modifiers::CTRL | Modifiers::SHIFT,
|
||||
Some("\x01"),
|
||||
mode
|
||||
),
|
||||
"\x1b[97:65;6u"
|
||||
);
|
||||
// AZERTY : Ctrl+Shift+& donne « 1 »
|
||||
assert_eq!(
|
||||
press(
|
||||
ch("&"),
|
||||
ch("1"),
|
||||
Modifiers::CTRL | Modifiers::SHIFT,
|
||||
None,
|
||||
mode
|
||||
),
|
||||
"\x1b[38:49;6u"
|
||||
);
|
||||
// Sans le drapeau 4, pas d'alternative
|
||||
assert_eq!(
|
||||
press(
|
||||
ch("a"),
|
||||
ch("A"),
|
||||
Modifiers::CTRL | Modifiers::SHIFT,
|
||||
Some("\x01"),
|
||||
DISAMBIGUATE
|
||||
),
|
||||
"\x1b[97;6u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_keys() {
|
||||
let up = || named(Named::ArrowUp);
|
||||
assert_eq!(
|
||||
press(up(), up(), Modifiers::empty(), None, DISAMBIGUATE),
|
||||
"\x1b[A"
|
||||
);
|
||||
assert_eq!(
|
||||
press(up(), up(), Modifiers::CTRL, None, DISAMBIGUATE),
|
||||
"\x1b[1;5A"
|
||||
);
|
||||
let f3 = || named(Named::F3);
|
||||
assert_eq!(
|
||||
press(f3(), f3(), Modifiers::empty(), None, DISAMBIGUATE),
|
||||
"\x1b[13~"
|
||||
);
|
||||
let del = || named(Named::Delete);
|
||||
assert_eq!(
|
||||
press(del(), del(), Modifiers::SHIFT, None, DISAMBIGUATE),
|
||||
"\x1b[3;2~"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_types() {
|
||||
let mode = DISAMBIGUATE | TermMode::REPORT_EVENT_TYPES;
|
||||
assert_eq!(
|
||||
event(
|
||||
ch("a"),
|
||||
ch("a"),
|
||||
Modifiers::empty(),
|
||||
None,
|
||||
KeyAction::Release,
|
||||
mode
|
||||
),
|
||||
"\x1b[97;1:3u"
|
||||
);
|
||||
assert_eq!(
|
||||
event(
|
||||
ch("a"),
|
||||
ch("a"),
|
||||
Modifiers::CTRL,
|
||||
Some("\x01"),
|
||||
KeyAction::Repeat,
|
||||
mode
|
||||
),
|
||||
"\x1b[97;5:2u"
|
||||
);
|
||||
// Pas de relâchement d'Entrée hors « toutes les touches »
|
||||
let enter = || named(Named::Enter);
|
||||
assert_eq!(
|
||||
event(
|
||||
enter(),
|
||||
enter(),
|
||||
Modifiers::empty(),
|
||||
None,
|
||||
KeyAction::Release,
|
||||
mode
|
||||
),
|
||||
""
|
||||
);
|
||||
// Ni de relâchement du tout sans le drapeau 2
|
||||
assert_eq!(
|
||||
event(
|
||||
ch("a"),
|
||||
ch("a"),
|
||||
Modifiers::empty(),
|
||||
None,
|
||||
KeyAction::Release,
|
||||
DISAMBIGUATE
|
||||
),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_all_keys() {
|
||||
let mode = TermMode::REPORT_ALL_KEYS_AS_ESC | TermMode::REPORT_ASSOCIATED_TEXT;
|
||||
assert_eq!(
|
||||
press(ch("a"), ch("a"), Modifiers::empty(), Some("a"), mode),
|
||||
"\x1b[97;1;97u"
|
||||
);
|
||||
let enter = || named(Named::Enter);
|
||||
assert_eq!(
|
||||
press(enter(), enter(), Modifiers::empty(), Some("\r"), mode),
|
||||
"\x1b[13u"
|
||||
);
|
||||
// Touche de modification : son propre bit est posé à l'appui
|
||||
let shift = || named(Named::Shift);
|
||||
assert_eq!(
|
||||
press(shift(), shift(), Modifiers::empty(), None, mode),
|
||||
"\x1b[57441;2u"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numpad_keys() {
|
||||
let input = |key: &Key, text| {
|
||||
String::from_utf8(encode(
|
||||
&KeyInput {
|
||||
key,
|
||||
modified_key: key,
|
||||
location: Location::Numpad,
|
||||
modifiers: Modifiers::empty(),
|
||||
text,
|
||||
action: KeyAction::Press,
|
||||
},
|
||||
DISAMBIGUATE,
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
// Un chiffre du pavé produit du texte : envoyé tel quel
|
||||
assert_eq!(input(&ch("5"), Some("5")), "5");
|
||||
// Entrée du pavé n'en produit pas d'imprimable : code dédié
|
||||
assert_eq!(input(&named(Named::Enter), Some("\r")), "\x1b[57414u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_with_disambiguate_or_all() {
|
||||
assert!(!active(TermMode::empty()));
|
||||
assert!(!active(TermMode::REPORT_EVENT_TYPES));
|
||||
assert!(active(DISAMBIGUATE));
|
||||
assert!(active(TermMode::REPORT_ALL_KEYS_AS_ESC));
|
||||
}
|
||||
|
||||
/// Le terminal répond à `CSI ? u` et applique `CSI > 5 u` dès que
|
||||
/// `kitty_keyboard` est actif : c'est ce que Claude Code interroge.
|
||||
#[test]
|
||||
fn term_reports_and_applies_kitty_modes() {
|
||||
use alacritty_terminal::{
|
||||
Term,
|
||||
event::{Event, EventListener},
|
||||
term::{Config, test::TermSize},
|
||||
vte::ansi::Processor,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Replies(Arc<Mutex<Vec<String>>>);
|
||||
impl EventListener for Replies {
|
||||
fn send_event(&self, event: Event) {
|
||||
if let Event::PtyWrite(text) = event {
|
||||
self.0.lock().unwrap().push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replies = Replies::default();
|
||||
let config = Config {
|
||||
kitty_keyboard: true,
|
||||
..Config::default()
|
||||
};
|
||||
let mut term = Term::new(config, &TermSize::new(80, 24), replies.clone());
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, b"\x1b[?u\x1b[>5u\x1b[?u");
|
||||
|
||||
assert_eq!(*replies.0.lock().unwrap(), ["\x1b[?0u", "\x1b[?5u"]);
|
||||
let mode = *term.mode();
|
||||
assert!(mode.contains(TermMode::DISAMBIGUATE_ESC_CODES));
|
||||
assert!(mode.contains(TermMode::REPORT_ALTERNATE_KEYS));
|
||||
assert!(active(mode));
|
||||
|
||||
parser.advance(&mut term, b"\x1b[<u");
|
||||
assert!(!active(*term.mode()));
|
||||
}
|
||||
}
|
||||
506
src/main.rs
506
src/main.rs
|
|
@ -68,9 +68,19 @@ mod localize;
|
|||
use menu::menu_bar;
|
||||
mod menu;
|
||||
|
||||
use terminal::{MONITOR_SILENCE_SECONDS, MonitorKind, Terminal, TerminalPaneGrid, TerminalScroll};
|
||||
use terminal::{
|
||||
AttentionKind, MONITOR_SILENCE_SECONDS, MonitorKind, PtyEvent, Terminal, TerminalPaneGrid,
|
||||
TerminalScroll,
|
||||
};
|
||||
mod terminal;
|
||||
|
||||
mod desktop_notify;
|
||||
|
||||
use osc_tap::OscEvent;
|
||||
mod osc_tap;
|
||||
|
||||
mod kitty_keyboard;
|
||||
|
||||
use terminal_box::terminal_box;
|
||||
|
||||
use crate::dnd::DndDrop;
|
||||
|
|
@ -94,11 +104,18 @@ pub fn icon_cache_get(name: &'static str, size: u16) -> widget::icon::Icon {
|
|||
|
||||
/// Tab bar indicator for the monitor state of a terminal:
|
||||
/// an alert icon if one fired, an "armed" icon while monitoring, else none.
|
||||
/// Un appel d'attention (cloche, notification) passe avant les moniteurs.
|
||||
fn monitor_icon_name(terminal: &Terminal) -> Option<&'static str> {
|
||||
match terminal.attention {
|
||||
Some(AttentionKind::Bell) => return Some("notification-alert-symbolic"),
|
||||
Some(AttentionKind::Notification) => return Some("notification-new-symbolic"),
|
||||
None => {}
|
||||
}
|
||||
match terminal.monitor_alert {
|
||||
Some(MonitorKind::Activity) => Some("dialog-information-symbolic"),
|
||||
Some(MonitorKind::Silence) => Some("alarm-symbolic"),
|
||||
Some(MonitorKind::ProcessFinished) => Some("emblem-ok-symbolic"),
|
||||
None if terminal.read_only => Some("changes-prevent-symbolic"),
|
||||
None if terminal.monitoring_enabled() => Some("view-reveal-symbolic"),
|
||||
None => None,
|
||||
}
|
||||
|
|
@ -297,6 +314,9 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
// Terminal config setup
|
||||
let term_config = term::Config {
|
||||
scrolling_history: 100_000,
|
||||
// [yoda] Protocole clavier kitty : alacritty_terminal tient la pile des
|
||||
// modes et répond à `CSI ? u`, l'encodage est dans `kitty_keyboard`.
|
||||
kitty_keyboard: true,
|
||||
..term::Config::default()
|
||||
};
|
||||
// Set up environmental variables for terminal
|
||||
|
|
@ -371,6 +391,7 @@ pub enum Action {
|
|||
LayoutLoad,
|
||||
LayoutSave,
|
||||
Monitor(MonitorKind),
|
||||
ToggleReadOnly,
|
||||
PaneFocusDown,
|
||||
PaneFocusLeft,
|
||||
PaneFocusRight,
|
||||
|
|
@ -430,6 +451,7 @@ impl Action {
|
|||
Self::LayoutLoad => Message::LayoutLoad,
|
||||
Self::LayoutSave => Message::LayoutSave,
|
||||
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
|
||||
Self::ToggleReadOnly => Message::ReadOnlyToggle(entity_opt),
|
||||
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
|
||||
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
|
||||
Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
|
||||
|
|
@ -537,6 +559,7 @@ pub enum Message {
|
|||
ShortcutSearch(String),
|
||||
MonitorTick,
|
||||
MonitorToggle(MonitorKind, Option<segmented_button::Entity>),
|
||||
ReadOnlyToggle(Option<segmented_button::Entity>),
|
||||
MouseEnter(pane_grid::Pane),
|
||||
Opacity(u8),
|
||||
PaneClicked(pane_grid::Pane),
|
||||
|
|
@ -566,16 +589,19 @@ pub enum Message {
|
|||
ReorderTab(Pane, ReorderEvent),
|
||||
SaveOutput(Option<segmented_button::Entity>),
|
||||
SaveOutputResult(pane_grid::Pane, segmented_button::Entity, DialogResult),
|
||||
Surface(surface::Action),
|
||||
Surface(surface::Action<Message>),
|
||||
SelectAll(Option<segmented_button::Entity>),
|
||||
ShowAdvancedFontSettings(bool),
|
||||
ShowHeaderBar(bool),
|
||||
SshConnect(usize),
|
||||
ShowPaneBorders(bool),
|
||||
UnfocusedPaneDim(u8),
|
||||
|
||||
SyntaxTheme(ColorSchemeKind, usize),
|
||||
SystemThemeChange,
|
||||
TabNewInheritWorkingDirectory(bool),
|
||||
CopyOnSelectClipboard(bool),
|
||||
SelectionDone(pane_grid::Pane, segmented_button::Entity),
|
||||
TabActivate(segmented_button::Entity),
|
||||
TabActivateJump(usize),
|
||||
TabClose(Option<segmented_button::Entity>),
|
||||
|
|
@ -592,7 +618,9 @@ pub enum Message {
|
|||
TabRenameCancel,
|
||||
TabRenameResetToDefault,
|
||||
TermEvent(pane_grid::Pane, segmented_button::Entity, TermEvent),
|
||||
TermEventTx(mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>),
|
||||
TermEventTx(mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>),
|
||||
TermOsc(pane_grid::Pane, segmented_button::Entity, OscEvent),
|
||||
DesktopNotify(desktop_notify::Event),
|
||||
ToggleFullscreen,
|
||||
ToggleContextPage(ContextPage),
|
||||
UpdateDefaultProfile((bool, ProfileId)),
|
||||
|
|
@ -604,7 +632,6 @@ pub enum Message {
|
|||
ZoomIn,
|
||||
ZoomOut,
|
||||
ZoomReset,
|
||||
ContextMenuPopupClosed(window::Id),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
|
|
@ -659,7 +686,9 @@ pub struct App {
|
|||
find_search_id: widget::Id,
|
||||
find_search_value: String,
|
||||
term_event_tx_opt:
|
||||
Option<mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>>,
|
||||
Option<mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>>,
|
||||
notify_tx_opt: Option<mpsc::UnboundedSender<desktop_notify::Request>>,
|
||||
window_focused: bool,
|
||||
startup_options: Option<tty::Options>,
|
||||
startup_profile_id: Option<ProfileId>,
|
||||
term_config: term::Config,
|
||||
|
|
@ -682,19 +711,56 @@ pub struct App {
|
|||
shortcut_search_regex: Option<regex::Regex>,
|
||||
shortcut_search_value: String,
|
||||
modifiers: Modifiers,
|
||||
context_menu_popup: Option<(
|
||||
window::Id,
|
||||
pane_grid::Pane,
|
||||
segmented_button::Entity,
|
||||
Option<String>,
|
||||
widget::Id,
|
||||
cosmic::iced::Point,
|
||||
)>,
|
||||
#[cfg(feature = "password_manager")]
|
||||
password_mgr: password_manager::PasswordManager,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// L'onglet est-il sous les yeux de l'utilisateur : onglet actif du panneau
|
||||
/// focalisé, dans une fenêtre qui a le focus.
|
||||
fn tab_in_view(&self, pane: pane_grid::Pane, entity: segmented_button::Entity) -> bool {
|
||||
self.window_focused
|
||||
&& self.pane_model.focused() == pane
|
||||
&& self
|
||||
.pane_model
|
||||
.panes
|
||||
.get(pane)
|
||||
.is_some_and(|tab_model| tab_model.active() == entity)
|
||||
}
|
||||
|
||||
/// Allume l'indicateur d'attention d'un onglet ; il s'éteint quand
|
||||
/// l'onglet reprend le focus (voir `TerminalPaneGrid::update_terminal_focus`).
|
||||
fn set_tab_attention(
|
||||
&mut self,
|
||||
pane: pane_grid::Pane,
|
||||
entity: segmented_button::Entity,
|
||||
kind: AttentionKind,
|
||||
) {
|
||||
let Some(tab_model) = self.pane_model.panes.get_mut(pane) else {
|
||||
return;
|
||||
};
|
||||
let mut icon_update = None;
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
// Une notification n'est pas rétrogradée en simple cloche
|
||||
if terminal.attention != Some(AttentionKind::Notification) {
|
||||
terminal.attention = Some(kind);
|
||||
icon_update = Some(monitor_icon_name(&terminal));
|
||||
}
|
||||
}
|
||||
if let Some(icon_name) = icon_update {
|
||||
monitor_update_tab_icon(tab_model, entity, icon_name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recale le focus des terminaux après un changement d'onglet : l'onglet
|
||||
/// quitté reçoit son focus-out (mode 1004), le nouvel onglet son focus-in.
|
||||
fn sync_terminal_focus(&mut self) {
|
||||
if self.window_focused && !self.core.window.show_context && self.rename_tab.is_none() {
|
||||
self.pane_model.update_terminal_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_names(&self, color_scheme_kind: ColorSchemeKind) -> &Vec<String> {
|
||||
match color_scheme_kind {
|
||||
ColorSchemeKind::Dark => &self.theme_names_dark,
|
||||
|
|
@ -1670,6 +1736,15 @@ impl App {
|
|||
.add(
|
||||
widget::settings::item::builder(fl!("show-pane-borders"))
|
||||
.toggler(self.config.show_pane_borders, Message::ShowPaneBorders),
|
||||
)
|
||||
.add(
|
||||
widget::settings::item::builder(fl!("unfocused-pane-dim"))
|
||||
.description(format!("{}%", self.config.unfocused_pane_dim))
|
||||
.control(widget::slider(
|
||||
0..=config::UNFOCUSED_PANE_DIM_MAX,
|
||||
self.config.unfocused_pane_dim,
|
||||
Message::UnfocusedPaneDim,
|
||||
)),
|
||||
);
|
||||
|
||||
let advanced_section = widget::settings::section()
|
||||
|
|
@ -1686,6 +1761,14 @@ impl App {
|
|||
self.config.tab_new_inherit_working_directory,
|
||||
Message::TabNewInheritWorkingDirectory,
|
||||
),
|
||||
)
|
||||
.add(
|
||||
widget::settings::item::builder(fl!("copy-on-select-clipboard"))
|
||||
.description(fl!("copy-on-select-clipboard-description"))
|
||||
.toggler(
|
||||
self.config.copy_on_select_clipboard,
|
||||
Message::CopyOnSelectClipboard,
|
||||
),
|
||||
);
|
||||
|
||||
widget::settings::view_column(vec![
|
||||
|
|
@ -1740,6 +1823,22 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn terminal_read_only(&self, pane: pane_grid::Pane, entity: segmented_button::Entity) -> bool {
|
||||
self.pane_model
|
||||
.panes
|
||||
.get(pane)
|
||||
.and_then(|tab_model| tab_model.data::<Mutex<Terminal>>(entity))
|
||||
.is_some_and(|terminal| terminal.lock().unwrap().read_only)
|
||||
}
|
||||
|
||||
fn active_terminal_read_only(&self) -> bool {
|
||||
let pane = self.pane_model.focused();
|
||||
self.pane_model
|
||||
.panes
|
||||
.get(pane)
|
||||
.is_some_and(|tab_model| self.terminal_read_only(pane, tab_model.active()))
|
||||
}
|
||||
|
||||
fn active_terminal_monitors(&self) -> (bool, bool, bool) {
|
||||
let pane = self.pane_model.focused();
|
||||
if let Some(tab_model) = self.pane_model.panes.get(pane) {
|
||||
|
|
@ -1999,6 +2098,7 @@ impl App {
|
|||
log::warn!("tried to create new tab before having event channel");
|
||||
}
|
||||
}
|
||||
self.sync_terminal_focus();
|
||||
self.update_title(Some(pane))
|
||||
}
|
||||
}
|
||||
|
|
@ -2194,6 +2294,8 @@ impl Application for App {
|
|||
startup_profile_id: flags.startup_profile_id,
|
||||
term_config: flags.term_config,
|
||||
term_event_tx_opt: None,
|
||||
notify_tx_opt: None,
|
||||
window_focused: true,
|
||||
color_scheme_errors: Vec::new(),
|
||||
color_scheme_expanded: None,
|
||||
color_scheme_renaming: None,
|
||||
|
|
@ -2213,7 +2315,6 @@ impl Application for App {
|
|||
shortcut_search_regex: None,
|
||||
shortcut_search_value: String::new(),
|
||||
modifiers: Modifiers::empty(),
|
||||
context_menu_popup: None,
|
||||
#[cfg(feature = "password_manager")]
|
||||
password_mgr: Default::default(),
|
||||
};
|
||||
|
|
@ -2963,6 +3064,20 @@ impl Application for App {
|
|||
}
|
||||
}
|
||||
}
|
||||
Message::ReadOnlyToggle(entity_opt) => {
|
||||
if let Some(tab_model) = self.pane_model.active_mut() {
|
||||
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
|
||||
let mut icon_update = None;
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.read_only = !terminal.read_only;
|
||||
icon_update = Some(monitor_icon_name(&terminal));
|
||||
}
|
||||
if let Some(icon_name) = icon_update {
|
||||
monitor_update_tab_icon(tab_model, entity, icon_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::MonitorToggle(monitor_kind, entity_opt) => {
|
||||
if let Some(tab_model) = self.pane_model.active_mut() {
|
||||
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
|
||||
|
|
@ -3299,6 +3414,24 @@ impl Application for App {
|
|||
return self.update_config();
|
||||
}
|
||||
}
|
||||
Message::CopyOnSelectClipboard(copy_on_select_clipboard) => {
|
||||
if copy_on_select_clipboard != self.config.copy_on_select_clipboard {
|
||||
config_set!(copy_on_select_clipboard, copy_on_select_clipboard);
|
||||
}
|
||||
}
|
||||
Message::SelectionDone(pane, entity) => {
|
||||
// [yoda] Copie à la sélection vers le presse-papiers ; la
|
||||
// sélection primaire, elle, suit le relâchement du bouton.
|
||||
if self.config.copy_on_select_clipboard
|
||||
&& let Some(tab_model) = self.pane_model.panes.get(pane)
|
||||
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
|
||||
{
|
||||
let text = terminal.lock().unwrap().term.lock().selection_to_string();
|
||||
if let Some(text) = text.filter(|text| !text.is_empty()) {
|
||||
return clipboard::write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::TabNewInheritWorkingDirectory(tab_new_inherit_working_directory) => {
|
||||
config_set!(
|
||||
tab_new_inherit_working_directory,
|
||||
|
|
@ -3310,6 +3443,12 @@ impl Application for App {
|
|||
config_set!(show_pane_borders, show_pane_borders);
|
||||
}
|
||||
}
|
||||
Message::UnfocusedPaneDim(dim) => {
|
||||
config_set!(
|
||||
unfocused_pane_dim,
|
||||
cmp::min(config::UNFOCUSED_PANE_DIM_MAX, dim)
|
||||
);
|
||||
}
|
||||
Message::UseBrightBold(use_bright_bold) => {
|
||||
if use_bright_bold != self.config.use_bright_bold {
|
||||
config_set!(use_bright_bold, use_bright_bold);
|
||||
|
|
@ -3377,6 +3516,7 @@ impl Application for App {
|
|||
monitor_update_tab_icon(tab_model, entity, icon_name);
|
||||
}
|
||||
}
|
||||
self.sync_terminal_focus();
|
||||
return self.update_title(None);
|
||||
}
|
||||
Message::TabActivateJump(pos) => {
|
||||
|
|
@ -3430,19 +3570,11 @@ impl Application for App {
|
|||
}
|
||||
}
|
||||
|
||||
self.sync_terminal_focus();
|
||||
return self.update_title(None);
|
||||
}
|
||||
Message::TabContextAction(entity, action) => {
|
||||
// Close context menu popup
|
||||
let mut tasks = Vec::new();
|
||||
if let Some((_popup_id, _, _, _, _, _)) = self.context_menu_popup.take() {
|
||||
#[cfg(feature = "wayland")]
|
||||
if is_wayland() {
|
||||
tasks.push(cosmic::task::message(Message::Surface(
|
||||
cosmic::surface::action::destroy_popup(_popup_id),
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Close terminal context menu state
|
||||
if let Some(tab_model) = self.pane_model.active()
|
||||
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
|
||||
|
|
@ -3451,11 +3583,8 @@ impl Application for App {
|
|||
//Some actions need the menu_state,
|
||||
//so only clear the position for them.
|
||||
match action {
|
||||
Action::LaunchUrlByMenu | Action::CopyUrlByMenu => {
|
||||
if let Some(context_menu) = terminal.context_menu.as_mut() {
|
||||
context_menu.position = None;
|
||||
}
|
||||
}
|
||||
// these read the link from the menu state and clear it themselves
|
||||
Action::LaunchUrlByMenu | Action::CopyUrlByMenu => {}
|
||||
_ => {
|
||||
terminal.context_menu = None;
|
||||
}
|
||||
|
|
@ -3465,94 +3594,29 @@ impl Application for App {
|
|||
return cosmic::Task::batch(tasks);
|
||||
}
|
||||
Message::TabContextMenu(pane, menu_state) => {
|
||||
#[allow(unused_mut)]
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
// Close existing context menu popup if any
|
||||
if let Some((_popup_id, _, _, _, _, _)) = self.context_menu_popup.take() {
|
||||
#[cfg(feature = "wayland")]
|
||||
if is_wayland() {
|
||||
tasks.push(cosmic::task::message(Message::Surface(
|
||||
cosmic::surface::action::destroy_popup(_popup_id),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all terminal context_menu state
|
||||
for (_, tab_model) in self.pane_model.panes.iter() {
|
||||
for entity in tab_model.iter() {
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.context_menu = None;
|
||||
if terminal.context_menu.take().is_some() {
|
||||
terminal.active_regex_match = None;
|
||||
terminal.needs_update = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(menu_state) = menu_state {
|
||||
if let Some(_position) = menu_state.position {
|
||||
let local_position = menu_state.local_position.unwrap_or(_position);
|
||||
if let Some(tab_model) = self.pane_model.panes.get(pane) {
|
||||
let entity = tab_model.active();
|
||||
let link = menu_state.link.clone();
|
||||
let popup_id = window::Id::unique();
|
||||
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.context_menu = Some(menu_state);
|
||||
}
|
||||
|
||||
self.context_menu_popup = Some((
|
||||
popup_id,
|
||||
pane,
|
||||
entity,
|
||||
link,
|
||||
widget::Id::unique(),
|
||||
local_position,
|
||||
));
|
||||
|
||||
#[cfg(feature = "wayland")]
|
||||
if is_wayland() {
|
||||
let main_window = self.core.main_window_id().unwrap();
|
||||
let pos_x = _position.x as i32;
|
||||
let pos_y = _position.y as i32;
|
||||
|
||||
tasks.push(cosmic::task::message(Message::Surface(
|
||||
cosmic::surface::action::app_popup(
|
||||
|_| Default::default(),
|
||||
move |_app: &mut Self| {
|
||||
use cosmic::cctk::wayland_protocols::xdg::shell::client::xdg_positioner::{Anchor, Gravity};
|
||||
use cosmic::iced::runtime::platform_specific::wayland::popup::{SctkPopupSettings, SctkPositioner};
|
||||
|
||||
SctkPopupSettings {
|
||||
parent: main_window,
|
||||
id: popup_id,
|
||||
positioner: SctkPositioner {
|
||||
size: None,
|
||||
anchor_rect: cosmic::iced::Rectangle {
|
||||
x: pos_x,
|
||||
y: pos_y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
anchor: Anchor::None,
|
||||
gravity: Gravity::BottomRight,
|
||||
reactive: true,
|
||||
..Default::default()
|
||||
},
|
||||
parent_size: None,
|
||||
grab: true,
|
||||
close_with_children: false,
|
||||
input_zone: None,
|
||||
}
|
||||
}, None),
|
||||
)));
|
||||
}
|
||||
}
|
||||
// A right press records what is under the cursor
|
||||
if let Some(menu_state) = menu_state
|
||||
&& let Some(tab_model) = self.pane_model.panes.get(pane)
|
||||
{
|
||||
let entity = tab_model.active();
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
terminal.lock().unwrap().context_menu = Some(menu_state);
|
||||
}
|
||||
self.pane_model.set_focus(pane);
|
||||
}
|
||||
|
||||
return cosmic::Task::batch(tasks);
|
||||
}
|
||||
Message::TabNew => {
|
||||
// The profile from `--profile` only applies to the first tab
|
||||
|
|
@ -3676,7 +3740,13 @@ impl Application for App {
|
|||
Message::TermEvent(pane, entity, event) => {
|
||||
match event {
|
||||
TermEvent::Bell => {
|
||||
//TODO: audible or visible bell options?
|
||||
// Comme le défaut de Ghostty (`bell-features = attention,title`),
|
||||
// sans la demande d'attention xdg-activation : cosmic-comp donne
|
||||
// un jeton valide à toute application non sandboxée, la fenêtre
|
||||
// volerait le focus. Il reste l'indicateur d'onglet.
|
||||
if !self.tab_in_view(pane, entity) {
|
||||
self.set_tab_attention(pane, entity, AttentionKind::Bell);
|
||||
}
|
||||
}
|
||||
TermEvent::ClipboardLoad(kind, callback) => {
|
||||
match kind {
|
||||
|
|
@ -3710,7 +3780,7 @@ impl Application for App {
|
|||
let terminal = terminal.lock().unwrap();
|
||||
let rgb = terminal.effective_color(index);
|
||||
let text = f(rgb);
|
||||
terminal.input_no_scroll(text.into_bytes());
|
||||
terminal.respond(text.into_bytes());
|
||||
}
|
||||
}
|
||||
TermEvent::CursorBlinkingChange => {
|
||||
|
|
@ -3724,7 +3794,7 @@ impl Application for App {
|
|||
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
|
||||
{
|
||||
let terminal = terminal.lock().unwrap();
|
||||
terminal.input_no_scroll(text.into_bytes());
|
||||
terminal.respond(text.into_bytes());
|
||||
}
|
||||
}
|
||||
TermEvent::ResetTitle => {
|
||||
|
|
@ -3749,7 +3819,7 @@ impl Application for App {
|
|||
{
|
||||
let terminal = terminal.lock().unwrap();
|
||||
let text = f(terminal.size().into());
|
||||
terminal.input_no_scroll(text.into_bytes());
|
||||
terminal.respond(text.into_bytes());
|
||||
}
|
||||
}
|
||||
TermEvent::Title(title) => {
|
||||
|
|
@ -3836,6 +3906,76 @@ impl Application for App {
|
|||
// Spawn first tab
|
||||
return self.update(Message::TabNew);
|
||||
}
|
||||
Message::TermOsc(pane, entity, event) => match event {
|
||||
OscEvent::Notify { title, body } => {
|
||||
// Rien à signaler pour l'onglet qu'on a sous les yeux
|
||||
if self.tab_in_view(pane, entity) {
|
||||
return Task::none();
|
||||
}
|
||||
self.set_tab_attention(pane, entity, AttentionKind::Notification);
|
||||
|
||||
let Some(tab_model) = self.pane_model.panes.get(pane) else {
|
||||
return Task::none();
|
||||
};
|
||||
let allowed = tab_model
|
||||
.data::<Mutex<Terminal>>(entity)
|
||||
.is_some_and(|terminal| {
|
||||
terminal
|
||||
.lock()
|
||||
.unwrap()
|
||||
.notify_allowed(title.as_deref(), &body)
|
||||
});
|
||||
if !allowed {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Titre de la séquence, suivi de l'onglet d'où elle vient :
|
||||
// plusieurs sessions Claude Code se distinguent ainsi.
|
||||
let tab_title = tab_model.text(entity).unwrap_or_default().to_string();
|
||||
let summary = match title {
|
||||
Some(title) if !tab_title.is_empty() && title != tab_title => {
|
||||
format!("{title} · {tab_title}")
|
||||
}
|
||||
Some(title) => title,
|
||||
None if !tab_title.is_empty() => tab_title,
|
||||
None => fl!("cosmic-terminal"),
|
||||
};
|
||||
if let Some(notify_tx) = &self.notify_tx_opt {
|
||||
let _ = notify_tx.send(desktop_notify::Request::Show {
|
||||
tab: (pane, entity),
|
||||
summary,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
Message::DesktopNotify(event) => match event {
|
||||
desktop_notify::Event::Ready(notify_tx) => {
|
||||
self.notify_tx_opt = Some(notify_tx);
|
||||
}
|
||||
desktop_notify::Event::Activated {
|
||||
tab: (pane, entity),
|
||||
token,
|
||||
} => {
|
||||
// Clic sur la notification : retour à l'onglet d'origine,
|
||||
// puis la fenêtre au premier plan avec le jeton
|
||||
// xdg-activation fourni par le serveur de notifications.
|
||||
let mut tasks = Vec::new();
|
||||
if let Some(tab_model) = self.pane_model.panes.get_mut(pane)
|
||||
&& tab_model.contains_item(entity)
|
||||
{
|
||||
tab_model.activate(entity);
|
||||
self.pane_model.set_focus(pane);
|
||||
tasks.push(self.update(Message::TabActivate(entity)));
|
||||
}
|
||||
if let Some(token) = token {
|
||||
tasks.push(Task::done(cosmic::Action::Cosmic(
|
||||
cosmic::app::Action::Activate(token),
|
||||
)));
|
||||
}
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
},
|
||||
Message::ToggleContextPage(context_page) => {
|
||||
if self.context_page == context_page {
|
||||
self.core.window.show_context = !self.core.window.show_context;
|
||||
|
|
@ -3926,12 +4066,14 @@ impl Application for App {
|
|||
}
|
||||
},
|
||||
Message::WindowFocused => {
|
||||
self.window_focused = true;
|
||||
if !self.core.window.show_context {
|
||||
self.pane_model.update_terminal_focus();
|
||||
}
|
||||
return self.update_focus();
|
||||
}
|
||||
Message::WindowUnfocused => {
|
||||
self.window_focused = false;
|
||||
self.pane_model.unfocus_all_terminals();
|
||||
}
|
||||
Message::ZoomIn => {
|
||||
|
|
@ -3944,26 +4086,8 @@ impl Application for App {
|
|||
self.reset_active_pane_zoom();
|
||||
return self.update_config();
|
||||
}
|
||||
Message::ContextMenuPopupClosed(id) => {
|
||||
if let Some((popup_id, pane, entity, _, _, _)) = &self.context_menu_popup
|
||||
&& id == *popup_id
|
||||
{
|
||||
// Clear link underline on the terminal
|
||||
if let Some(tab_model) = self.pane_model.panes.get(*pane)
|
||||
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(*entity)
|
||||
{
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.context_menu = None;
|
||||
terminal.active_regex_match = None;
|
||||
terminal.needs_update = true;
|
||||
}
|
||||
self.context_menu_popup = None;
|
||||
}
|
||||
}
|
||||
Message::Surface(a) => {
|
||||
return cosmic::task::message(cosmic::Action::Cosmic(
|
||||
cosmic::app::Action::Surface(a),
|
||||
));
|
||||
return cosmic::task::message(cosmic::Action::Surface(a));
|
||||
}
|
||||
Message::ReorderTab(
|
||||
pane,
|
||||
|
|
@ -4098,6 +4222,7 @@ impl Application for App {
|
|||
&self.config,
|
||||
&self.key_binds,
|
||||
self.active_terminal_monitors(),
|
||||
self.active_terminal_read_only(),
|
||||
&self.ssh_hosts,
|
||||
)]
|
||||
}
|
||||
|
|
@ -4112,32 +4237,7 @@ impl Application for App {
|
|||
]
|
||||
}
|
||||
|
||||
fn on_close_requested(&self, id: window::Id) -> Option<Self::Message> {
|
||||
if let Some((popup_id, _, _, _, _, _)) = &self.context_menu_popup
|
||||
&& id == *popup_id
|
||||
{
|
||||
return Some(Message::ContextMenuPopupClosed(id));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn view_window(&self, window_id: window::Id) -> Element<'_, Message> {
|
||||
if let Some((popup_id, pane, entity, ref link, ref autosize_id, _)) =
|
||||
self.context_menu_popup
|
||||
&& window_id == popup_id
|
||||
{
|
||||
return widget::autosize::autosize(
|
||||
menu::context_menu(
|
||||
&self.config,
|
||||
&self.key_binds,
|
||||
entity,
|
||||
link.clone(),
|
||||
self.terminal_monitors(pane, entity),
|
||||
),
|
||||
autosize_id.clone(),
|
||||
)
|
||||
.into();
|
||||
}
|
||||
match &self.dialog_opt {
|
||||
Some(dialog) => dialog.view(window_id),
|
||||
None => widget::text("Unknown window ID").into(),
|
||||
|
|
@ -4226,51 +4326,51 @@ impl Application for App {
|
|||
terminal_box = terminal_box.on_mouse_enter(move || Message::MouseEnter(pane));
|
||||
}
|
||||
|
||||
// If a context menu popup is active for this pane, inform the
|
||||
// terminal_box so it will emit on_context_menu(None) on click
|
||||
// to dismiss the popup.
|
||||
if self.context_menu_popup.is_some() {
|
||||
terminal_box = terminal_box.context_menu(cosmic::iced::Point::ORIGIN);
|
||||
if self.config.copy_on_select_clipboard {
|
||||
terminal_box =
|
||||
terminal_box.on_selection_done(move || Message::SelectionDone(pane, entity));
|
||||
}
|
||||
|
||||
let use_wayland_popup = {
|
||||
#[cfg(feature = "wayland")]
|
||||
{
|
||||
is_wayland()
|
||||
}
|
||||
#[cfg(not(feature = "wayland"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
// [yoda] Voile sur les panneaux non focalisés (à la Ghostty), seulement
|
||||
// quand il y en a plusieurs : on voit d'un coup d'œil où l'on tape.
|
||||
if let Some(dim) = self.config.unfocused_pane_dim_ratio()
|
||||
&& self.pane_model.panes.panes.len() > 1
|
||||
&& pane != self.pane_model.focused()
|
||||
{
|
||||
terminal_box = terminal_box.dim(dim);
|
||||
}
|
||||
|
||||
let tab_element: Element<'_, Message> = if !use_wayland_popup {
|
||||
// Fallback: render context menu as an inline popover
|
||||
if let Some((_, popup_pane, popup_entity, ref link, _, point)) =
|
||||
self.context_menu_popup
|
||||
{
|
||||
if pane == popup_pane {
|
||||
let mut popover = widget::popover(terminal_box.context_menu(point));
|
||||
popover = popover
|
||||
.popup(menu::context_menu(
|
||||
&self.config,
|
||||
&self.key_binds,
|
||||
popup_entity,
|
||||
link.clone(),
|
||||
self.terminal_monitors(popup_pane, popup_entity),
|
||||
))
|
||||
.position(widget::popover::Position::Point(point));
|
||||
popover.into()
|
||||
} else {
|
||||
terminal_box.into()
|
||||
}
|
||||
} else {
|
||||
terminal_box.into()
|
||||
}
|
||||
} else {
|
||||
terminal_box.into()
|
||||
};
|
||||
tab_column = tab_column.push(tab_element);
|
||||
// The terminal records what was under the right press; the widget opens the
|
||||
// menu on release, so build it from that state
|
||||
let context_link = terminal
|
||||
.lock()
|
||||
.unwrap()
|
||||
.context_menu
|
||||
.as_ref()
|
||||
.map(|menu_state| menu_state.link.clone());
|
||||
if context_link.is_some() {
|
||||
terminal_box = terminal_box.context_menu_open(true);
|
||||
}
|
||||
let mut context_menu = widget::context_menu(
|
||||
terminal_box,
|
||||
context_link.map(|link| {
|
||||
menu::context_menu(
|
||||
&self.config,
|
||||
&self.key_binds,
|
||||
entity,
|
||||
link,
|
||||
self.terminal_monitors(pane, entity),
|
||||
self.terminal_read_only(pane, entity),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.item_width(widget::menu::ItemWidth::Uniform(360))
|
||||
.on_close(Message::TabContextMenu(pane, None))
|
||||
.on_surface_action(Message::Surface);
|
||||
if let Some(window_id) = self.core.main_window_id() {
|
||||
context_menu = context_menu.window_id(window_id);
|
||||
}
|
||||
tab_column = tab_column.push(context_menu);
|
||||
}
|
||||
|
||||
//Only draw find in the currently focused pane
|
||||
|
|
@ -4409,16 +4509,18 @@ impl Application for App {
|
|||
output.send(Message::TermEventTx(event_tx)).await.unwrap();
|
||||
|
||||
while let Some((pane, entity, event)) = event_rx.recv().await {
|
||||
output
|
||||
.send(Message::TermEvent(pane, entity, event))
|
||||
.await
|
||||
.unwrap();
|
||||
let message = match event {
|
||||
PtyEvent::Term(event) => Message::TermEvent(pane, entity, event),
|
||||
PtyEvent::Osc(event) => Message::TermOsc(pane, entity, event),
|
||||
};
|
||||
output.send(message).await.unwrap();
|
||||
}
|
||||
|
||||
panic!("terminal event channel closed");
|
||||
},
|
||||
)
|
||||
}),
|
||||
desktop_notify::subscription().map(Message::DesktopNotify),
|
||||
cosmic_config::config_subscription(
|
||||
TypeId::of::<ConfigSubscription>(),
|
||||
Self::APP_ID.into(),
|
||||
|
|
@ -4451,14 +4553,6 @@ impl Application for App {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wayland")]
|
||||
fn is_wayland() -> bool {
|
||||
matches!(
|
||||
cosmic::app::cosmic::windowing_system(),
|
||||
Some(cosmic::app::cosmic::WindowingSystem::Wayland)
|
||||
)
|
||||
}
|
||||
|
||||
/// Divider color painted behind the pane grid to form pane borders.
|
||||
///
|
||||
/// When blur is active the transparent container must be used, otherwise the
|
||||
|
|
|
|||
194
src/menu.rs
194
src/menu.rs
|
|
@ -1,17 +1,15 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use cosmic::iced::Point;
|
||||
use cosmic::widget::menu::action::MenuAction;
|
||||
use cosmic::widget::menu::key_bind::KeyBind;
|
||||
use cosmic::widget::menu::{Item as MenuItem, menu_button};
|
||||
use cosmic::widget::space;
|
||||
use cosmic::{
|
||||
Element,
|
||||
app::Core,
|
||||
iced::core::Border,
|
||||
iced::{Background, Length, advanced::widget::text::Style as TextStyle},
|
||||
theme,
|
||||
iced::{Background, Length},
|
||||
widget::{
|
||||
self, divider,
|
||||
self,
|
||||
menu::{ItemHeight, ItemWidth},
|
||||
responsive_menu_bar, segmented_button,
|
||||
},
|
||||
|
|
@ -23,147 +21,94 @@ use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl, termina
|
|||
static MENU_ID: LazyLock<cosmic::widget::Id> =
|
||||
LazyLock::new(|| cosmic::widget::Id::new("responsive-menu"));
|
||||
|
||||
/// What the terminal found under the right-click
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuState {
|
||||
pub position: Option<Point>,
|
||||
pub local_position: Option<Point>,
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
pub fn context_menu<'a>(
|
||||
/// A context menu action dispatched to the tab the menu was opened on.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TabAction(pub segmented_button::Entity, pub Action);
|
||||
|
||||
impl MenuAction for TabAction {
|
||||
type Message = Message;
|
||||
|
||||
fn message(&self) -> Message {
|
||||
Message::TabContextAction(self.0, self.1)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn context_menu(
|
||||
config: &Config,
|
||||
key_binds: &HashMap<KeyBind, Action>,
|
||||
entity: segmented_button::Entity,
|
||||
link: Option<String>,
|
||||
monitors: (bool, bool, bool),
|
||||
) -> Element<'a, Message> {
|
||||
let find_key = |action: &Action| -> String {
|
||||
for (key_bind, key_action) in key_binds {
|
||||
if action == key_action {
|
||||
return key_bind.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
fn key_style(theme: &cosmic::Theme) -> TextStyle {
|
||||
let mut color = theme.cosmic().background(theme.transparent).component.on;
|
||||
color.alpha *= 0.75;
|
||||
TextStyle {
|
||||
color: Some(color.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
let menu_item = |label, action| {
|
||||
let key = find_key(&action);
|
||||
menu_button(vec![
|
||||
widget::text(label).into(),
|
||||
space::horizontal().into(),
|
||||
widget::text(key)
|
||||
.class(theme::Text::Custom(key_style))
|
||||
.into(),
|
||||
])
|
||||
.on_press(Message::TabContextAction(entity, action))
|
||||
read_only: bool,
|
||||
) -> Vec<widget::menu::Tree<Message>> {
|
||||
let item =
|
||||
|label: String, action: Action| MenuItem::Button(label, None, TabAction(entity, action));
|
||||
let check = |label: String, value: bool, action: Action| {
|
||||
MenuItem::CheckBox(label, None, value, TabAction(entity, action))
|
||||
};
|
||||
|
||||
let menu_checkbox = |label, value, action| {
|
||||
menu_button(vec![
|
||||
widget::text(label).into(),
|
||||
widget::space::horizontal().into(),
|
||||
widget::toggler(value)
|
||||
.on_toggle(move |_| Message::TabContextAction(entity, action))
|
||||
.size(16.0)
|
||||
.into(),
|
||||
])
|
||||
.on_press(Message::TabContextAction(entity, action))
|
||||
};
|
||||
|
||||
let mut rows = vec![
|
||||
Element::from(menu_item(fl!("copy"), Action::Copy)),
|
||||
Element::from(menu_item(fl!("paste"), Action::Paste)),
|
||||
Element::from(menu_item(fl!("select-all"), Action::SelectAll)),
|
||||
Element::from(divider::horizontal::light()),
|
||||
Element::from(menu_item(fl!("clear-scrollback"), Action::ClearScrollback)),
|
||||
Element::from(menu_item(fl!("save-output"), Action::SaveOutput)),
|
||||
Element::from(divider::horizontal::light()),
|
||||
Element::from(menu_checkbox(
|
||||
let mut items = vec![
|
||||
item(fl!("new-tab"), Action::TabNew),
|
||||
item(fl!("rename-tab"), Action::TabRename),
|
||||
MenuItem::Divider,
|
||||
item(fl!("copy"), Action::Copy),
|
||||
item(fl!("paste"), Action::Paste),
|
||||
item(fl!("select-all"), Action::SelectAll),
|
||||
MenuItem::Divider,
|
||||
item(fl!("clear-scrollback"), Action::ClearScrollback),
|
||||
item(fl!("save-output"), Action::SaveOutput),
|
||||
MenuItem::Divider,
|
||||
check(
|
||||
fl!("monitor-activity"),
|
||||
monitors.0,
|
||||
Action::Monitor(MonitorKind::Activity),
|
||||
)),
|
||||
Element::from(menu_checkbox(
|
||||
),
|
||||
check(
|
||||
fl!("monitor-silence"),
|
||||
monitors.1,
|
||||
Action::Monitor(MonitorKind::Silence),
|
||||
)),
|
||||
Element::from(menu_checkbox(
|
||||
),
|
||||
check(
|
||||
fl!("monitor-process-finished"),
|
||||
monitors.2,
|
||||
Action::Monitor(MonitorKind::ProcessFinished),
|
||||
)),
|
||||
Element::from(divider::horizontal::light()),
|
||||
Element::from(menu_item(
|
||||
fl!("split-horizontal"),
|
||||
Action::PaneSplitHorizontal,
|
||||
)),
|
||||
Element::from(menu_item(fl!("split-vertical"), Action::PaneSplitVertical)),
|
||||
Element::from(menu_item(
|
||||
fl!("pane-toggle-maximize"),
|
||||
Action::PaneToggleMaximized,
|
||||
)),
|
||||
Element::from(divider::horizontal::light()),
|
||||
Element::from(menu_item(fl!("new-tab"), Action::TabNew)),
|
||||
Element::from(menu_item(fl!("rename-tab"), Action::TabRename)),
|
||||
Element::from(menu_item(fl!("menu-settings"), Action::Settings)),
|
||||
Element::from(menu_item(fl!("menu-about"), Action::About)),
|
||||
),
|
||||
check(fl!("read-only"), read_only, Action::ToggleReadOnly),
|
||||
MenuItem::Divider,
|
||||
item(fl!("split-horizontal"), Action::PaneSplitHorizontal),
|
||||
item(fl!("split-vertical"), Action::PaneSplitVertical),
|
||||
item(fl!("pane-toggle-maximize"), Action::PaneToggleMaximized),
|
||||
MenuItem::Divider,
|
||||
MenuItem::CheckBox(
|
||||
fl!("window-header"),
|
||||
None,
|
||||
config.show_headerbar,
|
||||
TabAction(entity, Action::ShowHeaderBar(!config.show_headerbar)),
|
||||
),
|
||||
MenuItem::Divider,
|
||||
];
|
||||
#[cfg(feature = "password_manager")]
|
||||
{
|
||||
rows.push(Element::from(menu_item(
|
||||
fl!("menu-password-manager"),
|
||||
Action::PasswordManager,
|
||||
)));
|
||||
}
|
||||
rows.push(Element::from(menu_checkbox(
|
||||
fl!("show-headerbar"),
|
||||
config.show_headerbar,
|
||||
Action::ShowHeaderBar(!config.show_headerbar),
|
||||
)));
|
||||
|
||||
//If we have a link
|
||||
//prepend the Open Link item
|
||||
items.push(item(fl!("menu-password-manager"), Action::PasswordManager));
|
||||
items.push(item(fl!("menu-settings"), Action::Settings));
|
||||
items.push(item(fl!("menu-about"), Action::About));
|
||||
// If we have a link, prepend the link items
|
||||
if link.is_some() {
|
||||
rows.insert(
|
||||
0,
|
||||
Element::from(menu_item(fl!("open-link"), Action::LaunchUrlByMenu)),
|
||||
);
|
||||
rows.insert(
|
||||
1,
|
||||
Element::from(menu_item(fl!("copy-link"), Action::CopyUrlByMenu)),
|
||||
);
|
||||
rows.insert(2, Element::from(divider::horizontal::light()));
|
||||
items.insert(0, item(fl!("open-link"), Action::LaunchUrlByMenu));
|
||||
items.insert(1, item(fl!("copy-link"), Action::CopyUrlByMenu));
|
||||
items.insert(2, MenuItem::Divider);
|
||||
}
|
||||
let content = widget::menu::menu_column::MenuColumn::with_children(rows);
|
||||
widget::container(content)
|
||||
.padding(1)
|
||||
//TODO: move style to libcosmic
|
||||
.style(|theme| {
|
||||
let cosmic = theme.cosmic();
|
||||
let component = &cosmic.background(theme.transparent).component;
|
||||
widget::container::Style {
|
||||
icon_color: Some(component.on.into()),
|
||||
text_color: Some(component.on.into()),
|
||||
background: Some(Background::Color(component.base.into())),
|
||||
border: Border {
|
||||
radius: cosmic.radius_s().map(|x| x + 1.0).into(),
|
||||
width: 1.0,
|
||||
color: component.divider.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.width(Length::Fixed(360.0))
|
||||
.into()
|
||||
|
||||
let key_binds: HashMap<KeyBind, TabAction> = key_binds
|
||||
.iter()
|
||||
.map(|(key_bind, action)| (key_bind.clone(), TabAction(entity, *action)))
|
||||
.collect();
|
||||
widget::menu::items(&key_binds, items)
|
||||
}
|
||||
|
||||
pub fn color_scheme_menu<'a>(
|
||||
|
|
@ -220,6 +165,7 @@ pub fn menu_bar<'a>(
|
|||
config: &Config,
|
||||
key_binds: &HashMap<KeyBind, Action>,
|
||||
monitors: (bool, bool, bool),
|
||||
read_only: bool,
|
||||
ssh_hosts: &[String],
|
||||
) -> Element<'a, Message> {
|
||||
let mut profile_items = Vec::with_capacity(config.profiles.len());
|
||||
|
|
@ -321,6 +267,12 @@ pub fn menu_bar<'a>(
|
|||
monitors.2,
|
||||
Action::Monitor(MonitorKind::ProcessFinished),
|
||||
),
|
||||
MenuItem::CheckBox(
|
||||
fl!("read-only"),
|
||||
None,
|
||||
read_only,
|
||||
Action::ToggleReadOnly,
|
||||
),
|
||||
MenuItem::Divider,
|
||||
MenuItem::Button(
|
||||
fl!("menu-color-schemes"),
|
||||
|
|
|
|||
407
src/osc_tap.rs
Normal file
407
src/osc_tap.rs
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
//! Séquences OSC que vte ignore, interceptées à la sortie du PTY (yoda).
|
||||
//!
|
||||
//! alacritty_terminal 0.25 / vte 0.15 jettent les OSC 9 et 777 (« unhandled
|
||||
//! osc_dispatch ») : l'application ne les voit jamais. On les repère donc dans
|
||||
//! les octets lus sur le PTY, juste avant que vte ne les analyse, en
|
||||
//! enveloppant le `Pty` : ni alacritty_terminal ni vte ne sont forkés.
|
||||
//!
|
||||
//! Formes reconnues, comme dans Ghostty :
|
||||
//! - `OSC 777 ; notify ; titre ; corps` (forme rxvt, canal « ghostty » de
|
||||
//! Claude Code) ;
|
||||
//! - `OSC 9 ; texte` (forme iTerm2), sauf les commandes ConEmu
|
||||
//! `OSC 9 ; N [; …]` (barre de progression `9;4` comprise), qui ne sont pas
|
||||
//! des notifications.
|
||||
|
||||
/// Taille maximale retenue pour le contenu d'une séquence OSC ; au-delà, la
|
||||
/// séquence est ignorée jusqu'à son terminateur.
|
||||
const OSC_MAX_LEN: usize = 4096;
|
||||
/// Longueur maximale (en caractères) du titre et du corps d'une notification.
|
||||
const TITLE_MAX_CHARS: usize = 200;
|
||||
const BODY_MAX_CHARS: usize = 1000;
|
||||
|
||||
/// Événement extrait d'une séquence OSC.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OscEvent {
|
||||
/// Notification de bureau demandée par le programme (OSC 9 ou 777).
|
||||
Notify { title: Option<String>, body: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum State {
|
||||
#[default]
|
||||
Ground,
|
||||
Escape,
|
||||
Osc,
|
||||
}
|
||||
|
||||
/// Automate minimal qui suit le flux d'octets du PTY et ne retient que le
|
||||
/// contenu des séquences OSC. Les séquences peuvent être coupées entre deux
|
||||
/// lectures : l'état est conservé d'un appel à l'autre.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OscScanner {
|
||||
state: State,
|
||||
buf: Vec<u8>,
|
||||
overflow: bool,
|
||||
}
|
||||
|
||||
impl OscScanner {
|
||||
pub fn feed(&mut self, bytes: &[u8], emit: &mut dyn FnMut(OscEvent)) {
|
||||
for &byte in bytes {
|
||||
match self.state {
|
||||
State::Ground => {
|
||||
if byte == 0x1b {
|
||||
self.state = State::Escape;
|
||||
}
|
||||
}
|
||||
State::Escape => {
|
||||
self.state = match byte {
|
||||
b']' => {
|
||||
self.buf.clear();
|
||||
self.overflow = false;
|
||||
State::Osc
|
||||
}
|
||||
0x1b => State::Escape,
|
||||
_ => State::Ground,
|
||||
};
|
||||
}
|
||||
State::Osc => match byte {
|
||||
// BEL termine la séquence.
|
||||
0x07 => {
|
||||
self.dispatch(emit);
|
||||
self.state = State::Ground;
|
||||
}
|
||||
// ESC termine aussi la séquence, comme dans vte : c'est le
|
||||
// début de ST (`ESC \`) ou d'une nouvelle séquence.
|
||||
0x1b => {
|
||||
self.dispatch(emit);
|
||||
self.state = State::Escape;
|
||||
}
|
||||
// CAN et SUB annulent la séquence.
|
||||
0x18 | 0x1a => self.state = State::Ground,
|
||||
_ => {
|
||||
if self.buf.len() < OSC_MAX_LEN {
|
||||
self.buf.push(byte);
|
||||
} else {
|
||||
self.overflow = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, emit: &mut dyn FnMut(OscEvent)) {
|
||||
if !self.overflow
|
||||
&& let Some(event) = parse_osc(&self.buf)
|
||||
{
|
||||
emit(event);
|
||||
}
|
||||
self.buf.clear();
|
||||
self.overflow = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Interprète le contenu d'une séquence OSC (entre `ESC ]` et le terminateur).
|
||||
pub fn parse_osc(payload: &[u8]) -> Option<OscEvent> {
|
||||
if let Some(rest) = payload.strip_prefix(b"777;notify;") {
|
||||
match rest.iter().position(|&b| b == b';') {
|
||||
Some(i) => notify(&rest[..i], &rest[i + 1..]),
|
||||
None => notify(b"", rest),
|
||||
}
|
||||
} else if let Some(rest) = payload.strip_prefix(b"9;") {
|
||||
if is_conemu_command(rest) {
|
||||
None
|
||||
} else {
|
||||
notify(b"", rest)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `OSC 9 ; N` ou `OSC 9 ; N ; …` avec N numérique : commande ConEmu
|
||||
/// (progression, titre d'onglet, répertoire…), pas une notification.
|
||||
fn is_conemu_command(rest: &[u8]) -> bool {
|
||||
let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count();
|
||||
digits > 0 && matches!(rest.get(digits), None | Some(b';'))
|
||||
}
|
||||
|
||||
fn notify(title: &[u8], body: &[u8]) -> Option<OscEvent> {
|
||||
let title = clean(title, TITLE_MAX_CHARS);
|
||||
let body = clean(body, BODY_MAX_CHARS);
|
||||
match (title.is_empty(), body.is_empty()) {
|
||||
(true, true) => None,
|
||||
// Un titre seul devient le corps.
|
||||
(false, true) => Some(OscEvent::Notify {
|
||||
title: None,
|
||||
body: title,
|
||||
}),
|
||||
(true, false) => Some(OscEvent::Notify { title: None, body }),
|
||||
(false, false) => Some(OscEvent::Notify {
|
||||
title: Some(title),
|
||||
body,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-8 tolérant, caractères de contrôle remplacés par des espaces, longueur
|
||||
/// bornée.
|
||||
fn clean(bytes: &[u8], max_chars: usize) -> String {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.take(max_chars)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use self::unix::OscTapPty;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use super::{OscEvent, OscScanner};
|
||||
use alacritty_terminal::{
|
||||
event::{OnResize, WindowSize},
|
||||
tty::{ChildEvent, EventedPty, EventedReadWrite, Pty},
|
||||
};
|
||||
use polling::{Event, PollMode, Poller};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Read},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// Lecteur du PTY qui passe chaque lecture au [`OscScanner`] avant de la
|
||||
/// rendre à la boucle d'alacritty_terminal.
|
||||
pub struct TapReader {
|
||||
file: File,
|
||||
scanner: OscScanner,
|
||||
sink: Box<dyn FnMut(OscEvent) + Send>,
|
||||
}
|
||||
|
||||
impl Read for TapReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let len = self.file.read(buf)?;
|
||||
self.scanner.feed(&buf[..len], &mut *self.sink);
|
||||
Ok(len)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Pty` d'alacritty_terminal dont la lecture est observée par un
|
||||
/// [`TapReader`]. Le lecteur lit un duplicata du descripteur maître (même
|
||||
/// description de fichier, donc même mode non bloquant) ; l'enregistrement
|
||||
/// auprès du `Poller`, l'écriture, le redimensionnement et la fin du
|
||||
/// processus restent ceux du `Pty`.
|
||||
pub struct OscTapPty {
|
||||
// Déclaré avant `pty` : le duplicata est fermé avant le maître.
|
||||
reader: TapReader,
|
||||
pty: Pty,
|
||||
}
|
||||
|
||||
impl OscTapPty {
|
||||
pub fn new(pty: Pty, sink: Box<dyn FnMut(OscEvent) + Send>) -> io::Result<Self> {
|
||||
let file = pty.file().try_clone()?;
|
||||
Ok(Self {
|
||||
reader: TapReader {
|
||||
file,
|
||||
scanner: OscScanner::default(),
|
||||
sink,
|
||||
},
|
||||
pty,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EventedReadWrite for OscTapPty {
|
||||
type Reader = TapReader;
|
||||
type Writer = File;
|
||||
|
||||
unsafe fn register(
|
||||
&mut self,
|
||||
poll: &Arc<Poller>,
|
||||
interest: Event,
|
||||
mode: PollMode,
|
||||
) -> io::Result<()> {
|
||||
unsafe { self.pty.register(poll, interest, mode) }
|
||||
}
|
||||
|
||||
fn reregister(
|
||||
&mut self,
|
||||
poll: &Arc<Poller>,
|
||||
interest: Event,
|
||||
mode: PollMode,
|
||||
) -> io::Result<()> {
|
||||
self.pty.reregister(poll, interest, mode)
|
||||
}
|
||||
|
||||
fn deregister(&mut self, poll: &Arc<Poller>) -> io::Result<()> {
|
||||
self.pty.deregister(poll)
|
||||
}
|
||||
|
||||
fn reader(&mut self) -> &mut TapReader {
|
||||
&mut self.reader
|
||||
}
|
||||
|
||||
fn writer(&mut self) -> &mut File {
|
||||
self.pty.writer()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventedPty for OscTapPty {
|
||||
fn next_child_event(&mut self) -> Option<ChildEvent> {
|
||||
self.pty.next_child_event()
|
||||
}
|
||||
}
|
||||
|
||||
impl OnResize for OscTapPty {
|
||||
fn on_resize(&mut self, window_size: WindowSize) {
|
||||
self.pty.on_resize(window_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<OscEvent> {
|
||||
let mut scanner = OscScanner::default();
|
||||
let mut events = Vec::new();
|
||||
for chunk in chunks {
|
||||
scanner.feed(chunk, &mut |event| events.push(event));
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
fn notify(title: Option<&str>, body: &str) -> OscEvent {
|
||||
OscEvent::Notify {
|
||||
title: title.map(str::to_string),
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_777_with_bel_and_st() {
|
||||
assert_eq!(
|
||||
scan(&[b"x\x1b]777;notify;Claude Code;En attente\x07y"]),
|
||||
[notify(Some("Claude Code"), "En attente")]
|
||||
);
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]777;notify;T;corps; avec point-virgule\x1b\\"]),
|
||||
[notify(Some("T"), "corps; avec point-virgule")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_text_but_not_conemu() {
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]9;Claude Code: termine\x07"]),
|
||||
[notify(None, "Claude Code: termine")]
|
||||
);
|
||||
// Commandes ConEmu : progression, avec ou sans argument.
|
||||
assert!(scan(&[b"\x1b]9;4;1;50\x07\x1b]9;4;0;\x07\x1b]9;12\x07"]).is_empty());
|
||||
// Un chiffre suivi d'autre chose qu'un `;` reste du texte.
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]9;3 tests en echec\x07"]),
|
||||
[notify(None, "3 tests en echec")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_split_across_reads() {
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b", b"]777;no", b"tify;Ti", b"tre;Co", b"rps\x1b", b"\\"]),
|
||||
[notify(Some("Titre"), "Corps")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_osc_and_noise_are_ignored() {
|
||||
assert!(scan(&[b"\x1b]0;titre\x07\x1b]8;;https://x\x1b\\lien\x1b]8;;\x1b\\"]).is_empty());
|
||||
assert!(scan(&[b"\x1b]777;notify;;\x07\x1b]9;\x07"]).is_empty());
|
||||
// CAN annule la séquence en cours.
|
||||
assert!(scan(&[b"\x1b]9;annulee\x18\x07"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_chars_cleaned_and_title_alone_becomes_body() {
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]777;notify;Seul\x07"]),
|
||||
[notify(None, "Seul")]
|
||||
);
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]9;a\tb\x01c\x07"]),
|
||||
[notify(None, "a b c")]
|
||||
);
|
||||
}
|
||||
|
||||
/// De bout en bout : un vrai processus écrit dans un vrai PTY, lu par la
|
||||
/// boucle d'alacritty_terminal à travers l'enveloppe.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn tap_sees_osc_written_by_child() {
|
||||
use alacritty_terminal::{
|
||||
Term,
|
||||
event::{Event, EventListener, WindowSize},
|
||||
event_loop::EventLoop,
|
||||
sync::FairMutex,
|
||||
term::{Config, test::TermSize},
|
||||
tty::{self, Options, Shell},
|
||||
};
|
||||
use std::{sync::Arc, sync::mpsc, time::Duration};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Listener;
|
||||
impl EventListener for Listener {
|
||||
fn send_event(&self, _event: Event) {}
|
||||
}
|
||||
|
||||
let options = Options {
|
||||
shell: Some(Shell::new(
|
||||
"printf".into(),
|
||||
vec!["avant\x1b]777;notify;Titre;Corps\x07apres".into()],
|
||||
)),
|
||||
drain_on_exit: true,
|
||||
..Default::default()
|
||||
};
|
||||
let window_size = WindowSize {
|
||||
num_lines: 24,
|
||||
num_cols: 80,
|
||||
cell_width: 8,
|
||||
cell_height: 16,
|
||||
};
|
||||
let pty = tty::new(&options, window_size, 0).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let pty = OscTapPty::new(
|
||||
pty,
|
||||
Box::new(move |event| {
|
||||
let _ = tx.send(event);
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let term = Arc::new(FairMutex::new(Term::new(
|
||||
Config::default(),
|
||||
&TermSize::new(80, 24),
|
||||
Listener,
|
||||
)));
|
||||
let event_loop = EventLoop::new(term, Listener, pty, true, false).unwrap();
|
||||
let _join = event_loop.spawn();
|
||||
|
||||
assert_eq!(
|
||||
rx.recv_timeout(Duration::from_secs(5)).unwrap(),
|
||||
notify(Some("Titre"), "Corps")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_sequence_is_dropped() {
|
||||
let mut long = b"\x1b]9;".to_vec();
|
||||
long.extend(std::iter::repeat_n(b'a', OSC_MAX_LEN + 10));
|
||||
long.extend(b"\x07\x1b]9;ensuite\x07");
|
||||
assert_eq!(scan(&[&long]), [notify(None, "ensuite")]);
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ pub enum KeyBindAction {
|
|||
TabPrev,
|
||||
TabRename,
|
||||
ToggleFullscreen,
|
||||
ToggleReadOnly,
|
||||
WindowClose,
|
||||
WindowNew,
|
||||
ZoomIn,
|
||||
|
|
@ -132,6 +133,7 @@ impl KeyBindAction {
|
|||
Self::TabPrev => Some(Action::TabPrev),
|
||||
Self::TabRename => Some(Action::TabRename),
|
||||
Self::ToggleFullscreen => Some(Action::ToggleFullscreen),
|
||||
Self::ToggleReadOnly => Some(Action::ToggleReadOnly),
|
||||
Self::WindowClose => Some(Action::WindowClose),
|
||||
Self::WindowNew => Some(Action::WindowNew),
|
||||
Self::ZoomIn => Some(Action::ZoomIn),
|
||||
|
|
@ -295,6 +297,7 @@ pub fn action_label(action: KeyBindAction) -> String {
|
|||
KeyBindAction::TabPrev => fl!("previous-tab"),
|
||||
KeyBindAction::TabRename => fl!("rename-tab"),
|
||||
KeyBindAction::ToggleFullscreen => fl!("toggle-fullscreen"),
|
||||
KeyBindAction::ToggleReadOnly => fl!("read-only"),
|
||||
KeyBindAction::WindowClose => fl!("close-window"),
|
||||
KeyBindAction::WindowNew => fl!("new-window"),
|
||||
KeyBindAction::ZoomIn => fl!("zoom-in"),
|
||||
|
|
@ -369,7 +372,11 @@ pub fn shortcut_groups() -> Vec<ShortcutGroup> {
|
|||
KeyBindAction::ZoomReset,
|
||||
],
|
||||
});
|
||||
let mut other_actions = vec![KeyBindAction::ClearScrollback, KeyBindAction::SaveOutput];
|
||||
let mut other_actions = vec![
|
||||
KeyBindAction::ClearScrollback,
|
||||
KeyBindAction::SaveOutput,
|
||||
KeyBindAction::ToggleReadOnly,
|
||||
];
|
||||
#[cfg(feature = "password_manager")]
|
||||
other_actions.push(KeyBindAction::PasswordManager);
|
||||
groups.push(ShortcutGroup {
|
||||
|
|
|
|||
200
src/terminal.rs
200
src/terminal.rs
|
|
@ -27,13 +27,15 @@ use indexmap::IndexSet;
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
fs, io, mem,
|
||||
fs,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
io, mem,
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
|
|
@ -43,6 +45,7 @@ use crate::{
|
|||
config::{ColorSchemeKind, Config as AppConfig, ProfileId},
|
||||
menu::MenuState,
|
||||
mouse_reporter::MouseReporter,
|
||||
osc_tap::OscEvent,
|
||||
};
|
||||
|
||||
/// Minimum contrast between a fixed cursor color and the cell's background.
|
||||
|
|
@ -65,6 +68,28 @@ pub enum MonitorKind {
|
|||
/// Seconds without output before a silence alert fires (Konsole default).
|
||||
pub const MONITOR_SILENCE_SECONDS: u64 = 10;
|
||||
|
||||
/// Appel d'attention d'un onglet en arrière-plan (yoda).
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AttentionKind {
|
||||
/// Cloche (BEL).
|
||||
Bell,
|
||||
/// Notification de bureau (OSC 9 / 777).
|
||||
Notification,
|
||||
}
|
||||
|
||||
/// Anti-rafale des notifications d'un onglet, aux valeurs de Ghostty : au plus
|
||||
/// une par seconde, et une notification identique au plus toutes les 5 s.
|
||||
const NOTIFY_MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const NOTIFY_SAME_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Événement remonté d'un terminal : celui d'alacritty_terminal, ou une
|
||||
/// séquence OSC interceptée à la sortie du PTY (voir [`crate::osc_tap`]).
|
||||
#[derive(Debug)]
|
||||
pub enum PtyEvent {
|
||||
Term(Event),
|
||||
Osc(OscEvent),
|
||||
}
|
||||
|
||||
/// https://github.com/alacritty/alacritty/blob/4a7728bf7fac06a35f27f6c4f31e0d9214e5152b/alacritty/src/config/ui_config.rs#L36-L39
|
||||
fn url_regex_search() -> RegexSearch {
|
||||
let url_regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\
|
||||
|
|
@ -109,13 +134,13 @@ impl From<Size> for WindowSize {
|
|||
pub struct EventProxy(
|
||||
pane_grid::Pane,
|
||||
segmented_button::Entity,
|
||||
mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, Event)>,
|
||||
mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>,
|
||||
);
|
||||
|
||||
impl EventListener for EventProxy {
|
||||
fn send_event(&self, event: Event) {
|
||||
//TODO: handle error
|
||||
let _ = self.2.send((self.0, self.1, event));
|
||||
let _ = self.2.send((self.0, self.1, PtyEvent::Term(event)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,23 +221,47 @@ impl TerminalPaneGrid {
|
|||
self.focus
|
||||
}
|
||||
|
||||
pub fn update_terminal_focus(&self) {
|
||||
for (pane, tab_model) in self.panes.panes.iter() {
|
||||
let entity = tab_model.active();
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.set_focused(self.focus == *pane);
|
||||
terminal.update();
|
||||
pub fn update_terminal_focus(&mut self) {
|
||||
let focus = self.focus;
|
||||
for (pane, tab_model) in self.panes.panes.iter_mut() {
|
||||
let active = tab_model.active();
|
||||
let entities: Vec<segmented_button::Entity> = tab_model.iter().collect();
|
||||
for entity in entities {
|
||||
let mut icon_update = None;
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
if entity == active {
|
||||
let focused = focus == *pane;
|
||||
terminal.set_focused(focused);
|
||||
terminal.update();
|
||||
// Regarder l'onglet acquitte son appel d'attention
|
||||
if focused && terminal.attention.take().is_some() {
|
||||
icon_update = Some(crate::monitor_icon_name(&terminal));
|
||||
}
|
||||
} else {
|
||||
// Onglet en arrière-plan : il perd le focus (rapport
|
||||
// 1004), sinon un agent qui s'y trouve croit encore
|
||||
// qu'on le regarde.
|
||||
terminal.set_focused(false);
|
||||
}
|
||||
}
|
||||
if let Some(icon_name) = icon_update {
|
||||
crate::monitor_update_tab_icon(tab_model, entity, icon_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn unfocus_all_terminals(&self) {
|
||||
for tab_model in self.panes.panes.values() {
|
||||
let entity = tab_model.active();
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.set_focused(false);
|
||||
terminal.update();
|
||||
let active = tab_model.active();
|
||||
for entity in tab_model.iter() {
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.set_focused(false);
|
||||
if entity == active {
|
||||
terminal.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -331,6 +380,11 @@ pub struct Terminal {
|
|||
pub monitor_alert: Option<MonitorKind>,
|
||||
pub monitor_last_output: Instant,
|
||||
pub monitor_job_running: bool,
|
||||
pub attention: Option<AttentionKind>,
|
||||
notify_last: Option<(Instant, u64)>,
|
||||
/// [yoda] Lecture seule : aucune saisie de l'utilisateur n'atteint le PTY
|
||||
/// (touches, collage, souris) ; les réponses du terminal passent toujours.
|
||||
pub read_only: bool,
|
||||
pub needs_update: bool,
|
||||
pub profile_id_opt: Option<ProfileId>,
|
||||
pub tab_title_override: Option<String>,
|
||||
|
|
@ -361,7 +415,7 @@ impl Terminal {
|
|||
pub fn new(
|
||||
pane: pane_grid::Pane,
|
||||
entity: segmented_button::Entity,
|
||||
event_tx: mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, Event)>,
|
||||
event_tx: mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>,
|
||||
config: Config,
|
||||
options: Options,
|
||||
app_config: &AppConfig,
|
||||
|
|
@ -413,6 +467,8 @@ impl Terminal {
|
|||
cell_width,
|
||||
cell_height,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let osc_tx = event_tx.clone();
|
||||
let event_proxy = EventProxy(pane, entity, event_tx);
|
||||
let term = Arc::new(FairMutex::new(Term::new(
|
||||
config,
|
||||
|
|
@ -427,6 +483,15 @@ impl Terminal {
|
|||
#[cfg(windows)]
|
||||
let shell_pid = pty.child_watcher().pid().map(|pid| pid.get());
|
||||
|
||||
// OSC 9 / 777 : interceptés avant vte, qui les ignore
|
||||
#[cfg(unix)]
|
||||
let pty = crate::osc_tap::OscTapPty::new(
|
||||
pty,
|
||||
Box::new(move |event| {
|
||||
let _ = osc_tx.send((pane, entity, PtyEvent::Osc(event)));
|
||||
}),
|
||||
)?;
|
||||
|
||||
let pty_event_loop =
|
||||
EventLoop::new(term.clone(), event_proxy, pty, options.drain_on_exit, false)?;
|
||||
let notifier = Notifier(pty_event_loop.channel());
|
||||
|
|
@ -450,6 +515,9 @@ impl Terminal {
|
|||
monitor_alert: None,
|
||||
monitor_last_output: Instant::now(),
|
||||
monitor_job_running: false,
|
||||
attention: None,
|
||||
notify_last: None,
|
||||
read_only: false,
|
||||
mouse_reporter: Default::default(),
|
||||
needs_update: true,
|
||||
notifier,
|
||||
|
|
@ -513,6 +581,25 @@ impl Terminal {
|
|||
self.zoom_adj = value;
|
||||
}
|
||||
|
||||
/// Anti-rafale des notifications de bureau de cet onglet ; `true` si
|
||||
/// celle-ci peut partir.
|
||||
pub fn notify_allowed(&mut self, title: Option<&str>, body: &str) -> bool {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
(title, body).hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
let now = Instant::now();
|
||||
if let Some((last, last_hash)) = self.notify_last {
|
||||
let elapsed = now.duration_since(last);
|
||||
if elapsed < NOTIFY_MIN_INTERVAL
|
||||
|| (hash == last_hash && elapsed < NOTIFY_SAME_INTERVAL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.notify_last = Some((now, hash));
|
||||
true
|
||||
}
|
||||
|
||||
fn set_focused(&mut self, is_focused: bool) {
|
||||
let focus_changed = self.is_focused != is_focused;
|
||||
self.is_focused = is_focused;
|
||||
|
|
@ -524,7 +611,7 @@ impl Terminal {
|
|||
const FOCUS_OUT: &[u8] = b"\x1b[O";
|
||||
|
||||
let input = if is_focused { FOCUS_IN } else { FOCUS_OUT };
|
||||
self.input_no_scroll(input);
|
||||
self.respond(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -537,7 +624,18 @@ impl Terminal {
|
|||
self.with_buffer_mut(|buffer| buffer.set_redraw(redraw));
|
||||
}
|
||||
|
||||
/// Saisie de l'utilisateur vers le PTY ; ignorée en lecture seule.
|
||||
pub fn input_no_scroll<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
self.notifier.notify(input);
|
||||
}
|
||||
|
||||
/// [yoda] Réponse du terminal lui-même (rapports demandés par
|
||||
/// l'application, focus) : jamais bloquée par la lecture seule, sinon
|
||||
/// l'application attendrait une réponse qui ne vient pas.
|
||||
pub fn respond<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
|
||||
self.notifier.notify(input);
|
||||
}
|
||||
|
||||
|
|
@ -555,6 +653,10 @@ impl Terminal {
|
|||
}
|
||||
|
||||
pub fn input_scroll<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
|
||||
// En lecture seule, une frappe ne ramène pas non plus la vue en bas
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
self.input_no_scroll(input);
|
||||
self.scroll(TerminalScroll::Bottom);
|
||||
}
|
||||
|
|
@ -1216,8 +1318,11 @@ impl Terminal {
|
|||
y,
|
||||
);
|
||||
|
||||
for code in codes {
|
||||
self.notifier.notify(code);
|
||||
// Saisie de l'utilisateur : rien en lecture seule
|
||||
if !self.read_only {
|
||||
for code in codes {
|
||||
self.notifier.notify(code);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.scroll_as_arrows(delta);
|
||||
|
|
@ -1430,6 +1535,61 @@ impl Drop for Terminal {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Lecture seule, de bout en bout : un vrai `cat` dans un vrai PTY. La
|
||||
/// saisie bloquée n'arrive pas ; une réponse du terminal, si ; la saisie
|
||||
/// repasse quand le verrou est levé.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_only_blocks_input_but_not_replies() {
|
||||
use std::time::Duration;
|
||||
|
||||
let (_panes, pane) = pane_grid::State::new(());
|
||||
let mut model = segmented_button::Model::<segmented_button::SingleSelect>::default();
|
||||
let entity = model.insert().id();
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let options = Options {
|
||||
shell: Some(tty::Shell::new("cat".into(), Vec::new())),
|
||||
..Options::default()
|
||||
};
|
||||
let mut terminal = Terminal::new(
|
||||
pane,
|
||||
entity,
|
||||
event_tx,
|
||||
Config::default(),
|
||||
options,
|
||||
&AppConfig::default(),
|
||||
crate::terminal_theme::cosmic_dark(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let wait_for = |terminal: &Terminal, needle: &str| {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if terminal.scrollback_text().contains(needle) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
terminal.read_only = true;
|
||||
terminal.input_scroll(&b"bloque\n"[..]);
|
||||
terminal.paste("colle\n".to_string());
|
||||
terminal.respond(&b"repond\n"[..]);
|
||||
assert!(wait_for(&terminal, "repond"));
|
||||
// Écrites avant la réponse, la saisie et le collage seraient déjà là
|
||||
let text = terminal.scrollback_text();
|
||||
assert!(!text.contains("bloque"), "{text}");
|
||||
assert!(!text.contains("colle"), "{text}");
|
||||
|
||||
terminal.read_only = false;
|
||||
terminal.input_scroll(&b"libre\n"[..]);
|
||||
assert!(wait_for(&terminal, "libre"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_fill_couvre_toute_la_plage_unicode() {
|
||||
// U+2580–U+259F : chaque élément de bloc a sa géométrie native.
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ use std::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
Action, Terminal, TerminalScroll,
|
||||
Action, Terminal, TerminalScroll, kitty_keyboard,
|
||||
menu::MenuState,
|
||||
terminal::{BlockFill, Metadata},
|
||||
};
|
||||
|
|
@ -116,12 +116,14 @@ pub struct TerminalBox<'a, Message> {
|
|||
show_headerbar: bool,
|
||||
pane_border_radius: Option<Radius>,
|
||||
click_timing: Duration,
|
||||
context_menu: Option<Point>,
|
||||
context_menu_open: bool,
|
||||
on_context_menu: Option<Box<dyn Fn(Option<MenuState>) -> Message + 'a>>,
|
||||
on_mouse_enter: Option<Box<dyn Fn() -> Message + 'a>>,
|
||||
opacity: Option<f32>,
|
||||
dim: Option<f32>,
|
||||
mouse_inside_boundary: Option<bool>,
|
||||
on_middle_click: Option<Box<dyn Fn() -> Message + 'a>>,
|
||||
on_selection_done: Option<Box<dyn Fn() -> Message + 'a>>,
|
||||
on_open_hyperlink: Option<Box<dyn Fn(String) -> Message + 'a>>,
|
||||
on_window_focused: Option<Box<dyn Fn() -> Message + 'a>>,
|
||||
on_window_unfocused: Option<Box<dyn Fn() -> Message + 'a>>,
|
||||
|
|
@ -143,12 +145,14 @@ where
|
|||
show_headerbar: true,
|
||||
pane_border_radius: None,
|
||||
click_timing: Duration::from_millis(500),
|
||||
context_menu: None,
|
||||
context_menu_open: false,
|
||||
on_context_menu: None,
|
||||
on_mouse_enter: None,
|
||||
opacity: None,
|
||||
dim: None,
|
||||
mouse_inside_boundary: None,
|
||||
on_middle_click: None,
|
||||
on_selection_done: None,
|
||||
key_binds,
|
||||
on_open_hyperlink: None,
|
||||
on_window_focused: None,
|
||||
|
|
@ -188,8 +192,8 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
pub fn context_menu(mut self, position: Point) -> Self {
|
||||
self.context_menu = Some(position);
|
||||
pub fn context_menu_open(mut self, open: bool) -> Self {
|
||||
self.context_menu_open = open;
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +210,14 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
/// [yoda] Fin d'un geste de sélection fait dans ce panneau (glisser, double
|
||||
/// ou triple clic, Shift+clic) : le moment de copier la sélection. Un
|
||||
/// simple clic ailleurs dans la fenêtre ne le déclenche pas.
|
||||
pub fn on_selection_done(mut self, on_selection_done: impl Fn() -> Message + 'a) -> Self {
|
||||
self.on_selection_done = Some(Box::new(on_selection_done));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_middle_click(mut self, on_middle_click: impl Fn() -> Message + 'a) -> Self {
|
||||
self.on_middle_click = Some(Box::new(on_middle_click));
|
||||
self
|
||||
|
|
@ -216,6 +228,13 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
/// [yoda] Voile de la couleur du fond posé sur tout le panneau (texte
|
||||
/// compris), d'opacité `amount` : panneau non focalisé.
|
||||
pub fn dim(mut self, amount: f32) -> Self {
|
||||
self.dim = Some(amount);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn sharp_corners(mut self, sharp_corners: bool) -> Self {
|
||||
self.sharp_corners = sharp_corners;
|
||||
self
|
||||
|
|
@ -253,7 +272,7 @@ where
|
|||
if !state.is_focused {
|
||||
return InputMethod::Disabled;
|
||||
}
|
||||
if self.context_menu.is_some() {
|
||||
if self.context_menu_open {
|
||||
return InputMethod::Disabled;
|
||||
}
|
||||
|
||||
|
|
@ -915,6 +934,32 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
// [yoda] Panneau non focalisé : voile de la couleur du fond, comme
|
||||
// `unfocused-split-opacity` de Ghostty. Calque à part, sinon le texte
|
||||
// (primitive du même calque) serait tracé par-dessus.
|
||||
if let Some(dim) = self.dim {
|
||||
let bg = terminal.metadata_set[terminal.default_attrs().metadata].bg;
|
||||
renderer.with_layer(layout.bounds(), |renderer| {
|
||||
renderer.fill_quad(
|
||||
Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: Border {
|
||||
radius: border_radius,
|
||||
..Default::default()
|
||||
},
|
||||
snap: true,
|
||||
..Default::default()
|
||||
},
|
||||
Color::from_rgba(
|
||||
f32::from(bg.r()) / 255.0,
|
||||
f32::from(bg.g()) / 255.0,
|
||||
f32::from(bg.b()) / 255.0,
|
||||
dim,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let duration = instant.elapsed();
|
||||
log::trace!("redraw {}, {}: {:?}", view_w, view_h, duration);
|
||||
}
|
||||
|
|
@ -939,7 +984,11 @@ where
|
|||
let buffer_size = terminal.with_buffer(|buffer| buffer.size());
|
||||
|
||||
let is_app_cursor = terminal.term.lock().mode().contains(TermMode::APP_CURSOR);
|
||||
let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE);
|
||||
// [yoda] En lecture seule, la souris revient au terminal : sélection et
|
||||
// défilement locaux, même dans une application qui la capture.
|
||||
let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE)
|
||||
&& !terminal.read_only;
|
||||
let term_mode = *terminal.term.lock().mode();
|
||||
match event {
|
||||
Event::Window(event) => match event {
|
||||
cosmic::iced::window::Event::Focused => {
|
||||
|
|
@ -979,6 +1028,101 @@ where
|
|||
}
|
||||
_ => {}
|
||||
},
|
||||
// [yoda] Protocole clavier kitty, quand l'application au premier plan
|
||||
// l'a demandé (voir `kitty_keyboard`) ; sinon encodage legacy plus bas.
|
||||
Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
physical_key,
|
||||
location,
|
||||
modifiers,
|
||||
text,
|
||||
repeat,
|
||||
}) if state.is_focused && kitty_keyboard::active(term_mode) => {
|
||||
for key_bind in self.key_binds.keys() {
|
||||
if key_bind.matches(*modifiers, key, Some(physical_key)) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Comportements locaux conservés, comme en legacy : Shift+PageUp…
|
||||
// fait défiler l'historique, Échap annule une sélection.
|
||||
let local_scroll = match modified_key {
|
||||
Key::Named(Named::PageUp) if modifiers.shift() => Some(TerminalScroll::PageUp),
|
||||
Key::Named(Named::PageDown) if modifiers.shift() => {
|
||||
Some(TerminalScroll::PageDown)
|
||||
}
|
||||
Key::Named(Named::Home) if modifiers.shift() => Some(TerminalScroll::Top),
|
||||
Key::Named(Named::End) if modifiers.shift() => Some(TerminalScroll::Bottom),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(scroll) = local_scroll {
|
||||
terminal.scroll(scroll);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if *modified_key == Key::Named(Named::Escape) {
|
||||
let had_selection = terminal.term.lock().selection.take().is_some();
|
||||
if had_selection {
|
||||
terminal.update();
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let input = kitty_keyboard::KeyInput {
|
||||
key,
|
||||
modified_key,
|
||||
location: *location,
|
||||
modifiers: *modifiers,
|
||||
text: text.as_deref(),
|
||||
action: if *repeat {
|
||||
kitty_keyboard::KeyAction::Repeat
|
||||
} else {
|
||||
kitty_keyboard::KeyAction::Press
|
||||
},
|
||||
};
|
||||
let bytes = kitty_keyboard::encode(&input, term_mode);
|
||||
if !bytes.is_empty() {
|
||||
// Une touche de modification seule (« toutes les touches »)
|
||||
// ne ramène pas la vue en bas.
|
||||
let modifier_only = matches!(
|
||||
modified_key,
|
||||
Key::Named(Named::Shift | Named::Control | Named::Alt | Named::Super)
|
||||
);
|
||||
if modifier_only {
|
||||
terminal.input_no_scroll(bytes);
|
||||
} else {
|
||||
terminal.input_scroll(bytes);
|
||||
}
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Event::Keyboard(KeyEvent::KeyReleased {
|
||||
key,
|
||||
modified_key,
|
||||
location,
|
||||
modifiers,
|
||||
..
|
||||
}) if state.is_focused
|
||||
&& kitty_keyboard::active(term_mode)
|
||||
&& term_mode.contains(TermMode::REPORT_EVENT_TYPES) =>
|
||||
{
|
||||
let input = kitty_keyboard::KeyInput {
|
||||
key,
|
||||
modified_key,
|
||||
location: *location,
|
||||
modifiers: *modifiers,
|
||||
text: None,
|
||||
action: kitty_keyboard::KeyAction::Release,
|
||||
};
|
||||
let bytes = kitty_keyboard::encode(&input, term_mode);
|
||||
if !bytes.is_empty() {
|
||||
terminal.input_no_scroll(bytes);
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(named),
|
||||
physical_key,
|
||||
|
|
@ -1094,7 +1238,15 @@ where
|
|||
shell.capture_event();
|
||||
}
|
||||
Named::Enter => {
|
||||
terminal.input_scroll(format!("{}{}", alt_prefix, "\x0D").into_bytes());
|
||||
// [yoda] Shift+Entrée envoie LF (`\n`, comme Ctrl+J) : saut de
|
||||
// ligne dans les éditeurs qui ne parlent pas le protocole kitty
|
||||
// (rustyline de Modjo/Goose) ; pour un shell, c'est Entrée.
|
||||
let code = if modifiers.shift() && !modifiers.control() {
|
||||
"\x0A"
|
||||
} else {
|
||||
"\x0D"
|
||||
};
|
||||
terminal.input_scroll(format!("{alt_prefix}{code}").into_bytes());
|
||||
shell.capture_event();
|
||||
}
|
||||
Named::Escape => {
|
||||
|
|
@ -1392,11 +1544,11 @@ where
|
|||
}
|
||||
// Update context menu state
|
||||
if let Some(on_context_menu) = &self.on_context_menu {
|
||||
match self.context_menu {
|
||||
Some(_) => {
|
||||
match self.context_menu_open {
|
||||
true => {
|
||||
shell.publish(on_context_menu(None));
|
||||
}
|
||||
None => {
|
||||
false => {
|
||||
if *button == Button::Right {
|
||||
let x = p.x - self.padding.left;
|
||||
let y = p.y - self.padding.top;
|
||||
|
|
@ -1414,15 +1566,7 @@ where
|
|||
None,
|
||||
);
|
||||
let link = get_hyperlink(&terminal, location);
|
||||
let abs = cosmic::iced::Point::new(
|
||||
layout.bounds().x + p.x,
|
||||
layout.bounds().y + p.y,
|
||||
);
|
||||
shell.publish(on_context_menu(Some(MenuState {
|
||||
position: Some(abs),
|
||||
local_position: Some(p),
|
||||
link,
|
||||
})));
|
||||
shell.publish(on_context_menu(Some(MenuState { link })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1447,6 +1591,9 @@ where
|
|||
}
|
||||
}
|
||||
terminal.needs_update = true;
|
||||
if let Some(on_selection_done) = &self.on_selection_done {
|
||||
shell.publish(on_selection_done());
|
||||
}
|
||||
}
|
||||
if let Some(p) = cursor_position.position_in(layout.bounds()) {
|
||||
let x = p.x - self.padding.left;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue