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. 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.
- 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.
---
- 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] [package]
name = "cosmic-term" name = "cosmic-term"
version = "1.8.0" version = "1.10.0"
authors = ["Jeremy Soller <jeremy@system76.com>"] authors = ["Jeremy Soller <jeremy@system76.com>"]
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"
@ -18,6 +18,9 @@ ron = "0.11"
serde = { version = "1", features = ["serde_derive"] } serde = { version = "1", features = ["serde_derive"] }
shlex = "1" shlex = "1"
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } 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 # HTTP client for Modjo AI integration
reqwest = { version = "0.12", features = ["json", "stream"], optional = true } reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
serde_json = { version = "1", 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 cosmic-term (1.8.0) noble; urgency=medium
* Epoch 1.8.0 version update * Epoch 1.8.0 version update

View file

@ -1,4 +1,6 @@
tar-ignore=.github 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=vendor
tar-ignore=target

View file

@ -45,8 +45,8 @@ use-bright-bold = Světlejší tučný text
splits = Rozdělení splits = Rozdělení
focus-follow-mouse = Zaměření psaní sleduje myš focus-follow-mouse = Zaměření psaní sleduje myš
advanced = Pokročilé advanced = Pokročilé
show-headerbar = Zobrazit hlavičku show-headerbar = Zobrazit záhlaví
show-header-description = Hlavičku lze odkrýt z nabídky zobrazené po kliknutí pravým tlačítkem myši 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-placeholder = Najít...
find-previous = Najít předchozí find-previous = Najít předchozí
find-next = Najít další find-next = Najít další
@ -69,7 +69,7 @@ next-tab = Další karta
previous-tab = Předchozí karta previous-tab = Předchozí karta
split-horizontal = Rozdělit horizontálně split-horizontal = Rozdělit horizontálně
split-vertical = Rozdělit vertiká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-color-schemes = Barevná schémata...
menu-settings = Nastavení... menu-settings = Nastavení...
menu-about = O aplikaci Terminál COSMIC... 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 toggle-fullscreen = Přepnout režim celé obrazovky
type-to-search = Pište pro vyhledávání... type-to-search = Pište pro vyhledávání...
copy-or-sigint = Kopírovat nebo SIGINT copy-or-sigint = Kopírovat nebo SIGINT
focus-pane-down = Zaměřit panel dole focus-pane-down = Zaměřit podokno dole
focus-pane-left = Zaměřit panel vlevo focus-pane-left = Zaměřit podokno vlevo
focus-pane-right = Zaměřit panel vpravo focus-pane-right = Zaměřit podokno vpravo
focus-pane-up = Zaměřit panel nahoře focus-pane-up = Zaměřit podokno nahoře
shortcut-replace-body = { $binding } je již přiřazena k „{ $existing }“. Nahradit za „{ $new_action }“? shortcut-replace-body = { $binding } je již přiřazena k „{ $existing }“. Nahradit za „{ $new_action }“?
shortcut-capture-hint = Stiskněte kombinaci kláves shortcut-capture-hint = Stiskněte kombinaci kláves
paste-primary = Vložit primární paste-primary = Vložit primární
copy-link = Kopírovat odkaz copy-link = Kopírovat odkaz
tab-new-inherit-working-directory = Nové karty a okna používají aktuální adresář 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 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 previous-tab = Vorheriger Tab
split-horizontal = Horizontal teilen split-horizontal = Horizontal teilen
split-vertical = Vertikal teilen split-vertical = Vertikal teilen
pane-toggle-maximize = Maximierung umschalten pane-toggle-maximize = Bereich maximieren oder wiederherstellen
menu-color-schemes = Farbschemen ... menu-color-schemes = Farbschemen ...
menu-settings = Einstellungen ... menu-settings = Einstellungen ...
menu-about = Über COSMIC Terminal ... 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 = Neue Tabs und Fenster verwenden das aktuelle Verzeichnis
tab-new-inherit-working-directory-description = Neue Tabs und Fenster im Arbeitsverzeichnis des aktiven Tabs öffnen tab-new-inherit-working-directory-description = Neue Tabs und Fenster im Arbeitsverzeichnis des aktiven Tabs öffnen
show-pane-borders = Bereichsrahmen anzeigen 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 = Προηγούμενη καρτέλα previous-tab = Προηγούμενη καρτέλα
split-horizontal = Οριζόντιος διαχωρισμός split-horizontal = Οριζόντιος διαχωρισμός
split-vertical = Κάθετος διαχωρισμός split-vertical = Κάθετος διαχωρισμός
pane-toggle-maximize = Εναλλαγή μεγιστοποίησης pane-toggle-maximize = Μεγιστοποίηση ή επαναφορά προβολής
menu-color-schemes = Χρωματικοί συνδυασμοί... menu-color-schemes = Χρωματικοί συνδυασμοί...
menu-settings = Ρυθμίσεις... menu-settings = Ρυθμίσεις...
menu-about = Σχετικά με το Τερματικό COSMIC... menu-about = Σχετικά με το Τερματικό COSMIC...
@ -153,3 +153,6 @@ shortcut-replace-body = Ο συνδυασμός { $binding } έχει ήδη α
tab-new-inherit-working-directory = Οι νέες καρτέλες και παράθυρα χρησιμοποιούν τον τρέχοντα κατάλογο tab-new-inherit-working-directory = Οι νέες καρτέλες και παράθυρα χρησιμοποιούν τον τρέχοντα κατάλογο
tab-new-inherit-working-directory-description = Άνοιγμα νέων καρτελών και παραθύρων στον τρέχοντα κατάλογο της ενεργής καρτέλας tab-new-inherit-working-directory-description = Άνοιγμα νέων καρτελών και παραθύρων στον τρέχοντα κατάλογο της ενεργής καρτέλας
show-pane-borders = Εμφάνιση περιγραμμάτων πλαισίων 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 previous-tab = Previous tab
split-horizontal = Split horizontal split-horizontal = Split horizontal
split-vertical = Split vertical split-vertical = Split vertical
pane-toggle-maximize = Toggle maximized pane-toggle-maximize = Maximise or restore pane
menu-color-schemes = Colour schemes... menu-color-schemes = Colour schemes...
menu-settings = Settings... menu-settings = Settings...
menu-about = About COSMIC Terminal... menu-about = About COSMIC Terminal...
@ -115,3 +115,4 @@ cancel = Cancel
keyboard-shortcuts = Keyboard shortcuts keyboard-shortcuts = Keyboard shortcuts
replace = Replace replace = Replace
menu-keyboard-shortcuts = Keyboard shortcuts... menu-keyboard-shortcuts = Keyboard shortcuts...
window-header = Window header

View file

@ -59,13 +59,17 @@ use-bright-bold = Make bold text brighter
splits = Splits splits = Splits
focus-follow-mouse = Typing focus follows mouse focus-follow-mouse = Typing focus follows mouse
show-pane-borders = Show pane borders show-pane-borders = Show pane borders
unfocused-pane-dim = Dim unfocused panes
### Advanced ### Advanced
advanced = Advanced advanced = Advanced
show-headerbar = Show header show-headerbar = Show header
window-header = Window header
show-header-description = Reveal the header from the right-click menu 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 = 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 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 ### Keyboard shortcuts
add-another-keybinding = Add another keybinding add-another-keybinding = Add another keybinding
@ -138,6 +142,10 @@ save-output = Save output as text...
monitor-activity = Monitor for activity monitor-activity = Monitor for activity
monitor-silence = Monitor for silence monitor-silence = Monitor for silence
monitor-process-finished = Monitor for process finishing monitor-process-finished = Monitor for process finishing
read-only = Read-only
## Notifications
notification-show = Show tab
## Open ## Open
open-link = Open Link open-link = Open Link
@ -150,9 +158,11 @@ zoom-reset = Default text size
zoom-out = Smaller text zoom-out = Smaller text
next-tab = Next tab next-tab = Next tab
previous-tab = Previous tab previous-tab = Previous tab
move-tab-left = Move tab left
move-tab-right = Move tab right
split-horizontal = Split horizontal split-horizontal = Split horizontal
split-vertical = Split vertical split-vertical = Split vertical
pane-toggle-maximize = Toggle maximized pane-toggle-maximize = Maximize or restore pane
menu-color-schemes = Color schemes... menu-color-schemes = Color schemes...
menu-settings = Settings... menu-settings = Settings...
menu-about = About COSMIC Terminal... menu-about = About COSMIC Terminal...

View file

@ -107,7 +107,7 @@ next-tab = Seuraava välilehti
previous-tab = Edellinen välilehti previous-tab = Edellinen välilehti
split-horizontal = Halkaise näkymä vaakasuunnassa split-horizontal = Halkaise näkymä vaakasuunnassa
split-vertical = Halkaise näkymä pystysuunnassa 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-color-schemes = Väriteemat…
menu-settings = Asetukset… menu-settings = Asetukset…
menu-about = Tietoa COSMICin päätteestä… 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 = Uudet välilehdet käyttävät nykyistä hakemistoa
tab-new-inherit-working-directory-description = Avaa uudet välilehdet aktiivisen välilehden työskentelyhakemistossa tab-new-inherit-working-directory-description = Avaa uudet välilehdet aktiivisen välilehden työskentelyhakemistossa
show-pane-borders = Näytä paneelin rajat 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
edit = Modifier edit = Édition
copy = Copier copy = Copier
paste = Coller paste = Coller
select-all = Sélectionner tout select-all = Sélectionner tout
@ -108,6 +108,10 @@ save-output = Enregistrer la sortie comme texte...
monitor-activity = Surveiller l'activité monitor-activity = Surveiller l'activité
monitor-silence = Surveiller le silence monitor-silence = Surveiller le silence
monitor-process-finished = Surveiller la fin du processus monitor-process-finished = Surveiller la fin du processus
read-only = Lecture seule
## Notifications
notification-show = Afficher l'onglet
## View ## View
@ -119,7 +123,7 @@ next-tab = Onglet suivant
previous-tab = Onglet précédent previous-tab = Onglet précédent
split-horizontal = Scinder en horizontal split-horizontal = Scinder en horizontal
split-vertical = Scinder en vertical 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-color-schemes = Palettes de couleurs...
menu-settings = Paramètres... menu-settings = Paramètres...
menu-about = À propos de terminal COSMIC... menu-about = À propos de terminal COSMIC...
@ -168,4 +172,10 @@ comment = Émulateur de terminal pour le bureau COSMIC
keywords = Commande;Shell;Terminal;CLI; 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 = 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 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 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 previous-tab = An cluaisín roimhe seo
split-horizontal = Scoilt cothrománach split-horizontal = Scoilt cothrománach
split-vertical = Scoilt ingearach 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-color-schemes = Scéimeanna dathanna...
menu-settings = Socruithe... menu-settings = Socruithe...
menu-about = Maidir le COSMIC Teirminéal... menu-about = Maidir le COSMIC Teirminéal...
@ -149,3 +149,5 @@ keywords = Ordú;Blaosc;Teirminéal;CLI;
copy-link = Cóipeáil Nasc 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 = Ú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 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 settings = Axustes
appearance = Apariencia appearance = Apariencia
theme = Tema theme = Tema
match-desktop = Axustar ao escritorio match-desktop = Emparexar co escritorio
dark = Escuro dark = Escuro
light = Claro light = Claro
default-zoom-step = Pasos do zoom default-zoom-step = Pasos do zoom
@ -110,3 +110,6 @@ passwords-title = Contrasinais
add-password = Engadir contrasinal add-password = Engadir contrasinal
password-input = Contrasinal password-input = Contrasinal
password-input-description = Descripción 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
font = Betűtípus font = Betűkészlet
advanced-font-settings = Speciális betűtípus-beállítások advanced-font-settings = Speciális betűkészlet-beállítások
default-font = Betűtípus default-font = Betűkészlet
default-font-size = Betűméret default-font-size = Betűméret
default-font-stretch = Betűszélesség default-font-stretch = Betűszélesség
default-font-weight = Normál betűsúly default-font-weight = Normál betűvastagság
default-dim-font-weight = Vékony betűsúly default-dim-font-weight = Vékony betűvastagság
default-bold-font-weight = Félkövér betűsúly default-bold-font-weight = Félkövér betűvastagság
use-bright-bold = Félkövér szöveg világosítása use-bright-bold = Félkövér szöveg világosítása
### Splits ### Splits
@ -109,7 +109,7 @@ next-tab = Következő lap
previous-tab = Előző lap previous-tab = Előző lap
split-horizontal = Vízszintes felosztás split-horizontal = Vízszintes felosztás
split-vertical = Függőleges 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-color-schemes = Színsémák…
menu-settings = Beállítások… menu-settings = Beállítások…
menu-about = A COSMIC Terminál névjegye… 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 = 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 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 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 support = Dukungan
color-schemes = Skema warna color-schemes = Skema warna
cosmic-terminal = Terminal COSMIC cosmic-terminal = Terminal COSMIC
rename = Ganti nama rename = Ganti Nama
export = Ekspor export = Ekspor
delete = Hapus delete = Hapus
import = Impor import = Impor
@ -23,7 +23,7 @@ remain-open = Tetap buka setelah proses anak berakhir.
settings = Pengaturan settings = Pengaturan
appearance = Tampilan appearance = Tampilan
theme = Tema theme = Tema
match-desktop = Cocok desktop match-desktop = Sesuaikan desktop
dark = Gelap dark = Gelap
light = Terang light = Terang
syntax-dark = Skema warna gelap syntax-dark = Skema warna gelap
@ -68,7 +68,7 @@ next-tab = Tab selanjutnya
previous-tab = Tab sebelumnya previous-tab = Tab sebelumnya
split-horizontal = Pemisahan horisontal split-horizontal = Pemisahan horisontal
split-vertical = Pemisahan vertikal split-vertical = Pemisahan vertikal
pane-toggle-maximize = Ubah ke maksimal pane-toggle-maximize = Maksimalkan atau pulihkan panel
menu-color-schemes = Skema warna... menu-color-schemes = Skema warna...
menu-settings = Pengaturan... menu-settings = Pengaturan...
menu-about = Tentang Terminal COSMIC... 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 = Tab dan jendela baru menggunakan direktori saat ini
tab-new-inherit-working-directory-description = Buka tab dan jendela baru di direktori pekerjaan tab aktif tab-new-inherit-working-directory-description = Buka tab dan jendela baru di direktori pekerjaan tab aktif
show-pane-borders = Tampilkan batas panel 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 name = Isem
delete = kkes delete = kkes
rename = Snifel isem rename = Snifel isem
quit = Tuffɣa quit = Ffeɣ
support = Tallalt support = Tallelt
repository = Asarsay repository = Asarsay
settings = Iɣewwaṛen settings = Iɣewwaṛen
appearance = Timeẓri appearance = Timeẓri
@ -14,7 +14,7 @@ paste = Senteḍ
select-all = Fren akk select-all = Fren akk
view = Wali view = Wali
menu-settings = Iɣewwaṛen… menu-settings = Iɣewwaṛen…
match-desktop = Amṣada d tnarit match-desktop = Semṣada akked tnarit
file = Afaylu file = Afaylu
new-window = Asfaylu amaynut new-window = Asfaylu amaynut
edit = Ẓreg edit = Ẓreg

View file

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

View file

@ -27,13 +27,13 @@ light = Ronî
advanced = Pêşketî advanced = Pêşketî
support = Piştgirî support = Piştgirî
default-font = Cûrenivîs default-font = Cûrenivîs
paste = Pêve bike paste = Pê ve bike
menu-settings = Sazkarî... menu-settings = Sazkarî...
command-line = Rêza fermanê command-line = Rêza fermanê
view = Dîtin view = Dîtin
dark = Tarî dark = Tarî
hold = Ragire hold = Ragire
match-desktop = Bi sermaseyê re li hev bîne match-desktop = Bi sermaseyê re guncav bike
find-previous = Yê paş bibîne find-previous = Yê paş bibîne
cosmic-terminal = Termînalê COSMIC cosmic-terminal = Termînalê COSMIC
find-placeholder = Bibîne... 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-right = Balê bikişîne ser destgeha rastê
focus-pane-up = Balê bikişîne ser destgeha jorîn focus-pane-up = Balê bikişîne ser destgeha jorîn
password-manager = Rêveberê borînpeyvê 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 reset-to-default = Vegere rewşa berdest
shortcut-capture-hint = Kombînasyona kilîtan bitikîne 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î? 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... type-to-search = Ji bo lêgerînê binivîse...
copy-link = Girêdanê jê bigire copy-link = Girêdanê jê bigire
show-pane-borders = Sînorên kilîtdankê nîşan bide 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 appearance = Aparéncia
theme = Tèma theme = Tèma
match-desktop = Assortir al burèu match-desktop = Assortir al burèu
dark = Fosc dark = Escur
light = Clar light = Clar
file = Fichièr 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 cosmic-terminal = Terminal COSMIC
comment = Emulator terminala dla pulpitu COSMIC comment = Emulator terminala dla środowiska COSMIC
keywords = Cli;WierszPoleceń;Powłoka;Terminal; keywords = Cli;WierszPoleceń;Powłoka;Terminal;
new-terminal = Nowy terminal new-terminal = Nowy terminal
@ -73,7 +73,7 @@ advanced = Zaawansowane
show-headerbar = Pokaż pasek nagłówka show-headerbar = Pokaż pasek nagłówka
show-header-description = Ukazuje pasek nagłówka z menu wybieranego prawym przyciskiem myszki show-header-description = Ukazuje pasek nagłówka z menu wybieranego prawym przyciskiem myszki
# Find # Find
find-placeholder = Szukaj… find-placeholder = Wyszukaj…
find-previous = Znajdź poprzedni find-previous = Znajdź poprzedni
find-next = Znajdź następny find-next = Znajdź następny
@ -96,7 +96,7 @@ edit = Edytuj
copy = Kopiuj copy = Kopiuj
paste = Wklej paste = Wklej
select-all = Zaznacz wszystko select-all = Zaznacz wszystko
find = Szukaj find = Wyszukaj
clear-scrollback = Wyczyść przewijanie clear-scrollback = Wyczyść przewijanie
## View ## View
@ -109,7 +109,7 @@ next-tab = Następna karta
previous-tab = Poprzednia karta previous-tab = Poprzednia karta
split-horizontal = Podziel w poziomie split-horizontal = Podziel w poziomie
split-vertical = Podziel w pionie 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-color-schemes = Schemat kolorów...
menu-settings = Ustawienia… menu-settings = Ustawienia…
menu-about = O Terminalu COSMIC... 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 = Nowe karty i okna użyją obecnego katalogu
tab-new-inherit-working-directory-description = Otwieraj nowe karty i okna w katalogu roboczym aktywnej karty tab-new-inherit-working-directory-description = Otwieraj nowe karty i okna w katalogu roboczym aktywnej karty
show-pane-borders = Pokaż obramowania paneli 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
advanced = Avançado 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 show-header-description = Mostrar o cabeçalho ao clicar no menu com o botão direito do mouse
# Find # Find
find-placeholder = Localizar... find-placeholder = Localizar...
@ -109,7 +109,7 @@ next-tab = Próxima aba
previous-tab = Aba anterior previous-tab = Aba anterior
split-horizontal = Dividir horizontalmente split-horizontal = Dividir horizontalmente
split-vertical = Dividir verticalmente 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-color-schemes = Esquemas de cores...
menu-settings = Configurações... menu-settings = Configurações...
menu-about = Sobre o Terminal... 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 = 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 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 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 = Предыдущая вкладка previous-tab = Предыдущая вкладка
split-horizontal = Разделение по горизонтали split-horizontal = Разделение по горизонтали
split-vertical = Разделение по вертикали split-vertical = Разделение по вертикали
pane-toggle-maximize = Переключить на весь экран pane-toggle-maximize = Развернуть/восстановить область
menu-color-schemes = Цветовые схемы... menu-color-schemes = Цветовые схемы...
menu-settings = Параметры... menu-settings = Параметры...
menu-about = О Терминале COSMIC... menu-about = О Терминале COSMIC...
@ -143,4 +143,7 @@ type-to-search = Введите для поиска...
copy-link = Копировать ссылку copy-link = Копировать ссылку
tab-new-inherit-working-directory = Текущий каталог в новых вкладках и окнах tab-new-inherit-working-directory = Текущий каталог в новых вкладках и окнах
tab-new-inherit-working-directory-description = Открывать новые вкладки и окна в рабочем каталоге активной вкладки 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 previous-tab = Föregående flik
split-horizontal = Dela horisontellt split-horizontal = Dela horisontellt
split-vertical = Dela vertikalt split-vertical = Dela vertikalt
pane-toggle-maximize = Växla maximerad pane-toggle-maximize = Maximera eller återställ fält
clear-scrollback = Töm tillbakarullning clear-scrollback = Töm tillbakarullning
menu-color-schemes = Färgscheman… menu-color-schemes = Färgscheman…
menu-settings = Inställningar... 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-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 tab-new-inherit-working-directory = Nya flikar och fönster använder aktuell katalog
show-pane-borders = Visa ram runt fält 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 = ลักษณะ appearance = ลักษณะ
theme = ธีม theme = ธีม
match-desktop = ใช้ตามธีมหน้าจอหลัก match-desktop = ใช้ตามธีมหน้าจอหลัก
dark = ธีมมืด dark = มืด
light = ธีมสว่าง light = สว่าง
syntax-dark = โทนสีมืด syntax-dark = โทนสีมืด
syntax-light = โทนสีสว่าง syntax-light = โทนสีสว่าง
default-zoom-step = ระยะซูม default-zoom-step = ระยะซูม
@ -109,3 +109,8 @@ pane-toggle-maximize = ขยายให้ใหญ่สุด
menu-color-schemes = โทนสี... menu-color-schemes = โทนสี...
menu-settings = การตั้งค่า... menu-settings = การตั้งค่า...
menu-about = เกี่ยวกับ เทอร์มินัล COSMIC... menu-about = เกี่ยวกับ เทอร์มินัล COSMIC...
cancel = ยกเลิก
reset-to-default = รีเซ็ตเป็นค่าเริ่มต้น
type-to-search = พิมพ์เพื่อค้นหา
add-another-keybinding = เพิ่มคีย์ลัดเพิ่มเติม
replace = แทนที่

View file

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

View file

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

View file

@ -10,7 +10,7 @@ new-terminal = 新建終端機
## Color schemes ## Color schemes
color-schemes = 配色方案 color-schemes = 配色方案
rename = 重命名 rename = 重新命名
export = 匯出 export = 匯出
delete = 刪除 delete = 刪除
import = 匯入 import = 匯入
@ -104,7 +104,7 @@ next-tab = 下個分頁
previous-tab = 上個分頁 previous-tab = 上個分頁
split-horizontal = 水平分割 split-horizontal = 水平分割
split-vertical = 垂直分割 split-vertical = 垂直分割
pane-toggle-maximize = 切換最大化 pane-toggle-maximize = 將窗格最大化或還原
menu-color-schemes = 配色方案... menu-color-schemes = 配色方案...
menu-settings = 設定... menu-settings = 設定...
menu-about = 關於 COSMIC 終端機… menu-about = 關於 COSMIC 終端機…
@ -150,3 +150,6 @@ comment = COSMIC 桌面終端機模擬器
tab-new-inherit-working-directory = 新分頁和視窗使用目前目錄 tab-new-inherit-working-directory = 新分頁和視窗使用目前目錄
tab-new-inherit-working-directory-description = 在作用中分頁的工作目錄開啟新分頁和視窗 tab-new-inherit-working-directory-description = 在作用中分頁的工作目錄開啟新分頁和視窗
show-pane-borders = 顯示窗格邊框 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 profiles: BTreeMap<ProfileId, Profile>,
pub show_headerbar: bool, pub show_headerbar: bool,
pub show_pane_borders: 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 use_bright_bold: bool,
pub syntax_theme_dark: String, pub syntax_theme_dark: String,
pub syntax_theme_light: String, pub syntax_theme_light: String,
pub focus_follow_mouse: bool, pub focus_follow_mouse: bool,
#[serde(default)] #[serde(default)]
pub tab_new_inherit_working_directory: bool, 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>, pub default_profile: Option<ProfileId>,
#[serde(default)] #[serde(default)]
pub shortcuts_custom: Shortcuts, 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 { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -253,6 +269,7 @@ impl Default for Config {
dim_font_weight: Weight::NORMAL.0, dim_font_weight: Weight::NORMAL.0,
focus_follow_mouse: false, focus_follow_mouse: false,
tab_new_inherit_working_directory: false, tab_new_inherit_working_directory: false,
copy_on_select_clipboard: false,
font_name: "Noto Sans Mono".to_string(), font_name: "Noto Sans Mono".to_string(),
font_size: 14, font_size: 14,
font_size_zoom_step_mul_100: 100, font_size_zoom_step_mul_100: 100,
@ -262,6 +279,7 @@ impl Default for Config {
profiles: BTreeMap::new(), profiles: BTreeMap::new(),
show_headerbar: true, show_headerbar: true,
show_pane_borders: false, show_pane_borders: false,
unfocused_pane_dim: default_unfocused_pane_dim(),
syntax_theme_dark: COSMIC_THEME_DARK.to_string(), syntax_theme_dark: COSMIC_THEME_DARK.to_string(),
syntax_theme_light: COSMIC_THEME_LIGHT.to_string(), syntax_theme_light: COSMIC_THEME_LIGHT.to_string(),
use_bright_bold: false, use_bright_bold: false,
@ -343,6 +361,12 @@ impl Config {
Metrics::new(font_size, line_height) 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 { pub fn opacity_ratio(&self) -> f32 {
f32::from(self.opacity) / 100.0 f32::from(self.opacity) / 100.0
} }
@ -404,3 +428,22 @@ impl Config {
})[&self.font_stretch] })[&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::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind; use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::pane_grid::Pane; use cosmic::widget::pane_grid::Pane;
use cosmic::widget::segmented_button::ReorderEvent; use cosmic::widget::segmented_button::{InsertPosition, ReorderEvent};
use cosmic::{ use cosmic::{
Application, ApplicationExt, Element, action, Application, ApplicationExt, Element, action,
app::{Core, Settings, Task, context_drawer}, app::{Core, Settings, Task, context_drawer},
@ -68,9 +68,19 @@ mod localize;
use menu::menu_bar; use menu::menu_bar;
mod menu; 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 terminal;
mod desktop_notify;
use osc_tap::OscEvent;
mod osc_tap;
mod kitty_keyboard;
use terminal_box::terminal_box; use terminal_box::terminal_box;
use crate::dnd::DndDrop; 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: /// Tab bar indicator for the monitor state of a terminal:
/// an alert icon if one fired, an "armed" icon while monitoring, else none. /// 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> { 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 { match terminal.monitor_alert {
Some(MonitorKind::Activity) => Some("dialog-information-symbolic"), Some(MonitorKind::Activity) => Some("dialog-information-symbolic"),
Some(MonitorKind::Silence) => Some("alarm-symbolic"), Some(MonitorKind::Silence) => Some("alarm-symbolic"),
Some(MonitorKind::ProcessFinished) => Some("emblem-ok-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 if terminal.monitoring_enabled() => Some("view-reveal-symbolic"),
None => None, None => None,
} }
@ -297,6 +314,9 @@ fn main() -> Result<(), Box<dyn Error>> {
// Terminal config setup // Terminal config setup
let term_config = term::Config { let term_config = term::Config {
scrolling_history: 100_000, 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() ..term::Config::default()
}; };
// Set up environmental variables for terminal // Set up environmental variables for terminal
@ -371,6 +391,7 @@ pub enum Action {
LayoutLoad, LayoutLoad,
LayoutSave, LayoutSave,
Monitor(MonitorKind), Monitor(MonitorKind),
ToggleReadOnly,
PaneFocusDown, PaneFocusDown,
PaneFocusLeft, PaneFocusLeft,
PaneFocusRight, PaneFocusRight,
@ -404,6 +425,8 @@ pub enum Action {
TabNext, TabNext,
TabPrev, TabPrev,
TabRename, TabRename,
TabMoveLeft,
TabMoveRight,
ToggleFullscreen, ToggleFullscreen,
WindowClose, WindowClose,
WindowNew, WindowNew,
@ -430,6 +453,7 @@ impl Action {
Self::LayoutLoad => Message::LayoutLoad, Self::LayoutLoad => Message::LayoutLoad,
Self::LayoutSave => Message::LayoutSave, Self::LayoutSave => Message::LayoutSave,
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt), 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::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left), Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right), Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
@ -463,6 +487,8 @@ impl Action {
Self::TabNext => Message::TabNext, Self::TabNext => Message::TabNext,
Self::TabPrev => Message::TabPrev, Self::TabPrev => Message::TabPrev,
Self::TabRename => Message::TabRenameOpen(entity_opt), Self::TabRename => Message::TabRenameOpen(entity_opt),
Self::TabMoveLeft => Message::TabMoveLeft,
Self::TabMoveRight => Message::TabMoveRight,
Self::ToggleFullscreen => Message::ToggleFullscreen, Self::ToggleFullscreen => Message::ToggleFullscreen,
Self::WindowClose => Message::WindowClose, Self::WindowClose => Message::WindowClose,
Self::WindowNew => Message::WindowNew, Self::WindowNew => Message::WindowNew,
@ -537,6 +563,7 @@ pub enum Message {
ShortcutSearch(String), ShortcutSearch(String),
MonitorTick, MonitorTick,
MonitorToggle(MonitorKind, Option<segmented_button::Entity>), MonitorToggle(MonitorKind, Option<segmented_button::Entity>),
ReadOnlyToggle(Option<segmented_button::Entity>),
MouseEnter(pane_grid::Pane), MouseEnter(pane_grid::Pane),
Opacity(u8), Opacity(u8),
PaneClicked(pane_grid::Pane), PaneClicked(pane_grid::Pane),
@ -566,16 +593,19 @@ pub enum Message {
ReorderTab(Pane, ReorderEvent), ReorderTab(Pane, ReorderEvent),
SaveOutput(Option<segmented_button::Entity>), SaveOutput(Option<segmented_button::Entity>),
SaveOutputResult(pane_grid::Pane, segmented_button::Entity, DialogResult), SaveOutputResult(pane_grid::Pane, segmented_button::Entity, DialogResult),
Surface(surface::Action), Surface(surface::Action<Message>),
SelectAll(Option<segmented_button::Entity>), SelectAll(Option<segmented_button::Entity>),
ShowAdvancedFontSettings(bool), ShowAdvancedFontSettings(bool),
ShowHeaderBar(bool), ShowHeaderBar(bool),
SshConnect(usize), SshConnect(usize),
ShowPaneBorders(bool), ShowPaneBorders(bool),
UnfocusedPaneDim(u8),
SyntaxTheme(ColorSchemeKind, usize), SyntaxTheme(ColorSchemeKind, usize),
SystemThemeChange, SystemThemeChange,
TabNewInheritWorkingDirectory(bool), TabNewInheritWorkingDirectory(bool),
CopyOnSelectClipboard(bool),
SelectionDone(pane_grid::Pane, segmented_button::Entity),
TabActivate(segmented_button::Entity), TabActivate(segmented_button::Entity),
TabActivateJump(usize), TabActivateJump(usize),
TabClose(Option<segmented_button::Entity>), TabClose(Option<segmented_button::Entity>),
@ -591,8 +621,12 @@ pub enum Message {
TabRenameCommit, TabRenameCommit,
TabRenameCancel, TabRenameCancel,
TabRenameResetToDefault, TabRenameResetToDefault,
TabMoveLeft,
TabMoveRight,
TermEvent(pane_grid::Pane, segmented_button::Entity, TermEvent), 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, ToggleFullscreen,
ToggleContextPage(ContextPage), ToggleContextPage(ContextPage),
UpdateDefaultProfile((bool, ProfileId)), UpdateDefaultProfile((bool, ProfileId)),
@ -604,7 +638,6 @@ pub enum Message {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
ZoomReset, ZoomReset,
ContextMenuPopupClosed(window::Id),
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -659,7 +692,9 @@ pub struct App {
find_search_id: widget::Id, find_search_id: widget::Id,
find_search_value: String, find_search_value: String,
term_event_tx_opt: 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_options: Option<tty::Options>,
startup_profile_id: Option<ProfileId>, startup_profile_id: Option<ProfileId>,
term_config: term::Config, term_config: term::Config,
@ -682,19 +717,56 @@ pub struct App {
shortcut_search_regex: Option<regex::Regex>, shortcut_search_regex: Option<regex::Regex>,
shortcut_search_value: String, shortcut_search_value: String,
modifiers: Modifiers, 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")] #[cfg(feature = "password_manager")]
password_mgr: password_manager::PasswordManager, password_mgr: password_manager::PasswordManager,
} }
impl App { 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> { fn theme_names(&self, color_scheme_kind: ColorSchemeKind) -> &Vec<String> {
match color_scheme_kind { match color_scheme_kind {
ColorSchemeKind::Dark => &self.theme_names_dark, ColorSchemeKind::Dark => &self.theme_names_dark,
@ -1670,6 +1742,15 @@ impl App {
.add( .add(
widget::settings::item::builder(fl!("show-pane-borders")) widget::settings::item::builder(fl!("show-pane-borders"))
.toggler(self.config.show_pane_borders, Message::ShowPaneBorders), .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() let advanced_section = widget::settings::section()
@ -1686,6 +1767,14 @@ impl App {
self.config.tab_new_inherit_working_directory, self.config.tab_new_inherit_working_directory,
Message::TabNewInheritWorkingDirectory, 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![ 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) { fn active_terminal_monitors(&self) -> (bool, bool, bool) {
let pane = self.pane_model.focused(); let pane = self.pane_model.focused();
if let Some(tab_model) = self.pane_model.panes.get(pane) { 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"); log::warn!("tried to create new tab before having event channel");
} }
} }
self.sync_terminal_focus();
self.update_title(Some(pane)) self.update_title(Some(pane))
} }
} }
@ -2148,7 +2254,7 @@ impl Application for App {
.comments(fl!("comment")) .comments(fl!("comment"))
.license("GPL-3.0-only") .license("GPL-3.0-only")
.license_url("https://spdx.org/licenses/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([ .links([
(fl!("repository"), "https://github.com/pop-os/cosmic-term"), (fl!("repository"), "https://github.com/pop-os/cosmic-term"),
( (
@ -2194,6 +2300,8 @@ impl Application for App {
startup_profile_id: flags.startup_profile_id, startup_profile_id: flags.startup_profile_id,
term_config: flags.term_config, term_config: flags.term_config,
term_event_tx_opt: None, term_event_tx_opt: None,
notify_tx_opt: None,
window_focused: true,
color_scheme_errors: Vec::new(), color_scheme_errors: Vec::new(),
color_scheme_expanded: None, color_scheme_expanded: None,
color_scheme_renaming: None, color_scheme_renaming: None,
@ -2213,7 +2321,6 @@ impl Application for App {
shortcut_search_regex: None, shortcut_search_regex: None,
shortcut_search_value: String::new(), shortcut_search_value: String::new(),
modifiers: Modifiers::empty(), modifiers: Modifiers::empty(),
context_menu_popup: None,
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
password_mgr: Default::default(), 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) => { Message::MonitorToggle(monitor_kind, entity_opt) => {
if let Some(tab_model) = self.pane_model.active_mut() { if let Some(tab_model) = self.pane_model.active_mut() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active()); let entity = entity_opt.unwrap_or_else(|| tab_model.active());
@ -3299,6 +3420,24 @@ impl Application for App {
return self.update_config(); 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) => { Message::TabNewInheritWorkingDirectory(tab_new_inherit_working_directory) => {
config_set!( config_set!(
tab_new_inherit_working_directory, tab_new_inherit_working_directory,
@ -3310,6 +3449,12 @@ impl Application for App {
config_set!(show_pane_borders, show_pane_borders); 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) => { Message::UseBrightBold(use_bright_bold) => {
if use_bright_bold != self.config.use_bright_bold { if use_bright_bold != self.config.use_bright_bold {
config_set!(use_bright_bold, 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); monitor_update_tab_icon(tab_model, entity, icon_name);
} }
} }
self.sync_terminal_focus();
return self.update_title(None); return self.update_title(None);
} }
Message::TabActivateJump(pos) => { Message::TabActivateJump(pos) => {
@ -3430,19 +3576,11 @@ impl Application for App {
} }
} }
self.sync_terminal_focus();
return self.update_title(None); return self.update_title(None);
} }
Message::TabContextAction(entity, action) => { Message::TabContextAction(entity, action) => {
// Close context menu popup
let mut tasks = Vec::new(); 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 // Close terminal context menu state
if let Some(tab_model) = self.pane_model.active() if let Some(tab_model) = self.pane_model.active()
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) && let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
@ -3451,11 +3589,8 @@ impl Application for App {
//Some actions need the menu_state, //Some actions need the menu_state,
//so only clear the position for them. //so only clear the position for them.
match action { match action {
Action::LaunchUrlByMenu | Action::CopyUrlByMenu => { // these read the link from the menu state and clear it themselves
if let Some(context_menu) = terminal.context_menu.as_mut() { Action::LaunchUrlByMenu | Action::CopyUrlByMenu => {}
context_menu.position = None;
}
}
_ => { _ => {
terminal.context_menu = None; terminal.context_menu = None;
} }
@ -3465,94 +3600,29 @@ impl Application for App {
return cosmic::Task::batch(tasks); return cosmic::Task::batch(tasks);
} }
Message::TabContextMenu(pane, menu_state) => { 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 // Clear all terminal context_menu state
for (_, tab_model) in self.pane_model.panes.iter() { for (_, tab_model) in self.pane_model.panes.iter() {
for entity in tab_model.iter() { for entity in tab_model.iter() {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap(); 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 { // A right press records what is under the cursor
if let Some(_position) = menu_state.position { if let Some(menu_state) = menu_state
let local_position = menu_state.local_position.unwrap_or(_position); && let Some(tab_model) = self.pane_model.panes.get(pane)
if let Some(tab_model) = self.pane_model.panes.get(pane) { {
let entity = tab_model.active(); let entity = tab_model.active();
let link = menu_state.link.clone(); if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let popup_id = window::Id::unique(); terminal.lock().unwrap().context_menu = Some(menu_state);
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),
)));
}
}
} }
self.pane_model.set_focus(pane); self.pane_model.set_focus(pane);
} }
return cosmic::Task::batch(tasks);
} }
Message::TabNew => { Message::TabNew => {
// The profile from `--profile` only applies to the first tab // The profile from `--profile` only applies to the first tab
@ -3673,10 +3743,43 @@ impl Application for App {
} }
self.pane_model.update_terminal_focus(); 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) => { Message::TermEvent(pane, entity, event) => {
match event { match event {
TermEvent::Bell => { 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) => { TermEvent::ClipboardLoad(kind, callback) => {
match kind { match kind {
@ -3710,7 +3813,7 @@ impl Application for App {
let terminal = terminal.lock().unwrap(); let terminal = terminal.lock().unwrap();
let rgb = terminal.effective_color(index); let rgb = terminal.effective_color(index);
let text = f(rgb); let text = f(rgb);
terminal.input_no_scroll(text.into_bytes()); terminal.respond(text.into_bytes());
} }
} }
TermEvent::CursorBlinkingChange => { TermEvent::CursorBlinkingChange => {
@ -3724,7 +3827,7 @@ impl Application for App {
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) && let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
{ {
let terminal = terminal.lock().unwrap(); let terminal = terminal.lock().unwrap();
terminal.input_no_scroll(text.into_bytes()); terminal.respond(text.into_bytes());
} }
} }
TermEvent::ResetTitle => { TermEvent::ResetTitle => {
@ -3749,7 +3852,7 @@ impl Application for App {
{ {
let terminal = terminal.lock().unwrap(); let terminal = terminal.lock().unwrap();
let text = f(terminal.size().into()); let text = f(terminal.size().into());
terminal.input_no_scroll(text.into_bytes()); terminal.respond(text.into_bytes());
} }
} }
TermEvent::Title(title) => { TermEvent::Title(title) => {
@ -3836,6 +3939,76 @@ impl Application for App {
// Spawn first tab // Spawn first tab
return self.update(Message::TabNew); 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) => { Message::ToggleContextPage(context_page) => {
if self.context_page == context_page { if self.context_page == context_page {
self.core.window.show_context = !self.core.window.show_context; self.core.window.show_context = !self.core.window.show_context;
@ -3926,12 +4099,14 @@ impl Application for App {
} }
}, },
Message::WindowFocused => { Message::WindowFocused => {
self.window_focused = true;
if !self.core.window.show_context { if !self.core.window.show_context {
self.pane_model.update_terminal_focus(); self.pane_model.update_terminal_focus();
} }
return self.update_focus(); return self.update_focus();
} }
Message::WindowUnfocused => { Message::WindowUnfocused => {
self.window_focused = false;
self.pane_model.unfocus_all_terminals(); self.pane_model.unfocus_all_terminals();
} }
Message::ZoomIn => { Message::ZoomIn => {
@ -3944,26 +4119,8 @@ impl Application for App {
self.reset_active_pane_zoom(); self.reset_active_pane_zoom();
return self.update_config(); 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) => { Message::Surface(a) => {
return cosmic::task::message(cosmic::Action::Cosmic( return cosmic::task::message(cosmic::Action::Surface(a));
cosmic::app::Action::Surface(a),
));
} }
Message::ReorderTab( Message::ReorderTab(
pane, pane,
@ -4098,6 +4255,7 @@ impl Application for App {
&self.config, &self.config,
&self.key_binds, &self.key_binds,
self.active_terminal_monitors(), self.active_terminal_monitors(),
self.active_terminal_read_only(),
&self.ssh_hosts, &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> { 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 { match &self.dialog_opt {
Some(dialog) => dialog.view(window_id), Some(dialog) => dialog.view(window_id),
None => widget::text("Unknown window ID").into(), 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)); terminal_box = terminal_box.on_mouse_enter(move || Message::MouseEnter(pane));
} }
// If a context menu popup is active for this pane, inform the if self.config.copy_on_select_clipboard {
// terminal_box so it will emit on_context_menu(None) on click terminal_box =
// to dismiss the popup. terminal_box.on_selection_done(move || Message::SelectionDone(pane, entity));
if self.context_menu_popup.is_some() {
terminal_box = terminal_box.context_menu(cosmic::iced::Point::ORIGIN);
} }
let use_wayland_popup = { // [yoda] Voile sur les panneaux non focalisés (à la Ghostty), seulement
#[cfg(feature = "wayland")] // 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()
is_wayland() && self.pane_model.panes.panes.len() > 1
} && pane != self.pane_model.focused()
#[cfg(not(feature = "wayland"))] {
{ terminal_box = terminal_box.dim(dim);
false }
}
};
let tab_element: Element<'_, Message> = if !use_wayland_popup { // The terminal records what was under the right press; the widget opens the
// Fallback: render context menu as an inline popover // menu on release, so build it from that state
if let Some((_, popup_pane, popup_entity, ref link, _, point)) = let context_link = terminal
self.context_menu_popup .lock()
{ .unwrap()
if pane == popup_pane { .context_menu
let mut popover = widget::popover(terminal_box.context_menu(point)); .as_ref()
popover = popover .map(|menu_state| menu_state.link.clone());
.popup(menu::context_menu( if context_link.is_some() {
&self.config, terminal_box = terminal_box.context_menu_open(true);
&self.key_binds, }
popup_entity, let mut context_menu = widget::context_menu(
link.clone(), terminal_box,
self.terminal_monitors(popup_pane, popup_entity), context_link.map(|link| {
)) menu::context_menu(
.position(widget::popover::Position::Point(point)); &self.config,
popover.into() &self.key_binds,
} else { entity,
terminal_box.into() link,
} self.terminal_monitors(pane, entity),
} else { self.terminal_read_only(pane, entity),
terminal_box.into() )
} }),
} else { )
terminal_box.into() .item_width(widget::menu::ItemWidth::Uniform(360))
}; .on_close(Message::TabContextMenu(pane, None))
tab_column = tab_column.push(tab_element); .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 //Only draw find in the currently focused pane
@ -4409,16 +4542,18 @@ impl Application for App {
output.send(Message::TermEventTx(event_tx)).await.unwrap(); output.send(Message::TermEventTx(event_tx)).await.unwrap();
while let Some((pane, entity, event)) = event_rx.recv().await { while let Some((pane, entity, event)) = event_rx.recv().await {
output let message = match event {
.send(Message::TermEvent(pane, entity, event)) PtyEvent::Term(event) => Message::TermEvent(pane, entity, event),
.await PtyEvent::Osc(event) => Message::TermOsc(pane, entity, event),
.unwrap(); };
output.send(message).await.unwrap();
} }
panic!("terminal event channel closed"); panic!("terminal event channel closed");
}, },
) )
}), }),
desktop_notify::subscription().map(Message::DesktopNotify),
cosmic_config::config_subscription( cosmic_config::config_subscription(
TypeId::of::<ConfigSubscription>(), TypeId::of::<ConfigSubscription>(),
Self::APP_ID.into(), 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. /// Divider color painted behind the pane grid to form pane borders.
/// ///
/// When blur is active the transparent container must be used, otherwise the /// 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 // 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::key_bind::KeyBind;
use cosmic::widget::menu::{Item as MenuItem, menu_button}; use cosmic::widget::menu::{Item as MenuItem, menu_button};
use cosmic::widget::space;
use cosmic::{ use cosmic::{
Element, Element,
app::Core, app::Core,
iced::core::Border, iced::core::Border,
iced::{Background, Length, advanced::widget::text::Style as TextStyle}, iced::{Background, Length},
theme,
widget::{ widget::{
self, divider, self,
menu::{ItemHeight, ItemWidth}, menu::{ItemHeight, ItemWidth},
responsive_menu_bar, segmented_button, 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> = static MENU_ID: LazyLock<cosmic::widget::Id> =
LazyLock::new(|| cosmic::widget::Id::new("responsive-menu")); LazyLock::new(|| cosmic::widget::Id::new("responsive-menu"));
/// What the terminal found under the right-click
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MenuState { pub struct MenuState {
pub position: Option<Point>,
pub local_position: Option<Point>,
pub link: Option<String>, 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, config: &Config,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
entity: segmented_button::Entity, entity: segmented_button::Entity,
link: Option<String>, link: Option<String>,
monitors: (bool, bool, bool), monitors: (bool, bool, bool),
) -> Element<'a, Message> { read_only: bool,
let find_key = |action: &Action| -> String { ) -> Vec<widget::menu::Tree<Message>> {
for (key_bind, key_action) in key_binds { let item =
if action == key_action { |label: String, action: Action| MenuItem::Button(label, None, TabAction(entity, action));
return key_bind.to_string(); let check = |label: String, value: bool, action: Action| {
} MenuItem::CheckBox(label, None, value, TabAction(entity, action))
}
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))
}; };
let menu_checkbox = |label, value, action| { let mut items = vec![
menu_button(vec![ item(fl!("new-tab"), Action::TabNew),
widget::text(label).into(), item(fl!("rename-tab"), Action::TabRename),
widget::space::horizontal().into(), MenuItem::Divider,
widget::toggler(value) item(fl!("copy"), Action::Copy),
.on_toggle(move |_| Message::TabContextAction(entity, action)) item(fl!("paste"), Action::Paste),
.size(16.0) item(fl!("select-all"), Action::SelectAll),
.into(), MenuItem::Divider,
]) item(fl!("clear-scrollback"), Action::ClearScrollback),
.on_press(Message::TabContextAction(entity, action)) item(fl!("save-output"), Action::SaveOutput),
}; MenuItem::Divider,
check(
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(
fl!("monitor-activity"), fl!("monitor-activity"),
monitors.0, monitors.0,
Action::Monitor(MonitorKind::Activity), Action::Monitor(MonitorKind::Activity),
)), ),
Element::from(menu_checkbox( check(
fl!("monitor-silence"), fl!("monitor-silence"),
monitors.1, monitors.1,
Action::Monitor(MonitorKind::Silence), Action::Monitor(MonitorKind::Silence),
)), ),
Element::from(menu_checkbox( check(
fl!("monitor-process-finished"), fl!("monitor-process-finished"),
monitors.2, monitors.2,
Action::Monitor(MonitorKind::ProcessFinished), Action::Monitor(MonitorKind::ProcessFinished),
)), ),
Element::from(divider::horizontal::light()), check(fl!("read-only"), read_only, Action::ToggleReadOnly),
Element::from(menu_item( MenuItem::Divider,
fl!("split-horizontal"), item(fl!("split-horizontal"), Action::PaneSplitHorizontal),
Action::PaneSplitHorizontal, item(fl!("split-vertical"), Action::PaneSplitVertical),
)), item(fl!("pane-toggle-maximize"), Action::PaneToggleMaximized),
Element::from(menu_item(fl!("split-vertical"), Action::PaneSplitVertical)), MenuItem::Divider,
Element::from(menu_item( MenuItem::CheckBox(
fl!("pane-toggle-maximize"), fl!("window-header"),
Action::PaneToggleMaximized, None,
)), config.show_headerbar,
Element::from(divider::horizontal::light()), TabAction(entity, Action::ShowHeaderBar(!config.show_headerbar)),
Element::from(menu_item(fl!("new-tab"), Action::TabNew)), ),
Element::from(menu_item(fl!("rename-tab"), Action::TabRename)), MenuItem::Divider,
Element::from(menu_item(fl!("menu-settings"), Action::Settings)),
Element::from(menu_item(fl!("menu-about"), Action::About)),
]; ];
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
{ items.push(item(fl!("menu-password-manager"), Action::PasswordManager));
rows.push(Element::from(menu_item( items.push(item(fl!("menu-settings"), Action::Settings));
fl!("menu-password-manager"), items.push(item(fl!("menu-about"), Action::About));
Action::PasswordManager, // If we have a link, prepend the link items
)));
}
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
if link.is_some() { if link.is_some() {
rows.insert( items.insert(0, item(fl!("open-link"), Action::LaunchUrlByMenu));
0, items.insert(1, item(fl!("copy-link"), Action::CopyUrlByMenu));
Element::from(menu_item(fl!("open-link"), Action::LaunchUrlByMenu)), items.insert(2, MenuItem::Divider);
);
rows.insert(
1,
Element::from(menu_item(fl!("copy-link"), Action::CopyUrlByMenu)),
);
rows.insert(2, Element::from(divider::horizontal::light()));
} }
let content = widget::menu::menu_column::MenuColumn::with_children(rows);
widget::container(content) let key_binds: HashMap<KeyBind, TabAction> = key_binds
.padding(1) .iter()
//TODO: move style to libcosmic .map(|(key_bind, action)| (key_bind.clone(), TabAction(entity, *action)))
.style(|theme| { .collect();
let cosmic = theme.cosmic(); widget::menu::items(&key_binds, items)
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()
} }
pub fn color_scheme_menu<'a>( pub fn color_scheme_menu<'a>(
@ -220,6 +165,7 @@ pub fn menu_bar<'a>(
config: &Config, config: &Config,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
monitors: (bool, bool, bool), monitors: (bool, bool, bool),
read_only: bool,
ssh_hosts: &[String], ssh_hosts: &[String],
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let mut profile_items = Vec::with_capacity(config.profiles.len()); 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!("next-tab"), None, Action::TabNext),
MenuItem::Button(fl!("previous-tab"), None, Action::TabPrev), MenuItem::Button(fl!("previous-tab"), None, Action::TabPrev),
MenuItem::Divider, MenuItem::Divider,
MenuItem::Button(fl!("move-tab-left"), None, Action::TabMoveLeft),
MenuItem::Button(fl!("move-tab-right"), None, Action::TabMoveRight),
MenuItem::Divider,
MenuItem::Button( MenuItem::Button(
fl!("split-horizontal"), fl!("split-horizontal"),
None, None,
@ -321,6 +270,12 @@ pub fn menu_bar<'a>(
monitors.2, monitors.2,
Action::Monitor(MonitorKind::ProcessFinished), Action::Monitor(MonitorKind::ProcessFinished),
), ),
MenuItem::CheckBox(
fl!("read-only"),
None,
read_only,
Action::ToggleReadOnly,
),
MenuItem::Divider, MenuItem::Divider,
MenuItem::Button( MenuItem::Button(
fl!("menu-color-schemes"), 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, TabNext,
TabPrev, TabPrev,
TabRename, TabRename,
TabMoveLeft,
TabMoveRight,
ToggleFullscreen, ToggleFullscreen,
ToggleReadOnly,
WindowClose, WindowClose,
WindowNew, WindowNew,
ZoomIn, ZoomIn,
@ -131,7 +134,10 @@ impl KeyBindAction {
Self::TabNext => Some(Action::TabNext), Self::TabNext => Some(Action::TabNext),
Self::TabPrev => Some(Action::TabPrev), Self::TabPrev => Some(Action::TabPrev),
Self::TabRename => Some(Action::TabRename), Self::TabRename => Some(Action::TabRename),
Self::TabMoveLeft => Some(Action::TabMoveLeft),
Self::TabMoveRight => Some(Action::TabMoveRight),
Self::ToggleFullscreen => Some(Action::ToggleFullscreen), Self::ToggleFullscreen => Some(Action::ToggleFullscreen),
Self::ToggleReadOnly => Some(Action::ToggleReadOnly),
Self::WindowClose => Some(Action::WindowClose), Self::WindowClose => Some(Action::WindowClose),
Self::WindowNew => Some(Action::WindowNew), Self::WindowNew => Some(Action::WindowNew),
Self::ZoomIn => Some(Action::ZoomIn), Self::ZoomIn => Some(Action::ZoomIn),
@ -294,7 +300,10 @@ pub fn action_label(action: KeyBindAction) -> String {
KeyBindAction::TabNext => fl!("next-tab"), KeyBindAction::TabNext => fl!("next-tab"),
KeyBindAction::TabPrev => fl!("previous-tab"), KeyBindAction::TabPrev => fl!("previous-tab"),
KeyBindAction::TabRename => fl!("rename-tab"), KeyBindAction::TabRename => fl!("rename-tab"),
KeyBindAction::TabMoveLeft => fl!("move-tab-left"),
KeyBindAction::TabMoveRight => fl!("move-tab-right"),
KeyBindAction::ToggleFullscreen => fl!("toggle-fullscreen"), KeyBindAction::ToggleFullscreen => fl!("toggle-fullscreen"),
KeyBindAction::ToggleReadOnly => fl!("read-only"),
KeyBindAction::WindowClose => fl!("close-window"), KeyBindAction::WindowClose => fl!("close-window"),
KeyBindAction::WindowNew => fl!("new-window"), KeyBindAction::WindowNew => fl!("new-window"),
KeyBindAction::ZoomIn => fl!("zoom-in"), KeyBindAction::ZoomIn => fl!("zoom-in"),
@ -329,6 +338,8 @@ pub fn shortcut_groups() -> Vec<ShortcutGroup> {
KeyBindAction::TabRename, KeyBindAction::TabRename,
KeyBindAction::TabNext, KeyBindAction::TabNext,
KeyBindAction::TabPrev, KeyBindAction::TabPrev,
KeyBindAction::TabMoveLeft,
KeyBindAction::TabMoveRight,
KeyBindAction::TabActivate0, KeyBindAction::TabActivate0,
KeyBindAction::TabActivate1, KeyBindAction::TabActivate1,
KeyBindAction::TabActivate2, KeyBindAction::TabActivate2,
@ -369,7 +380,11 @@ pub fn shortcut_groups() -> Vec<ShortcutGroup> {
KeyBindAction::ZoomReset, 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")] #[cfg(feature = "password_manager")]
other_actions.push(KeyBindAction::PasswordManager); other_actions.push(KeyBindAction::PasswordManager);
groups.push(ShortcutGroup { groups.push(ShortcutGroup {
@ -479,6 +494,10 @@ fn fallback_shortcuts() -> Shortcuts {
bind!([Ctrl], "Tab", TabNext); bind!([Ctrl], "Tab", TabNext);
bind!([Ctrl, Shift], "Tab", TabPrev); 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 // Ctrl+Shift+# activates tabs by index
bind!([Ctrl, Shift], "1", TabActivate0); bind!([Ctrl, Shift], "1", TabActivate0);
bind!([Ctrl, Shift], "2", TabActivate1); bind!([Ctrl, Shift], "2", TabActivate1);

View file

@ -27,13 +27,15 @@ use indexmap::IndexSet;
use std::{ use std::{
borrow::Cow, borrow::Cow,
collections::HashMap, collections::HashMap,
fs, io, mem, fs,
hash::{DefaultHasher, Hash, Hasher},
io, mem,
path::PathBuf, path::PathBuf,
sync::{ sync::{
Arc, Mutex, Weak, Arc, Mutex, Weak,
atomic::{AtomicU32, Ordering}, atomic::{AtomicU32, Ordering},
}, },
time::Instant, time::{Duration, Instant},
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
@ -43,6 +45,7 @@ use crate::{
config::{ColorSchemeKind, Config as AppConfig, ProfileId}, config::{ColorSchemeKind, Config as AppConfig, ProfileId},
menu::MenuState, menu::MenuState,
mouse_reporter::MouseReporter, mouse_reporter::MouseReporter,
osc_tap::OscEvent,
}; };
/// Minimum contrast between a fixed cursor color and the cell's background. /// 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). /// Seconds without output before a silence alert fires (Konsole default).
pub const MONITOR_SILENCE_SECONDS: u64 = 10; 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 /// https://github.com/alacritty/alacritty/blob/4a7728bf7fac06a35f27f6c4f31e0d9214e5152b/alacritty/src/config/ui_config.rs#L36-L39
fn url_regex_search() -> RegexSearch { fn url_regex_search() -> RegexSearch {
let url_regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\ 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( pub struct EventProxy(
pane_grid::Pane, pane_grid::Pane,
segmented_button::Entity, 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 { impl EventListener for EventProxy {
fn send_event(&self, event: Event) { fn send_event(&self, event: Event) {
//TODO: handle error //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 self.focus
} }
pub fn update_terminal_focus(&self) { pub fn update_terminal_focus(&mut self) {
for (pane, tab_model) in self.panes.panes.iter() { let focus = self.focus;
let entity = tab_model.active(); for (pane, tab_model) in self.panes.panes.iter_mut() {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { let active = tab_model.active();
let mut terminal = terminal.lock().unwrap(); let entities: Vec<segmented_button::Entity> = tab_model.iter().collect();
terminal.set_focused(self.focus == *pane); for entity in entities {
terminal.update(); 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) { pub fn unfocus_all_terminals(&self) {
for tab_model in self.panes.panes.values() { for tab_model in self.panes.panes.values() {
let entity = tab_model.active(); let active = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { for entity in tab_model.iter() {
let mut terminal = terminal.lock().unwrap(); if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
terminal.set_focused(false); let mut terminal = terminal.lock().unwrap();
terminal.update(); terminal.set_focused(false);
if entity == active {
terminal.update();
}
}
} }
} }
} }
@ -331,6 +380,11 @@ pub struct Terminal {
pub monitor_alert: Option<MonitorKind>, pub monitor_alert: Option<MonitorKind>,
pub monitor_last_output: Instant, pub monitor_last_output: Instant,
pub monitor_job_running: bool, 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 needs_update: bool,
pub profile_id_opt: Option<ProfileId>, pub profile_id_opt: Option<ProfileId>,
pub tab_title_override: Option<String>, pub tab_title_override: Option<String>,
@ -361,7 +415,7 @@ impl Terminal {
pub fn new( pub fn new(
pane: pane_grid::Pane, pane: pane_grid::Pane,
entity: segmented_button::Entity, 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, config: Config,
options: Options, options: Options,
app_config: &AppConfig, app_config: &AppConfig,
@ -413,6 +467,8 @@ impl Terminal {
cell_width, cell_width,
cell_height, cell_height,
}; };
#[cfg(unix)]
let osc_tx = event_tx.clone();
let event_proxy = EventProxy(pane, entity, event_tx); let event_proxy = EventProxy(pane, entity, event_tx);
let term = Arc::new(FairMutex::new(Term::new( let term = Arc::new(FairMutex::new(Term::new(
config, config,
@ -427,6 +483,15 @@ impl Terminal {
#[cfg(windows)] #[cfg(windows)]
let shell_pid = pty.child_watcher().pid().map(|pid| pid.get()); 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 = let pty_event_loop =
EventLoop::new(term.clone(), event_proxy, pty, options.drain_on_exit, false)?; EventLoop::new(term.clone(), event_proxy, pty, options.drain_on_exit, false)?;
let notifier = Notifier(pty_event_loop.channel()); let notifier = Notifier(pty_event_loop.channel());
@ -450,6 +515,9 @@ impl Terminal {
monitor_alert: None, monitor_alert: None,
monitor_last_output: Instant::now(), monitor_last_output: Instant::now(),
monitor_job_running: false, monitor_job_running: false,
attention: None,
notify_last: None,
read_only: false,
mouse_reporter: Default::default(), mouse_reporter: Default::default(),
needs_update: true, needs_update: true,
notifier, notifier,
@ -513,6 +581,25 @@ impl Terminal {
self.zoom_adj = value; 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) { fn set_focused(&mut self, is_focused: bool) {
let focus_changed = self.is_focused != is_focused; let focus_changed = self.is_focused != is_focused;
self.is_focused = is_focused; self.is_focused = is_focused;
@ -524,7 +611,7 @@ impl Terminal {
const FOCUS_OUT: &[u8] = b"\x1b[O"; const FOCUS_OUT: &[u8] = b"\x1b[O";
let input = if is_focused { FOCUS_IN } else { FOCUS_OUT }; 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)); 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) { 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); self.notifier.notify(input);
} }
@ -555,6 +653,10 @@ impl Terminal {
} }
pub fn input_scroll<I: Into<Cow<'static, [u8]>>>(&self, input: I) { 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.input_no_scroll(input);
self.scroll(TerminalScroll::Bottom); self.scroll(TerminalScroll::Bottom);
} }
@ -1216,8 +1318,11 @@ impl Terminal {
y, y,
); );
for code in codes { // Saisie de l'utilisateur : rien en lecture seule
self.notifier.notify(code); if !self.read_only {
for code in codes {
self.notifier.notify(code);
}
} }
} else { } else {
self.scroll_as_arrows(delta); self.scroll_as_arrows(delta);
@ -1430,6 +1535,61 @@ impl Drop for Terminal {
mod tests { mod tests {
use super::*; 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] #[test]
fn block_fill_couvre_toute_la_plage_unicode() { fn block_fill_couvre_toute_la_plage_unicode() {
// U+2580–U+259F : chaque élément de bloc a sa géométrie native. // 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::{ use crate::{
Action, Terminal, TerminalScroll, Action, Terminal, TerminalScroll, kitty_keyboard,
menu::MenuState, menu::MenuState,
terminal::{BlockFill, Metadata}, terminal::{BlockFill, Metadata},
}; };
@ -116,12 +116,14 @@ pub struct TerminalBox<'a, Message> {
show_headerbar: bool, show_headerbar: bool,
pane_border_radius: Option<Radius>, pane_border_radius: Option<Radius>,
click_timing: Duration, click_timing: Duration,
context_menu: Option<Point>, context_menu_open: bool,
on_context_menu: Option<Box<dyn Fn(Option<MenuState>) -> Message + 'a>>, on_context_menu: Option<Box<dyn Fn(Option<MenuState>) -> Message + 'a>>,
on_mouse_enter: Option<Box<dyn Fn() -> Message + 'a>>, on_mouse_enter: Option<Box<dyn Fn() -> Message + 'a>>,
opacity: Option<f32>, opacity: Option<f32>,
dim: Option<f32>,
mouse_inside_boundary: Option<bool>, mouse_inside_boundary: Option<bool>,
on_middle_click: Option<Box<dyn Fn() -> Message + 'a>>, 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_open_hyperlink: Option<Box<dyn Fn(String) -> Message + 'a>>,
on_window_focused: Option<Box<dyn Fn() -> Message + 'a>>, on_window_focused: Option<Box<dyn Fn() -> Message + 'a>>,
on_window_unfocused: Option<Box<dyn Fn() -> Message + 'a>>, on_window_unfocused: Option<Box<dyn Fn() -> Message + 'a>>,
@ -143,12 +145,14 @@ where
show_headerbar: true, show_headerbar: true,
pane_border_radius: None, pane_border_radius: None,
click_timing: Duration::from_millis(500), click_timing: Duration::from_millis(500),
context_menu: None, context_menu_open: false,
on_context_menu: None, on_context_menu: None,
on_mouse_enter: None, on_mouse_enter: None,
opacity: None, opacity: None,
dim: None,
mouse_inside_boundary: None, mouse_inside_boundary: None,
on_middle_click: None, on_middle_click: None,
on_selection_done: None,
key_binds, key_binds,
on_open_hyperlink: None, on_open_hyperlink: None,
on_window_focused: None, on_window_focused: None,
@ -188,8 +192,8 @@ where
self self
} }
pub fn context_menu(mut self, position: Point) -> Self { pub fn context_menu_open(mut self, open: bool) -> Self {
self.context_menu = Some(position); self.context_menu_open = open;
self self
} }
@ -206,6 +210,14 @@ where
self 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 { 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.on_middle_click = Some(Box::new(on_middle_click));
self self
@ -216,6 +228,13 @@ where
self 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 { pub fn sharp_corners(mut self, sharp_corners: bool) -> Self {
self.sharp_corners = sharp_corners; self.sharp_corners = sharp_corners;
self self
@ -253,7 +272,7 @@ where
if !state.is_focused { if !state.is_focused {
return InputMethod::Disabled; return InputMethod::Disabled;
} }
if self.context_menu.is_some() { if self.context_menu_open {
return InputMethod::Disabled; 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(); let duration = instant.elapsed();
log::trace!("redraw {}, {}: {:?}", view_w, view_h, duration); log::trace!("redraw {}, {}: {:?}", view_w, view_h, duration);
} }
@ -939,7 +984,11 @@ where
let buffer_size = terminal.with_buffer(|buffer| buffer.size()); let buffer_size = terminal.with_buffer(|buffer| buffer.size());
let is_app_cursor = terminal.term.lock().mode().contains(TermMode::APP_CURSOR); 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 { match event {
Event::Window(event) => match event { Event::Window(event) => match event {
cosmic::iced::window::Event::Focused => { 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 { Event::Keyboard(KeyEvent::KeyPressed {
key: Key::Named(named), key: Key::Named(named),
physical_key, physical_key,
@ -1094,7 +1238,15 @@ where
shell.capture_event(); shell.capture_event();
} }
Named::Enter => { 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(); shell.capture_event();
} }
Named::Escape => { Named::Escape => {
@ -1392,11 +1544,11 @@ where
} }
// Update context menu state // Update context menu state
if let Some(on_context_menu) = &self.on_context_menu { if let Some(on_context_menu) = &self.on_context_menu {
match self.context_menu { match self.context_menu_open {
Some(_) => { true => {
shell.publish(on_context_menu(None)); shell.publish(on_context_menu(None));
} }
None => { false => {
if *button == Button::Right { if *button == Button::Right {
let x = p.x - self.padding.left; let x = p.x - self.padding.left;
let y = p.y - self.padding.top; let y = p.y - self.padding.top;
@ -1414,15 +1566,7 @@ where
None, None,
); );
let link = get_hyperlink(&terminal, location); let link = get_hyperlink(&terminal, location);
let abs = cosmic::iced::Point::new( shell.publish(on_context_menu(Some(MenuState { link })));
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,
})));
} }
} }
} }
@ -1447,6 +1591,9 @@ where
} }
} }
terminal.needs_update = true; 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()) { if let Some(p) = cursor_position.position_in(layout.bounds()) {
let x = p.x - self.padding.left; let x = p.x - self.padding.left;