#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Barres de partage glissables à la souris (tâche 25) : un bouton par frontière ajustable (`#folders_splitter`, `#list_splitter`), qui redimensionne en direct pendant le glissement et persiste au relâchement — par le MÊME `_store_pane_size` que `+`/`-`/`0` au clavier (tâche 24), jamais un second magasin. Comme `test_mail_tui_resize.py` : tout ce qui compte ici n'a de sens que sur l'application montée pour de vrai — les RÉGIONS mesurées avant/après un glissement, jamais un attribut interne de `MailApp`. `Pilot.mouse_down`/ `hover`/`mouse_up` composent le glissement ; `hover`/`mouse_up` visent une coordonnée ÉCRAN absolue (`widget=None`), pas la barre elle-même, parce que la barre se déplace pendant le glissement (son voisin redimensionné la pousse) — cibler à nouveau la barre par sélecteur dériverait. """ import os import tempfile import unittest from pathlib import Path from script.todo.mail.accounts import account_from_preset from script.todo.mail.store import Store from script.todo.mail.tui import PANE_SIZE_MIN, Session class SplitterCase(unittest.IsolatedAsyncioTestCase): """Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que `test_mail_tui_resize.py`. """ def setUp(self): self.fake_home = tempfile.TemporaryDirectory() self._old_home = os.environ.get("HOME") os.environ["HOME"] = self.fake_home.name self.cache_dir = tempfile.TemporaryDirectory() self.account = account_from_preset("perso", "moi@x.ca", "generic") self.account.cache_mode = "clear" self.store = Store( self.account, mode="clear", base=Path(self.cache_dir.name) ) self.store.open() self.session = Session(self.account, self.store, None, password="x") def tearDown(self): self.store.close() if self._old_home is None: os.environ.pop("HOME", None) else: os.environ["HOME"] = self._old_home self.fake_home.cleanup() self.cache_dir.cleanup() async def _mounted_app(self): import textual.app from script.todo.mail.tui import run_tui captured = [] orig_init = textual.app.App.__init__ def capturing_init(app_self, *a, **kw): orig_init(app_self, *a, **kw) captured.append(app_self) textual.app.App.__init__ = capturing_init try: run_tui(run_app=False, sessions=[self.session]) finally: textual.app.App.__init__ = orig_init return captured[-1] async def _press_down(self, pilot, splitter_id: str): """`MouseDown` sur la barre `splitter_id`, à sa propre position (offset (0, 0) relatif à la barre) — rend son point de départ, en coordonnées ÉCRAN, pour que les étapes suivantes du glissement (`_move_to`/`_release_at`) ciblent une coordonnée ABSOLUE plutôt que la barre elle-même, qui se déplace pendant le glissement. """ splitter = pilot.app.query_one(f"#{splitter_id}") start = splitter.region.offset await pilot.mouse_down(f"#{splitter_id}", offset=(0, 0)) await pilot.pause() return start async def _move_to(self, pilot, offset): await pilot.hover(offset=offset) await pilot.pause() async def _release_at(self, pilot, offset): await pilot.mouse_up(offset=offset) await pilot.pause() async def _drag(self, pilot, splitter_id: str, delta_x=0, delta_y=0): """Glissement complet (presser, déplacer, relâcher) de `delta_x`/ `delta_y` cellules ÉCRAN, à partir de la position actuelle de la barre. """ start = await self._press_down(pilot, splitter_id) target = (start.x + delta_x, start.y + delta_y) await self._move_to(pilot, target) await self._release_at(pilot, target) class TestSplitterWidgetsPresent(SplitterCase): async def test_both_splitters_exist_and_are_not_focusable(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() folders_splitter = app.query_one("#folders_splitter") list_splitter = app.query_one("#list_splitter") self.assertFalse(folders_splitter.can_focus) self.assertFalse(list_splitter.can_focus) # Ni l'une ni l'autre ne doit jamais recevoir le focus par # défaut (`Screen.AUTO_FOCUS`) à la place de `#folders`. from textual.widgets import Tree self.assertIsInstance(app.focused, Tree) class TestDragResizesColumns(SplitterCase): """Disposition par défaut (`columns`) : les deux barres sont verticales — glisser HORIZONTALEMENT redimensionne. """ async def test_dragging_folders_splitter_widens_folders_and_narrows_right( self, ): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() folders = app.query_one("#folders") right = app.query_one("#right") width_before = folders.region.width right_before = right.region.width await self._drag(pilot, "folders_splitter", delta_x=6) self.assertEqual(folders.region.width, width_before + 6) self.assertEqual(right.region.width, right_before - 6) async def test_dragging_list_splitter_widens_list_and_narrows_preview( self, ): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() list_pane = app.query_one("#list_pane") preview = app.query_one("#preview") width_before = list_pane.region.width preview_before = preview.region.width await self._drag(pilot, "list_splitter", delta_x=5) self.assertEqual(list_pane.region.width, width_before + 5) self.assertEqual(preview.region.width, preview_before - 5) async def test_dragging_left_narrows_folders_and_widens_right(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() folders = app.query_one("#folders") right = app.query_one("#right") width_before = folders.region.width right_before = right.region.width await self._drag(pilot, "folders_splitter", delta_x=-6) self.assertEqual(folders.region.width, width_before - 6) self.assertEqual(right.region.width, right_before + 6) class TestDragIsLive(SplitterCase): async def test_the_pane_resizes_before_release_not_only_after(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() folders = app.query_one("#folders") width_before = folders.region.width start = await self._press_down(pilot, "folders_splitter") target = (start.x + 6, start.y) await self._move_to(pilot, target) # Toujours en cours de glissement : le volet a DÉJÀ bougé. self.assertEqual(folders.region.width, width_before + 6) await self._release_at(pilot, target) self.assertEqual(folders.region.width, width_before + 6) class TestDragPersistenceAndSharedStore(SplitterCase): async def test_nothing_is_written_to_disk_before_release(self): from script.todo import todo_prefs app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() start = await self._press_down(pilot, "folders_splitter") target = (start.x + 6, start.y) await self._move_to(pilot, target) # `_store_pane_size` fait un aller-retour disque à chaque appel # -- il ne doit tourner qu'À LA LEVÉE, jamais pendant le # glissement lui-même. self.assertEqual(todo_prefs.get("mail_pane_sizes"), {}) await self._release_at(pilot, target) self.assertIn("columns", todo_prefs.get("mail_pane_sizes")) async def test_the_released_size_is_stored_under_the_same_key_only(self): from script.todo import todo_prefs app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() folders = app.query_one("#folders") await self._drag(pilot, "folders_splitter", delta_x=6) width_after = folders.region.width sizes = todo_prefs.get("mail_pane_sizes") # AUCUNE autre clé de premier niveau : un seul magasin, celui # que la tâche 24 a créé -- jamais un second, parallèle. self.assertEqual(set(sizes.keys()), {"columns"}) self.assertEqual(set(sizes["columns"].keys()), {"folders"}) self.assertEqual(sizes["columns"]["folders"], width_after) async def test_the_keyboard_sees_the_size_the_mouse_just_set(self): from textual.widgets import DataTable app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() list_pane = app.query_one("#list_pane") await self._drag(pilot, "list_splitter", delta_x=5) width_after_drag = list_pane.region.width app.query_one("#list", DataTable).focus() await pilot.pause() await pilot.press("+") await pilot.pause() # Le clavier reprend EXACTEMENT où la souris a laissé la # taille -- la preuve qu'il n'y a qu'un seul magasin. from script.todo.mail.tui import PANE_SIZE_STEP self.assertEqual( list_pane.region.width, width_after_drag + PANE_SIZE_STEP ) class TestDragStopsAtTheMinimum(SplitterCase): async def test_dragging_far_left_stops_folders_at_the_minimum(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() folders = app.query_one("#folders") await self._press_down(pilot, "folders_splitter") # Bord gauche de l'écran : un delta négatif bien au-delà de ce # qu'aucun terminal ne pourrait fournir, mais une coordonnée # ÉCRAN toujours VALIDE (donc jamais `OutOfBounds`). await self._move_to(pilot, (0, 0)) await self._release_at(pilot, (0, 0)) self.assertEqual(folders.region.width, PANE_SIZE_MIN) async def test_dragging_far_right_leaves_rights_own_children_above_the_minimum( self, ): """`#right` n'est pas une feuille : il héberge à son tour `list_pane`/`list_splitter`/`preview` (voir `_PANE_SIBLING_MIN`, tâche 24). Pousser `#folders` jusqu'à son plafond ne doit donc PAS écraser `#right` à `PANE_SIZE_MIN` -- ce plancher est celui de `list_pane`/`preview` eux-mêmes, chacun encore mesuré ICI plutôt que supposé, exactement l'invariant que la tâche 24 a fini par tester après avoir été mordue une première fois par un nombre figé plutôt que par l'invariant réel (voir son rapport, « round 2 »). """ app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() right = app.query_one("#right") list_pane = app.query_one("#list_pane") list_splitter = app.query_one("#list_splitter") preview = app.query_one("#preview") screen_width = app.screen.size.width await self._press_down(pilot, "folders_splitter") edge = (screen_width - 1, 0) await self._move_to(pilot, edge) await self._release_at(pilot, edge) self.assertGreaterEqual(list_pane.region.width, PANE_SIZE_MIN) self.assertGreaterEqual(preview.region.width, PANE_SIZE_MIN) self.assertEqual( right.region.width, list_pane.region.width + list_splitter.region.width + preview.region.width, ) async def test_dragging_far_left_stops_list_pane_at_the_minimum(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() list_pane = app.query_one("#list_pane") await self._press_down(pilot, "list_splitter") await self._move_to(pilot, (0, 0)) await self._release_at(pilot, (0, 0)) self.assertEqual(list_pane.region.width, PANE_SIZE_MIN) async def test_dragging_far_right_stops_preview_at_the_minimum(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() preview = app.query_one("#preview") screen_width = app.screen.size.width await self._press_down(pilot, "list_splitter") edge = (screen_width - 1, 0) await self._move_to(pilot, edge) await self._release_at(pilot, edge) self.assertEqual(preview.region.width, PANE_SIZE_MIN) class TestDragOrientationFollowsLayout(SplitterCase): """Les deux barres suivent l'orientation RÉELLE du conteneur qu'elles jouxtent (`MailApp._pane_dimension`), jamais une table par disposition. """ async def test_dragging_in_stacked_resizes_by_height_not_width(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() await pilot.press("v") # split await pilot.pause() await pilot.press("v") # stacked await pilot.pause() self.assertTrue( app.query_one("#panes").has_class("layout-stacked") ) folders = app.query_one("#folders") right = app.query_one("#right") width_before = folders.region.width height_before = folders.region.height right_height_before = right.region.height # `delta_y=1`, pas davantage : en `stacked`, `#panes` ne fait que # 21 lignes de haut (écran 80x24, moins l'en-tête/le pied) -- # `#right` doit en garder au moins 9 (`list_pane` + la barre + # `preview`, chacun `>= PANE_SIZE_MIN`), donc `folders` ne peut # grandir que de 1 avant de buter sur ce plafond ; un delta plus # grand serait borné et ce test mesurerait le bornage, pas le # suivi d'axe qu'il vérifie ici (voir `TestDragStopsAtTheMinimum` # pour le bornage lui-même). await self._drag(pilot, "folders_splitter", delta_y=1) self.assertEqual(folders.region.height, height_before + 1) self.assertEqual(right.region.height, right_height_before - 1) # La largeur, elle, ne bouge pas -- ce n'est plus l'axe partagé. self.assertEqual(folders.region.width, width_before) async def test_dragging_in_split_list_splitter_resizes_by_height(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() await pilot.press("v") # split await pilot.pause() self.assertTrue(app.query_one("#panes").has_class("layout-split")) list_pane = app.query_one("#list_pane") preview = app.query_one("#preview") height_before = list_pane.region.height preview_height_before = preview.region.height await self._drag(pilot, "list_splitter", delta_y=3) self.assertEqual(list_pane.region.height, height_before + 3) self.assertEqual(preview.region.height, preview_height_before - 3) class TestInterruptedDragDoesNotStick(SplitterCase): async def test_release_captures_only_via_the_bar_not_the_release_point( self, ): """Le relâchement arrive loin de la barre (une seule cellule de large) -- la capture de souris doit tout de même router le `MouseUp` vers elle (voir `Screen._forward_event`), et la libérer : rien de coincé après. """ app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() await self._drag(pilot, "folders_splitter", delta_x=6) self.assertIsNone(app.mouse_captured) # L'app reste utilisable : un second glissement, ailleurs, # fonctionne normalement -- la preuve qu'aucun état ne traîne. list_pane = app.query_one("#list_pane") width_before = list_pane.region.width await self._drag(pilot, "list_splitter", delta_x=3) self.assertEqual(list_pane.region.width, width_before + 3) async def test_app_blur_mid_drag_ends_it_and_persists_the_last_value( self, ): from textual import events from script.todo import todo_prefs app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() start = await self._press_down(pilot, "folders_splitter") target = (start.x + 6, start.y) await self._move_to(pilot, target) self.assertIsNotNone(app.mouse_captured) app.post_message(events.AppBlur()) await pilot.pause() self.assertIsNone(app.mouse_captured) self.assertIn( "folders", todo_prefs.get("mail_pane_sizes").get("columns", {}) ) async def test_fullscreen_mid_drag_ends_it_without_getting_stuck(self): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() start = await self._press_down(pilot, "folders_splitter") target = (start.x + 6, start.y) await self._move_to(pilot, target) self.assertIsNotNone(app.mouse_captured) app.action_toggle_fullscreen() await pilot.pause() self.assertIsNone(app.mouse_captured) self.assertTrue(app.query_one("#panes").has_class("fullscreen")) class TestSplittersHiddenInFullscreen(SplitterCase): async def test_both_splitters_vanish_in_fullscreen_in_every_layout(self): from script.todo.mail.tui import MAIL_LAYOUTS app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() panes = app.query_one("#panes") folders_splitter = app.query_one("#folders_splitter") list_splitter = app.query_one("#list_splitter") for layout_id, _ in MAIL_LAYOUTS: while app.mail_layout != layout_id: await pilot.press("v") await pilot.pause() panes.add_class("fullscreen") await pilot.pause() self.assertEqual(folders_splitter.region.width, 0) self.assertEqual(folders_splitter.region.height, 0) self.assertEqual(list_splitter.region.width, 0) self.assertEqual(list_splitter.region.height, 0) panes.remove_class("fullscreen") await pilot.pause() class TestModalPushEndsAnyPendingDrag(SplitterCase): """`App.push_screen` (`app.py:2937`) revokes mouse capture out from under a drag that hasn't been released yet -- `App.capture_mouse` (`app.py:3222`) posts `MouseRelease` to whatever WAS captured whenever capture changes, including to `None`. `_PaneSplitter` must react to that (`on_mouse_release`), or `MailApp`'s drag state stays pointed at the ABANDONED slot: since capture is now cleared, the very first (synthetic, pre-`MouseDown`) `MouseMove` of the NEXT, entirely unrelated drag is routed by ordinary hit-testing to whatever's under the pointer, and gets misapplied to the STALE slot before that new drag's own `MouseDown` has a chance to reset the state. """ async def test_pushing_a_modal_mid_drag_does_not_corrupt_the_next_drag( self, ): app = await self._mounted_app() async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.pause() folders = app.query_one("#folders") list_pane = app.query_one("#list_pane") preview = app.query_one("#preview") # Glissement de `folders` JAMAIS relâché : le bouton de la # souris est toujours, conceptuellement, enfoncé au moment où # le modal ci-dessous est poussé. start = await self._press_down(pilot, "folders_splitter") target = (start.x + 5, start.y) await self._move_to(pilot, target) width_mid_drag = folders.region.width await pilot.press("l") # LogScreen (push_screen) await pilot.pause() await pilot.press("escape") # ferme LogScreen (dismiss) await pilot.pause() list_width_before = list_pane.region.width preview_width_before = preview.region.width # Glissement SUIVANT, SANS RAPPORT, sur l'AUTRE barre. await self._drag(pilot, "list_splitter", delta_x=3) # `folders` n'a plus bougé depuis le modal -- rien de périmé ne # devait plus le toucher. self.assertEqual(folders.region.width, width_mid_drag) # Le glissement de `list_splitter` a atterri exactement là où # il atterrirait sans aucun modal impliqué. self.assertEqual(list_pane.region.width, list_width_before + 3) self.assertEqual(preview.region.width, preview_width_before - 3) if __name__ == "__main__": unittest.main()