From 23ec50c43c2285f499a861766acc5473053f90dc Mon Sep 17 00:00:00 2001 From: David Chincharashvili Date: Fri, 23 Oct 2026 15:35:05 +0400 Subject: [PATCH] fix(gui): nothing cut off at the minimum window size Setup tab: at 680x460 step 3 ended 164 px below the tab. The dependency report is now the row that gives way (6 lines by default, grows with the window, shrinks and scrolls at the minimum), Re-check sits on the step-1 heading row, and the voice-preview status line is hidden while empty. Config tab: the "VLC RC playlist mode on Windows (vlc_rc; uncheck sets TTS_KA_VLC_RC=0)" checkbox ran off the right edge. It now reads "Windows: play all chunks in one VLC window (vlc_rc)"; the TTS_KA_VLC_RC=0 detail moved to the help text below it. Tests: at the minimum size, step 3 is on screen, and on every tab each label, button, checkbox and radio button gets its full size inside the tab. Co-Authored-By: Claude Opus 5.5 --- src/TTS_ka/gui.py | 28 ++++++++------ tests/test_gui_smoke.py | 83 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/TTS_ka/gui.py b/src/TTS_ka/gui.py index ebf1949..be9b644 100644 --- a/src/TTS_ka/gui.py +++ b/src/TTS_ka/gui.py @@ -419,14 +419,16 @@ def _build_setup_tab(self, frm: Any) -> None: ).grid(row=1, column=0, sticky=tk.W, pady=(2, 10)) # --- Step 1: verify setup ------------------------------------------- - ttk.Label(frm, text="1. Verify your setup", style="Heading.TLabel").grid(row=2, column=0, sticky=tk.W) - self._setup_report = _scrolled_text(frm, height=9, wrap=tk.WORD) - self._setup_report.grid(row=3, column=0, sticky=(tk.E, tk.W), pady=(4, 0)) + step1 = ttk.Frame(frm) + step1.grid(row=2, column=0, sticky=tk.EW) + ttk.Label(step1, text="1. Verify your setup", style="Heading.TLabel").pack(side=tk.LEFT) + ttk.Button(step1, text="Re-check", command=self._on_setup_recheck).pack(side=tk.RIGHT) + # The report is the row that gives way, so steps 2 and 3 stay visible at the minimum size. + self._setup_report = _scrolled_text(frm, height=6, wrap=tk.WORD) + self._setup_report.grid(row=3, column=0, sticky=tk.NSEW, pady=(4, 0)) + frm.rowconfigure(3, weight=1) self._setup_report.insert("1.0", "Checking dependencies…") self._setup_report.configure(state=tk.DISABLED) - ttk.Button(frm, text="Re-check", command=self._on_setup_recheck).grid( - row=4, column=0, sticky=tk.W, pady=(4, 0) - ) ttk.Separator(frm, orient=tk.HORIZONTAL).grid( row=5, column=0, sticky=(tk.E, tk.W), pady=12 @@ -449,8 +451,11 @@ def _build_setup_tab(self, frm: Any) -> None: ) self._setup_preview_btn.pack(side=tk.LEFT, padx=(8, 0)) self.setup_status = tk.StringVar(value="") - ttk.Label(frm, textvariable=self.setup_status, wraplength=520, justify=tk.LEFT).grid( - row=8, column=0, sticky=tk.W, pady=(4, 0) + status_lbl = ttk.Label(frm, textvariable=self.setup_status, wraplength=520, justify=tk.LEFT) + status_lbl.grid(row=8, column=0, sticky=tk.W, pady=(4, 0)) + status_lbl.grid_remove() # an empty line would push step 3 down + self.setup_status.trace_add( + "write", lambda *_: status_lbl.grid() if self.setup_status.get() else status_lbl.grid_remove() ) ttk.Separator(frm, orient=tk.HORIZONTAL).grid( @@ -668,13 +673,14 @@ def _build_config_tab(self, frm: Any) -> None: ttk.Checkbutton( frm, - text="VLC RC playlist mode on Windows (vlc_rc; uncheck sets TTS_KA_VLC_RC=0)", + text="Windows: play all chunks in one VLC window (vlc_rc)", variable=self.cfg_vlc_rc, - ).grid(row=7, column=0, columnspan=2, sticky=tk.W) + ).grid(row=7, column=0, columnspan=3, sticky=tk.W) help_txt = ( "Saved keys match ~/.tts_config.json: lang (Speak tab), output, chunk_seconds, " - "parallel, no_play, stream, no_gui, no_turbo, vlc_rc." + "parallel, no_play, stream, no_gui, no_turbo, vlc_rc. " + "Unticking the VLC option sets TTS_KA_VLC_RC=0 (one VLC per chunk)." ) ttk.Label(frm, text=help_txt, wraplength=480, justify=tk.LEFT).grid( row=8, column=0, columnspan=3, sticky=tk.W, pady=(12, 0) diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py index 179493b..7bdbc34 100644 --- a/tests/test_gui_smoke.py +++ b/tests/test_gui_smoke.py @@ -106,3 +106,86 @@ def test_speak_tab_file_row_and_player_window_follow_their_toggles() -> None: assert not app._player_window_cb.instate(["disabled"]) finally: app.root.destroy() + + +def test_setup_tab_fits_at_minimum_size() -> None: + """At the window's minimum size, the last Setup step must still be on screen.""" + tk = pytest.importorskip("tkinter") + try: + probe = tk.Tk() + probe.destroy() + except tk.TclError: + pytest.skip("no display available") + + from tkinter import ttk + + from TTS_ka import gui + + with patch("TTS_ka.deps.collect_dep_rows", return_value=[]), \ + patch("TTS_ka.deps.format_dep_report", return_value="ok"), \ + patch("TTS_ka.native_hotkeys.NativeHotkeyManager", MagicMock()), \ + patch.object(gui.TTSSpeakApp, "_run_setup_check_async"): + app = gui.TTSSpeakApp({"lang": "en"}) + try: + w, h = app.root.minsize() + app.root.geometry(f"{w}x{h}") + app.root.update() + tab = app.root.nametowidget(app._nb.tabs()[0]) + go = next( + c for c in tab.winfo_children() + if isinstance(c, ttk.Button) and c.cget("text").startswith("Go to Speak") + ) + assert go.winfo_y() + go.winfo_height() <= tab.winfo_height() + finally: + app.root.destroy() + + +def test_no_tab_cuts_off_text_at_the_minimum_window() -> None: + """At the minimum size every label / button / check / radio gets its full size inside its tab. + + Text boxes and entries are left out: they are meant to shrink. + """ + tk = pytest.importorskip("tkinter") + try: + probe = tk.Tk() + probe.destroy() + except tk.TclError: + pytest.skip("no display available") + + from TTS_ka import gui + + fixed = {"TLabel", "TButton", "TCheckbutton", "TRadiobutton"} + + def descendants(w): + for c in w.winfo_children(): + yield c + yield from descendants(c) + + with patch("TTS_ka.deps.collect_dep_rows", return_value=[]), \ + patch("TTS_ka.deps.format_dep_report", return_value="ok"), \ + patch("TTS_ka.native_hotkeys.NativeHotkeyManager", MagicMock()), \ + patch.object(gui.TTSSpeakApp, "_run_setup_check_async"): + app = gui.TTSSpeakApp({"lang": "en"}) + try: + w, h = app.root.minsize() + app.root.geometry(f"{w}x{h}") + cut = [] + for tab_id in app._nb.tabs(): + app._nb.select(tab_id) + app.root.update() + tab = app.root.nametowidget(tab_id) + right = tab.winfo_rootx() + tab.winfo_width() + bottom = tab.winfo_rooty() + tab.winfo_height() + for c in descendants(tab): + if c.winfo_class() not in fixed or not c.winfo_ismapped(): + continue + if ( + c.winfo_width() < c.winfo_reqwidth() + or c.winfo_height() < c.winfo_reqheight() + or c.winfo_rootx() + c.winfo_width() > right + or c.winfo_rooty() + c.winfo_height() > bottom + ): + cut.append((app._nb.tab(tab_id, "text"), c.winfo_class(), str(c.cget("text"))[:40])) + assert not cut, cut + finally: + app.root.destroy()