feat(mosaic): add native spatial canvas layout

This commit is contained in:
fy59 2026-10-10 06:22:12 +02:00
parent a57e3f2c4d
commit 56c73e5e6e
25 changed files with 2179 additions and 40 deletions

53
PKGBUILD Normal file
View file

@ -0,0 +1,53 @@
# Maintainer: fy59 / labfytools
# CONTRACT: coexistence avec swayfx ; ce paquet possède uniquement
# /usr/bin/swayfx-mosaic et n'annonce aucun remplacement de sway/swayfx.
pkgname=swayfx-mosaic
pkgver=0.6+mosaic.1
pkgrel=1
pkgdesc='SwayFX 0.6 with the native Mosaic / Spatial Canvas layout'
arch=('x86_64')
url='https://git.labfytools.com/fy59/swayfx-mosaic'
license=('MIT')
options=('!debug')
depends=('cairo' 'gdk-pixbuf2' 'libevdev' 'libinput' 'json-c'
'systemd-libs' 'wayland' 'libxcb' 'libxkbcommon' 'pango' 'pcre2'
'scenefx' 'ttf-font' 'wlroots0.20')
makedepends=('git' 'meson' 'ninja' 'pkgconf' 'python' 'wayland-protocols'
'libdrm' 'pixman')
checkdepends=('kitty' 'grim')
_upstream_commit=fd71a6bdc061bd633b488ae7b83e8a6981d22f86
source=("swayfx::git+https://github.com/WillPower3309/swayfx.git#commit=${_upstream_commit}"
"mosaic.patch::file://${startdir}/patches/0001-mosaic-layout-viewport.patch"
"headless-ipc.py::file://${startdir}/tests/headless-ipc.py")
sha256sums=('SKIP'
'a04cd06a6ef4fc58e3b40333a53226849e82d9a4757b9ccee58ad7b59a23ff4b'
'0a16c887ca6a87f0392c2eef498b4fad78b9f32afaf7d2a6c0e5e0e37c3c1744')
prepare() {
cd "$srcdir/swayfx"
# No automatic forward port: an upstream commit change must be reviewed.
[[ $(git rev-parse HEAD) == "$_upstream_commit" ]] || return 1
git apply --check "$srcdir/mosaic.patch" || return 1
git apply "$srcdir/mosaic.patch"
}
build() {
meson setup "$srcdir/swayfx" "$srcdir/build-mosaic" \
--prefix=/usr --bindir=bin \
-Dwerror=false -Dsd-bus-provider=libsystemd \
-Dmosaic-package=true -Dswaybar=false -Dswaynag=false \
-Dman-pages=disabled -Ddefault-wallpaper=false
meson compile -C "$srcdir/build-mosaic"
}
check() {
meson test -C "$srcdir/build-mosaic" --print-errorlogs
MOSAIC_BINARY="$srcdir/build-mosaic/sway/swayfx-mosaic" \
python "$srcdir/headless-ipc.py"
}
package() {
meson install -C "$srcdir/build-mosaic" --destdir "$pkgdir"
[[ -x "$pkgdir/usr/bin/swayfx-mosaic" ]] || return 1
[[ $(find "$pkgdir" -type f | wc -l) -eq 1 ]] || return 1
}

87
docs/STEP20A.md Normal file
View file

@ -0,0 +1,87 @@
# STEP20A — fondation Spatial Canvas native
## Base et provenance
- Installation protégée : paquet Arch `swayfx 0.6-1`, binaire `/usr/bin/sway`, `swayfx version 0.6 (based on sway 1.12.0)`. `pacman -Qkk swayfx` : 58 fichiers, aucun altéré.
- Source amont : `https://github.com/WillPower3309/swayfx`, tag `0.6`, commit `fd71a6bdc061bd633b488ae7b83e8a6981d22f86` ; la branche `master` courante n'a pas été utilisée.
- Bibliothèques installées : `wlroots0.20 0.20.2-1` (`pkg-config wlroots-0.20` : `0.20.2`), `scenefx 0.5-1` (`pkg-config scenefx-0.5` : `0.5.0`). Le tag demande `wlroots-0.20 >=0.20,<0.21` et `scenefx-0.5`.
- Le cache AUR local contient un `PKGBUILD` **0.5.3-4**. Le dépôt AUR distant, commit `55c2e27f83e54eadd9d811bc123b868e6ba56866`, contient **0.6-0**, alors que le paquet local est **0.6-1** ; celui-ci a donc une recette ou révision locale qui n'est plus disponible ici. Les métadonnées du paquet ne contiennent pas son archive source ni son commit. La correspondance binaire exacte au commit du tag n'est donc pas démontrable. Le tag `0.6` est la base vérifiable qui reproduit la version annoncée et se compile avec les dépendances installées.
- Options Arch pertinentes reprises : `-Dwerror=false -Dsd-bus-provider=libsystemd`, avec les bibliothèques système ci-dessus. `-Dwerror=true` échoue aussi sur une incompatibilité amont avec `libinput 1.32.0` : le `switch` de `sway/ipc-json.c` ne traite pas `LIBINPUT_CONFIG_SCROLL_CIRCULAR`.
## Baseline avant modification
```sh
meson setup build-baseline -Dwerror=false -Dsd-bus-provider=libsystemd
meson compile -C build-baseline -j 8
meson test -C build-baseline --print-errorlogs
build-baseline/sway/sway --version
```
Résultat : build PASS, binaire répond `swayfx version 0.6-fd71a6b ... (based on sway 1.12.0)`. Meson indique `No tests defined.` pour l'amont. Aucune installation système.
La baseline binaire reste dans `build-baseline/sway/sway`, SHA-256 `5c377fdd5b6f72d343699dd1949956dfa73fe08b7c5dd305c45a5bb020bd8ccb` ; elle a été construite avant la première modification fonctionnelle et n'a pas été reconstruite depuis.
## Validation du fork
```sh
meson setup build-mosaic -Dwerror=false -Dsd-bus-provider=libsystemd \
-Dprefix=/usr -Dbindir=bin -Dswaybar=false -Dswaynag=false \
-Dman-pages=disabled -Ddefault-wallpaper=false
meson compile -C build-mosaic -j 8
meson test -C build-mosaic --print-errorlogs
python tests/headless-ipc.py
meson install -C build-mosaic --destdir /tmp/step20a-stage
git diff --check
```
Résultat : build PASS ; modèle `1/1 PASS` ; headless IPC et six fenêtres Kitty PASS ; installation de staging limitée au binaire ; `git diff --check` PASS. La validation de la configuration quotidienne par `/usr/bin/sway -C -c /home/fy59/.config/sway/config` retourne 0 ; wlroots y écrit deux diagnostics EGL `EGL_BAD_ALLOC`, sans démarrer de session ni invalider la configuration.
## Carte des points d'insertion
| Couche | Fichier/fonction | Rôle et point d'insertion |
| --- | --- | --- |
| Arbre | `include/sway/tree/container.h`, `workspace.h` | Enum de layout, workspace et listes `tiling`/`floating` ; état Mosaic attaché au workspace. |
| Insertion/suppression | `sway/tree/workspace.c:workspace_add_tiling`, `sway/tree/view.c:view_map`, `sway/tree/container.c:container_detach` | L'ordre de `workspace->tiling` fait autorité pour Row0. Insertion après le dernier actif, nettoyage de la référence de focus à la sortie. |
| Géométrie logique | `sway/tree/arrange.c:arrange_workspace`, `apply_horiz_layout` | Calcule `pending` et les tailles des enfants. En STEP20A, Mosaic emprunte le calcul horizontal standard. |
| Transaction/scène | `sway/desktop/transaction.c:arrange_children`, `arrange_workspace_tiling` | Applique l'état `current`, reparent les scènes et positionne les containers. Le nouvel enum doit passer ici également. |
| Output/rendu | `sway/desktop/output.c` (`wlr_scene_output_build_state`) | Composition de la scène dans le buffer d'output. |
| Clipping | `sway/tree/view.c:view_center_and_clip_surface`, `sway/desktop/transaction.c:arrange_container` | Clip de contenu au rectangle d'une vue, pas un clip général du viewport Mosaic. |
| Hit testing | `sway/input/cursor.c:node_at_coords` | Interroge `wlr_scene_node_at` ; toute translation de caméra devra conserver la correspondance avec la scène interactive. |
| Focus/move | `sway/commands/focus.c`, `move.c`, `sway/input/seat.c:seat_set_workspace_focus` | Navigation directionnelle, réorganisation et dernier focus par ligne ; aucune nouvelle touche n'est câblée ici. |
| IPC | `sway/ipc-json.c:ipc_json_describe_workspace`, `ipc_json_layout_description`, `sway/ipc-server.c` | `get_tree` publie `layout: "mosaic"` ; `get_workspaces` et les événements continuent par les chemins Sway existants. |
| Parser | `sway/commands/layout.c:cmd_layout` | `layout mosaic` active explicitement le mode sur le workspace ; les layouts classiques restent disponibles. |
## État réalisé
```text
Workspace 1 (layout=mosaic, opt-in)
└── Row0 (children emprunte workspace->tiling)
├── A
├── B
└── C
scroll_x = 1200 (coordonnée logique, non rendue en STEP20A)
focused = B
active_row = 0
```
`mosaic_workspace_state` possède les objets `mosaic_row`, jamais les containers Sway. Row0 emprunte `workspace->tiling`, sans copie de l'ordre. Les autres lignes ont leur propre liste dans le modèle et sont vérifiées par test unitaire ; leur branchement à l'arbre, aux transactions et à l'IPC attend une tranche ultérieure. Un `scroll_x` négatif ou non fini est refusé. Changer le focus ou `scroll_x` ne réordonne pas les enfants. L'état est libéré avec le workspace et reste dormant si l'on repasse à un layout classique.
La première ligne reste tiled et non flottante. Le test headless a observé `A B C`, puis `A X B C` après focus de A et création de X, puis `A X B` après fermeture de C. Un `move right` suivi de `move left` garde Mosaic actif et permet ensuite d'insérer Y immédiatement après X. Après retour à `splith`, Mosaic peut être réactivé sur un workspace occupé et la nouvelle fenêtre s'insère encore après A. Le test vérifie aussi des rectangles sans chevauchement et `floating_nodes=[]`. Ce n'est **pas** encore une ligne plus large que le viewport ni un POC graphique scrollable : aucun ratio de largeur de cellule n'a été fixé.
## Exécution et coexistence
Le fork produit `build-mosaic/sway/swayfx-mosaic`. L'option Meson `mosaic-package=true` installe seulement ce binaire. La vérification `meson install -C build-mosaic --destdir /tmp/step20a-stage` n'a produit que `/tmp/step20a-stage/usr/bin/swayfx-mosaic`. Le paquet Arch futur pourra dépendre des bibliothèques communes et déclarer ce seul chemin ; il ne doit déclarer ni `provides=sway`, ni `conflicts=swayfx`, ni installer les fichiers `/etc/sway`, `swaymsg`, `swaybar`, les complétions ou la session `sway.desktop` déjà possédés par SwayFX. Une session dédiée, sous un autre nom, sera une étape ultérieure. Aucun fichier de `/usr` n'a été modifié ici.
Activation dans le seul processus du fork : `swaymsg -s <socket-du-fork> 'layout mosaic'`. Le socket explicite est essentiel en test : une commande envoyée à la session quotidienne n'active rien dans le fork. La session actuelle n'a pas été relancée ou rechargée.
## Compatibilité et limites
- IPC : `get_tree` annonce `mosaic` sur le workspace, en conservant les noeuds Sway et leurs identités. `get_workspaces` et les événements utilisent les chemins existants. Les clients qui traitent les layouts comme un enum fermé doivent ajouter `mosaic` avant de restaurer ce workspace.
- QuickShell : les vues générales IPC peuvent lire l'arbre ; `session_v2/schema.py` limite explicitement `LAYOUTS` à `splith`, `splitv`, `tabbed`, `stacked`, et `session_v2/snapshot.py` a aussi une condition sur les layouts à un enfant. STEP20E devra représenter les lignes, l'ordre, `active_row`, `scroll_x` et le focus de chaque ligne, avec des identités stables de fenêtres et un ordre de restauration défini. Aucun fichier QuickShell n'a été modifié dans STEP20A.
- Scratchpad, floating et fullscreen : les chemins Sway existants sont conservés. Le test vérifie le tiling normal, mais ne constitue pas encore une validation exhaustive des transitions scratchpad, floating et fullscreen sous Mosaic.
- Clavier : `$mod+Alt+m` ouvre Applications. `$mod+Alt+l` est libre et réservé au futur sélecteur de layout, sans commande factice. Son ancien usage Applications a été retiré. Le bind `limusic-app` qui occupait `$mod+Alt+m` a été supprimé ; son autostart reste en place. `$mod+s`, `$mod+Shift+w`, `$mod+e` restent inchangés. Aucun reload de la session actuelle n'a été déclenché.
## Risques et STEP20B
La scène wlroots peut positionner les noeuds indépendamment de leur ordre logique. Le moteur d'output compose déjà la partie visible dans le buffer physique, mais un vrai viewport Mosaic doit définir une translation de caméra et un clip **par workspace/ligne** pour éviter qu'une portion hors viewport apparaisse sur un autre output. Le clip existant des surfaces ne fournit pas cette frontière. Le hit testing utilise la scène ; l'offset visuel et les rectangles des commandes `focus`/`move` doivent rester cohérents. La transaction distingue `pending` et `current` et possède des animations : le déplacement de caméra doit être appliqué au bon instant, sans saut ni divergence IPC. Les changements d'output, de taille et de workspace doivent recalculer le viewport sans perdre `scroll_x`.
STEP20B doit livrer une première ligne réellement plus large que son viewport, scrollable par une commande native explicite, avec largeur temporaire isolée et configurable sans imposer de politique UX finale. Il doit relier géométrie logique, translation de scène, clip, input et focus directionnel ; couvrir les transactions et les changements de workspace/output par tests headless. Le modèle des lignes supplémentaires sera raccordé après la preuve d'une ligne utilisable.

