Compare commits

...

24 commits

Author SHA1 Message Date
0a614b42a7 chore: fige Cargo.lock (epoch-1.10.0, [patch] locaux)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:02:21 +02:00
d4ea340be0 merge: epoch-1.10.0 d'upstream
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:00:06 +02:00
Michael Murphy
3bfca1eb80
i18n: translation update from Hosted Weblate (#920)
Translations update from [Hosted Weblate](https://hosted.weblate.org)
for [Pop OS/COSMIC
Term](https://hosted.weblate.org/projects/pop-os/cosmic-term/).



Current translation status:

![Weblate translation
status](https://hosted.weblate.org/widget/pop-os/cosmic-term/matrix-auto.svg)
2026-10-06 21:50:59 +02:00
Michael Aaron Murphy
3bf0b8e469 Epoch 1.10.0 version update
[x] Generated by cosmic-epoch scripts/version-update.sh
2026-10-06 21:50:27 +02:00
Hosted Weblate
16a9ef5a23
i18n: translation updates from weblate
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Alex <aleha@users.noreply.hosted.weblate.org>
Co-authored-by: Andi Chandler <andi@gowling.com>
Co-authored-by: Arve Eriksson <arve.eriksson@startmail.com>
Co-authored-by: Baurzhan Muftakhidinov <baurthefirst@gmail.com>
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: David Carvalho <david.snt.carvalho@gmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Geeson Wan <wang14240@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jim Spentzos <jimspentzos2000@gmail.com>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: Julien Brouillard <julienbrouillard1@gmail.com>
Co-authored-by: Martin Meizoso <martinmeizoso@gmail.com>
Co-authored-by: VandaL <vandalhj@gmail.com>
Co-authored-by: Walter William Beckerleg Bruckman <spayk.99@protonmail.com>
Co-authored-by: Zahid Rizky Fakhri <zahidrizkyfakhri@gmail.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: taijuin Lee <taijuin@gmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: Димко <Dymkovych@proton.me>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/cs/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/de/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/el/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/en_GB/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/fi/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/fr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/ga/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/gl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/id/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/kk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/pl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/ru/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/sv/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/uk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/zh_Hant/
Translation: Pop OS/COSMIC Term
2026-10-06 06:51:29 +00:00
Jeremy Soller
3ef31d750d
Add moving tabs left and right with key shortcuts (#700)
- [x] I have disclosed use of any AI generated code in my commit
messages.
- If you are using an LLM, and do not fully understand the changes it is
making to the code base, do not create a PR.
- In our experience, AI generated code often results in overly complex
code that lacks enough context for a proper fix or feature inclusion.
This results in considerably longer code reviews. Due to this, AI
authored or partially authored PRs may be closed without comment.
- [x] I understand these changes in full and will be able to respond to
review comments.
- [x] My change is accurately described in the commit message.
- [x] My contribution is tested and working as described.
- [x] I have read the [Developer Certificate of
Origin](https://developercertificate.org/) and certify my contribution
under its conditions.


https://github.com/pop-os/cosmic-term/issues/441 This PR adds option to
move tabs with left and right with Ctrl+Shift+Left/Right Arrow or by
using buttons in view menu.
2026-10-01 14:15:27 -06:00
9b10e98ab1 yoda: copie a la selection vers le presse-papiers, a la Ghostty
La selection n'allait que dans la selection primaire (clic du milieu) ;
pour la coller avec Ctrl+V ailleurs, il fallait Ctrl+Shift+C. Reprise de
copy-on-select = clipboard de Ghostty, le reglage de l'ancienne config
Ghostty de Lionel.

- Config copy_on_select_clipboard (desactive par defaut, comme l'amont) ;
  case dans Reglages > Avance.
- terminal_box::on_selection_done : publie a la fin d'un geste de
  selection fait dans le panneau (glisser, double/triple clic, Shift+
  clic), pas a chaque relachement du bouton gauche. La copie primaire
  existante part elle a tout relachement dans la fenetre : la recopier
  dans le presse-papiers ecraserait, au simple clic sur un onglet, ce
  qu'on vient de copier ailleurs avec une vieille selection.
- Message::SelectionDone : copie la selection de cet onglet ; une
  selection vide (simple clic) ne donne pas de texte (range_simple).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:38:17 +02:00
a035b47e98 yoda: lecture seule par onglet, a la Ghostty (toggle_readonly)
Surveiller une session Claude Code ou Modjo sans risquer de lui taper
dedans, ni de lui coller quoi que ce soit.

- Terminal::read_only : input_no_scroll, input_scroll (donc paste) et
  la molette en mode souris n'ecrivent plus rien, et une frappe ne ramene
  plus la vue en bas. Couvre touches legacy et kitty, collage, clic
  milieu, Ctrl+C, gestionnaire de mots de passe, IME, glisser-deposer.
- Terminal::respond : ecriture non bloquee pour ce que le terminal repond
  lui-meme (PtyWrite, couleurs, taille, rapports de focus), sinon une
  application attendrait une reponse qui ne vient pas (CSI ? u de Claude
  Code, DA, DSR...).
- terminal_box : en lecture seule la souris revient au terminal
  (selection, copie et defilement locaux, meme dans une application qui
  la capture).
- Case « Lecture seule » dans Affichage et dans le menu contextuel ;
  action ToggleReadOnly dans les raccourcis (groupe Autre, sans
  raccourci par defaut, comme Ghostty et Konsole) ; cadenas
  changes-prevent-symbolic sur l'onglet, apres les alertes.

Test de bout en bout dans un vrai PTY (cat) : saisie et collage bloques,
reponse transmise, saisie retablie ; il echoue si le verrou est retire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:19:02 +02:00
db9ee02d8d yoda: attenuation des panneaux non focalises, a la Ghostty
Avec Claude Code et Modjo cote a cote, rien ne distinguait le panneau ou
l'on tape, hors le curseur creux. L'amont assombrissait le fond de 8 %
(aussi quand la fenetre perdait le focus) et l'a retire en juillet
(a43b5ae).

Reprise de unfocused-split-opacity de Ghostty : un voile de la couleur du
fond, texte compris, sur chaque panneau non focalise, seulement quand il y
en a plusieurs ; la perte de focus de la fenetre ne change rien.

- Config unfocused_pane_dim (pourcentage, defaut 30 = opacite 0,7 de
  Ghostty, 0 desactive, plafond 85 = opacite minimale 0,15 de Ghostty).
- Reglages > Panneaux : curseur « Attenuation des panneaux inactifs ».
- terminal_box : voile trace dans un calque a part en fin de rendu (dans
  le meme calque, la primitive de texte passerait par-dessus), aux coins
  du fond du panneau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:40:47 +02:00
52b37cc2f8 yoda: protocole clavier kitty, Shift+Entree pour Claude Code et Modjo
Claude Code (>= 2.1.269) n'active Shift+Entree que si le terminal repond
a CSI ? u. alacritty_terminal sait tenir la pile des modes et repondre,
mais seulement avec kitty_keyboard, et n'encode aucune touche : Shift+
Entree partait en \r, identique a Entree.

- term::Config : kitty_keyboard active.
- kitty_keyboard : encodeur du frontal (spec kitty, decoupe de l'encodeur
  d'Alacritty), utilise seulement quand l'application au premier plan a
  pousse le drapeau 1 ou 8 ; sinon l'encodage legacy reste seul. Gere
  desambiguisation, types d'evenements (repetition, relachement),
  touches alternatives, toutes les touches, texte associe, pave
  numerique, F1-F24, touches de modification. Shift+Entree -> CSI 13;2u.
- terminal_box : raccourcis de l'appli toujours prioritaires ;
  Shift+PageUp/PageDown/Home/End et Echap sur selection gardent leur
  comportement local ; relachements envoyes si le drapeau 2 est pose.
- Legacy : Shift+Entree envoie LF (comme Ctrl+J) au lieu de CR. rustyline
  le lit comme Ctrl+J, la touche de saut de ligne de Modjo/Goose ; pour
  un shell c'est toujours Entree.

Connu, non corrige ici : alacritty_terminal 0.25.1 push_keyboard_mode vide
title_stack au lieu de la pile des modes au-dela de 4096 empilements
(panique du fil PTY de l'onglet seulement, panic=unwind).

10 tests, dont la reponse a CSI ? u et l'application de CSI > 5 u.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:32:23 +02:00
2414aaece3 yoda: notifications de bureau OSC 9/777 et cloche utile, a la Ghostty
vte 0.15 jette les OSC 9 et 777 : Claude Code (canal « ghostty ») ou tout
programme qui veut signaler « j'attends » ne pouvait rien faire remonter,
et la cloche etait un TODO. Avec plusieurs sessions en onglets, rien ne
disait laquelle attendait une reponse.

- osc_tap : le Pty est enveloppe, son lecteur passe chaque lecture a un
  petit automate qui repere OSC 777;notify;titre;corps et OSC 9;texte
  (les commandes ConEmu 9;N, dont la progression 9;4, sont ecartees comme
  dans Ghostty). Ni alacritty_terminal ni vte ne sont forkes.
- desktop_notify : org.freedesktop.Notifications en direct (zbus), pour
  recuperer le jeton ActivationToken au clic : la notification ramene la
  fenetre au premier plan, sur le bon panneau et le bon onglet. Une
  notification par onglet au plus (replaces_id), anti-rafale de Ghostty
  (1/s, identique toutes les 5 s), titre suivi du nom de l'onglet.
- Rien n'est envoye pour l'onglet qu'on a sous les yeux. Sinon l'onglet
  porte une icone d'attention (cloche ou notification), eteinte quand il
  reprend le focus.
- Cloche : indicateur d'onglet seulement. Pas de demande d'attention
  xdg-activation : cosmic-comp donne un jeton valide a toute application
  non sandboxee, la fenetre volerait le focus.
- Focus 1004 : l'onglet quitte recoit enfin son focus-out (seul l'onglet
  actif de chaque panneau etait mis a jour) ; un agent en arriere-plan ne
  croit plus qu'on le regarde.
- Chevrons du corps remplaces par ‹ › : cosmic-notifications lit le corps
  en HTML sans decoder les entites.

7 tests, dont un de bout en bout dans un vrai PTY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:12:28 +02:00
Kartik Nayak
da80dcd744 fix: update developers to System76 2026-09-30 14:10:11 -06:00
Jeremy Soller
5139e36f56 COSMIC policy update
Generated by cosmic-epoch scripts/policy-update.sh. Uses `policy_nobuild` branch to avoid package builds as it contains no code changes.
2026-09-30 09:56:29 -06:00
vacenty
487d890e7e Add moving tabs to keyboard shortcut group tabs 2026-09-27 18:18:46 +02:00
vacenty
003a9b3b06 Add moving tabs left and right with key shortcuts 2026-09-27 18:18:46 +02:00
81dc7d3eeb chore: fige Cargo.lock (epoch-1.9.0, [patch] locaux)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:03:54 +02:00
083e09f97e merge: epoch-1.9.0 d'upstream
# Conflicts:
#	Cargo.lock
#	src/main.rs
#	src/menu.rs
2026-09-24 20:52:15 +02:00
Hojjat
9129277bed chore: update libcosmic to fix esc handling in menus 2026-09-23 11:55:55 -06:00
Hojjat
78eaba9db1 chore: tweak context menu items order and wording 2026-09-15 19:09:43 -04:00
Michael Aaron Murphy
d25c2108c0 chore: update cosmic-files 2026-09-15 19:09:43 -04:00
Hojjat
120b4fdb12 feat: use libcosmic's context_menu widget for the terminal context menu 2026-09-15 19:09:43 -04:00
Michael Aaron Murphy
4cd8f8d605 Epoch 1.9.0 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-09-15 19:09:43 -04:00
Michael Murphy
1d9e5784d2
i18n: translation update from Hosted Weblate (#908)
Translations update from [Hosted Weblate](https://hosted.weblate.org)
for [Pop OS/COSMIC
Term](https://hosted.weblate.org/projects/pop-os/cosmic-term/).



Current translation status:

![Weblate translation
status](https://hosted.weblate.org/widget/pop-os/cosmic-term/matrix-auto.svg)
2026-09-15 21:57:44 +02:00
Hosted Weblate
327febcc92
i18n: translation updates from weblate
Co-authored-by: Amadɣas <massiin@proton.me>
Co-authored-by: Blauen <butterflee555@gmail.com>
Co-authored-by: CYAXXX <85353920+CYAXXX@users.noreply.github.com>
Co-authored-by: Geeson Wan <wang14240@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kurt Harren Catindig <kurtharrencatindig03@gmail.com>
Co-authored-by: Quentin PAGÈS <quentinantonin@free.fr>
Co-authored-by: Satakun Utama <galaxytomostore@gmail.com>
Co-authored-by: VandaL <vandalhj@gmail.com>
Co-authored-by: Zahid Rizky Fakhri <zahidrizkyfakhri@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/id/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/kab/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/kmr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/oc/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/pl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/th/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-term/zh_Hant/
Translation: Pop OS/COSMIC Term
2026-09-13 01:56:16 +02:00
40 changed files with 2700 additions and 850 deletions

View file

@ -1,8 +1,10 @@
- [ ] I have disclosed use of any AI generated code in my commit messages.
- If you are using an LLM, and do not fully understand the changes it is making to the code base, do not create a PR.
- In our experience, AI generated code often results in overly complex code that lacks enough context for a proper fix or feature inclusion. This results in considerably longer code reviews. Due to this, AI authored or partially authored PRs may be closed without comment.
- [ ] I understand these changes in full and will be able to respond to review comments.
- [ ] My change is accurately described in the commit message.
- [ ] My contribution is tested and working as described.
- [ ] I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions.
Replace this text with a description of your changes, and why these changes are important. Read and acknowledge the list below. PRs without a completed checkbox will be closed.
---
- I have not included any LLM (also known as AI) generated content in this PR, including code, comments, and descriptions.
- I understand these changes in full and will be able to respond to review comments.
- My change is accurately described in the commit message.
- My contribution is tested and working as described.
- I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions.
- [ ] I have read and followed all of the above requirements. For more information, see our [contributor guide](https://github.com/pop-os/pop/blob/master/CONTRIBUTING.md).

14
.github/workflows/require-checklist.yml vendored Normal file
View file

@ -0,0 +1,14 @@
# From https://github.com/mheap/require-checklist-action, license MIT
name: Require Checklist
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
job1:
runs-on: ubuntu-latest
steps:
- uses: mheap/require-checklist-action@v2
with:
requireChecklist: true

756
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "cosmic-term"
version = "1.8.0"
version = "1.10.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 }

70
cargo.just Normal file
View file

@ -0,0 +1,70 @@
cargo-target-dir := env('CARGO_TARGET_DIR', 'target')
# Compile with debug profile
[no-cd]
build-debug *args:
cargo build {{args}}
# Compile with release profile
[no-cd]
build-release *args: (build-debug '--release' args)
# Compile with a vendored tarball
[no-cd]
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
# Check for errors and linter warnings
[no-cd]
check *args:
cargo clippy --all-features {{args}} -- -W clippy::pedantic
# Runs a check with JSON message format for IDE integration
[no-cd]
check-json: (check '--message-format=json')
# Remove Cargo build artifacts
[no-cd]
clean:
cargo clean
# Also remove .cargo and vendored dependencies
[no-cd]
clean-dist: clean
rm -rf .cargo vendor vendor.tar target
# Run the application for testing purposes
[no-cd]
run *args:
cargo run {{args}}
# Run `cargo test`
[no-cd]
test *args:
cargo test {{args}}
# Vendor Cargo dependencies locally
[no-cd]
vendor:
#!/usr/bin/env bash
mkdir -p .cargo
cargo vendor --locked | head -n -1 > .cargo/config.toml
echo 'directory = "vendor"' >> .cargo/config.toml
echo >> .cargo/config.toml
echo '[env]' >> .cargo/config.toml
if [ -n "${SOURCE_DATE_EPOCH}" ]
then
source_date="$(date -d "@${SOURCE_DATE_EPOCH}" "+%Y-%m-%d")"
echo "VERGEN_GIT_COMMIT_DATE = \"${source_date}\"" >> .cargo/config.toml
fi
if [ -n "${SOURCE_GIT_HASH}" ]
then
echo "VERGEN_GIT_SHA = \"${SOURCE_GIT_HASH}\"" >> .cargo/config.toml
fi
tar pcf vendor.tar .cargo vendor
rm -rf .cargo vendor
# Extracts vendored dependencies
[no-cd]
vendor-extract:
rm -rf vendor
tar pxf vendor.tar

12
debian/changelog vendored
View file

@ -1,3 +1,15 @@
cosmic-term (1.10.0) noble; urgency=medium
* Epoch 1.10.0 version update
-- Michael Murphy <michael@mmurphy.dev> Mon, 05 Oct 2026 21:28:17 +0200
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

View file

@ -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

View file

@ -45,8 +45,8 @@ use-bright-bold = Světlejší tučný text
splits = Rozdělení
focus-follow-mouse = Zaměření psaní sleduje myš
advanced = Pokročilé
show-headerbar = Zobrazit hlavičku
show-header-description = Hlavičku lze odkrýt z nabídky zobrazené po kliknutí pravým tlačítkem myši
show-headerbar = Zobrazit záhlaví
show-header-description = Záhlaví lze odkrýt v nabídce zobrazené po kliknutí pravým tlačítkem myši
find-placeholder = Najít...
find-previous = Najít předchozí
find-next = Najít další
@ -69,7 +69,7 @@ next-tab = Další karta
previous-tab = Předchozí karta
split-horizontal = Rozdělit horizontálně
split-vertical = Rozdělit vertikálně
pane-toggle-maximize = Přepnout maximalizaci
pane-toggle-maximize = Maximalizovat nebo obnovit podokno
menu-color-schemes = Barevná schémata...
menu-settings = Nastavení...
menu-about = O aplikaci Terminál COSMIC...
@ -99,14 +99,17 @@ shortcut-group-zoom = Přiblížení
toggle-fullscreen = Přepnout režim celé obrazovky
type-to-search = Pište pro vyhledávání...
copy-or-sigint = Kopírovat nebo SIGINT
focus-pane-down = Zaměřit panel dole
focus-pane-left = Zaměřit panel vlevo
focus-pane-right = Zaměřit panel vpravo
focus-pane-up = Zaměřit panel nahoře
focus-pane-down = Zaměřit podokno dole
focus-pane-left = Zaměřit podokno vlevo
focus-pane-right = Zaměřit podokno vpravo
focus-pane-up = Zaměřit podokno nahoře
shortcut-replace-body = { $binding } je již přiřazena k „{ $existing }“. Nahradit za „{ $new_action }“?
shortcut-capture-hint = Stiskněte kombinaci kláves
paste-primary = Vložit primární
copy-link = Kopírovat odkaz
tab-new-inherit-working-directory = Nové karty a okna používají aktuální adresář
tab-new-inherit-working-directory-description = Otevírat nové karty a okna v pracovním adresáři aktivní karty
show-pane-borders = Zobrazit ohraničení panelu
show-pane-borders = Zobrazit ohraničení podoken
window-header = Záhlaví okna
move-tab-left = Přesunout kartu doleva
move-tab-right = Přesunout kartu doprava

View file

@ -105,7 +105,7 @@ next-tab = Nächster Tab
previous-tab = Vorheriger Tab
split-horizontal = Horizontal teilen
split-vertical = Vertikal teilen
pane-toggle-maximize = Maximierung umschalten
pane-toggle-maximize = Bereich maximieren oder wiederherstellen
menu-color-schemes = Farbschemen ...
menu-settings = Einstellungen ...
menu-about = Über COSMIC Terminal ...
@ -150,3 +150,6 @@ keywords = Befehl;Shell;Terminal;CLI;
tab-new-inherit-working-directory = Neue Tabs und Fenster verwenden das aktuelle Verzeichnis
tab-new-inherit-working-directory-description = Neue Tabs und Fenster im Arbeitsverzeichnis des aktiven Tabs öffnen
show-pane-borders = Bereichsrahmen anzeigen
window-header = Fenstertitel
move-tab-left = Tab nach links verschieben
move-tab-right = Tab nach rechts verschieben

View file

@ -107,7 +107,7 @@ next-tab = Επόμενη καρτέλα
previous-tab = Προηγούμενη καρτέλα
split-horizontal = Οριζόντιος διαχωρισμός
split-vertical = Κάθετος διαχωρισμός
pane-toggle-maximize = Εναλλαγή μεγιστοποίησης
pane-toggle-maximize = Μεγιστοποίηση ή επαναφορά προβολής
menu-color-schemes = Χρωματικοί συνδυασμοί...
menu-settings = Ρυθμίσεις...
menu-about = Σχετικά με το Τερματικό COSMIC...
@ -153,3 +153,6 @@ shortcut-replace-body = Ο συνδυασμός { $binding } έχει ήδη α
tab-new-inherit-working-directory = Οι νέες καρτέλες και παράθυρα χρησιμοποιούν τον τρέχοντα κατάλογο
tab-new-inherit-working-directory-description = Άνοιγμα νέων καρτελών και παραθύρων στον τρέχοντα κατάλογο της ενεργής καρτέλας
show-pane-borders = Εμφάνιση περιγραμμάτων πλαισίων
window-header = Κεφαλίδα παραθύρου
move-tab-left = Μετακίνηση καρτέλας στα αριστερά
move-tab-right = Μετακίνηση καρτέλας στα δεξιά

View file

@ -105,7 +105,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 = Maximise or restore pane
menu-color-schemes = Colour schemes...
menu-settings = Settings...
menu-about = About COSMIC Terminal...
@ -115,3 +115,4 @@ cancel = Cancel
keyboard-shortcuts = Keyboard shortcuts
replace = Replace
menu-keyboard-shortcuts = Keyboard shortcuts...
window-header = Window header

View file

@ -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
@ -150,9 +158,11 @@ zoom-reset = Default text size
zoom-out = Smaller text
next-tab = Next tab
previous-tab = Previous tab
move-tab-left = Move tab left
move-tab-right = Move tab right
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...

View file

@ -107,7 +107,7 @@ next-tab = Seuraava välilehti
previous-tab = Edellinen välilehti
split-horizontal = Halkaise näkymä vaakasuunnassa
split-vertical = Halkaise näkymä pystysuunnassa
pane-toggle-maximize = Suurennus päällä tai pois
pane-toggle-maximize = Suurenna tai palauta paneeli
menu-color-schemes = Väriteemat…
menu-settings = Asetukset…
menu-about = Tietoa COSMICin päätteestä…
@ -152,3 +152,6 @@ shortcut-group-other = Muut
tab-new-inherit-working-directory = Uudet välilehdet käyttävät nykyistä hakemistoa
tab-new-inherit-working-directory-description = Avaa uudet välilehdet aktiivisen välilehden työskentelyhakemistossa
show-pane-borders = Näytä paneelin rajat
window-header = Ikkunan otsake
move-tab-left = Siirrä välilehti vasemmalle
move-tab-right = Siirrä välilehti oikealle

0
i18n/fil/cosmic_term.ftl Normal file
View file

View file

@ -96,7 +96,7 @@ quit = Quitter
## Edit
edit = Modifier
edit = Édition
copy = Copier
paste = Coller
select-all = Sélectionner tout
@ -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
@ -119,7 +123,7 @@ next-tab = Onglet suivant
previous-tab = Onglet précédent
split-horizontal = Scinder en horizontal
split-vertical = Scinder en vertical
pane-toggle-maximize = Vue unique
pane-toggle-maximize = Agrandir ou restaurer le panneau
menu-color-schemes = Palettes de couleurs...
menu-settings = Paramètres...
menu-about = À propos de terminal COSMIC...
@ -168,4 +172,10 @@ 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
window-header = Barre de titre de la fenêtre
move-tab-left = Déplacer l'onglet à gauche
move-tab-right = Déplacer l'onglet à droite

View file

@ -105,7 +105,7 @@ next-tab = An cluaisín ar aghaidh
previous-tab = An cluaisín roimhe seo
split-horizontal = Scoilt cothrománach
split-vertical = Scoilt ingearach
pane-toggle-maximize = Scoránaigh uasmhéadú
pane-toggle-maximize = Uasmhéadaigh nó athchóirigh an phána
menu-color-schemes = Scéimeanna dathanna...
menu-settings = Socruithe...
menu-about = Maidir le COSMIC Teirminéal...
@ -149,3 +149,5 @@ keywords = Ordú;Blaosc;Teirminéal;CLI;
copy-link = Cóipeáil Nasc
tab-new-inherit-working-directory = Úsáideann cluaisíní agus fuinneoga nua an t-eolaire reatha
tab-new-inherit-working-directory-description = Oscail cluaisíní agus fuinneoga nua in eolaire oibre an chluaisín ghníomhaigh
show-pane-borders = Taispeáin teorainneacha na pána
window-header = Ceanntásc na fuinneoige

View file

@ -7,7 +7,7 @@ support = Soporte
settings = Axustes
appearance = Apariencia
theme = Tema
match-desktop = Axustar ao escritorio
match-desktop = Emparexar co escritorio
dark = Escuro
light = Claro
default-zoom-step = Pasos do zoom
@ -110,3 +110,6 @@ passwords-title = Contrasinais
add-password = Engadir contrasinal
password-input = Contrasinal
password-input-description = Descripción
window-header = Cabeceira da xanela
move-tab-left = Mover a pestaña á esquerda
move-tab-right = Mover a pestaña á dereita

View file

@ -52,14 +52,14 @@ opacity = Háttér átlátszósága
### Font
font = Betűtípus
advanced-font-settings = Speciális betűtípus-beállítások
default-font = Betűtípus
font = Betűkészlet
advanced-font-settings = Speciális betűkészlet-beállítások
default-font = Betűkészlet
default-font-size = Betűméret
default-font-stretch = Betűszélesség
default-font-weight = Normál betűsúly
default-dim-font-weight = Vékony betűsúly
default-bold-font-weight = Félkövér betűsúly
default-font-weight = Normál betűvastagság
default-dim-font-weight = Vékony betűvastagság
default-bold-font-weight = Félkövér betűvastagság
use-bright-bold = Félkövér szöveg világosítása
### Splits
@ -109,7 +109,7 @@ next-tab = Következő lap
previous-tab = Előző lap
split-horizontal = Vízszintes felosztás
split-vertical = Függőleges felosztás
pane-toggle-maximize = Panel maximalizálása
pane-toggle-maximize = Panel maximalizálása vagy visszaállítása
menu-color-schemes = Színsémák…
menu-settings = Beállítások…
menu-about = A COSMIC Terminál névjegye…
@ -150,3 +150,6 @@ copy-link = Hivatkozás másolása
tab-new-inherit-working-directory = Az új lapok és ablakok az aktuális könyvtárat használják
tab-new-inherit-working-directory-description = Az új lapok és ablakok az aktív lap munkakönyvtárában nyílnak meg
show-pane-borders = Ablaktábla-szegélyek megjelenítése
window-header = Ablakfejléc
move-tab-left = Lap mozgatása balra
move-tab-right = Lap mozgatása jobbra

View file

@ -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
@ -23,7 +23,7 @@ remain-open = Tetap buka setelah proses anak berakhir.
settings = Pengaturan
appearance = Tampilan
theme = Tema
match-desktop = Cocok desktop
match-desktop = Sesuaikan desktop
dark = Gelap
light = Terang
syntax-dark = Skema warna gelap
@ -68,7 +68,7 @@ next-tab = Tab selanjutnya
previous-tab = Tab sebelumnya
split-horizontal = Pemisahan horisontal
split-vertical = Pemisahan vertikal
pane-toggle-maximize = Ubah ke maksimal
pane-toggle-maximize = Maksimalkan atau pulihkan panel
menu-color-schemes = Skema warna...
menu-settings = Pengaturan...
menu-about = Tentang Terminal COSMIC...
@ -110,3 +110,6 @@ copy-link = Salin Tautan
tab-new-inherit-working-directory = Tab dan jendela baru menggunakan direktori saat ini
tab-new-inherit-working-directory-description = Buka tab dan jendela baru di direktori pekerjaan tab aktif
show-pane-borders = Tampilkan batas panel
window-header = Header jendela
move-tab-left = Pindahkan tab ke kiri
move-tab-right = Pindahkan tab ke kanan

View file

@ -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

View file

@ -5,8 +5,8 @@ support = Қолдау
settings = Баптаулар
appearance = Сыртқы көрініс
theme = Тема
match-desktop = Жұмыс үстеліне сәйкес болу
dark = Қою
match-desktop = Жұмыс үстеліне сәйкес
dark = Қараңғы
light = Ашық
file = Файл
new-tab = Жаңа бет
@ -29,7 +29,7 @@ find = Табу
cosmic-terminal = COSMIC терминалы
new-terminal = Жаңа терминал
color-schemes = Түстер схемалары
rename = Атын өзгерту
rename = Атауын өзгерту
import-errors = Импорттау қателері
profiles = Профильдер
command-line = Командалық жол
@ -69,7 +69,7 @@ next-tab = Келесі бет
previous-tab = Алдыңғы бет
split-horizontal = Горизонталды бөлу
split-vertical = Вертикалды бөлу
pane-toggle-maximize = Толық экран режимін ауыстыру
pane-toggle-maximize = Панельді үлкейту немесе қалпына келтіру
menu-color-schemes = Түс схемалары...
menu-about = COSMIC терминалы туралы...
menu-password-manager = Парольдер...
@ -110,3 +110,6 @@ copy-link = Сілтемені көшіру
tab-new-inherit-working-directory = Жаңа беттерде және терезелерде ағымдағы буманы қолдану
tab-new-inherit-working-directory-description = Жаңа беттерді және терезелерді белсенді беттің жұмыс бумасында ашу
show-pane-borders = Панель жақтауларын көрсету
window-header = Терезе тақырыптамасы
move-tab-left = Бетті солға жылжыту
move-tab-right = Бетті оңға жылжыту

View file

@ -27,13 +27,13 @@ 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
dark = Tarî
hold = Ragire
match-desktop = Bi sermaseyê re li hev bîne
match-desktop = Bi sermaseyê re guncav bike
find-previous = Yê paş bibîne
cosmic-terminal = Termînalê COSMIC
find-placeholder = Bibîne...
@ -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î?
@ -110,3 +110,6 @@ toggle-fullscreen = Dîmendera tijî biguhêrîne
type-to-search = Ji bo lêgerînê binivîse...
copy-link = Girêdanê jê bigire
show-pane-borders = Sînorên kilîtdankê nîşan bide
window-header = Sernavê çarçoveyê
move-tab-left = Rûgerê bilivîne çepê
move-tab-right = Rûgerê bilivîne rastê

View file

@ -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

View file

@ -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
@ -109,7 +109,7 @@ next-tab = Następna karta
previous-tab = Poprzednia karta
split-horizontal = Podziel w poziomie
split-vertical = Podziel w pionie
pane-toggle-maximize = Przełącznik maksymalizacji
pane-toggle-maximize = Maksymalizuj lub przywróć panel
menu-color-schemes = Schemat kolorów...
menu-settings = Ustawienia…
menu-about = O Terminalu COSMIC...
@ -150,3 +150,4 @@ copy-link = Skopiuj Odnośnik
tab-new-inherit-working-directory = Nowe karty i okna użyją obecnego katalogu
tab-new-inherit-working-directory-description = Otwieraj nowe karty i okna w katalogu roboczym aktywnej karty
show-pane-borders = Pokaż obramowania paneli
window-header = Nagłówek okna

View file

@ -70,7 +70,7 @@ focus-follow-mouse = Foco de digitação segue o mouse
### Advanced
advanced = Avançado
show-headerbar = Mostrar a barra de título
show-headerbar = Mostrar cabeçalho
show-header-description = Mostrar o cabeçalho ao clicar no menu com o botão direito do mouse
# Find
find-placeholder = Localizar...
@ -109,7 +109,7 @@ next-tab = Próxima aba
previous-tab = Aba anterior
split-horizontal = Dividir horizontalmente
split-vertical = Dividir verticalmente
pane-toggle-maximize = Alternar maximização
pane-toggle-maximize = Maximizar ou restaurar o quadro
menu-color-schemes = Esquemas de cores...
menu-settings = Configurações...
menu-about = Sobre o Terminal...
@ -150,3 +150,6 @@ copy-link = Copiar link
tab-new-inherit-working-directory = Novas abas e janelas usam o diretório atual
tab-new-inherit-working-directory-description = Abrir novas abas e janelas no diretório de trabalho da aba ativa
show-pane-borders = Mostrar bordas do quadro
window-header = Cabeçalho da janela
move-tab-left = Mover a aba para a esquerda
move-tab-right = Mover a aba para a direita

View file

@ -106,7 +106,7 @@ next-tab = Следующая вкладка
previous-tab = Предыдущая вкладка
split-horizontal = Разделение по горизонтали
split-vertical = Разделение по вертикали
pane-toggle-maximize = Переключить на весь экран
pane-toggle-maximize = Развернуть/восстановить область
menu-color-schemes = Цветовые схемы...
menu-settings = Параметры...
menu-about = О Терминале COSMIC...
@ -143,4 +143,7 @@ type-to-search = Введите для поиска...
copy-link = Копировать ссылку
tab-new-inherit-working-directory = Текущий каталог в новых вкладках и окнах
tab-new-inherit-working-directory-description = Открывать новые вкладки и окна в рабочем каталоге активной вкладки
show-pane-borders = Показывать границы панелей
show-pane-borders = Показывать границы областей
window-header = Заголовок окна
move-tab-left = Перемещение вкладки налево
move-tab-right = Перемещение вкладки направо

View file

@ -110,7 +110,7 @@ next-tab = Nästa flik
previous-tab = Föregående flik
split-horizontal = Dela horisontellt
split-vertical = Dela vertikalt
pane-toggle-maximize = Växla maximerad
pane-toggle-maximize = Maximera eller återställ fält
clear-scrollback = Töm tillbakarullning
menu-color-schemes = Färgscheman…
menu-settings = Inställningar...
@ -154,3 +154,6 @@ copy-link = Kopiera länk
tab-new-inherit-working-directory-description = Öppna nya flikar och fönster i den aktiva flikens arbetskatalog
tab-new-inherit-working-directory = Nya flikar och fönster använder aktuell katalog
show-pane-borders = Visa ram runt fält
window-header = Fönstertitel
move-tab-left = Flytta flik vänster
move-tab-right = Flytta flik höger

View file

@ -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 = แทนที่

View file

@ -38,7 +38,7 @@ settings = Налаштування
appearance = Вигляд
theme = Тема
match-desktop = Системна
match-desktop = Підігнати під стільницю
dark = Темна
light = Світла
syntax-dark = Темна колірна схема
@ -150,3 +150,6 @@ copy-link = Копіювати ланку
tab-new-inherit-working-directory = Нові вкладки та вікна відкривати в поточному каталозі
tab-new-inherit-working-directory-description = Відкривати нові вкладки та вікна в робочому каталозі активної вкладки
show-pane-borders = Показувати межі панелей
window-header = Заголовок вікна
move-tab-left = Перемістити ліворуч
move-tab-right = Перемістити праворуч

View file

@ -105,7 +105,7 @@ next-tab = 下一个标签
previous-tab = 上一个标签
split-horizontal = 水平分割
split-vertical = 垂直分割
pane-toggle-maximize = 切换最大化
pane-toggle-maximize = 将窗格最大化或还原
menu-color-schemes = 配色方案…
menu-settings = 设置…
menu-about = 关于 COSMIC 终端…
@ -150,3 +150,6 @@ copy-link = 复制链接
tab-new-inherit-working-directory = 新标签和窗口使用当前目录
tab-new-inherit-working-directory-description = 在活动标签的工作目录中打开新标签和窗口
show-pane-borders = 显示窗格边框
window-header = 窗口标题栏
move-tab-left = 将标签向左移动
move-tab-right = 将标签向右移动

View file

@ -10,7 +10,7 @@ new-terminal = 新建終端機
## Color schemes
color-schemes = 配色方案
rename = 重命名
rename = 重新命名
export = 匯出
delete = 刪除
import = 匯入
@ -104,7 +104,7 @@ next-tab = 下個分頁
previous-tab = 上個分頁
split-horizontal = 水平分割
split-vertical = 垂直分割
pane-toggle-maximize = 切換最大化
pane-toggle-maximize = 將窗格最大化或還原
menu-color-schemes = 配色方案...
menu-settings = 設定...
menu-about = 關於 COSMIC 終端機…
@ -150,3 +150,6 @@ comment = COSMIC 桌面終端機模擬器
tab-new-inherit-working-directory = 新分頁和視窗使用目前目錄
tab-new-inherit-working-directory-description = 在作用中分頁的工作目錄開啟新分頁和視窗
show-pane-borders = 顯示窗格邊框
window-header = 視窗標題列
move-tab-left = 將分頁向左移動
move-tab-right = 將分頁向右移動

View file

@ -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
View 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 `&lt;` serait conforme à la spécification, mais cosmic-notifications ne
/// décode pas les entités (il afficherait `&lt;`), 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
View 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()));
}
}

View file

@ -8,7 +8,7 @@ use cosmic::iced::keyboard::key::Physical;
use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::pane_grid::Pane;
use cosmic::widget::segmented_button::ReorderEvent;
use cosmic::widget::segmented_button::{InsertPosition, ReorderEvent};
use cosmic::{
Application, ApplicationExt, Element, action,
app::{Core, Settings, Task, context_drawer},
@ -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,
@ -404,6 +425,8 @@ pub enum Action {
TabNext,
TabPrev,
TabRename,
TabMoveLeft,
TabMoveRight,
ToggleFullscreen,
WindowClose,
WindowNew,
@ -430,6 +453,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),
@ -463,6 +487,8 @@ impl Action {
Self::TabNext => Message::TabNext,
Self::TabPrev => Message::TabPrev,
Self::TabRename => Message::TabRenameOpen(entity_opt),
Self::TabMoveLeft => Message::TabMoveLeft,
Self::TabMoveRight => Message::TabMoveRight,
Self::ToggleFullscreen => Message::ToggleFullscreen,
Self::WindowClose => Message::WindowClose,
Self::WindowNew => Message::WindowNew,
@ -537,6 +563,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 +593,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>),
@ -591,8 +621,12 @@ pub enum Message {
TabRenameCommit,
TabRenameCancel,
TabRenameResetToDefault,
TabMoveLeft,
TabMoveRight,
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 +638,6 @@ pub enum Message {
ZoomIn,
ZoomOut,
ZoomReset,
ContextMenuPopupClosed(window::Id),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -659,7 +692,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 +717,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 +1742,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 +1767,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 +1829,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 +2104,7 @@ impl App {
log::warn!("tried to create new tab before having event channel");
}
}
self.sync_terminal_focus();
self.update_title(Some(pane))
}
}
@ -2148,7 +2254,7 @@ impl Application for App {
.comments(fl!("comment"))
.license("GPL-3.0-only")
.license_url("https://spdx.org/licenses/GPL-3.0-only")
.developers([("Jeremy Soller", "jeremy@system76.com")])
.developers([("System76", "info@system76.com")])
.links([
(fl!("repository"), "https://github.com/pop-os/cosmic-term"),
(
@ -2194,6 +2300,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 +2321,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 +3070,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 +3420,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 +3449,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 +3522,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 +3576,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 +3589,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 +3600,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
@ -3673,10 +3743,43 @@ impl Application for App {
}
self.pane_model.update_terminal_focus();
}
Message::TabMoveLeft => {
if let Some(tab_model) = self.pane_model.active_mut() {
let pos: usize = tab_model
.position(tab_model.active())
.expect("at least one tab is always open")
.into();
if pos > 0 {
let dragged = tab_model.iter().nth(pos).expect("tab unavailable");
let target = tab_model.iter().nth(pos - 1).expect("previous tab missing");
tab_model.reorder(dragged, target, InsertPosition::Before);
}
}
}
Message::TabMoveRight => {
if let Some(tab_model) = self.pane_model.active_mut() {
let len = tab_model.iter().count();
let pos: usize = tab_model
.position(tab_model.active())
.expect("at least one tab is always open")
.into();
if pos < len - 1 && len > 1 {
let dragged = tab_model.iter().nth(pos).expect("tab unavailable");
let target = tab_model.iter().nth(pos + 1).expect("next tab missing");
tab_model.reorder(dragged, target, InsertPosition::After);
}
}
}
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 +3813,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 +3827,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 +3852,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 +3939,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 +4099,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 +4119,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 +4255,7 @@ impl Application for App {
&self.config,
&self.key_binds,
self.active_terminal_monitors(),
self.active_terminal_read_only(),
&self.ssh_hosts,
)]
}
@ -4112,32 +4270,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 +4359,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 +4542,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 +4586,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

View file

@ -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());
@ -291,6 +237,9 @@ pub fn menu_bar<'a>(
MenuItem::Button(fl!("next-tab"), None, Action::TabNext),
MenuItem::Button(fl!("previous-tab"), None, Action::TabPrev),
MenuItem::Divider,
MenuItem::Button(fl!("move-tab-left"), None, Action::TabMoveLeft),
MenuItem::Button(fl!("move-tab-right"), None, Action::TabMoveRight),
MenuItem::Divider,
MenuItem::Button(
fl!("split-horizontal"),
None,
@ -321,6 +270,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
View 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")]);
}
}

View file

@ -89,7 +89,10 @@ pub enum KeyBindAction {
TabNext,
TabPrev,
TabRename,
TabMoveLeft,
TabMoveRight,
ToggleFullscreen,
ToggleReadOnly,
WindowClose,
WindowNew,
ZoomIn,
@ -131,7 +134,10 @@ impl KeyBindAction {
Self::TabNext => Some(Action::TabNext),
Self::TabPrev => Some(Action::TabPrev),
Self::TabRename => Some(Action::TabRename),
Self::TabMoveLeft => Some(Action::TabMoveLeft),
Self::TabMoveRight => Some(Action::TabMoveRight),
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),
@ -294,7 +300,10 @@ pub fn action_label(action: KeyBindAction) -> String {
KeyBindAction::TabNext => fl!("next-tab"),
KeyBindAction::TabPrev => fl!("previous-tab"),
KeyBindAction::TabRename => fl!("rename-tab"),
KeyBindAction::TabMoveLeft => fl!("move-tab-left"),
KeyBindAction::TabMoveRight => fl!("move-tab-right"),
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"),
@ -329,6 +338,8 @@ pub fn shortcut_groups() -> Vec<ShortcutGroup> {
KeyBindAction::TabRename,
KeyBindAction::TabNext,
KeyBindAction::TabPrev,
KeyBindAction::TabMoveLeft,
KeyBindAction::TabMoveRight,
KeyBindAction::TabActivate0,
KeyBindAction::TabActivate1,
KeyBindAction::TabActivate2,
@ -369,7 +380,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 {
@ -479,6 +494,10 @@ fn fallback_shortcuts() -> Shortcuts {
bind!([Ctrl], "Tab", TabNext);
bind!([Ctrl, Shift], "Tab", TabPrev);
// Ctrl+Shift+PageUp/Down move tabs left and right
bind!([Ctrl, Shift], "PageUp", TabMoveLeft);
bind!([Ctrl, Shift], "PageDown", TabMoveRight);
// Ctrl+Shift+# activates tabs by index
bind!([Ctrl, Shift], "1", TabActivate0);
bind!([Ctrl, Shift], "2", TabActivate1);

View file

@ -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.

View file

@ -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;