62
docs/STEP20B.md Normal file
View file

@ -0,0 +1,62 @@
# STEP20B — première ligne Mosaic scrollable
## Contrat et base
Base source : SwayFX tag `0.6`, commit `fd71a6bdc061bd633b488ae7b83e8a6981d22f86`. La correspondance byte pour byte au paquet local `swayfx 0.6-1` reste invérifiable faute de source de sa révision de paquet ; voir [STEP20A](STEP20A.md). Le fork est toujours compilé contre `wlroots-0.20` et `scenefx-0.5`. SwayFX standard conserve `/usr/bin/sway` et son cycle de mise à jour. Aucun paquet système n'a été installé.
Le dépôt de travail conserve une branche contenant la source complète. Le fichier `patches/0001-mosaic-layout-viewport.patch` rassemble le delta de production à appliquer uniquement sur le commit indiqué. `PKGBUILD` contrôle le commit avant application et ne fournit que `/usr/bin/swayfx-mosaic`. Il ne déclare aucun `provides`, `conflicts` ni `replaces` pour SwayFX. Une nouvelle version upstream exige une adaptation manuelle du patch et une nouvelle validation ; ne jamais réutiliser automatiquement le patch 0.6 sur 0.7.
## Fonctionnement natif
- `layout mosaic` active le layout du workspace courant. Le mode reste inactif par défaut.
- `layout toggle mosaic` bascule entre Mosaic et le dernier layout standard propre à ce workspace. Les commandes standards `layout ...` restent reconnues. Les raccourcis historiques `$mod+s`, `$mod+Shift+w` et `$mod+e` restent en place.
- `layout mosaic scroll <signed pixels>` déplace la caméra logique de Row0 ; la valeur est bornée entre zéro et le dernier pixel du canvas. Le focus directionnel ramène une cellule focalisée dans le viewport. Aucune combinaison clavier supplémentaire de scroll n'a été inventée.
- Row0 emprunte directement `workspace->tiling` : les noeuds restent de vrais conteneurs tiled dans l'arbre Sway. `pending`/`current` portent la géométrie logique au delà de l'écran ; la scène du tiling est translatée de `-scroll_x`. Le hit testing suit les coordonnées de la scène. `get_tree` conserve les champs Sway et ajoute `mosaic: {active_row, rows:[{children, scroll_x, focused}]}` seulement en mode Mosaic.
- La largeur minimale temporaire d'une cellule est 640 px, isolée dans le modèle (`minimum_cell_width`, zéro signifie 640). L'espace disponible est réparti si cela donne des cellules plus larges. Cette valeur n'est pas une politique UX finale ni une commande publique.
- Quand `layout tabbed` ou `layout stacking` a créé un seul conteneur racine autour des fenêtres d'un workspace occupé, l'entrée dans Mosaic promeut ses enfants dans Row0. La sortie restaure le layout standard au niveau du workspace. Les sous-arbres plus profonds restent des conteneurs groupés et sont une unité de Row0.
Exemple :
```text
Workspace 1 (layout=mosaic, previous_standard_layout=tabbed)
└── Row0, active_row=0
├── A [0,640)
├── B [640,1280)
├── C [1280,1920)
└── D [1920,2560)
scroll_x=400, focused=B
```
Les coordonnées ci-dessus sont illustratives, sans gaps Sway. La sortie physique ne rend que sa surface ; sur la machine mono-écran `eDP-1`, cela fournit une ligne effectivement scrollable. Le test headless capture deux images PPM avant/après `scroll 400` et observe un changement substantiel de pixels sans changement d'ordre ou des rectangles logiques IPC.
## Clavier et compatibilité standard
Dans les dotfiles partagés, `$mod+Alt+m` lance `applications-quickshell` et `$mod+Alt+l` appelle `~/.local/bin/labfy-layout-toggle`. Le helper n'a aucun état : il envoie `layout toggle mosaic` au socket du compositor courant. Sous SwayFX standard, la commande non reconnue n'altère pas le workspace et le helper retourne proprement. Le test headless séparé sur `/usr/bin/sway` vérifie le maintien de `splith`. La session actuelle n'a pas été rechargée ; le nouveau bind ne sera pris en compte qu'au prochain chargement normal de configuration.
QuickShell n'a pas été modifié. `session_v2` a encore un ensemble fermé de layouts standards ; STEP20E devra enregistrer l'ordre des conteneurs, les lignes, `active_row`, chaque `scroll_x`, le focus de chaque ligne et une identité durable pour restituer les fenêtres. L'IPC garde les événements Sway et `get_workspaces` habituels ; les lecteurs stricts de `get_tree.layout` devront reconnaître `mosaic`.
Floating, fullscreen et scratchpad suivent les chemins Sway existants et ne sont pas déplacés par la caméra du tiling. Le test couvre le tiling, la suppression, l'insertion, `move`, le focus, les transitions standard/Mosaic, ainsi qu'un aller-retour `floating`, `fullscreen` et `move scratchpad`/`scratchpad show`. Les interactions prolongées avec plusieurs outputs demandent encore une campagne dédiée avant publication générale.
## Limites et risques à traiter avant publication
- SceneFX 0.5 n'expose pas de clip générique pour un `wlr_scene_tree`. La sortie physique crope le canvas sur cette configuration mono-écran, mais une ligne traversant la frontière d'un output adjacent pourrait y apparaître. Le support multi-écran nécessite un clip de scène par viewport ou une stratégie de rendu équivalente, avec hit testing cohérent. Ce point bloque une publication générale multi-écran.
- Le déplacement de caméra est immédiat. L'animation courte et continue demandée pour le produit reste à implémenter sans diverger des transactions `pending/current` ni des animations de workspace.
- Le changement de taille et le déplacement de workspace vers un autre output recalculent la limite de scroll via le nouveau viewport ; le modèle garde la coordonnée tant qu'elle reste valide. Une validation de ces chemins et des transitions fullscreen est encore nécessaire.
- Les groupes imbriqués restent des cellules entières. La politique finale d'aplatissement, de largeur et de reflow multi-ligne est à fixer après la preuve d'une seule ligne.
- `minimum_cell_width` et `scroll_x` restent bornés par les coordonnées `int` de la scène. La capacité pratique au delà d'environ 2 milliards de pixels logiques n'est pas prise en charge par cette tranche.
## Validation locale
```sh
meson compile -C build-mosaic -j 8
meson test -C build-mosaic --print-errorlogs
MOSAIC_HELPER=/home/fy59/.local/bin/labfy-layout-toggle python tests/headless-ipc.py
meson install -C build-mosaic --destdir /tmp/step20b-stage
git diff --check
```
Le build de référence amont et ses résultats sont conservés dans STEP20A. Aucune relance ou reload de la session quotidienne n'a été effectué.
## STEP20C proposé
Introduire un clip de viewport correct par workspace, y compris lorsque deux outputs sont adjacents, puis valider rendu, hit testing et drag aux frontières. Ajouter l'animation de caméra et des tests headless couvrant resize d'output, bascule fullscreen, floating, scratchpad et changement de workspace. Définir ensuite la politique UX de largeur sans transformer la valeur temporaire de STEP20B en API durable.

View file

@ -18,6 +18,7 @@ enum sway_container_layout {
L_VERT, L_VERT,
L_STACKED, L_STACKED,
L_TABBED, L_TABBED,
L_MOSAIC,
}; };
enum sway_container_border { enum sway_container_border {

View file

@ -0,0 +1,57 @@
#ifndef _SWAY_MOSAIC_H
#define _SWAY_MOSAIC_H
#include <stdbool.h>
#include "list.h"
struct sway_container;
// CONTRACT: children are ordered Sway tiling containers; scroll_x is a
// logical canvas coordinate, independent of focus and rendered geometry.
struct mosaic_row_state {
double scroll_x;
// STEP20B temporary sizing policy. Zero selects the current 640 px
// minimum; this is not a stable Mosaic layout API.
int minimum_cell_width;
struct sway_container *focused;
};
struct mosaic_row {
list_t *children;
bool owns_children;
struct mosaic_row_state state;
};
struct mosaic_workspace_state {
list_t *rows; // struct mosaic_row; owns rows, never their containers.
int active_row;
};
// The first row may borrow workspace->tiling. The caller keeps that list alive
// until mosaic_state_destroy; later rows own only their lists, not children.
// NULL input/allocation failure returns NULL. Destroy accepts NULL.
struct mosaic_workspace_state *mosaic_state_create(list_t *first_children);
void mosaic_state_destroy(struct mosaic_workspace_state *state);
// Model operation only: wiring another row into Sway's tree and scene is later
// work. NULL/allocation failure returns NULL; child ownership stays with Sway.
struct mosaic_row *mosaic_state_add_row(struct mosaic_workspace_state *state);
// Rejects an invalid row index without changing active_row.
bool mosaic_state_select_row(struct mosaic_workspace_state *state, int index);
// Insertion/removal must occur from a Sway tree mutation path, which also
// maintains parent/workspace pointers, representation and transaction dirtiness.
int mosaic_row_insertion_index(const struct mosaic_row *row);
void mosaic_row_insert_after_focus(struct mosaic_row *row,
struct sway_container *child);
void mosaic_row_remove(struct mosaic_row *row, struct sway_container *child);
// Rejects a child not present in this row, preserving the last valid focus.
bool mosaic_row_focus(struct mosaic_row *row, struct sway_container *child);
// Rejects negative/NaN/infinite logical positions without changing the row.
bool mosaic_row_set_scroll(struct mosaic_row *row, double scroll_x);
// Clamp the logical camera to the currently arranged canvas. The viewport and
// row contents remain owned by Sway; this helper never mutates child order.
int mosaic_row_max_scroll(const struct mosaic_row *row, int viewport_width,
int gaps);
void mosaic_row_clamp_scroll(struct mosaic_row *row, int viewport_width,
int gaps);
#endif

View file

@ -10,6 +10,7 @@
#include "sway/tree/node.h" #include "sway/tree/node.h"
struct sway_view; struct sway_view;
struct mosaic_workspace_state;
struct sway_workspace_state { struct sway_workspace_state {
struct sway_container *fullscreen; struct sway_container *fullscreen;
@ -41,6 +42,9 @@ struct sway_workspace {
int width, height; int width, height;
enum sway_container_layout layout; enum sway_container_layout layout;
enum sway_container_layout prev_split_layout; enum sway_container_layout prev_split_layout;
// CONTRACT: workspace-local target restored by `layout toggle mosaic`.
// This is distinct from Sway's prev_split_layout toggle history.
enum sway_container_layout mosaic_previous_standard_layout;
struct side_gaps current_gaps; struct side_gaps current_gaps;
int gaps_inner; int gaps_inner;
@ -49,6 +53,8 @@ struct sway_workspace {
struct sway_output *output; // NULL if no outputs are connected struct sway_output *output; // NULL if no outputs are connected
list_t *floating; // struct sway_container list_t *floating; // struct sway_container
list_t *tiling; // struct sway_container list_t *tiling; // struct sway_container
// Mosaic row zero borrows tiling: Sway's tree remains the ordering authority.
struct mosaic_workspace_state *mosaic;
list_t *output_priority; list_t *output_priority;
bool urgent; bool urgent;
@ -130,6 +136,10 @@ void workspace_detach(struct sway_workspace *workspace);
struct sway_container *workspace_add_tiling(struct sway_workspace *workspace, struct sway_container *workspace_add_tiling(struct sway_workspace *workspace,
struct sway_container *con); struct sway_container *con);
// Reconcile the active Mosaic row after Sway reattaches a focused container.
// No-op outside Mosaic or when the active seat has no tiled focus in this row.
void workspace_mosaic_sync_focus(struct sway_workspace *workspace);
void workspace_add_floating(struct sway_workspace *workspace, void workspace_add_floating(struct sway_workspace *workspace,
struct sway_container *con); struct sway_container *con);

View file

@ -123,8 +123,9 @@ foreach sym : ['LIBINPUT_CONFIG_ACCEL_PROFILE_CUSTOM', 'LIBINPUT_CONFIG_DRAG_LOC
conf_data.set10('HAVE_' + sym, cc.has_header_symbol('libinput.h', sym, dependencies: libinput)) conf_data.set10('HAVE_' + sym, cc.has_header_symbol('libinput.h', sym, dependencies: libinput))
endforeach endforeach
scdoc = dependency('scdoc', version: '>=1.9.2', native: true, required: get_option('man-pages')) scdoc = dependency('scdoc', version: '>=1.9.2', native: true,
if scdoc.found() required: get_option('mosaic-package') ? false : get_option('man-pages'))
if scdoc.found() and not get_option('mosaic-package')
scdoc_prog = find_program(scdoc.get_variable(pkgconfig: 'scdoc'), native: true) scdoc_prog = find_program(scdoc.get_variable(pkgconfig: 'scdoc'), native: true)
mandir = get_option('mandir') mandir = get_option('mandir')
man_files = [ man_files = [
@ -205,15 +206,23 @@ subdir('include')
subdir('protocols') subdir('protocols')
subdir('common') subdir('common')
subdir('sway') subdir('sway')
subdir('swaymsg') model_test = executable('mosaic-model-test',
['tests/mosaic-model.c', 'sway/tree/mosaic.c'],
include_directories: sway_inc,
link_with: lib_sway_common,
dependencies: [cairo, pango, pangocairo, wayland_client])
test('mosaic-model', model_test)
if not get_option('mosaic-package')
subdir('swaymsg')
endif
if get_option('swaybar') or get_option('swaynag') if not get_option('mosaic-package') and (get_option('swaybar') or get_option('swaynag'))
subdir('client') subdir('client')
endif endif
if get_option('swaybar') if not get_option('mosaic-package') and get_option('swaybar')
subdir('swaybar') subdir('swaybar')
endif endif
if get_option('swaynag') if not get_option('mosaic-package') and get_option('swaynag')
subdir('swaynag') subdir('swaynag')
endif endif
@ -222,19 +231,18 @@ config.set('datadir', join_paths(prefix, datadir))
config.set('prefix', prefix) config.set('prefix', prefix)
config.set('sysconfdir', join_paths(prefix, sysconfdir)) config.set('sysconfdir', join_paths(prefix, sysconfdir))
configure_file( if not get_option('mosaic-package')
configuration: config, configure_file(
input: 'config.in', configuration: config,
output: '@BASENAME@', input: 'config.in',
install_dir: join_paths(sysconfdir, 'sway') output: '@BASENAME@',
) install_dir: join_paths(sysconfdir, 'sway')
)
install_data('sway.desktop',
install_dir: join_paths(datadir, 'wayland-sessions'))
endif
install_data( if get_option('default-wallpaper') and not get_option('mosaic-package')
'sway.desktop',
install_dir: join_paths(datadir, 'wayland-sessions')
)
if get_option('default-wallpaper')
wallpaper_files = files( wallpaper_files = files(
'assets/Sway_Wallpaper_Blue_768x1024.png', 'assets/Sway_Wallpaper_Blue_768x1024.png',
'assets/Sway_Wallpaper_Blue_768x1024_Portrait.png', 'assets/Sway_Wallpaper_Blue_768x1024_Portrait.png',
@ -250,10 +258,12 @@ if get_option('default-wallpaper')
install_data(wallpaper_files, install_dir: wallpaper_install_dir) install_data(wallpaper_files, install_dir: wallpaper_install_dir)
endif endif
subdir('completions') if not get_option('mosaic-package')
subdir('completions')
endif
summary({ summary({
'gdk-pixbuf': gdk_pixbuf.found(), 'gdk-pixbuf': gdk_pixbuf.found(),
'tray': have_tray, 'tray': have_tray and not get_option('mosaic-package') and get_option('swaybar'),
'man-pages': scdoc.found(), 'man-pages': scdoc.found() and not get_option('mosaic-package'),
}, bool_yn: true) }, bool_yn: true)

View file

@ -1,4 +1,5 @@
option('default-wallpaper', type: 'boolean', value: true, description: 'Install the default wallpaper.') option('default-wallpaper', type: 'boolean', value: true, description: 'Install the default wallpaper.')
option('mosaic-package', type: 'boolean', value: true, description: 'Install only the coexistence-safe swayfx-mosaic compositor binary.')
option('zsh-completions', type: 'boolean', value: true, description: 'Install zsh shell completions.') option('zsh-completions', type: 'boolean', value: true, description: 'Install zsh shell completions.')
option('bash-completions', type: 'boolean', value: true, description: 'Install bash shell completions.') option('bash-completions', type: 'boolean', value: true, description: 'Install bash shell completions.')
option('fish-completions', type: 'boolean', value: true, description: 'Install fish shell completions.') option('fish-completions', type: 'boolean', value: true, description: 'Install fish shell completions.')

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ static bool get_direction_from_next_prev(struct sway_container *container,
if (strcasecmp(name, "prev") == 0) { if (strcasecmp(name, "prev") == 0) {
switch (parent_layout) { switch (parent_layout) {
case L_HORIZ: case L_HORIZ:
case L_MOSAIC:
case L_TABBED: case L_TABBED:
*out = WLR_DIRECTION_LEFT; *out = WLR_DIRECTION_LEFT;
break; break;
@ -39,6 +40,7 @@ static bool get_direction_from_next_prev(struct sway_container *container,
} else if (strcasecmp(name, "next") == 0) { } else if (strcasecmp(name, "next") == 0) {
switch (parent_layout) { switch (parent_layout) {
case L_HORIZ: case L_HORIZ:
case L_MOSAIC:
case L_TABBED: case L_TABBED:
*out = WLR_DIRECTION_RIGHT; *out = WLR_DIRECTION_RIGHT;
break; break;
@ -93,7 +95,8 @@ static struct sway_node *get_node_in_output_direction(
if (ws->tiling->length > 0) { if (ws->tiling->length > 0) {
switch (dir) { switch (dir) {
case WLR_DIRECTION_LEFT: case WLR_DIRECTION_LEFT:
if (ws->layout == L_HORIZ || ws->layout == L_TABBED) { if (ws->layout == L_HORIZ || ws->layout == L_MOSAIC ||
ws->layout == L_TABBED) {
// get most right child of new output // get most right child of new output
container = ws->tiling->items[ws->tiling->length-1]; container = ws->tiling->items[ws->tiling->length-1];
} else { } else {
@ -101,7 +104,8 @@ static struct sway_node *get_node_in_output_direction(
} }
break; break;
case WLR_DIRECTION_RIGHT: case WLR_DIRECTION_RIGHT:
if (ws->layout == L_HORIZ || ws->layout == L_TABBED) { if (ws->layout == L_HORIZ || ws->layout == L_MOSAIC ||
ws->layout == L_TABBED) {
// get most left child of new output // get most left child of new output
container = ws->tiling->items[0]; container = ws->tiling->items[0];
} else { } else {
@ -163,7 +167,8 @@ static struct sway_node *node_get_in_direction_tiling(
list_t *siblings = container_get_siblings(current); list_t *siblings = container_get_siblings(current);
if (dir == WLR_DIRECTION_LEFT || dir == WLR_DIRECTION_RIGHT) { if (dir == WLR_DIRECTION_LEFT || dir == WLR_DIRECTION_RIGHT) {
if (parent_layout == L_HORIZ || parent_layout == L_TABBED) { if (parent_layout == L_HORIZ || parent_layout == L_MOSAIC ||
parent_layout == L_TABBED) {
can_move = true; can_move = true;
desired = idx + (dir == WLR_DIRECTION_LEFT ? -1 : 1); desired = idx + (dir == WLR_DIRECTION_LEFT ? -1 : 1);
} }

View file

@ -1,10 +1,14 @@
#include <stdbool.h> #include <stdbool.h>
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include <strings.h> #include <strings.h>
#include "sway/commands.h" #include "sway/commands.h"
#include "sway/output.h" #include "sway/output.h"
#include "sway/tree/arrange.h" #include "sway/tree/arrange.h"
#include "sway/tree/container.h" #include "sway/tree/container.h"
#include "sway/tree/mosaic.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
#include "log.h" #include "log.h"
@ -22,7 +26,9 @@ static enum sway_container_layout parse_layout_string(char *s) {
} }
static const char expected_syntax[] = static const char expected_syntax[] =
"Expected 'layout default|tabbed|stacking|splitv|splith' or " "Expected 'layout default|tabbed|stacking|splitv|splith|mosaic' or "
"'layout toggle mosaic' or "
"'layout mosaic scroll <signed pixels>' or "
"'layout toggle [split|all]' or " "'layout toggle [split|all]' or "
"'layout toggle [split|tabbed|stacking|splitv|splith] [split|tabbed|stacking|splitv|splith]...'"; "'layout toggle [split|tabbed|stacking|splitv|splith] [split|tabbed|stacking|splitv|splith]...'";
@ -97,6 +103,10 @@ static enum sway_container_layout get_layout(int argc, char **argv,
enum sway_container_layout layout, enum sway_container_layout layout,
enum sway_container_layout prev_split_layout, enum sway_container_layout prev_split_layout,
struct sway_output *output) { struct sway_output *output) {
// Mosaic is an explicit workspace mode, never part of the legacy toggle cycle.
if (argc == 1 && strcasecmp(argv[0], "mosaic") == 0) {
return L_MOSAIC;
}
// Check if assigned directly // Check if assigned directly
enum sway_container_layout parsed = parse_layout_string(argv[0]); enum sway_container_layout parsed = parse_layout_string(argv[0]);
if (parsed != L_NONE) { if (parsed != L_NONE) {
@ -125,6 +135,37 @@ struct cmd_results *cmd_layout(int argc, char **argv) {
} }
struct sway_container *container = config->handler_context.container; struct sway_container *container = config->handler_context.container;
struct sway_workspace *workspace = config->handler_context.workspace; struct sway_workspace *workspace = config->handler_context.workspace;
if (argc == 3 && strcasecmp(argv[0], "mosaic") == 0 &&
strcasecmp(argv[1], "scroll") == 0) {
if (!workspace || workspace->layout != L_MOSAIC || !workspace->mosaic) {
return cmd_results_new(CMD_FAILURE, "Mosaic est inactif sur ce workspace");
}
errno = 0;
char *end = NULL;
long delta = strtol(argv[2], &end, 10);
if (errno || end == argv[2] || *end || delta < -INT_MAX ||
delta > INT_MAX) {
return cmd_results_new(CMD_INVALID, "%s", expected_syntax);
}
struct mosaic_row *row = workspace->mosaic->rows->items[0];
double next = row->state.scroll_x + delta;
mosaic_row_set_scroll(row, next < 0 ? 0 : next);
struct wlr_box viewport;
workspace_get_box(workspace, &viewport);
mosaic_row_clamp_scroll(row, viewport.width, workspace->gaps_inner);
// The transaction updates the workspace tiling scene position; the
// ordered Sway children and their logical rectangles stay intact.
arrange_workspace(workspace);
return cmd_results_new(CMD_SUCCESS, NULL);
}
bool mosaic_command = argc == 1 && strcasecmp(argv[0], "mosaic") == 0;
bool mosaic_toggle = argc == 2 && strcasecmp(argv[0], "toggle") == 0 &&
strcasecmp(argv[1], "mosaic") == 0;
// CONTRACT: Mosaic belongs to the workspace, even when a view has focus.
if (mosaic_command || mosaic_toggle ||
(workspace && workspace->layout == L_MOSAIC)) {
container = NULL;
}
if (container && container_is_floating(container)) { if (container && container_is_floating(container)) {
return cmd_results_new(CMD_FAILURE, return cmd_results_new(CMD_FAILURE,
@ -161,28 +202,74 @@ struct cmd_results *cmd_layout(int argc, char **argv) {
container->pending.workspace->output); container->pending.workspace->output);
} else { } else {
old_layout = workspace->layout; old_layout = workspace->layout;
new_layout = get_layout(argc, argv, if (mosaic_toggle) {
workspace->layout, workspace->prev_split_layout, new_layout = old_layout == L_MOSAIC ?
workspace->output); workspace->mosaic_previous_standard_layout : L_MOSAIC;
} else {
new_layout = get_layout(argc, argv,
workspace->layout, workspace->prev_split_layout,
workspace->output);
}
} }
if (new_layout == L_NONE) { if (new_layout == L_NONE) {
return cmd_results_new(CMD_INVALID, "%s", expected_syntax); return cmd_results_new(CMD_INVALID, "%s", expected_syntax);
} }
if (new_layout == L_MOSAIC && old_layout != L_MOSAIC &&
workspace->mosaic_previous_standard_layout == L_NONE) {
// A defensive fallback for workspaces created before layout state init.
workspace->mosaic_previous_standard_layout = old_layout;
}
if (new_layout == L_MOSAIC && !workspace->mosaic) {
workspace->mosaic = mosaic_state_create(workspace->tiling);
if (!workspace->mosaic) {
return cmd_results_new(CMD_FAILURE,
"Impossible d'allouer l'état Mosaic");
}
}
if (new_layout == L_MOSAIC && old_layout != L_MOSAIC &&
workspace->tiling->length == 1) {
struct sway_container *wrap = workspace->tiling->items[0];
// Sway applies `layout tabbed/stacking` on an occupied workspace by
// wrapping its children. Promote that root wrapper's tabs to Row0 so
// Mosaic receives the same windows, then restore the remembered
// standard layout at the workspace level on the next toggle.
if (!wrap->view && wrap->pending.children->length > 0 &&
(wrap->pending.layout == L_TABBED ||
wrap->pending.layout == L_STACKED) &&
wrap->pending.layout == workspace->mosaic_previous_standard_layout) {
container_detach(wrap);
workspace_unwrap_children(workspace, wrap);
container_reap_empty(wrap);
}
}
if (new_layout == L_MOSAIC && config->handler_context.container) {
struct mosaic_row *row = workspace->mosaic->rows->items[0];
struct sway_container *focused = container_toplevel_ancestor(
config->handler_context.container);
mosaic_row_focus(row, focused);
}
if (new_layout != old_layout) { if (new_layout != old_layout) {
// The remembered standard layout belongs to the workspace, regardless
// of whether i3 applies the command to a focused parent container.
if (new_layout != L_MOSAIC) {
workspace->mosaic_previous_standard_layout = new_layout;
}
if (container) { if (container) {
if (old_layout != L_TABBED && old_layout != L_STACKED) { if (old_layout != L_TABBED && old_layout != L_STACKED) {
container->prev_split_layout = old_layout; container->prev_split_layout = old_layout;
} }
container->pending.layout = new_layout; container->pending.layout = new_layout;
container_update_representation(container); container_update_representation(container);
} else if (config->handler_context.container) { } else if (config->handler_context.container &&
old_layout != L_MOSAIC && new_layout != L_MOSAIC) {
// i3 avoids changing workspace layouts with a new container // i3 avoids changing workspace layouts with a new container
// https://github.com/i3/i3/blob/3cd1c45eba6de073bc4300eebb4e1cc1a0c4479a/src/con.c#L1817 // https://github.com/i3/i3/blob/3cd1c45eba6de073bc4300eebb4e1cc1a0c4479a/src/con.c#L1817
container = workspace_wrap_children(workspace); container = workspace_wrap_children(workspace);
container->pending.layout = new_layout; container->pending.layout = new_layout;
container_update_representation(container); container_update_representation(container);
} else { } else {
if (old_layout != L_TABBED && old_layout != L_STACKED) { if (old_layout != L_TABBED && old_layout != L_STACKED &&
old_layout != L_MOSAIC) {
workspace->prev_split_layout = old_layout; workspace->prev_split_layout = old_layout;
} }
workspace->layout = new_layout; workspace->layout = new_layout;

View file

@ -81,6 +81,8 @@ static bool is_parallel(enum sway_container_layout layout,
switch (layout) { switch (layout) {
case L_TABBED: case L_TABBED:
case L_HORIZ: case L_HORIZ:
case L_MOSAIC:
// Horizontal moves within Row0 must not reorient the workspace.
return dir == WLR_DIRECTION_LEFT || dir == WLR_DIRECTION_RIGHT; return dir == WLR_DIRECTION_LEFT || dir == WLR_DIRECTION_RIGHT;
case L_STACKED: case L_STACKED:
case L_VERT: case L_VERT:

View file

@ -1,5 +1,7 @@
#include <assert.h> #include <assert.h>
#include <stdbool.h> #include <stdbool.h>
#include <limits.h>
#include <stdint.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <time.h> #include <time.h>
@ -15,6 +17,7 @@
#include "sway/output.h" #include "sway/output.h"
#include "sway/server.h" #include "sway/server.h"
#include "sway/tree/container.h" #include "sway/tree/container.h"
#include "sway/tree/mosaic.h"
#include "sway/tree/node.h" #include "sway/tree/node.h"
#include "sway/tree/view.h" #include "sway/tree/view.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
@ -504,8 +507,10 @@ static void arrange_children(enum sway_container_layout layout, list_t *children
disable_container(child); disable_container(child);
} }
} }
} else if (layout == L_HORIZ) { } else if (layout == L_HORIZ || layout == L_MOSAIC) {
int off = 0; // Scene node positions are int; keep the logical canvas accumulator
// wide so a very large Mosaic row cannot overflow signed arithmetic.
int64_t off = 0;
for (int i = 0; i < children->length; i++) { for (int i = 0; i < children->length; i++) {
struct sway_container *child = children->items[i]; struct sway_container *child = children->items[i];
int cwidth = child->current.width; int cwidth = child->current.width;
@ -515,9 +520,9 @@ static void arrange_children(enum sway_container_layout layout, list_t *children
wlr_scene_node_set_enabled(&child->shadow->node, wlr_scene_node_set_enabled(&child->shadow->node,
container_has_shadow(child) && child->view); container_has_shadow(child) && child->view);
wlr_scene_node_reparent(&child->scene_tree->node, content); wlr_scene_node_reparent(&child->scene_tree->node, content);
if (cwidth > 0 && height > 0) { if (cwidth > 0 && height > 0 && off <= INT_MAX) {
arrange_container(child, cwidth, height, off, 0, true, gaps); arrange_container(child, cwidth, height, (int)off, 0, true, gaps);
off += cwidth + gaps; off += (int64_t)cwidth + gaps;
} else { } else {
disable_container(child); disable_container(child);
} }
@ -864,6 +869,18 @@ static void arrange_workspace_tiling(struct sway_workspace *ws,
width, height, ws->gaps_inner); width, height, ws->gaps_inner);
} }
static int workspace_tiling_scene_x(struct sway_workspace *ws,
struct wlr_box *area, struct side_gaps *gaps) {
int64_t x = (int64_t)area->x + gaps->left;
if (ws->current.layout == L_MOSAIC && ws->mosaic) {
struct mosaic_row *row = ws->mosaic->rows->items[0];
x -= (int64_t)row->state.scroll_x;
}
// The camera is kept in logical pixels; this conversion is the sole
// output-scene mapping and must never overflow wlroots' int positions.
return x < INT_MIN ? INT_MIN : x > INT_MAX ? INT_MAX : (int)x;
}
static void disable_workspace(struct sway_workspace *ws) { static void disable_workspace(struct sway_workspace *ws) {
// if any containers were just moved to a disabled workspace it will // if any containers were just moved to a disabled workspace it will
// have the parent of the old workspace. Move the workspace so that it won't // have the parent of the old workspace. Move the workspace so that it won't
@ -955,7 +972,8 @@ static void arrange_output(struct sway_output *output, int width, int height) {
struct side_gaps *gaps = &child->current_gaps; struct side_gaps *gaps = &child->current_gaps;
wlr_scene_node_set_position(&child->layers.tiling->node, wlr_scene_node_set_position(&child->layers.tiling->node,
gaps->left + area->x, gaps->top + area->y); workspace_tiling_scene_x(child, area, gaps),
gaps->top + area->y);
arrange_workspace_tiling(child, arrange_workspace_tiling(child,
area->width - gaps->left - gaps->right, area->width - gaps->left - gaps->right,
@ -971,7 +989,8 @@ static void arrange_output(struct sway_output *output, int width, int height) {
wlr_scene_node_set_enabled(&child->layers.fullscreen->node, false); wlr_scene_node_set_enabled(&child->layers.fullscreen->node, false);
wlr_scene_node_set_position(&child->layers.tiling->node, wlr_scene_node_set_position(&child->layers.tiling->node,
gaps->left + area->x, gaps->top + area->y); workspace_tiling_scene_x(child, area, gaps),
gaps->top + area->y);
arrange_workspace_tiling(child, arrange_workspace_tiling(child,
area->width - gaps->left - gaps->right, area->width - gaps->left - gaps->right,

View file

@ -31,6 +31,7 @@
#include "sway/server.h" #include "sway/server.h"
#include "sway/tree/arrange.h" #include "sway/tree/arrange.h"
#include "sway/tree/container.h" #include "sway/tree/container.h"
#include "sway/tree/mosaic.h"
#include "sway/tree/root.h" #include "sway/tree/root.h"
#include "sway/tree/view.h" #include "sway/tree/view.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
@ -1189,6 +1190,28 @@ static void seat_set_workspace_focus(struct sway_seat *seat, struct sway_node *n
if (container) { if (container) {
seat_set_raw_focus(seat, &container->node); seat_set_raw_focus(seat, &container->node);
seat_send_focus(&container->node, seat); seat_send_focus(&container->node, seat);
// Focus changes update row context without changing Sway tiling order.
if (new_workspace && new_workspace->layout == L_MOSAIC &&
new_workspace->mosaic) {
struct mosaic_row *row = new_workspace->mosaic->rows->items[0];
struct sway_container *top = container_toplevel_ancestor(container);
if (mosaic_row_focus(row, top) && top) {
// Keep focus separate from order. Only move the logical camera
// when the focused tiled cell falls outside the viewport.
struct wlr_box viewport;
workspace_get_box(new_workspace, &viewport);
double left = top->pending.x - viewport.x;
double right = left + top->pending.width;
if (left < row->state.scroll_x) {
row->state.scroll_x = left < 0 ? 0 : left;
} else if (right > row->state.scroll_x + viewport.width) {
row->state.scroll_x = right - viewport.width;
}
mosaic_row_clamp_scroll(row, viewport.width,
new_workspace->gaps_inner);
arrange_workspace(new_workspace);
}
}
} }
// emit ipc events // emit ipc events

View file

@ -16,6 +16,7 @@
#include "sway/scene_descriptor.h" #include "sway/scene_descriptor.h"
#include "sway/server.h" #include "sway/server.h"
#include "sway/tree/container.h" #include "sway/tree/container.h"
#include "sway/tree/mosaic.h"
#include "sway/tree/root.h" #include "sway/tree/root.h"
#include "sway/tree/view.h" #include "sway/tree/view.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
@ -54,6 +55,8 @@ static const char *ipc_json_layout_description(enum sway_container_layout l) {
return "splitv"; return "splitv";
case L_HORIZ: case L_HORIZ:
return "splith"; return "splith";
case L_MOSAIC:
return "mosaic";
case L_TABBED: case L_TABBED:
return "tabbed"; return "tabbed";
case L_STACKED: case L_STACKED:
@ -69,6 +72,7 @@ static const char *ipc_json_orientation_description(enum sway_container_layout l
case L_VERT: case L_VERT:
return "vertical"; return "vertical";
case L_HORIZ: case L_HORIZ:
case L_MOSAIC:
return "horizontal"; return "horizontal";
default: default:
return "none"; return "none";
@ -611,6 +615,32 @@ static void ipc_json_describe_workspace(struct sway_workspace *workspace,
json_object_object_add(object, "orientation", json_object_object_add(object, "orientation",
json_object_new_string( json_object_new_string(
ipc_json_orientation_description(workspace->layout))); ipc_json_orientation_description(workspace->layout)));
if (workspace->layout == L_MOSAIC && workspace->mosaic) {
// Optional extension: legacy IPC fields keep their Sway shapes.
// Row zero borrows the real workspace tiling order.
json_object *mosaic = json_object_new_object();
json_object *rows = json_object_new_array();
for (int i = 0; i < workspace->mosaic->rows->length; ++i) {
struct mosaic_row *row = workspace->mosaic->rows->items[i];
json_object *entry = json_object_new_object();
json_object *ids = json_object_new_array();
for (int j = 0; j < row->children->length; ++j) {
struct sway_container *child = row->children->items[j];
json_object_array_add(ids, json_object_new_int64(child->node.id));
}
json_object_object_add(entry, "children", ids);
json_object_object_add(entry, "scroll_x",
json_object_new_double(row->state.scroll_x));
json_object_object_add(entry, "focused",
row->state.focused ? json_object_new_int64(
row->state.focused->node.id) : NULL);
json_object_array_add(rows, entry);
}
json_object_object_add(mosaic, "rows", rows);
json_object_object_add(mosaic, "active_row",
json_object_new_int(workspace->mosaic->active_row));
json_object_object_add(object, "mosaic", mosaic);
}
// Floating // Floating
json_object *floating_array = json_object_new_array(); json_object *floating_array = json_object_new_array();

View file

@ -257,7 +257,7 @@ int main(int argc, char **argv) {
allow_unsupported_gpu = true; allow_unsupported_gpu = true;
break; break;
case 'v': // version case 'v': // version
printf("swayfx version " SWAY_VERSION " (based on sway " SWAY_ORIGINAL_VERSION ")\n"); printf("swayfx-mosaic version " SWAY_VERSION " (based on sway " SWAY_ORIGINAL_VERSION ")\n");
exit(EXIT_SUCCESS); exit(EXIT_SUCCESS);
break; break;
case 'V': // verbose case 'V': // verbose

View file

@ -236,6 +236,7 @@ sway_sources = files(
'tree/arrange.c', 'tree/arrange.c',
'tree/container.c', 'tree/container.c',
'tree/mosaic.c',
'tree/node.c', 'tree/node.c',
'tree/root.c', 'tree/root.c',
'tree/view.c', 'tree/view.c',
@ -272,7 +273,7 @@ if wlroots_features['libinput_backend']
endif endif
executable( executable(
'sway', 'swayfx-mosaic',
sway_sources + wl_protos_src, sway_sources + wl_protos_src,
include_directories: [sway_inc], include_directories: [sway_inc],
dependencies: sway_deps, dependencies: sway_deps,

View file

@ -1,11 +1,14 @@
#include <ctype.h> #include <ctype.h>
#include <stdbool.h> #include <stdbool.h>
#include <limits.h>
#include <stdint.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <wlr/types/wlr_output.h> #include <wlr/types/wlr_output.h>
#include <wlr/types/wlr_output_layout.h> #include <wlr/types/wlr_output_layout.h>
#include "sway/tree/arrange.h" #include "sway/tree/arrange.h"
#include "sway/tree/container.h" #include "sway/tree/container.h"
#include "sway/tree/mosaic.h"
#include "sway/output.h" #include "sway/output.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
#include "sway/tree/view.h" #include "sway/tree/view.h"
@ -97,6 +100,37 @@ static void apply_horiz_layout(list_t *children, struct wlr_box *parent) {
} }
} }
static void apply_mosaic_layout(list_t *children, struct wlr_box *viewport,
struct sway_workspace *ws) {
if (!children->length || !ws->mosaic) {
return;
}
struct mosaic_row *row = ws->mosaic->rows->items[0];
int count = children->length;
int gap = ws->gaps_inner > 0 ? ws->gaps_inner : 0;
int64_t gap_total = (int64_t)(count - 1) * gap;
int64_t fitting = ((int64_t)viewport->width - gap_total) / count;
// STEP20B temporary sizing policy: 640 px minimum demonstrates an
// overflowing tiled row. A later slice will define the UX cell sizing.
int cell = row->state.minimum_cell_width > 0 ?
row->state.minimum_cell_width : 640;
if (fitting > cell) {
cell = fitting > INT_MAX ? INT_MAX : (int)fitting;
}
int64_t x = viewport->x;
for (int i = 0; i < count; ++i) {
struct sway_container *child = children->items[i];
child->pending.x = x;
child->pending.y = viewport->y;
child->pending.width = cell;
child->pending.height = viewport->height;
child->child_total_width = (int64_t)count * cell > INT_MAX ?
INT_MAX : count * cell;
x += (int64_t)cell + gap;
}
mosaic_row_clamp_scroll(row, viewport->width, gap);
}
static void apply_vert_layout(list_t *children, struct wlr_box *parent) { static void apply_vert_layout(list_t *children, struct wlr_box *parent) {
if (!children->length) { if (!children->length) {
return; return;
@ -225,6 +259,19 @@ static void arrange_children(list_t *children,
case L_HORIZ: case L_HORIZ:
apply_horiz_layout(children, parent); apply_horiz_layout(children, parent);
break; break;
case L_MOSAIC:
// Only a workspace owns a Mosaic row. Nested containers retain Sway's
// normal tiling rules while row zero remains the real workspace tree.
if (children->length) {
struct sway_container *first = children->items[0];
struct sway_workspace *ws = first->pending.workspace;
if (ws && ws->tiling == children) {
apply_mosaic_layout(children, parent, ws);
} else {
apply_horiz_layout(children, parent);
}
}
break;
case L_VERT: case L_VERT:
apply_vert_layout(children, parent); apply_vert_layout(children, parent);
break; break;

View file

@ -17,6 +17,7 @@
#include "sway/output.h" #include "sway/output.h"
#include "sway/server.h" #include "sway/server.h"
#include "sway/tree/arrange.h" #include "sway/tree/arrange.h"
#include "sway/tree/mosaic.h"
#include "sway/tree/view.h" #include "sway/tree/view.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
#include "sway/xdg_decoration.h" #include "sway/xdg_decoration.h"
@ -829,6 +830,9 @@ size_t container_build_representation(enum sway_container_layout layout,
case L_HORIZ: case L_HORIZ:
lenient_strcat(buffer, "H["); lenient_strcat(buffer, "H[");
break; break;
case L_MOSAIC:
lenient_strcat(buffer, "M[");
break;
case L_TABBED: case L_TABBED:
lenient_strcat(buffer, "T["); lenient_strcat(buffer, "T[");
break; break;
@ -1528,6 +1532,7 @@ void container_insert_child(struct sway_container *parent,
child->pending.parent = parent; child->pending.parent = parent;
child->pending.workspace = parent->pending.workspace; child->pending.workspace = parent->pending.workspace;
container_for_each_child(child, set_workspace, NULL); container_for_each_child(child, set_workspace, NULL);
workspace_mosaic_sync_focus(child->pending.workspace);
container_handle_fullscreen_reparent(child); container_handle_fullscreen_reparent(child);
container_update_representation(parent); container_update_representation(parent);
} }
@ -1543,6 +1548,7 @@ void container_add_sibling(struct sway_container *fixed,
active->pending.parent = fixed->pending.parent; active->pending.parent = fixed->pending.parent;
active->pending.workspace = fixed->pending.workspace; active->pending.workspace = fixed->pending.workspace;
container_for_each_child(active, set_workspace, NULL); container_for_each_child(active, set_workspace, NULL);
workspace_mosaic_sync_focus(active->pending.workspace);
container_handle_fullscreen_reparent(active); container_handle_fullscreen_reparent(active);
container_update_representation(active); container_update_representation(active);
} }
@ -1556,6 +1562,7 @@ void container_add_child(struct sway_container *parent,
child->pending.parent = parent; child->pending.parent = parent;
child->pending.workspace = parent->pending.workspace; child->pending.workspace = parent->pending.workspace;
container_for_each_child(child, set_workspace, NULL); container_for_each_child(child, set_workspace, NULL);
workspace_mosaic_sync_focus(child->pending.workspace);
container_handle_fullscreen_reparent(child); container_handle_fullscreen_reparent(child);
container_update_representation(parent); container_update_representation(parent);
node_set_dirty(&child->node); node_set_dirty(&child->node);
@ -1572,6 +1579,16 @@ void container_detach(struct sway_container *child) {
struct sway_container *old_parent = child->pending.parent; struct sway_container *old_parent = child->pending.parent;
struct sway_workspace *old_workspace = child->pending.workspace; struct sway_workspace *old_workspace = child->pending.workspace;
// A Mosaic focus reference must not outlive a detached Sway container.
if (old_workspace && old_workspace->mosaic) {
struct mosaic_workspace_state *mosaic = old_workspace->mosaic;
for (int i = 0; i < mosaic->rows->length; ++i) {
struct mosaic_row *row = mosaic->rows->items[i];
if (row->state.focused == child) {
row->state.focused = NULL;
}
}
}
list_t *siblings = container_get_siblings(child); list_t *siblings = container_get_siblings(child);
if (siblings) { if (siblings) {
int index = list_find(siblings, child); int index = list_find(siblings, child);

155
sway/tree/mosaic.c Normal file
View file

@ -0,0 +1,155 @@
#include <math.h>
#include <limits.h>
#include <stdint.h>
#include <stdlib.h>
#include "sway/tree/mosaic.h"
static struct mosaic_row *row_create(list_t *children, bool owns_children) {
struct mosaic_row *row = calloc(1, sizeof(*row));
if (!row) {
return NULL;
}
row->children = children;
row->owns_children = owns_children;
return row;
}
struct mosaic_workspace_state *mosaic_state_create(list_t *first_children) {
if (!first_children) {
return NULL;
}
struct mosaic_workspace_state *state = calloc(1, sizeof(*state));
if (!state) {
return NULL;
}
state->rows = create_list();
if (!state->rows) {
free(state);
return NULL;
}
struct mosaic_row *row = row_create(first_children, false);
if (!row) {
list_free(state->rows);
free(state);
return NULL;
}
list_add(state->rows, row);
return state;
}
void mosaic_state_destroy(struct mosaic_workspace_state *state) {
if (!state) {
return;
}
for (int i = 0; i < state->rows->length; ++i) {
struct mosaic_row *row = state->rows->items[i];
if (row->owns_children) {
list_free(row->children);
}
free(row);
}
list_free(state->rows);
free(state);
}
struct mosaic_row *mosaic_state_add_row(struct mosaic_workspace_state *state) {
if (!state) {
return NULL;
}
list_t *children = create_list();
if (!children) {
return NULL;
}
struct mosaic_row *row = row_create(children, true);
if (!row) {
list_free(children);
return NULL;
}
list_add(state->rows, row);
return row;
}
bool mosaic_state_select_row(struct mosaic_workspace_state *state, int index) {
if (!state || index < 0 || index >= state->rows->length) {
return false;
}
state->active_row = index;
return true;
}
int mosaic_row_insertion_index(const struct mosaic_row *row) {
if (!row || !row->children) {
return 0;
}
int index = list_find(row->children, row->state.focused);
return index < 0 ? row->children->length : index + 1;
}
void mosaic_row_insert_after_focus(struct mosaic_row *row,
struct sway_container *child) {
if (row && child) {
list_insert(row->children, mosaic_row_insertion_index(row), child);
}
}
void mosaic_row_remove(struct mosaic_row *row, struct sway_container *child) {
if (!row || !child) {
return;
}
int index = list_find(row->children, child);
if (index >= 0) {
list_del(row->children, index);
}
// INVARIANT: no row retains focus on a detached or destroyed container.
if (row->state.focused == child) {
row->state.focused = NULL;
}
}
bool mosaic_row_focus(struct mosaic_row *row, struct sway_container *child) {
if (!row || (child && list_find(row->children, child) < 0)) {
return false;
}
row->state.focused = child;
return true;
}
bool mosaic_row_set_scroll(struct mosaic_row *row, double scroll_x) {
if (!row || !isfinite(scroll_x) || scroll_x < 0) {
return false;
}
row->state.scroll_x = scroll_x;
return true;
}
int mosaic_row_max_scroll(const struct mosaic_row *row, int viewport_width,
int gaps) {
if (!row || !row->children || viewport_width <= 0 ||
row->children->length == 0) {
return 0;
}
int count = row->children->length;
int cell = row->state.minimum_cell_width > 0 ?
row->state.minimum_cell_width : 640;
if (gaps < 0) {
gaps = 0;
}
int64_t occupied_gaps = (int64_t)(count - 1) * gaps;
int64_t fitting = ((int64_t)viewport_width - occupied_gaps) / count;
if (fitting > cell) {
cell = fitting > INT_MAX ? INT_MAX : (int)fitting;
}
int64_t overflow = (int64_t)count * cell + occupied_gaps - viewport_width;
return overflow <= 0 ? 0 : overflow > INT_MAX ? INT_MAX : (int)overflow;
}
void mosaic_row_clamp_scroll(struct mosaic_row *row, int viewport_width,
int gaps) {
if (!row) {
return;
}
int maximum = mosaic_row_max_scroll(row, viewport_width, gaps);
if (row->state.scroll_x > maximum) {
row->state.scroll_x = maximum;
}
}

View file

@ -32,6 +32,7 @@
#include "sway/sway_text_node.h" #include "sway/sway_text_node.h"
#include "sway/tree/arrange.h" #include "sway/tree/arrange.h"
#include "sway/tree/container.h" #include "sway/tree/container.h"
#include "sway/tree/mosaic.h"
#include "sway/tree/view.h" #include "sway/tree/view.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
#include "sway/config.h" #include "sway/config.h"
@ -896,6 +897,15 @@ void view_map(struct sway_view *view, struct wlr_surface *wlr_surface,
target_sibling = node->sway_container; target_sibling = node->sway_container;
} }
} }
if (ws && ws->layout == L_MOSAIC && ws->mosaic) {
// New tiled views join after the row's active top-level container;
// workspace->tiling remains the sole ordering authority.
struct mosaic_row *row = ws->mosaic->rows->items[0];
target_sibling = row->state.focused;
if (target_sibling && list_find(ws->tiling, target_sibling) < 0) {
target_sibling = NULL;
}
}
struct wlr_ext_foreign_toplevel_handle_v1_state foreign_toplevel_state = { struct wlr_ext_foreign_toplevel_handle_v1_state foreign_toplevel_state = {
.app_id = view_get_app_id(view), .app_id = view_get_app_id(view),

View file

@ -17,6 +17,7 @@
#include "sway/server.h" #include "sway/server.h"
#include "sway/tree/arrange.h" #include "sway/tree/arrange.h"
#include "sway/tree/container.h" #include "sway/tree/container.h"
#include "sway/tree/mosaic.h"
#include "sway/tree/node.h" #include "sway/tree/node.h"
#include "sway/tree/view.h" #include "sway/tree/view.h"
#include "sway/tree/workspace.h" #include "sway/tree/workspace.h"
@ -217,6 +218,7 @@ struct sway_workspace *workspace_create(struct sway_output *output,
ws->name = strdup(name); ws->name = strdup(name);
ws->prev_split_layout = L_NONE; ws->prev_split_layout = L_NONE;
ws->layout = output_get_default_layout(output); ws->layout = output_get_default_layout(output);
ws->mosaic_previous_standard_layout = ws->layout;
ws->floating = create_list(); ws->floating = create_list();
ws->tiling = create_list(); ws->tiling = create_list();
ws->output_priority = create_list(); ws->output_priority = create_list();
@ -294,6 +296,8 @@ void workspace_destroy(struct sway_workspace *workspace) {
free(workspace->representation); free(workspace->representation);
list_free_items_and_destroy(workspace->output_priority); list_free_items_and_destroy(workspace->output_priority);
list_free(workspace->floating); list_free(workspace->floating);
// Row zero borrows workspace->tiling; release the state before that list.
mosaic_state_destroy(workspace->mosaic);
list_free(workspace->tiling); list_free(workspace->tiling);
list_free(workspace->current.floating); list_free(workspace->current.floating);
list_free(workspace->current.tiling); list_free(workspace->current.tiling);
@ -955,9 +959,15 @@ struct sway_container *workspace_add_tiling(struct sway_workspace *workspace,
if (config->default_layout != L_NONE) { if (config->default_layout != L_NONE) {
con = container_split(con, config->default_layout); con = container_split(con, config->default_layout);
} }
list_add(workspace->tiling, con); if (workspace->layout == L_MOSAIC && workspace->mosaic) {
struct mosaic_row *row = workspace->mosaic->rows->items[0];
mosaic_row_insert_after_focus(row, con);
} else {
list_add(workspace->tiling, con);
}
con->pending.workspace = workspace; con->pending.workspace = workspace;
container_for_each_child(con, set_workspace, NULL); container_for_each_child(con, set_workspace, NULL);
workspace_mosaic_sync_focus(workspace);
container_handle_fullscreen_reparent(con); container_handle_fullscreen_reparent(con);
workspace_update_representation(workspace); workspace_update_representation(workspace);
node_set_dirty(&workspace->node); node_set_dirty(&workspace->node);
@ -965,6 +975,20 @@ struct sway_container *workspace_add_tiling(struct sway_workspace *workspace,
return con; return con;
} }
void workspace_mosaic_sync_focus(struct sway_workspace *workspace) {
if (!workspace || workspace->layout != L_MOSAIC || !workspace->mosaic) {
return;
}
struct sway_seat *seat = input_manager_current_seat();
struct sway_container *focused = seat_get_focused_container(seat);
if (!focused || focused->pending.workspace != workspace) {
return;
}
struct sway_container *top = container_toplevel_ancestor(focused);
struct mosaic_row *row = workspace->mosaic->rows->items[0];
mosaic_row_focus(row, top);
}
void workspace_add_floating(struct sway_workspace *workspace, void workspace_add_floating(struct sway_workspace *workspace,
struct sway_container *con) { struct sway_container *con) {
if (con->pending.workspace) { if (con->pending.workspace) {
@ -983,6 +1007,7 @@ void workspace_insert_tiling_direct(struct sway_workspace *workspace,
list_insert(workspace->tiling, index, con); list_insert(workspace->tiling, index, con);
con->pending.workspace = workspace; con->pending.workspace = workspace;
container_for_each_child(con, set_workspace, NULL); container_for_each_child(con, set_workspace, NULL);
workspace_mosaic_sync_focus(workspace);
container_handle_fullscreen_reparent(con); container_handle_fullscreen_reparent(con);
workspace_update_representation(workspace); workspace_update_representation(workspace);
node_set_dirty(&workspace->node); node_set_dirty(&workspace->node);

236
tests/headless-ipc.py Normal file
View file

@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Test d'intégration isolé : aucun socket ou processus de la session active."""
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
import time
def wait_for(predicate, seconds=8):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
result = predicate()
if result:
return result
time.sleep(0.05)
raise RuntimeError("Délai dépassé pendant le test headless")
def stop_group(process):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
def main():
if not all(shutil.which(name) for name in ("kitty", "swaymsg", "grim")):
raise RuntimeError("kitty, swaymsg et grim sont requis pour ce test")
binary = Path(os.environ.get("MOSAIC_BINARY", str(
Path(__file__).resolve().parents[1] / "build-mosaic/sway/swayfx-mosaic")))
if not binary.is_file():
raise RuntimeError(f"Binaire absent : {binary}")
with tempfile.TemporaryDirectory(prefix="mosaic-headless-") as root:
runtime = Path(root)
config = runtime / "config"
config.write_text("workspace 1\n", encoding="utf-8")
env = os.environ.copy()
env.update(XDG_RUNTIME_DIR=root, WLR_BACKENDS="headless",
WLR_HEADLESS_OUTPUTS="1", WLR_RENDERER="pixman")
env.pop("WAYLAND_DISPLAY", None)
env.pop("SWAYSOCK", None)
children = []
with (runtime / "compositor.log").open("w") as log:
compositor = subprocess.Popen([str(binary), "-c", str(config)],
env=env, stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True)
try:
def sockets():
ipc = list(runtime.glob("sway-ipc.*.sock"))
wayland = [entry for entry in runtime.glob("wayland-*")
if not entry.name.endswith(".lock")]
return (ipc[0], wayland[0]) if ipc and wayland else None
socket, wayland = wait_for(sockets)
env["WAYLAND_DISPLAY"] = wayland.name
env["SWAYSOCK"] = str(socket)
def ipc(*args):
result = subprocess.run(["swaymsg", "-s", str(socket), *args],
env=env, text=True, capture_output=True,
timeout=5, check=True)
return json.loads(result.stdout)
def workspace():
tree = ipc("-t", "get_tree")
return next(node for output in tree["nodes"]
for node in output["nodes"]
if node["type"] == "workspace" and node["name"] == "1")
assert workspace()["layout"] == "splith"
assert ipc("layout mosaic")[0]["success"]
assert workspace()["layout"] == "mosaic"
assert ipc("-t", "get_workspaces")[0]["name"] == "1"
def launch(index, expected):
child = subprocess.Popen(
["kitty", "--class", f"mosaic-step20a-{index}",
"/usr/bin/sleep", "30"], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
children.append(child)
return wait_for(lambda: workspace()["nodes"]
if len(workspace()["nodes"]) == expected else None)
a = launch(0, 1)[0]["id"]
b = launch(1, 2)[1]["id"]
c = launch(2, 3)[2]["id"]
assert ipc(f"[con_id={a}] focus")[0]["success"]
nodes = launch(3, 4)
ids = [node["id"] for node in nodes]
assert ids[0] == a and ids[2:] == [b, c], ids
assert all(node["type"] == "con" for node in nodes)
assert workspace()["floating_nodes"] == []
# Les rectangles tiled ne se chevauchent pas ; les gaps Sway
# configurés restent autorisés.
assert all(left["rect"]["x"] + left["rect"]["width"] <=
right["rect"]["x"]
for left, right in zip(nodes, nodes[1:]))
print("Insertion native :", ids, "(A, X, B, C)")
assert ipc("focus right")[0]["success"]
assert workspace()["focus"][0] == b
assert ipc("focus left")[0]["success"]
assert workspace()["focus"][0] == ids[1]
assert [node["id"] for node in workspace()["nodes"]] == ids
stop_group(children[2])
nodes = wait_for(lambda: workspace()["nodes"]
if len(workspace()["nodes"]) == 3 else None)
assert [node["id"] for node in nodes] == ids[:3]
print("Suppression native :", [node["id"] for node in nodes])
assert ipc("move right")[0]["success"]
assert workspace()["layout"] == "mosaic"
moved = [node["id"] for node in workspace()["nodes"]]
assert len(moved) == 3 and set(moved) == set(ids[:3]), moved
print("Déplacement native :", moved)
assert ipc("move left")[0]["success"]
assert [node["id"] for node in workspace()["nodes"]] == ids[:3]
after_move = [node["id"] for node in launch(4, 4)]
assert after_move[:2] == ids[:2] and after_move[3] == b
print("Insertion après déplacement :", after_move)
assert ipc("layout splith")[0]["success"]
assert workspace()["layout"] == "splith"
assert [node["id"] for node in workspace()["nodes"]] == after_move
assert ipc(f"[con_id={a}] focus")[0]["success"]
assert ipc("layout mosaic")[0]["success"]
assert workspace()["layout"] == "mosaic"
reenabled = [node["id"] for node in launch(5, 5)]
assert reenabled[0] == a and reenabled[2:] == after_move[1:]
assert workspace()["floating_nodes"] == []
print("Réactivation sur workspace occupé :", reenabled)
ws = workspace()
assert ws["nodes"][-1]["rect"]["x"] + ws["nodes"][-1]["rect"]["width"] > ws["rect"]["x"] + ws["rect"]["width"]
assert ws["mosaic"]["rows"][0]["children"] == reenabled
assert ipc(f"[con_id={a}] focus")[0]["success"]
assert workspace()["mosaic"]["rows"][0]["scroll_x"] == 0
time.sleep(0.2) # Laisse finir la transaction Wayland précédente.
def frame():
screenshot = subprocess.run(["grim", "-t", "ppm", "-"], env=env,
capture_output=True, timeout=5,
check=True)
return screenshot.stdout
before_scroll = frame()
logical_rects = [node["rect"] for node in workspace()["nodes"]]
assert ipc("layout mosaic scroll 400")[0]["success"]
assert workspace()["mosaic"]["rows"][0]["scroll_x"] == 400
time.sleep(0.2)
after_scroll = frame()
assert len(after_scroll) == len(before_scroll)
# A translated tiled scene changes a substantial number of
# pixels; a blinking cursor alone cannot satisfy this bound.
changed = sum(left != right for left, right in
zip(before_scroll, after_scroll))
print("Composantes de couleur modifiées par scroll :", changed)
assert changed > 1000
assert [node["rect"] for node in workspace()["nodes"]] == logical_rects
assert ipc("layout mosaic scroll -200")[0]["success"]
assert workspace()["mosaic"]["rows"][0]["scroll_x"] == 200
assert ipc(f"[con_id={reenabled[-1]}] focus")[0]["success"]
assert workspace()["mosaic"]["rows"][0]["scroll_x"] > 200
assert workspace()["mosaic"]["rows"][0]["focused"] == reenabled[-1]
assert ipc(f"[con_id={a}] focus")[0]["success"]
assert workspace()["mosaic"]["rows"][0]["scroll_x"] == 0
print("Canvas tiled débordant et caméra logique : PASS")
assert ipc("layout toggle mosaic")[0]["success"]
assert workspace()["layout"] == "splith"
assert ipc("layout tabbed")[0]["success"]
assert ipc("layout toggle mosaic")[0]["success"]
assert workspace()["layout"] == "mosaic"
assert len(workspace()["nodes"]) == 5
assert ipc("layout toggle mosaic")[0]["success"]
assert workspace()["layout"] == "tabbed"
print("Retour au layout tabbed choisi sur workspace occupé : PASS")
assert ipc("workspace 2")[0]["success"]
assert ipc("layout tabbed")[0]["success"]
tree = ipc("-t", "get_tree")
ws2 = next(node for output in tree["nodes"]
for node in output["nodes"]
if node["type"] == "workspace" and node["name"] == "2")
assert ws2["layout"] == "tabbed"
assert ipc("layout toggle mosaic")[0]["success"]
assert ipc("layout toggle mosaic")[0]["success"]
tree = ipc("-t", "get_tree")
ws2 = next(node for output in tree["nodes"]
for node in output["nodes"]
if node["type"] == "workspace" and node["name"] == "2")
assert ws2["layout"] == "tabbed"
assert ipc("workspace 1")[0]["success"]
assert ipc("layout toggle mosaic")[0]["success"]
assert workspace()["layout"] == "mosaic"
assert ipc("layout toggle mosaic")[0]["success"]
assert workspace()["layout"] == "tabbed"
helper = os.environ.get("MOSAIC_HELPER")
if helper:
subprocess.run([helper], env=env, check=True, timeout=5)
assert workspace()["layout"] == "mosaic"
subprocess.run([helper], env=env, check=True, timeout=5)
assert workspace()["layout"] == "tabbed"
print("Toggle Standard↔Mosaic par workspace : PASS")
assert ipc("layout toggle mosaic")[0]["success"]
assert ipc(f"[con_id={a}] focus")[0]["success"]
assert ipc("floating enable")[0]["success"]
wait_for(lambda: workspace()["floating_nodes"]
if len(workspace()["floating_nodes"]) == 1 else None)
assert workspace()["layout"] == "mosaic"
assert ipc("floating disable")[0]["success"]
wait_for(lambda: workspace()["nodes"]
if len(workspace()["nodes"]) == 5 else None)
assert ipc("fullscreen enable")[0]["success"]
assert workspace()["layout"] == "mosaic"
assert ipc("fullscreen disable")[0]["success"]
assert ipc("move scratchpad")[0]["success"]
wait_for(lambda: workspace()["nodes"]
if len(workspace()["nodes"]) == 4 else None)
assert ipc("scratchpad show")[0]["success"]
wait_for(lambda: workspace()["floating_nodes"]
if len(workspace()["floating_nodes"]) == 1 else None)
print("Floating, fullscreen et scratchpad : PASS")
print("IPC et bascules de layout : PASS")
finally:
for child in children:
stop_group(child)
stop_group(compositor)
if __name__ == "__main__":
main()

58
tests/mosaic-model.c Normal file
View file

@ -0,0 +1,58 @@
#include <assert.h>
#include <stddef.h>
#include "sway/tree/mosaic.h"
// Distinct addresses stand for containers; the model must never dereference
// them or infer geometry from their identity.
static int a, b, c, d, x, f;
#define CON(v) ((struct sway_container *)&(v))
int main(void) {
list_t *tiling = create_list();
assert(tiling);
list_add(tiling, CON(a));
list_add(tiling, CON(b));
list_add(tiling, CON(c));
list_add(tiling, CON(d));
struct mosaic_workspace_state *state = mosaic_state_create(tiling);
assert(state && state->rows->length == 1);
struct mosaic_row *row0 = state->rows->items[0];
assert(row0->children == tiling && !row0->owns_children);
assert(mosaic_row_focus(row0, CON(a)));
mosaic_row_insert_after_focus(row0, CON(x));
assert(tiling->length == 5 && tiling->items[0] == CON(a) &&
tiling->items[1] == CON(x) && tiling->items[2] == CON(b) &&
tiling->items[3] == CON(c) && tiling->items[4] == CON(d));
mosaic_row_remove(row0, CON(c));
assert(tiling->length == 4 && tiling->items[3] == CON(d));
assert(mosaic_row_focus(row0, CON(b)));
assert(tiling->items[0] == CON(a) && tiling->items[1] == CON(x));
assert(mosaic_row_set_scroll(row0, 1200));
assert(!mosaic_row_set_scroll(row0, -1));
assert(tiling->items[2] == CON(b));
assert(mosaic_row_max_scroll(row0, 1280, 0) == 1280);
mosaic_row_clamp_scroll(row0, 1280, 0);
assert(row0->state.scroll_x == 1200);
assert(mosaic_row_max_scroll(row0, 2560, 0) == 0);
// Clamping the camera does not change the borrowed Sway tiling order.
assert(tiling->items[2] == CON(b));
struct mosaic_row *row1 = mosaic_state_add_row(state);
assert(row1 && row1->owns_children);
list_add(row1->children, CON(f));
assert(mosaic_row_max_scroll(row1, 300, 0) == 340);
assert(mosaic_row_focus(row1, CON(f)));
assert(mosaic_row_set_scroll(row1, 400));
assert(mosaic_state_select_row(state, 1));
assert(mosaic_state_select_row(state, 0));
assert(row0->state.scroll_x == 1200 && row0->state.focused == CON(b));
assert(row1->state.scroll_x == 400 && row1->state.focused == CON(f));
mosaic_row_remove(row0, CON(b));
assert(row0->state.focused == NULL);
assert(!mosaic_state_select_row(state, 2));
mosaic_state_destroy(state);
assert(tiling->length == 3); // Borrowed Sway list survives state destruction.
list_free(tiling);
return 0;
}

13
tooling/export-patch.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/sh
# Rebuild the distributable delta with a private Git index; the real index and
# upstream commit stay untouched. Run only after validating this source tree.
set -eu
cd "$(dirname "$0")/.."
temporary=$(mktemp -d)
trap 'rm -rf "$temporary"' EXIT HUP INT TERM
export GIT_INDEX_FILE="$temporary/index"
git read-tree HEAD
git add -A -- include meson.build meson_options.txt sway tests/mosaic-model.c
git diff --cached --binary --full-index -- include meson.build meson_options.txt sway tests/mosaic-model.c \
> patches/0001-mosaic-layout-viewport.patch
git apply --check --reverse patches/0001-mosaic-layout-viewport.patch