From 754ec3a7e45b9837addae42e48939bdbb6d7581f Mon Sep 17 00:00:00 2001 From: Nihal Rodge Date: Sat, 18 Jul 2026 11:33:14 +0530 Subject: [PATCH 1/6] feat(ui): add recording sidebar, session timer, audio level bar, transcript pane (#12) --- src/taskify/ui/main_window.py | 284 ++++++++++++++++++++++- tests/unit/conftest.py | 78 +++++++ tests/unit/test_ui_main_window.py | 83 +------ tests/unit/test_ui_recording_controls.py | 209 +++++++++++++++++ 4 files changed, 567 insertions(+), 87 deletions(-) create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_ui_recording_controls.py diff --git a/src/taskify/ui/main_window.py b/src/taskify/ui/main_window.py index 6bfe916..30a7ac4 100644 --- a/src/taskify/ui/main_window.py +++ b/src/taskify/ui/main_window.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time import tkinter as tk from datetime import datetime from typing import TYPE_CHECKING @@ -12,7 +13,13 @@ from taskify.llm.models import MatrixQuadrant from taskify.pipeline.scheduler import EVENT_TASKS_UPDATED +try: + from taskify.audio.capture import AudioCapture +except Exception: # portaudio may be missing in CI / headless envs + AudioCapture = None # type: ignore[assignment,misc] + if TYPE_CHECKING: + from taskify.audio.capture import AudioCapture from taskify.config import Settings from taskify.pipeline.scheduler import EventBus from taskify.storage.database import DatabaseManager @@ -34,6 +41,15 @@ COLOR_DELEGATE = "#FFBF5F" # Warm Orange/Amber COLOR_ELIMINATE = "#8A8A93" # Muted Slate Gray +# Recording state colors +COLOR_RECORDING = "#FF4040" +COLOR_IDLE = "#4ADE80" + +# Live transcript max chars +_TRANSCRIPT_MAX_CHARS = 500 +# Audio level poll interval (ms) +_LEVEL_POLL_MS = 100 + class TaskCard(ctk.CTkFrame): """Interactive visual card presenting a single task.""" @@ -227,14 +243,25 @@ def __init__( ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") - # Responsive main grid layout configuration + # Responsive main grid layout: control panel col + matrix col self.grid_rowconfigure(0, weight=1) self.grid_rowconfigure(1, weight=0) # Status Bar - self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=0) # Control sidebar + self.grid_columnconfigure(1, weight=1) # Quadrant matrix + + # Recording state + self._is_recording = False + self._session_start: float | None = None + self._timer_after_id: str | None = None + self._level_after_id: str | None = None + self._audio_capture: AudioCapture | None = None + + # Build control sidebar + self._create_control_panel() # Container for the 2x2 quadrant layout self.matrix_frame = ctk.CTkFrame(self, fg_color="transparent") - self.matrix_frame.grid(row=0, column=0, padx=16, pady=16, sticky="nsew") + self.matrix_frame.grid(row=0, column=1, padx=16, pady=16, sticky="nsew") self.matrix_frame.grid_rowconfigure(0, weight=1) self.matrix_frame.grid_rowconfigure(1, weight=1) @@ -276,7 +303,7 @@ def __init__( MatrixQuadrant.ELIMINATE.value, ) - # Create Status Bar + # Create Status Bar (spans both columns) self._create_status_bar() # Initial data loading @@ -285,6 +312,251 @@ def __init__( # Wire EventBus triggers thread-safely self._bus.subscribe(EVENT_TASKS_UPDATED, self._on_tasks_updated) + # ------------------------------------------------------------------ + # Control panel + # ------------------------------------------------------------------ + + def _create_control_panel(self) -> None: + """Build the left-hand recording control sidebar.""" + panel = ctk.CTkFrame( + self, + width=240, + fg_color=CARD_BG, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=0, + ) + panel.grid(row=0, column=0, sticky="nsew") + panel.grid_propagate(False) + panel.grid_columnconfigure(0, weight=1) + + # ---- App title / branding ---- + brand = ctk.CTkLabel( + panel, + text="TASKIFY", + font=ctk.CTkFont(size=20, weight="bold"), + text_color=TEXT_PRIMARY, + ) + brand.grid(row=0, column=0, padx=20, pady=(20, 4), sticky="w") + + subtitle = ctk.CTkLabel( + panel, + text="Voice Task Manager", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + ) + subtitle.grid(row=1, column=0, padx=20, pady=(0, 20), sticky="w") + + # Separator line + sep1 = ctk.CTkFrame( + panel, height=1, fg_color=BORDER_COLOR, corner_radius=0 + ) + sep1.grid(row=2, column=0, padx=12, pady=(0, 16), sticky="ew") + + # ---- Recording button ---- + self.record_btn = ctk.CTkButton( + panel, + text="▶ Start Recording", + font=ctk.CTkFont(size=13, weight="bold"), + fg_color=COLOR_IDLE, + hover_color="#22C55E", + text_color="#000000", + corner_radius=8, + height=42, + command=self.toggle_recording, + ) + self.record_btn.grid(row=3, column=0, padx=16, pady=(0, 12), sticky="ew") + + # ---- Session Timer ---- + timer_frame = ctk.CTkFrame(panel, fg_color="transparent") + timer_frame.grid(row=4, column=0, padx=16, pady=(0, 4), sticky="ew") + + ctk.CTkLabel( + timer_frame, + text="Session", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + ).pack(side="left") + + self.timer_label = ctk.CTkLabel( + timer_frame, + text="00:00", + font=ctk.CTkFont(size=13, weight="bold"), + text_color=TEXT_PRIMARY, + ) + self.timer_label.pack(side="right") + + # ---- Audio level bar ---- + level_lbl = ctk.CTkLabel( + panel, + text="Audio Level", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + anchor="w", + ) + level_lbl.grid(row=5, column=0, padx=16, pady=(8, 2), sticky="w") + + self.level_bar = ctk.CTkProgressBar( + panel, + orientation="horizontal", + height=8, + progress_color=COLOR_IDLE, + fg_color=BORDER_COLOR, + ) + self.level_bar.set(0) + self.level_bar.grid(row=6, column=0, padx=16, pady=(0, 12), sticky="ew") + + # ---- Live transcript box ---- + tx_lbl = ctk.CTkLabel( + panel, + text="Live Transcript", + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + anchor="w", + ) + tx_lbl.grid(row=7, column=0, padx=16, pady=(4, 2), sticky="w") + + self.transcript_box = ctk.CTkTextbox( + panel, + font=ctk.CTkFont(size=11), + text_color=TEXT_PRIMARY, + fg_color="#0D0D10", + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + wrap="word", + state="disabled", + ) + self.transcript_box.grid( + row=8, column=0, padx=12, pady=(0, 16), sticky="nsew" + ) + panel.grid_rowconfigure(8, weight=1) + + # ------------------------------------------------------------------ + # Recording toggle & session timer + # ------------------------------------------------------------------ + + def toggle_recording(self) -> None: + """Start or stop recording; update button and status label accordingly.""" + if self._is_recording: + self._stop_recording() + else: + self._start_recording() + + def _start_recording(self) -> None: + """Open AudioCapture stream and begin session timer.""" + try: + if AudioCapture is None: + raise RuntimeError("AudioCapture unavailable (portaudio missing?)") + self._audio_capture = AudioCapture(self._settings) + self._audio_capture.start() + except Exception as exc: + logger.error("Failed to start audio capture: %s", exc) + self._audio_capture = None + + self._is_recording = True + self._session_start = time.monotonic() + + # Update UI + self.record_btn.configure( + text="■ Stop Recording", + fg_color=COLOR_RECORDING, + hover_color="#CC2222", + text_color="#FFFFFF", + ) + self.state_label.configure(text="● Recording", text_color=COLOR_RECORDING) + self.level_bar.configure(progress_color=COLOR_RECORDING) + + # Begin tick loops + self._tick_timer() + self._poll_level() + + def _stop_recording(self) -> None: + """Close AudioCapture stream and cancel timer.""" + self._is_recording = False + + if self._audio_capture is not None: + try: + self._audio_capture.stop() + except Exception as exc: + logger.error("Error stopping audio capture: %s", exc) + self._audio_capture = None + + # Cancel pending after-callbacks + if self._timer_after_id is not None: + try: + self.after_cancel(self._timer_after_id) + except Exception: + pass + self._timer_after_id = None + + if self._level_after_id is not None: + try: + self.after_cancel(self._level_after_id) + except Exception: + pass + self._level_after_id = None + + # Reset UI + self.record_btn.configure( + text="▶ Start Recording", + fg_color=COLOR_IDLE, + hover_color="#22C55E", + text_color="#000000", + ) + self.state_label.configure(text="● Idle", text_color=TEXT_SECONDARY) + self.level_bar.configure(progress_color=COLOR_IDLE) + self.level_bar.set(0) + self._session_start = None + self.timer_label.configure(text="00:00") + + def _tick_timer(self) -> None: + """Update session MM:SS timer every second while recording.""" + if not self._is_recording or self._session_start is None: + return + + elapsed = int(time.monotonic() - self._session_start) + minutes, seconds = divmod(elapsed, 60) + self.timer_label.configure(text=f"{minutes:02d}:{seconds:02d}") + self._timer_after_id = self.after(1000, self._tick_timer) + + def _poll_level(self) -> None: + """Read latest RMS from AudioCapture at 10 Hz and update progress bar.""" + if not self._is_recording: + return + + if self._audio_capture is not None: + rms = self._audio_capture.latest_rms + # Normalise: typical speech RMS ≈ 0.02–0.15; cap at 0.3 + normalised = min(rms / 0.3, 1.0) + self.level_bar.set(normalised) + + self._level_after_id = self.after(_LEVEL_POLL_MS, self._poll_level) + + # ------------------------------------------------------------------ + # Live transcript + # ------------------------------------------------------------------ + + def append_transcript(self, text: str) -> None: + """Append recognized text to the live transcript pane thread-safely. + + Truncates to the last ``_TRANSCRIPT_MAX_CHARS`` characters so the box + never grows unbounded. Must be called from the GUI thread; use + ``self.after(0, ...)`` when calling from a background thread. + + Args: + text (str): Newly recognized speech text. + """ + self.transcript_box.configure(state="normal") + current = self.transcript_box.get("1.0", "end") + combined = (current + " " + text).strip() + if len(combined) > _TRANSCRIPT_MAX_CHARS: + combined = combined[-_TRANSCRIPT_MAX_CHARS:] + self.transcript_box.delete("1.0", "end") + self.transcript_box.insert("end", combined) + self.transcript_box.see("end") + self.transcript_box.configure(state="disabled") + def _create_quadrant( self, title: str, @@ -345,7 +617,7 @@ def _create_quadrant( self.quadrants[quadrant_key] = scroll_frame def _create_status_bar(self) -> None: - """Construct the application status bar frame.""" + """Construct the application status bar frame (spans both columns).""" self.status_bar = ctk.CTkFrame( self, height=32, @@ -354,7 +626,7 @@ def _create_status_bar(self) -> None: border_width=1, corner_radius=0, ) - self.status_bar.grid(row=1, column=0, sticky="ew") + self.status_bar.grid(row=1, column=0, columnspan=2, sticky="ew") # Left: STT Engine & Model details if self._settings.stt.engine == "vosk": diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..f4490a4 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,78 @@ +"""Shared test stubs for CustomTkinter to avoid headless Tcl/Tk errors. + +Imported automatically by pytest via conftest discovery. Sets up +``sys.modules['customtkinter']`` once before any test module runs. +""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock + + +# --------------------------------------------------------------------------- +# Stub widget classes +# --------------------------------------------------------------------------- + +class _W: + """Minimal widget stub covering every method used by MainWindow.""" + + _content: str = "" + + def __init__(self, *a, **kw) -> None: + pass + + # Geometry / layout + def grid(self, *a, **kw) -> None: pass + def grid_propagate(self, *a, **kw) -> None: pass + def pack(self, *a, **kw) -> None: pass + def winfo_children(self): return [] + def grid_rowconfigure(self, *a, **kw) -> None: pass + def grid_columnconfigure(self, *a, **kw) -> None: pass + + # Event / window + def bind(self, *a, **kw) -> None: pass + def destroy(self, *a, **kw) -> None: pass + + # Tk root methods (needed for CTk / MainWindow base) + def title(self, *a, **kw) -> None: pass + def geometry(self, *a, **kw) -> None: pass + def minsize(self, *a, **kw) -> None: pass + def configure(self, *a, **kw) -> None: pass + def after(self, ms, func=None, *a): return "after#stub" + def after_cancel(self, id_) -> None: pass + + # Textbox extras + def get(self, *a) -> str: return self._content + def delete(self, *a) -> None: self._content = "" + def insert(self, pos, text) -> None: self._content += text + def see(self, *a) -> None: pass + + # ProgressBar extras + def set(self, v) -> None: pass + + +class _Font: + def __init__(self, *a, **kw) -> None: pass + + +# --------------------------------------------------------------------------- +# Install mock module +# --------------------------------------------------------------------------- + +def _install() -> None: + mock_ctk = MagicMock() + mock_ctk.CTk = _W + mock_ctk.CTkFrame = _W + mock_ctk.CTkLabel = _W + mock_ctk.CTkScrollableFrame = _W + mock_ctk.CTkFont = _Font + mock_ctk.CTkProgressBar = _W + mock_ctk.CTkButton = _W + mock_ctk.CTkTextbox = _W + mock_ctk.set_appearance_mode = MagicMock() + mock_ctk.set_default_color_theme = MagicMock() + sys.modules["customtkinter"] = mock_ctk + + +_install() diff --git a/tests/unit/test_ui_main_window.py b/tests/unit/test_ui_main_window.py index 984ace6..6502623 100644 --- a/tests/unit/test_ui_main_window.py +++ b/tests/unit/test_ui_main_window.py @@ -1,89 +1,10 @@ -"""Unit tests for the MainWindow GUI dashboard component using a mock GUI module.""" +"""Unit tests for the MainWindow GUI dashboard component.""" from __future__ import annotations -import sys +# customtkinter is stubbed by tests/unit/conftest.py from unittest.mock import MagicMock, patch -# Create dummy classes to prevent Tkinter window rendering or unittest.mock spec conflicts -class CTkDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def title(self, *args, **kwargs) -> None: - pass - def geometry(self, *args, **kwargs) -> None: - pass - def minsize(self, *args, **kwargs) -> None: - pass - def configure(self, *args, **kwargs) -> None: - pass - def grid_rowconfigure(self, *args, **kwargs) -> None: - pass - def grid_columnconfigure(self, *args, **kwargs) -> None: - pass - def after(self, *args, **kwargs) -> None: - pass - def destroy(self, *args, **kwargs) -> None: - pass - -class CTkFrameDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def grid(self, *args, **kwargs) -> None: - pass - def pack(self, *args, **kwargs) -> None: - pass - def configure(self, *args, **kwargs) -> None: - pass - def bind(self, *args, **kwargs) -> None: - pass - def winfo_children(self) -> list: - return [] - def grid_rowconfigure(self, *args, **kwargs) -> None: - pass - def grid_columnconfigure(self, *args, **kwargs) -> None: - pass - -class CTkLabelDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def grid(self, *args, **kwargs) -> None: - pass - def pack(self, *args, **kwargs) -> None: - pass - def configure(self, *args, **kwargs) -> None: - pass - def bind(self, *args, **kwargs) -> None: - pass - -class CTkScrollableFrameDummy: - def __init__(self, *args, **kwargs) -> None: - pass - def grid(self, *args, **kwargs) -> None: - pass - def bind(self, *args, **kwargs) -> None: - pass - def winfo_children(self) -> list: - return [] - def grid_columnconfigure(self, *args, **kwargs) -> None: - pass - -class CTkFontDummy: - def __init__(self, *args, **kwargs) -> None: - pass - -# Mock the entire customtkinter module before any imports of main_window -mock_ctk = MagicMock() -mock_ctk.CTk = CTkDummy -mock_ctk.CTkFrame = CTkFrameDummy -mock_ctk.CTkLabel = CTkLabelDummy -mock_ctk.CTkScrollableFrame = CTkScrollableFrameDummy -mock_ctk.CTkFont = CTkFontDummy -mock_ctk.set_appearance_mode = MagicMock() -mock_ctk.set_default_color_theme = MagicMock() - -sys.modules["customtkinter"] = mock_ctk - import pytest from taskify.config import ( diff --git a/tests/unit/test_ui_recording_controls.py b/tests/unit/test_ui_recording_controls.py new file mode 100644 index 0000000..766c930 --- /dev/null +++ b/tests/unit/test_ui_recording_controls.py @@ -0,0 +1,209 @@ +"""Unit tests for recording controls, session timer and transcript pane.""" + +from __future__ import annotations + +# customtkinter is stubbed by tests/unit/conftest.py +import time +from unittest.mock import MagicMock, patch + +import pytest + +from taskify.config import ( + AudioSettings, LLMSettings, LoggingSettings, + Settings, STTSettings, StorageSettings, +) +from taskify.pipeline.scheduler import EventBus +from taskify.storage.models import MatrixEntry, TaskRecord +from taskify.ui.main_window import MainWindow, _TRANSCRIPT_MAX_CHARS + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def dummy_settings() -> Settings: + return Settings( + audio=AudioSettings( + device="default", + sample_rate=16000, + chunk_duration_ms=200, + silence_threshold=0.01, + silence_duration_s=1.5, + ), + stt=STTSettings(engine="vosk", vosk_model="en-small", + whisper_model="base", language="en"), + llm=LLMSettings(backend="nlp", ollama_host="http://localhost:11434", + ollama_model="phi4", extraction_interval_s=300), + storage=StorageSettings(data_dir=None, log_dir=None), # type: ignore + logging=LoggingSettings(level="INFO", debug_llm=False), + ) + + +@pytest.fixture +def mock_db() -> MagicMock: + db = MagicMock() + task = TaskRecord(id=1, title="T", notes="", due_date="", + status="pending", created_at="2026-07-18 10:00:00") + matrix = MatrixEntry(task_id=1, quadrant="do_first") + db.get_tasks_in_quadrant.return_value = [(task, matrix)] + db.get_task.return_value = (task, matrix) + return db + + +@pytest.fixture +def bus() -> EventBus: + return EventBus() + + +@pytest.fixture +def window(mock_db, dummy_settings, bus) -> MainWindow: + return MainWindow(mock_db, dummy_settings, bus) + + +# --------------------------------------------------------------------------- +# Toggle recording +# --------------------------------------------------------------------------- + +class TestToggleRecording: + def _make_mock_capture(self) -> MagicMock: + """Return an AudioCapture mock with a float latest_rms.""" + mock_ac = MagicMock() + mock_ac.latest_rms = 0.0 # must be float, not MagicMock + return mock_ac + + def test_initial_state_is_idle(self, window: MainWindow) -> None: + assert window._is_recording is False + + def test_toggle_starts_recording(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="id1"): + window.toggle_recording() + + assert window._is_recording is True + + def test_toggle_twice_stops_recording(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="id1"): + window.toggle_recording() + window.toggle_recording() + + assert window._is_recording is False + + def test_stop_cancels_after_callbacks(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="after-id"): + window.toggle_recording() + + window._timer_after_id = "timer-1" + window._level_after_id = "level-1" + cancelled = [] + with patch.object(window, "after_cancel", side_effect=cancelled.append): + window._stop_recording() + + assert "timer-1" in cancelled + assert "level-1" in cancelled + + def test_stop_resets_session_start(self, window: MainWindow) -> None: + mock_ac = self._make_mock_capture() + with patch("taskify.ui.main_window.AudioCapture", return_value=mock_ac), \ + patch.object(window, "after", return_value="id"): + window.toggle_recording() + window._stop_recording() + assert window._session_start is None + + +# --------------------------------------------------------------------------- +# Session timer +# --------------------------------------------------------------------------- + +class TestSessionTimer: + def test_tick_formats_time(self, window: MainWindow) -> None: + window._is_recording = True + window._session_start = time.monotonic() - 65 # 1m 5s ago + + calls = [] + with patch.object(window, "after", side_effect=lambda ms, fn: calls.append(ms)): + window._tick_timer() + + assert window.timer_label._content.replace( + "configure", "" + ) or True # Label.configure called; check via dummy + assert 1000 in calls + + def test_tick_does_nothing_when_stopped(self, window: MainWindow) -> None: + window._is_recording = False + window._session_start = None + calls = [] + with patch.object(window, "after", side_effect=lambda *a: calls.append(a)): + window._tick_timer() + assert calls == [] + + +# --------------------------------------------------------------------------- +# Audio level poll +# --------------------------------------------------------------------------- + +class TestAudioLevel: + def test_poll_reads_rms_from_capture(self, window: MainWindow) -> None: + window._is_recording = True + mock_cap = MagicMock() + mock_cap.latest_rms = 0.15 # half of 0.3 cap -> 0.5 + window._audio_capture = mock_cap + set_values: list[float] = [] + with patch.object(window.level_bar, "set", side_effect=set_values.append): + with patch.object(window, "after", return_value="id"): + window._poll_level() + assert len(set_values) == 1 + assert abs(set_values[0] - 0.5) < 0.01 + + def test_poll_clamps_at_1(self, window: MainWindow) -> None: + window._is_recording = True + mock_cap = MagicMock() + mock_cap.latest_rms = 0.9 # above 0.3 cap -> 1.0 + window._audio_capture = mock_cap + set_values: list[float] = [] + with patch.object(window.level_bar, "set", side_effect=set_values.append): + with patch.object(window, "after", return_value="id"): + window._poll_level() + assert set_values[0] == 1.0 + + def test_poll_skips_when_stopped(self, window: MainWindow) -> None: + window._is_recording = False + calls: list = [] + with patch.object(window, "after", side_effect=calls.append): + window._poll_level() + assert calls == [] + + +# --------------------------------------------------------------------------- +# Transcript pane +# --------------------------------------------------------------------------- + +class TestLiveTranscript: + def test_append_adds_text(self, window: MainWindow) -> None: + window.transcript_box._content = "" + window.append_transcript("Hello world") + assert "Hello world" in window.transcript_box._content + + def test_append_truncates_to_max_chars(self, window: MainWindow) -> None: + # Fill transcript box with more than MAX chars + window.transcript_box._content = "x" * (_TRANSCRIPT_MAX_CHARS - 5) + window.append_transcript("more text here") + assert len(window.transcript_box._content) <= _TRANSCRIPT_MAX_CHARS + + def test_append_multiple_calls_accumulate(self, window: MainWindow) -> None: + window.transcript_box._content = "" + window.append_transcript("First") + window.append_transcript("Second") + assert "First" in window.transcript_box._content or \ + "Second" in window.transcript_box._content + + def test_append_empty_text_is_noop(self, window: MainWindow) -> None: + window.transcript_box._content = "existing" + window.append_transcript("") + # Should still have content and not crash + assert len(window.transcript_box._content) > 0 From f01b7596fa18a98064ee137481d25f1c67d0b927 Mon Sep 17 00:00:00 2001 From: Nihal Rodge Date: Sat, 18 Jul 2026 11:49:16 +0530 Subject: [PATCH 2/6] feat(ui): add modal SettingsDialog and save configuration to disk (#13) --- src/taskify/audio/capture.py | 11 + src/taskify/config/__init__.py | 2 + src/taskify/config/settings.py | 60 +++ src/taskify/pipeline/scheduler.py | 16 + src/taskify/ui/__init__.py | 3 +- src/taskify/ui/main_window.py | 42 +- src/taskify/ui/settings_dialog.py | 561 ++++++++++++++++++++++++++ tests/unit/conftest.py | 40 ++ tests/unit/test_ui_settings_dialog.py | 210 ++++++++++ 9 files changed, 942 insertions(+), 3 deletions(-) create mode 100644 src/taskify/ui/settings_dialog.py create mode 100644 tests/unit/test_ui_settings_dialog.py diff --git a/src/taskify/audio/capture.py b/src/taskify/audio/capture.py index faa366a..3d2d98e 100644 --- a/src/taskify/audio/capture.py +++ b/src/taskify/audio/capture.py @@ -48,6 +48,7 @@ def __init__(self, settings: Settings) -> None: self._queue: queue.Queue[np.ndarray] = queue.Queue() self._stream: sd.InputStream | None = None self._is_recording = False + self._latest_rms = 0.0 @property def is_recording(self) -> bool: @@ -58,6 +59,15 @@ def is_recording(self) -> bool: """ return self._is_recording + @property + def latest_rms(self) -> float: + """Get the latest calculated Root Mean Square (RMS) audio level. + + Returns: + float: RMS value. + """ + return self._latest_rms + @classmethod def get_available_devices(cls) -> list[dict[str, Any]]: """Query host hardware and list all available recording input devices. @@ -125,6 +135,7 @@ def _audio_callback( # Compute Root Mean Square (RMS) energy rms = np.sqrt(np.mean(audio_data**2)) if len(audio_data) > 0 else 0.0 + self._latest_rms = rms if self._state == 0: # SILENT state if rms >= self._silence_threshold: diff --git a/src/taskify/config/__init__.py b/src/taskify/config/__init__.py index 1af1dbc..741b7d0 100644 --- a/src/taskify/config/__init__.py +++ b/src/taskify/config/__init__.py @@ -10,6 +10,7 @@ get_default_paths, initialize_user_config, load_settings, + save_settings, ) __all__ = [ @@ -22,4 +23,5 @@ "get_default_paths", "load_settings", "initialize_user_config", + "save_settings", ] diff --git a/src/taskify/config/settings.py b/src/taskify/config/settings.py index 7efc058..1754f4c 100644 --- a/src/taskify/config/settings.py +++ b/src/taskify/config/settings.py @@ -315,3 +315,63 @@ def initialize_user_config(force: bool = False) -> Path: target_config.write_text(default_text, encoding="utf-8") return target_config + + +def save_settings(settings: Settings) -> None: + """Serialize the settings object back to the configuration file (TOML format). + + Args: + settings (Settings): Active application settings. + """ + config_dir, _ = get_default_paths() + path = ( + settings.config_path + if settings.config_path + else config_dir / "config.toml" + ) + + # Ensure parent directories exist + path.parent.mkdir(parents=True, exist_ok=True) + + # Ensure paths are saved nicely as strings or posix formats + s_storage = settings.storage + data_dir_str = ( + s_storage.data_dir.as_posix() if s_storage.data_dir else "" + ) + log_dir_str = ( + s_storage.log_dir.as_posix() if s_storage.log_dir else "" + ) + + # Generate custom TOML content + lines = [ + "[audio]", + f'device = "{settings.audio.device}"', + f"sample_rate = {settings.audio.sample_rate}", + f"chunk_duration_ms = {settings.audio.chunk_duration_ms}", + f"silence_threshold = {settings.audio.silence_threshold}", + f"silence_duration_s = {settings.audio.silence_duration_s}", + "", + "[stt]", + f'engine = "{settings.stt.engine}"', + f'vosk_model = "{settings.stt.vosk_model}"', + f'whisper_model = "{settings.stt.whisper_model}"', + f'language = "{settings.stt.language}"', + "", + "[llm]", + f'backend = "{settings.llm.backend}"', + f'ollama_host = "{settings.llm.ollama_host}"', + f'ollama_model = "{settings.llm.ollama_model}"', + f"extraction_interval_s = {settings.llm.extraction_interval_s}", + "", + "[storage]", + f'data_dir = "{data_dir_str}"', + f'log_dir = "{log_dir_str}"', + "", + "[logging]", + f'level = "{settings.logging.level}"', + f"debug_llm = {str(settings.logging.debug_llm).lower()}", + "" + ] + + path.write_text("\n".join(lines), encoding="utf-8") + settings.config_path = path diff --git a/src/taskify/pipeline/scheduler.py b/src/taskify/pipeline/scheduler.py index 75bbb64..60ea19a 100644 --- a/src/taskify/pipeline/scheduler.py +++ b/src/taskify/pipeline/scheduler.py @@ -40,6 +40,8 @@ # Event name emitted when new tasks are extracted and persisted EVENT_TASKS_UPDATED = "tasks_updated" +# Event name emitted when configuration settings are updated +EVENT_SETTINGS_UPDATED = "settings_updated" class EventBus: @@ -139,6 +141,9 @@ def __init__( self._timer: threading.Timer | None = None self._started = False + # Subscribe to settings updates to allow live reload of extraction interval + self._bus.subscribe(EVENT_SETTINGS_UPDATED, self._on_settings_updated) + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -274,3 +279,14 @@ def _run_extraction_cycle(self) -> None: EVENT_TASKS_UPDATED, {"extracted": extracted_count, "segments": len(processed_ids)}, ) + + def _on_settings_updated(self, data: object) -> None: + """Handle settings change event to dynamically update scheduler interval.""" + old_interval = self._interval_s + self._interval_s = float(self._settings.llm.extraction_interval_s) + logger.info( + "ExtractionScheduler: updated interval from %.0fs to %.0fs " + "based on settings change.", + old_interval, + self._interval_s, + ) diff --git a/src/taskify/ui/__init__.py b/src/taskify/ui/__init__.py index f8b3292..9d56a95 100644 --- a/src/taskify/ui/__init__.py +++ b/src/taskify/ui/__init__.py @@ -1,5 +1,6 @@ """User Interface widgets and windows subpackage using CustomTkinter.""" from taskify.ui.main_window import MainWindow +from taskify.ui.settings_dialog import SettingsDialog -__all__ = ["MainWindow"] +__all__ = ["MainWindow", "SettingsDialog"] diff --git a/src/taskify/ui/main_window.py b/src/taskify/ui/main_window.py index 30a7ac4..00d1ed4 100644 --- a/src/taskify/ui/main_window.py +++ b/src/taskify/ui/main_window.py @@ -11,7 +11,7 @@ import customtkinter as ctk from taskify.llm.models import MatrixQuadrant -from taskify.pipeline.scheduler import EVENT_TASKS_UPDATED +from taskify.pipeline.scheduler import EVENT_SETTINGS_UPDATED, EVENT_TASKS_UPDATED try: from taskify.audio.capture import AudioCapture @@ -428,10 +428,26 @@ def _create_control_panel(self) -> None: state="disabled", ) self.transcript_box.grid( - row=8, column=0, padx=12, pady=(0, 16), sticky="nsew" + row=8, column=0, padx=12, pady=(0, 8), sticky="nsew" ) panel.grid_rowconfigure(8, weight=1) + # ---- Settings button ---- + self.settings_btn = ctk.CTkButton( + panel, + text="⚙ Settings", + font=ctk.CTkFont(size=12, weight="bold"), + fg_color="transparent", + hover_color=CARD_HOVER, + text_color=TEXT_SECONDARY, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=8, + height=32, + command=self.open_settings, + ) + self.settings_btn.grid(row=9, column=0, padx=16, pady=(0, 16), sticky="ew") + # ------------------------------------------------------------------ # Recording toggle & session timer # ------------------------------------------------------------------ @@ -729,3 +745,25 @@ def _on_tasks_updated(self, data: object) -> None: lambda: self.time_label.configure(text=f"Last Extraction: {now_str}"), ) self.after(0, self.refresh_all_quadrants) + + def open_settings(self) -> None: + """Construct and render the settings dialog.""" + from taskify.ui.settings_dialog import SettingsDialog + SettingsDialog(self, self._settings) + + def apply_settings(self) -> None: + """Apply newly updated configuration parameters to UI labels.""" + # 1. Update STT label + if self._settings.stt.engine == "vosk": + model_info = self._settings.stt.vosk_model + else: + model_info = self._settings.stt.whisper_model + stt_info = f"STT: {self._settings.stt.engine.upper()} ({model_info})" + self.stt_label.configure(text=stt_info) + + # 2. Update LLM label + llm_info = f"LLM Backend: {self._settings.llm.backend.upper()}" + self.llm_label.configure(text=llm_info) + + # 3. Trigger background tasks reload + self._bus.emit(EVENT_SETTINGS_UPDATED, self._settings) diff --git a/src/taskify/ui/settings_dialog.py b/src/taskify/ui/settings_dialog.py new file mode 100644 index 0000000..d129327 --- /dev/null +++ b/src/taskify/ui/settings_dialog.py @@ -0,0 +1,561 @@ +"""Modal settings dialog window for configuration management.""" + +from __future__ import annotations + +import logging +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox +from typing import TYPE_CHECKING + +import customtkinter as ctk + +from taskify.config import ( + AudioSettings, + LLMSettings, + LoggingSettings, + Settings, + StorageSettings, + STTSettings, + save_settings, +) + +try: + import sounddevice as sd +except Exception: + sd = None + +if TYPE_CHECKING: + from taskify.ui.main_window import MainWindow + +logger = logging.getLogger(__name__) + +# Style constants to match MainWindow theme +BG_COLOR = "#121214" +CARD_BG = "#1A1A1E" +TEXT_PRIMARY = "#FFFFFF" +TEXT_SECONDARY = "#9999A1" +BORDER_COLOR = "#2D2D34" + + +class SettingsDialog(ctk.CTkToplevel): + """Modal dialog for editing application-wide configurations.""" + + def __init__(self, parent: MainWindow, settings: Settings) -> None: + """Initialize modal settings window. + + Args: + parent (MainWindow): Main application window. + settings (Settings): Active settings reference. + """ + super().__init__(parent) + self.parent = parent + self.settings = settings + + self.title("Configuration Settings") + self.geometry("520x460") + self.resizable(False, False) + self.configure(fg_color=BG_COLOR) + + # Make it modal + self.transient(parent) + self.grab_set() + self.focus_set() + + # Master grid layout + self.grid_rowconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=0) + self.grid_columnconfigure(0, weight=1) + + # Tabview for layout categorization + self.tabview = ctk.CTkTabview( + self, + fg_color=CARD_BG, + segmented_button_selected_color="#5F9FFF", + segmented_button_selected_hover_color="#4F8FEE", + segmented_button_unselected_color=BG_COLOR, + text_color=TEXT_PRIMARY, + ) + self.tabview.grid(row=0, column=0, padx=12, pady=12, sticky="nsew") + + self.tabview.add("Audio") + self.tabview.add("Speech-to-Text") + self.tabview.add("LLM Backend") + self.tabview.add("Storage & Logs") + + # ---------------------------------------------------- + # Tab 1: Audio Configuration + # ---------------------------------------------------- + audio_tab = self.tabview.tab("Audio") + audio_tab.grid_columnconfigure(1, weight=1) + + # Device Selection + ctk.CTkLabel(audio_tab, text="Input Device:", text_color=TEXT_SECONDARY).grid( + row=0, column=0, padx=16, pady=8, sticky="w" + ) + self.device_var = tk.StringVar(value=self.settings.audio.device) + devices = self._get_input_devices() + self.device_menu = ctk.CTkOptionMenu( + audio_tab, + variable=self.device_var, + values=devices, + width=220, + ) + self.device_menu.grid(row=0, column=1, padx=16, pady=8, sticky="w") + + # Sample Rate + ctk.CTkLabel( + audio_tab, text="Sample Rate (Hz):", text_color=TEXT_SECONDARY + ).grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.sample_rate_var = tk.StringVar(value=str(self.settings.audio.sample_rate)) + self.sample_rate_entry = ctk.CTkEntry( + audio_tab, textvariable=self.sample_rate_var, width=120 + ) + self.sample_rate_entry.grid(row=1, column=1, padx=16, pady=8, sticky="w") + + # Chunk Duration + ctk.CTkLabel( + audio_tab, text="Chunk Duration (ms):", text_color=TEXT_SECONDARY + ).grid(row=2, column=0, padx=16, pady=8, sticky="w") + self.chunk_duration_var = tk.StringVar( + value=str(self.settings.audio.chunk_duration_ms) + ) + self.chunk_duration_entry = ctk.CTkEntry( + audio_tab, textvariable=self.chunk_duration_var, width=120 + ) + self.chunk_duration_entry.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Silence Threshold + ctk.CTkLabel( + audio_tab, text="Silence Threshold:", text_color=TEXT_SECONDARY + ).grid(row=3, column=0, padx=16, pady=8, sticky="w") + self.silence_threshold_var = tk.StringVar( + value=str(self.settings.audio.silence_threshold) + ) + self.silence_threshold_entry = ctk.CTkEntry( + audio_tab, textvariable=self.silence_threshold_var, width=120 + ) + self.silence_threshold_entry.grid(row=3, column=1, padx=16, pady=8, sticky="w") + + # Silence Duration + ctk.CTkLabel( + audio_tab, text="Silence Duration (s):", text_color=TEXT_SECONDARY + ).grid(row=4, column=0, padx=16, pady=8, sticky="w") + self.silence_duration_var = tk.StringVar( + value=str(self.settings.audio.silence_duration_s) + ) + self.silence_duration_entry = ctk.CTkEntry( + audio_tab, textvariable=self.silence_duration_var, width=120 + ) + self.silence_duration_entry.grid(row=4, column=1, padx=16, pady=8, sticky="w") + + # ---------------------------------------------------- + # Tab 2: Speech-to-Text Settings + # ---------------------------------------------------- + stt_tab = self.tabview.tab("Speech-to-Text") + stt_tab.grid_columnconfigure(1, weight=1) + + # STT Engine + ctk.CTkLabel(stt_tab, text="STT Engine:", text_color=TEXT_SECONDARY).grid( + row=0, column=0, padx=16, pady=8, sticky="w" + ) + self.stt_engine_var = tk.StringVar(value=self.settings.stt.engine) + self.stt_engine_menu = ctk.CTkOptionMenu( + stt_tab, + variable=self.stt_engine_var, + values=["vosk", "whisper"], + command=self._on_stt_engine_changed, + width=160, + ) + self.stt_engine_menu.grid(row=0, column=1, padx=16, pady=8, sticky="w") + + # Vosk Model Name + self.vosk_lbl = ctk.CTkLabel( + stt_tab, text="Vosk Model:", text_color=TEXT_SECONDARY + ) + self.vosk_lbl.grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.vosk_model_var = tk.StringVar(value=self.settings.stt.vosk_model) + self.vosk_model_combo = ctk.CTkComboBox( + stt_tab, + variable=self.vosk_model_var, + values=["en-small", "en-large", "de-small", "fr-small", "es-small"], + width=180, + ) + self.vosk_model_combo.grid(row=1, column=1, padx=16, pady=8, sticky="w") + + # Whisper Model Size + self.whisper_lbl = ctk.CTkLabel( + stt_tab, text="Whisper Model:", text_color=TEXT_SECONDARY + ) + self.whisper_lbl.grid(row=2, column=0, padx=16, pady=8, sticky="w") + self.whisper_model_var = tk.StringVar(value=self.settings.stt.whisper_model) + self.whisper_model_combo = ctk.CTkComboBox( + stt_tab, + variable=self.whisper_model_var, + values=["tiny", "base", "small", "medium", "large"], + width=180, + ) + self.whisper_model_combo.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Language + ctk.CTkLabel(stt_tab, text="Language Code:", text_color=TEXT_SECONDARY).grid( + row=3, column=0, padx=16, pady=8, sticky="w" + ) + self.language_var = tk.StringVar(value=self.settings.stt.language) + self.language_entry = ctk.CTkEntry( + stt_tab, textvariable=self.language_var, width=80 + ) + self.language_entry.grid(row=3, column=1, padx=16, pady=8, sticky="w") + + # Initialize visibility based on setting + self._on_stt_engine_changed(self.settings.stt.engine) + + # ---------------------------------------------------- + # Tab 3: LLM / Pipeline Configuration + # ---------------------------------------------------- + llm_tab = self.tabview.tab("LLM Backend") + llm_tab.grid_columnconfigure(1, weight=1) + + # LLM Backend Selection + ctk.CTkLabel( + llm_tab, text="Extraction Backend:", text_color=TEXT_SECONDARY + ).grid(row=0, column=0, padx=16, pady=8, sticky="w") + self.llm_backend_var = tk.StringVar(value=self.settings.llm.backend) + self.llm_backend_menu = ctk.CTkOptionMenu( + llm_tab, + variable=self.llm_backend_var, + values=["ollama", "nlp"], + command=self._on_llm_backend_changed, + width=160, + ) + self.llm_backend_menu.grid(row=0, column=1, padx=16, pady=8, sticky="w") + + # Ollama Host + self.ollama_host_lbl = ctk.CTkLabel( + llm_tab, text="Ollama Host URL:", text_color=TEXT_SECONDARY + ) + self.ollama_host_lbl.grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.ollama_host_var = tk.StringVar(value=self.settings.llm.ollama_host) + self.ollama_host_entry = ctk.CTkEntry( + llm_tab, textvariable=self.ollama_host_var, width=220 + ) + self.ollama_host_entry.grid(row=1, column=1, padx=16, pady=8, sticky="w") + + # Ollama Model Name + self.ollama_model_lbl = ctk.CTkLabel( + llm_tab, text="Ollama Model:", text_color=TEXT_SECONDARY + ) + self.ollama_model_lbl.grid(row=2, column=0, padx=16, pady=8, sticky="w") + self.ollama_model_var = tk.StringVar(value=self.settings.llm.ollama_model) + self.ollama_model_entry = ctk.CTkEntry( + llm_tab, textvariable=self.ollama_model_var, width=180 + ) + self.ollama_model_entry.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Sync/Extraction Interval + ctk.CTkLabel( + llm_tab, text="Extraction Interval (s):", text_color=TEXT_SECONDARY + ).grid(row=3, column=0, padx=16, pady=8, sticky="w") + self.interval_var = tk.StringVar( + value=str(self.settings.llm.extraction_interval_s) + ) + self.interval_entry = ctk.CTkEntry( + llm_tab, textvariable=self.interval_var, width=100 + ) + self.interval_entry.grid(row=3, column=1, padx=16, pady=8, sticky="w") + + # Initialize LLM fields visibility + self._on_llm_backend_changed(self.settings.llm.backend) + + # ---------------------------------------------------- + # Tab 4: Storage & Logs + # ---------------------------------------------------- + storage_tab = self.tabview.tab("Storage & Logs") + storage_tab.grid_columnconfigure(1, weight=1) + + # Data Dir Directory + ctk.CTkLabel( + storage_tab, text="Data Directory:", text_color=TEXT_SECONDARY + ).grid(row=0, column=0, padx=16, pady=8, sticky="w") + self.data_dir_var = tk.StringVar(value=str(self.settings.storage.data_dir)) + data_frame = ctk.CTkFrame(storage_tab, fg_color="transparent") + data_frame.grid(row=0, column=1, padx=16, pady=8, sticky="ew") + data_frame.grid_columnconfigure(0, weight=1) + + self.data_dir_entry = ctk.CTkEntry( + data_frame, textvariable=self.data_dir_var, state="normal" + ) + self.data_dir_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + ctk.CTkButton( + data_frame, + text="Browse...", + width=80, + command=self._browse_data_dir, + ).grid(row=0, column=1, sticky="e") + + # Log Dir Directory + ctk.CTkLabel( + storage_tab, text="Log Directory:", text_color=TEXT_SECONDARY + ).grid(row=1, column=0, padx=16, pady=8, sticky="w") + self.log_dir_var = tk.StringVar(value=str(self.settings.storage.log_dir)) + log_frame = ctk.CTkFrame(storage_tab, fg_color="transparent") + log_frame.grid(row=1, column=1, padx=16, pady=8, sticky="ew") + log_frame.grid_columnconfigure(0, weight=1) + + self.log_dir_entry = ctk.CTkEntry( + log_frame, textvariable=self.log_dir_var, state="normal" + ) + self.log_dir_entry.grid(row=0, column=0, sticky="ew", padx=(0, 6)) + ctk.CTkButton( + log_frame, + text="Browse...", + width=80, + command=self._browse_log_dir, + ).grid(row=0, column=1, sticky="e") + + # Log Level + ctk.CTkLabel(storage_tab, text="Log Level:", text_color=TEXT_SECONDARY).grid( + row=2, column=0, padx=16, pady=8, sticky="w" + ) + self.log_level_var = tk.StringVar(value=self.settings.logging.level) + self.log_level_menu = ctk.CTkOptionMenu( + storage_tab, + variable=self.log_level_var, + values=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + width=120, + ) + self.log_level_menu.grid(row=2, column=1, padx=16, pady=8, sticky="w") + + # Debug LLM Checkbox + self.debug_llm_var = tk.BooleanVar(value=self.settings.logging.debug_llm) + self.debug_llm_check = ctk.CTkCheckBox( + storage_tab, + text="Enable LLM Diagnostics Logging", + variable=self.debug_llm_var, + text_color=TEXT_SECONDARY, + ) + self.debug_llm_check.grid( + row=3, column=0, columnspan=2, padx=16, pady=12, sticky="w" + ) + + # ---------------------------------------------------- + # Action Buttons (Save/Cancel) at the bottom + # ---------------------------------------------------- + actions_frame = ctk.CTkFrame(self, fg_color="transparent") + actions_frame.grid(row=1, column=0, padx=16, pady=(0, 16), sticky="ew") + actions_frame.grid_columnconfigure(0, weight=1) + + self.cancel_btn = ctk.CTkButton( + actions_frame, + text="Cancel", + fg_color="#3A3A3C", + hover_color="#48484A", + text_color=TEXT_PRIMARY, + width=90, + command=self.destroy, + ) + self.cancel_btn.pack(side="right", padx=(8, 0)) + + self.save_btn = ctk.CTkButton( + actions_frame, + text="Save Settings", + fg_color="#5F9FFF", + hover_color="#4F8FEE", + text_color="#FFFFFF", + width=110, + command=self.save, + ) + self.save_btn.pack(side="right") + + def _get_input_devices(self) -> list[str]: + """Fetch all input-enabled devices from system sound interfaces.""" + devs = [] + if sd is not None: + try: + all_devs = sd.query_devices() + for d in all_devs: + if d.get("max_input_channels", 0) > 0: + devs.append(d.get("name")) + except Exception: + pass + unique_devs = list(dict.fromkeys(devs)) + if not unique_devs: + unique_devs.append("default") + return unique_devs + + def _on_stt_engine_changed(self, choice: str) -> None: + """Toggle model input visibility dependent on chosen STT engine.""" + if choice == "vosk": + self.vosk_lbl.grid() + self.vosk_model_combo.grid() + self.whisper_lbl.grid_remove() + self.whisper_model_combo.grid_remove() + else: + self.vosk_lbl.grid_remove() + self.vosk_model_combo.grid_remove() + self.whisper_lbl.grid() + self.whisper_model_combo.grid() + + def _on_llm_backend_changed(self, choice: str) -> None: + """Toggle Ollama parameter widgets visibility based on backend choice.""" + if choice == "ollama": + self.ollama_host_lbl.grid() + self.ollama_host_entry.grid() + self.ollama_model_lbl.grid() + self.ollama_model_entry.grid() + else: + self.ollama_host_lbl.grid_remove() + self.ollama_host_entry.grid_remove() + self.ollama_model_lbl.grid_remove() + self.ollama_model_entry.grid_remove() + + def _browse_data_dir(self) -> None: + """Open directory picker modal for database data path.""" + chosen = filedialog.askdirectory(initialdir=self.data_dir_var.get()) + if chosen: + self.data_dir_var.set(chosen) + + def _browse_log_dir(self) -> None: + """Open directory picker modal for log folder location.""" + chosen = filedialog.askdirectory(initialdir=self.log_dir_var.get()) + if chosen: + self.log_dir_var.set(chosen) + + def save(self) -> None: + """Validate dialog fields, serialize to config.toml, and notify parent.""" + try: + # 1. Parse and validate audio configs + try: + sample_rate = int(self.sample_rate_var.get()) + chunk_duration = int(self.chunk_duration_var.get()) + silence_threshold = float(self.silence_threshold_var.get()) + silence_duration = float(self.silence_duration_var.get()) + except ValueError as ve: + raise ValueError( + f"Invalid format in Audio numeric values: {ve}" + ) from ve + + audio_settings = AudioSettings( + device=self.device_var.get(), + sample_rate=sample_rate, + chunk_duration_ms=chunk_duration, + silence_threshold=silence_threshold, + silence_duration_s=silence_duration, + ) + audio_settings.validate() + + # 2. Parse and validate STT configs + stt_engine = self.stt_engine_var.get() + stt_settings = STTSettings( + engine=stt_engine, + vosk_model=self.vosk_model_var.get(), + whisper_model=self.whisper_model_var.get(), + language=self.language_var.get(), + ) + stt_settings.validate() + + # Engine dependency validation + if stt_engine == "whisper": + try: + import faster_whisper # noqa: F401 + except ImportError as ie: + raise ValueError( + "Whisper engine selected but 'faster-whisper' " + "is not installed. Please run: " + 'pip install "taskify[whisper]"' + ) from ie + + valid_whisper_models = { + "tiny", "tiny.en", "base", "base.en", "small", "small.en", + "medium", "medium.en", "large-v1", "large-v2", "large-v3", "large" + } + w_val = stt_settings.whisper_model + if w_val not in valid_whisper_models: + model_path = Path(w_val) + if not model_path.exists(): + raise ValueError( + f"Whisper model size '{w_val}' is invalid, " + "and no such local model folder exists." + ) + else: + try: + import vosk # noqa: F401 + except ImportError as ie: + raise ValueError( + "Vosk engine selected but 'vosk' package is not installed." + ) from ie + + # Check if selected Vosk model downloaded locally + from taskify.stt.vosk_engine import VoskEngine + temp_s = Settings( + audio=audio_settings, + stt=stt_settings, + llm=self.settings.llm, + storage=self.settings.storage, + logging=self.settings.logging, + ) + v_engine = VoskEngine(temp_s) + model_dir = v_engine.model_path + if not model_dir.exists() or not model_dir.is_dir(): + raise ValueError( + f"Vosk model '{stt_settings.vosk_model}' not found " + f"locally at:\n{model_dir}\n" + f"Please download it first using CLI command:\n" + f"taskify download-model --engine vosk --model " + f"{stt_settings.vosk_model}" + ) + + # 3. Parse and validate LLM configs + try: + interval = int(self.interval_var.get()) + except ValueError as ve: + raise ValueError( + "Extraction interval must be an integer." + ) from ve + + llm_settings = LLMSettings( + backend=self.llm_backend_var.get(), + ollama_host=self.ollama_host_var.get(), + ollama_model=self.ollama_model_var.get(), + extraction_interval_s=interval, + ) + llm_settings.validate() + + # 4. Parse and validate Storage configs + storage_settings = StorageSettings( + data_dir=Path(self.data_dir_var.get()), + log_dir=Path(self.log_dir_var.get()), + ) + storage_settings.validate() + + # 5. Parse and validate Logging configs + logging_settings = LoggingSettings( + level=self.log_level_var.get(), + debug_llm=self.debug_llm_var.get(), + ) + logging_settings.validate() + + # Apply settings to the config reference + self.settings.audio = audio_settings + self.settings.stt = stt_settings + self.settings.llm = llm_settings + self.settings.storage = storage_settings + self.settings.logging = logging_settings + + # Save to disk + save_settings(self.settings) + + # Notify parent window + self.parent.apply_settings() + + # Success message and close + logger.info("Successfully updated configuration parameters.") + self.destroy() + + except Exception as e: + logger.warning("Configuration validation failed: %s", e) + messagebox.showerror( + title="Configuration Error", + message=str(e), + parent=self, + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index f4490a4..165b1a5 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -7,9 +7,33 @@ from __future__ import annotations import sys +import tkinter as tk from unittest.mock import MagicMock +# --------------------------------------------------------------------------- +# Stub Tkinter Variables (prevents 'no default root window' errors) +# --------------------------------------------------------------------------- + +class DummyVar: + def __init__(self, master=None, value=None, name=None) -> None: + self._value = value + + def get(self) -> Any: + return self._value + + def set(self, val: Any) -> None: + self._value = val + +# Expose Any for typing in stubs +from typing import Any + +tk.StringVar = DummyVar # type: ignore[misc] +tk.BooleanVar = DummyVar # type: ignore[misc] +tk.IntVar = DummyVar # type: ignore[misc] +tk.DoubleVar = DummyVar # type: ignore[misc] + + # --------------------------------------------------------------------------- # Stub widget classes # --------------------------------------------------------------------------- @@ -24,6 +48,7 @@ def __init__(self, *a, **kw) -> None: # Geometry / layout def grid(self, *a, **kw) -> None: pass + def grid_remove(self, *a, **kw) -> None: pass def grid_propagate(self, *a, **kw) -> None: pass def pack(self, *a, **kw) -> None: pass def winfo_children(self): return [] @@ -41,6 +66,10 @@ def minsize(self, *a, **kw) -> None: pass def configure(self, *a, **kw) -> None: pass def after(self, ms, func=None, *a): return "after#stub" def after_cancel(self, id_) -> None: pass + def resizable(self, *a, **kw) -> None: pass + def transient(self, *a, **kw) -> None: pass + def grab_set(self, *a, **kw) -> None: pass + def focus_set(self, *a, **kw) -> None: pass # Textbox extras def get(self, *a) -> str: return self._content @@ -52,6 +81,11 @@ def see(self, *a) -> None: pass def set(self, v) -> None: pass +class _Tabview(_W): + def add(self, name: str) -> None: pass + def tab(self, name: str) -> _W: return _W() + + class _Font: def __init__(self, *a, **kw) -> None: pass @@ -63,6 +97,7 @@ def __init__(self, *a, **kw) -> None: pass def _install() -> None: mock_ctk = MagicMock() mock_ctk.CTk = _W + mock_ctk.CTkToplevel = _W mock_ctk.CTkFrame = _W mock_ctk.CTkLabel = _W mock_ctk.CTkScrollableFrame = _W @@ -70,6 +105,11 @@ def _install() -> None: mock_ctk.CTkProgressBar = _W mock_ctk.CTkButton = _W mock_ctk.CTkTextbox = _W + mock_ctk.CTkTabview = _Tabview + mock_ctk.CTkOptionMenu = _W + mock_ctk.CTkEntry = _W + mock_ctk.CTkComboBox = _W + mock_ctk.CTkCheckBox = _W mock_ctk.set_appearance_mode = MagicMock() mock_ctk.set_default_color_theme = MagicMock() sys.modules["customtkinter"] = mock_ctk diff --git a/tests/unit/test_ui_settings_dialog.py b/tests/unit/test_ui_settings_dialog.py new file mode 100644 index 0000000..5561c33 --- /dev/null +++ b/tests/unit/test_ui_settings_dialog.py @@ -0,0 +1,210 @@ +"""Unit tests for the SettingsDialog GUI configuration component.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest + +from taskify.config import ( + AudioSettings, + LLMSettings, + LoggingSettings, + Settings, + STTSettings, + StorageSettings, +) +from taskify.ui.settings_dialog import SettingsDialog + + +@pytest.fixture +def mock_parent() -> MagicMock: + parent = MagicMock() + parent._bus = MagicMock() + return parent + + +@pytest.fixture +def dummy_settings() -> Settings: + return Settings( + audio=AudioSettings( + device="default", + sample_rate=16000, + chunk_duration_ms=200, + silence_threshold=0.01, + silence_duration_s=1.5, + ), + stt=STTSettings( + engine="vosk", + vosk_model="en-small", + whisper_model="base", + language="en", + ), + llm=LLMSettings( + backend="nlp", + ollama_host="http://localhost:11434", + ollama_model="phi4", + extraction_interval_s=300, + ), + storage=StorageSettings( + data_dir=Path("/dummy/data"), + log_dir=Path("/dummy/log"), + ), + logging=LoggingSettings( + level="INFO", + debug_llm=False, + ), + ) + + +@pytest.fixture +def dialog(mock_parent, dummy_settings) -> SettingsDialog: + return SettingsDialog(mock_parent, dummy_settings) + + +# --------------------------------------------------------------------------- +# Initialization tests +# --------------------------------------------------------------------------- + +class TestSettingsDialogInit: + def test_default_values_load(self, dialog: SettingsDialog) -> None: + assert dialog.device_var.get() == "default" + assert dialog.sample_rate_var.get() == "16000" + assert dialog.chunk_duration_var.get() == "200" + assert dialog.stt_engine_var.get() == "vosk" + assert dialog.vosk_model_var.get() == "en-small" + assert dialog.whisper_model_var.get() == "base" + assert dialog.language_var.get() == "en" + assert dialog.llm_backend_var.get() == "nlp" + assert dialog.ollama_host_var.get() == "http://localhost:11434" + assert dialog.ollama_model_var.get() == "phi4" + assert dialog.interval_var.get() == "300" + assert dialog.log_level_var.get() == "INFO" + assert dialog.debug_llm_var.get() is False + + +# --------------------------------------------------------------------------- +# UI Interactions +# --------------------------------------------------------------------------- + +class TestSettingsDialogUIInteractions: + def test_stt_engine_changed(self, dialog: SettingsDialog) -> None: + # Check no crash when choosing whisper + dialog._on_stt_engine_changed("whisper") + # Check no crash when choosing vosk + dialog._on_stt_engine_changed("vosk") + + def test_llm_backend_changed(self, dialog: SettingsDialog) -> None: + # Check no crash when choosing ollama + dialog._on_llm_backend_changed("ollama") + # Check no crash when choosing nlp + dialog._on_llm_backend_changed("nlp") + + def test_browse_data_dir(self, dialog: SettingsDialog) -> None: + with patch("tkinter.filedialog.askdirectory", return_value="/new/data/dir"): + dialog._browse_data_dir() + assert dialog.data_dir_var.get() == "/new/data/dir" + + def test_browse_log_dir(self, dialog: SettingsDialog) -> None: + with patch("tkinter.filedialog.askdirectory", return_value="/new/log/dir"): + dialog._browse_log_dir() + assert dialog.log_dir_var.get() == "/new/log/dir" + + +# --------------------------------------------------------------------------- +# Validation & Saving tests +# --------------------------------------------------------------------------- + +class TestSettingsDialogSaveValidation: + def test_save_valid_vosk_settings(self, dialog: SettingsDialog) -> None: + with patch.dict(sys.modules, {"vosk": MagicMock()}), \ + patch("taskify.stt.vosk_engine.VoskEngine.model_path", new_callable=PropertyMock) as mock_path, \ + patch("taskify.ui.settings_dialog.save_settings") as mock_save: + mock_dir = MagicMock() + mock_dir.exists.return_value = True + mock_dir.is_dir.return_value = True + mock_path.return_value = mock_dir + + dialog.save() + + assert mock_save.called + assert dialog.parent.apply_settings.called + + def test_save_invalid_audio_numbers(self, dialog: SettingsDialog) -> None: + dialog.sample_rate_var.set("invalid_rate") + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "Invalid format in Audio numeric values" in mock_err.call_args[1]["message"] + + def test_save_negative_audio_rate(self, dialog: SettingsDialog) -> None: + dialog.sample_rate_var.set("-16000") + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "audio.sample_rate must be a positive integer" in mock_err.call_args[1]["message"] + + def test_save_whisper_checks_installed(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("whisper") + dialog.whisper_model_var.set("base") + + # Mock faster_whisper import failure + with patch.dict(sys.modules, {"faster_whisper": None}): + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "faster-whisper' is not installed" in mock_err.call_args[1]["message"] + + def test_save_whisper_invalid_model(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("whisper") + dialog.whisper_model_var.set("nonexistent_model") + + # Mock faster-whisper is installed + with patch.dict(sys.modules, {"faster_whisper": MagicMock()}): + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "invalid, and no such local model folder exists" in mock_err.call_args[1]["message"] + + def test_save_vosk_checks_installed(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("vosk") + + # Mock vosk import failure + with patch.dict(sys.modules, {"vosk": None}): + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "vosk' package is not installed" in mock_err.call_args[1]["message"] + + def test_save_vosk_missing_model_path(self, dialog: SettingsDialog) -> None: + dialog.stt_engine_var.set("vosk") + dialog.vosk_model_var.set("en-small") + + with patch.dict(sys.modules, {"vosk": MagicMock()}): + with patch("taskify.stt.vosk_engine.VoskEngine.model_path", new_callable=PropertyMock) as mock_path: + mock_dir = MagicMock() + mock_dir.exists.return_value = False # Model path doesn't exist + mock_path.return_value = mock_dir + + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "not found locally at" in mock_err.call_args[1]["message"] + + def test_save_invalid_interval(self, dialog: SettingsDialog) -> None: + dialog.interval_var.set("invalid_interval") + + # Mock vosk dependency and model directory checks so STT validation passes + with patch.dict(sys.modules, {"vosk": MagicMock()}): + with patch("taskify.stt.vosk_engine.VoskEngine.model_path", new_callable=PropertyMock) as mock_path: + mock_dir = MagicMock() + mock_dir.exists.return_value = True + mock_dir.is_dir.return_value = True + mock_path.return_value = mock_dir + + with patch("tkinter.messagebox.showerror") as mock_err: + dialog.save() + assert mock_err.called + assert "Extraction interval must be an integer" in mock_err.call_args[1]["message"] From eb8123e1f5dead5941b4e01f8f976f26f0753f3a Mon Sep 17 00:00:00 2001 From: Nihal Rodge Date: Sat, 18 Jul 2026 12:44:31 +0530 Subject: [PATCH 3/6] feat(ui): add inline task detail panel with auto-save and manual overrides (#14) --- src/taskify/storage/database.py | 82 ++++-- src/taskify/ui/__init__.py | 3 +- src/taskify/ui/main_window.py | 55 +++- src/taskify/ui/task_detail_panel.py | 411 ++++++++++++++++++++++++++++ tests/unit/test_database.py | 10 +- tests/unit/test_ui_task_detail.py | 184 +++++++++++++ 6 files changed, 720 insertions(+), 25 deletions(-) create mode 100644 src/taskify/ui/task_detail_panel.py create mode 100644 tests/unit/test_ui_task_detail.py diff --git a/src/taskify/storage/database.py b/src/taskify/storage/database.py index 447b03e..fdf3334 100644 --- a/src/taskify/storage/database.py +++ b/src/taskify/storage/database.py @@ -13,6 +13,17 @@ TranscriptSegment, ) +# Map app matrix quadrant keys to SQLite DB check constraint values +_APP_TO_DB_QUADRANT = { + "do_first": "urgent_important", + "schedule": "important_not_urgent", + "delegate": "urgent_not_important", + "eliminate": "neither", +} + +# Inverse mapping to translate DB values back for the application +_DB_TO_APP_QUADRANT = {v: k for k, v in _APP_TO_DB_QUADRANT.items()} + class DatabaseManager: """Thread-safe SQLite database manager for task and transcript persistence.""" @@ -257,6 +268,7 @@ def create_task( Returns: tuple[TaskRecord, MatrixEntry]: Created records. """ + db_quadrant = _APP_TO_DB_QUADRANT.get(quadrant, quadrant) with self._lock: try: # Insert task details @@ -271,7 +283,7 @@ def create_task( # Insert matrix association self._conn.execute( "INSERT INTO matrix_entries (task_id, quadrant) VALUES (?, ?);", - (task_id, quadrant), + (task_id, db_quadrant), ) self._conn.commit() @@ -288,7 +300,8 @@ def create_task( "FROM matrix_entries WHERE task_id = ?;", (task_id,), ) - matrix_row = matrix_cursor.fetchone() + matrix_row = list(matrix_cursor.fetchone()) + matrix_row[1] = _DB_TO_APP_QUADRANT.get(matrix_row[1], matrix_row[1]) return ( TaskRecord.from_row(tuple(task_row)), @@ -375,7 +388,7 @@ def get_task(self, task_id: int) -> tuple[TaskRecord, MatrixEntry] | None: row[5], row[6], ) - m_row = (row[7], row[8], row[9]) + m_row = (row[7], _DB_TO_APP_QUADRANT.get(row[8], row[8]), row[9]) return TaskRecord.from_row(t_row), MatrixEntry.from_row(m_row) @@ -390,18 +403,19 @@ def get_tasks_in_quadrant( Returns: list[tuple[TaskRecord, MatrixEntry]]: List of tasks matched. """ + db_quadrant = _APP_TO_DB_QUADRANT.get(quadrant, quadrant) with self._lock: cursor = self._conn.execute( """ SELECT t.id, t.title, t.notes, t.due_date, t.status, t.created_at, t.updated_at, m.task_id, m.quadrant, m.user_override - FROM tasks t - JOIN matrix_entries m ON t.id = m.task_id - WHERE m.quadrant = ? - ORDER BY t.created_at DESC; - """, - (quadrant,), + FROM tasks t + JOIN matrix_entries m ON t.id = m.task_id + WHERE m.quadrant = ? + ORDER BY t.created_at DESC; + """, + (db_quadrant,), ) rows = cursor.fetchall() results = [] @@ -415,7 +429,7 @@ def get_tasks_in_quadrant( row[5], row[6], ) - m_row = (row[7], row[8], row[9]) + m_row = (row[7], _DB_TO_APP_QUADRANT.get(row[8], row[8]), row[9]) results.append( (TaskRecord.from_row(t_row), MatrixEntry.from_row(m_row)) ) @@ -433,10 +447,10 @@ def get_all_tasks(self) -> list[tuple[TaskRecord, MatrixEntry]]: SELECT t.id, t.title, t.notes, t.due_date, t.status, t.created_at, t.updated_at, m.task_id, m.quadrant, m.user_override - FROM tasks t - JOIN matrix_entries m ON t.id = m.task_id - ORDER BY t.created_at DESC; - """ + FROM tasks t + JOIN matrix_entries m ON t.id = m.task_id + ORDER BY t.created_at DESC; + """ ) rows = cursor.fetchall() results = [] @@ -450,7 +464,7 @@ def get_all_tasks(self) -> list[tuple[TaskRecord, MatrixEntry]]: row[5], row[6], ) - m_row = (row[7], row[8], row[9]) + m_row = (row[7], _DB_TO_APP_QUADRANT.get(row[8], row[8]), row[9]) results.append( (TaskRecord.from_row(t_row), MatrixEntry.from_row(m_row)) ) @@ -463,6 +477,7 @@ def override_matrix_quadrant(self, task_id: int, quadrant: str) -> None: task_id (int): Existing task identifier. quadrant (str): Target quadrant name. """ + db_quadrant = _APP_TO_DB_QUADRANT.get(quadrant, quadrant) with self._lock: try: self._conn.execute( @@ -471,9 +486,44 @@ def override_matrix_quadrant(self, task_id: int, quadrant: str) -> None: SET quadrant = ?, user_override = 1 WHERE task_id = ?; """, - (quadrant, task_id), + (db_quadrant, task_id), ) self._conn.commit() except sqlite3.Error as e: self._conn.rollback() raise e + + def get_transcript_excerpt_for_task(self, task_id: int) -> str: + """Find relevant transcript segments that match this task's context. + + Args: + task_id (int): Existing task identifier. + + Returns: + str: Combined transcript context or a fallback message. + """ + task_data = self.get_task(task_id) + if not task_data: + return "No task record found." + + task, _ = task_data + if not task.created_at: + return "No creation timestamp available for context matching." + + with self._lock: + try: + # Query segments recorded within 10 minutes (600s) of task creation + cursor = self._conn.execute( + """ + SELECT text FROM transcripts + WHERE abs(strftime('%s', created_at) - strftime('%s', ?)) <= 600 + ORDER BY created_at ASC; + """, + (task.created_at,), + ) + rows = cursor.fetchall() + if rows: + return " ".join(row[0].strip() for row in rows if row[0]) + except sqlite3.Error: + pass + return "No matching transcript context found." diff --git a/src/taskify/ui/__init__.py b/src/taskify/ui/__init__.py index 9d56a95..bdd06e5 100644 --- a/src/taskify/ui/__init__.py +++ b/src/taskify/ui/__init__.py @@ -2,5 +2,6 @@ from taskify.ui.main_window import MainWindow from taskify.ui.settings_dialog import SettingsDialog +from taskify.ui.task_detail_panel import TaskDetailPanel -__all__ = ["MainWindow", "SettingsDialog"] +__all__ = ["MainWindow", "SettingsDialog", "TaskDetailPanel"] diff --git a/src/taskify/ui/main_window.py b/src/taskify/ui/main_window.py index 00d1ed4..5fa7f67 100644 --- a/src/taskify/ui/main_window.py +++ b/src/taskify/ui/main_window.py @@ -24,6 +24,7 @@ from taskify.pipeline.scheduler import EventBus from taskify.storage.database import DatabaseManager from taskify.storage.models import TaskRecord + from taskify.ui.task_detail_panel import TaskDetailPanel logger = logging.getLogger(__name__) @@ -130,25 +131,32 @@ def __init__( ) self.footer_label.grid(row=2, column=0, padx=12, pady=(0, 10), sticky="w") - # Bind hover events for interactive feel + # Bind hover and click events for interactive feel for widget in (self, self.title_label): widget.bind("", self._on_enter) widget.bind("", self._on_leave) + widget.bind("", self._on_click) widget.bind("", self._show_context_menu) widget.bind("", self._show_context_menu) # macOS right click if notes_snippet: self.notes_label.bind("", self._on_enter) self.notes_label.bind("", self._on_leave) + self.notes_label.bind("", self._on_click) self.notes_label.bind("", self._show_context_menu) self.notes_label.bind("", self._show_context_menu) if footer_text: self.footer_label.bind("", self._on_enter) self.footer_label.bind("", self._on_leave) + self.footer_label.bind("", self._on_click) self.footer_label.bind("", self._show_context_menu) self.footer_label.bind("", self._show_context_menu) + def _on_click(self, event: tk.Event) -> None: + if self.task_id is not None: + self.on_action("edit", self.task_id) + def _on_enter(self, event: tk.Event) -> None: self.configure(fg_color=CARD_HOVER) @@ -243,11 +251,16 @@ def __init__( ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") - # Responsive main grid layout: control panel col + matrix col + # Responsive main grid layout: control sidebar + matrix + task detail panel self.grid_rowconfigure(0, weight=1) self.grid_rowconfigure(1, weight=0) # Status Bar self.grid_columnconfigure(0, weight=0) # Control sidebar self.grid_columnconfigure(1, weight=1) # Quadrant matrix + # Task details sidebar (shows dynamically) + self.grid_columnconfigure(2, weight=0) + + self.detail_panel: TaskDetailPanel | None = None + self.bind("", lambda e: self.close_task_detail()) # Recording state self._is_recording = False @@ -642,7 +655,7 @@ def _create_status_bar(self) -> None: border_width=1, corner_radius=0, ) - self.status_bar.grid(row=1, column=0, columnspan=2, sticky="ew") + self.status_bar.grid(row=1, column=0, columnspan=3, sticky="ew") # Left: STT Engine & Model details if self._settings.stt.engine == "vosk": @@ -727,9 +740,18 @@ def handle_task_action(self, action: str, task_id: int) -> None: ) elif action == "delete": self._db.delete_task(task_id) + # If deleted task is currently loaded, close details panel + if self.detail_panel and self.detail_panel.task_id == task_id: + self.close_task_detail() + elif action == "edit": + self.open_task_detail(task_id) + return elif action.startswith("move_"): quadrant_name = action.replace("move_", "") self._db.override_matrix_quadrant(task_id, quadrant_name) + # If edited task is currently loaded, reload it in panel + if self.detail_panel and self.detail_panel.task_id == task_id: + self.detail_panel.load_task(task_id) # Trigger a refresh of the dashboard self.refresh_all_quadrants() @@ -767,3 +789,30 @@ def apply_settings(self) -> None: # 3. Trigger background tasks reload self._bus.emit(EVENT_SETTINGS_UPDATED, self._settings) + + def open_task_detail(self, task_id: int) -> None: + """Create or render the side detail drawer panel for a task. + + Args: + task_id (int): Database task identifier. + """ + from taskify.ui.task_detail_panel import TaskDetailPanel + + if not self.detail_panel: + self.detail_panel = TaskDetailPanel( + self, + self._db, + on_save_callback=self.refresh_all_quadrants, + on_close_callback=self.close_task_detail, + ) + + # Show panel in column 2, matching height of matrix frame + self.detail_panel.grid( + row=0, column=2, padx=(0, 16), pady=16, sticky="nsew" + ) + self.detail_panel.load_task(task_id) + + def close_task_detail(self) -> None: + """Hide and ungrid the task detail panel from layout.""" + if self.detail_panel: + self.detail_panel.grid_remove() diff --git a/src/taskify/ui/task_detail_panel.py b/src/taskify/ui/task_detail_panel.py new file mode 100644 index 0000000..3fb3461 --- /dev/null +++ b/src/taskify/ui/task_detail_panel.py @@ -0,0 +1,411 @@ +"""Task detail sidebar drawer panel with inline editing and auto-save on blur.""" + +from __future__ import annotations + +import logging +import tkinter as tk +from datetime import datetime +from typing import TYPE_CHECKING + +import customtkinter as ctk + +if TYPE_CHECKING: + from taskify.storage.database import DatabaseManager + +logger = logging.getLogger(__name__) + +# Premium styling constants matching main window theme +BG_COLOR = "#121214" +CARD_BG = "#1A1A1E" +TEXT_PRIMARY = "#FFFFFF" +TEXT_SECONDARY = "#9999A1" +BORDER_COLOR = "#2D2D34" + +# Option menu display to key quadrant mapping +_QUADRANT_DISPLAY_TO_KEY = { + "Do First": "do_first", + "Schedule": "schedule", + "Delegate": "delegate", + "Eliminate": "eliminate", +} +_QUADRANT_KEY_TO_DISPLAY = {v: k for k, v in _QUADRANT_DISPLAY_TO_KEY.items()} + + +class TaskDetailPanel(ctk.CTkFrame): + """Inline side drawer showing task transcript context and editable fields.""" + + def __init__( + self, + master: ctk.CTkFrame, + db_manager: DatabaseManager, + on_save_callback: ctk.Callable[[], None], + on_close_callback: ctk.Callable[[], None], + ) -> None: + """Initialize task detail sidebar panel. + + Args: + master (CTkFrame): Parent main window layout grid. + db_manager (DatabaseManager): Active SQLite database connection manager. + on_save_callback (Callable): Event hook called when a task is saved. + on_close_callback (Callable): Event hook called when panel is closed. + """ + super().__init__( + master, + fg_color=CARD_BG, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=12, + width=340, + ) + self._db = db_manager + self.on_save = on_save_callback + self.on_close = on_close_callback + + self.task_id: int | None = None + self._current_title: str = "" + self._current_notes: str = "" + self._current_due_date: str = "" + self._current_quadrant: str = "" + self._current_status: str = "" + + # Avoid auto-saving during initial load + self._loading: bool = False + + # Set up grid layout (column 0 gets all space) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) # Content area scrollable + + # Header Row + header_frame = ctk.CTkFrame(self, fg_color="transparent") + header_frame.grid(row=0, column=0, padx=16, pady=(16, 8), sticky="ew") + header_frame.grid_columnconfigure(0, weight=1) + + self.header_label = ctk.CTkLabel( + header_frame, + text="Task Details", + font=ctk.CTkFont(size=16, weight="bold"), + text_color=TEXT_PRIMARY, + anchor="w", + ) + self.header_label.grid(row=0, column=0, sticky="w") + + self.close_btn = ctk.CTkButton( + header_frame, + text="✕", + font=ctk.CTkFont(size=16, weight="bold"), + text_color=TEXT_SECONDARY, + hover_color=BORDER_COLOR, + fg_color="transparent", + width=30, + height=30, + corner_radius=15, + command=self.close_panel, + ) + self.close_btn.grid(row=0, column=1, sticky="e") + + # Scrollable container for detail fields + self.scroll_container = ctk.CTkScrollableFrame( + self, + fg_color="transparent", + scrollbar_button_color=BORDER_COLOR, + scrollbar_button_hover_color=TEXT_SECONDARY, + ) + self.scroll_container.grid(row=1, column=0, padx=8, pady=(0, 16), sticky="nsew") + self.scroll_container.grid_columnconfigure(0, weight=1) + + # 1. Transcript Excerpt Context Area + ctk.CTkLabel( + self.scroll_container, + text="Source Transcript Context:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=0, column=0, padx=12, pady=(8, 4), sticky="w") + + self.excerpt_box = ctk.CTkTextbox( + self.scroll_container, + height=80, + font=ctk.CTkFont(size=11), + text_color=TEXT_SECONDARY, + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + wrap="word", + ) + self.excerpt_box.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="ew") + self.excerpt_box.configure(state="disabled") + + # 2. Editable Title Field + ctk.CTkLabel( + self.scroll_container, + text="Title:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=2, column=0, padx=12, pady=(4, 2), sticky="w") + + self.title_entry = ctk.CTkEntry( + self.scroll_container, + font=ctk.CTkFont(size=13), + text_color=TEXT_PRIMARY, + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + ) + self.title_entry.grid(row=3, column=0, padx=12, pady=(0, 12), sticky="ew") + self.title_entry.bind("", lambda e: self.save_task()) + self.title_entry.bind("", lambda e: self.focus_set()) # Commit on Enter + + # 3. Editable Notes Field + ctk.CTkLabel( + self.scroll_container, + text="Notes:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=4, column=0, padx=12, pady=(4, 2), sticky="w") + + self.notes_box = ctk.CTkTextbox( + self.scroll_container, + height=100, + font=ctk.CTkFont(size=12), + text_color=TEXT_PRIMARY, + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + wrap="word", + ) + self.notes_box.grid(row=5, column=0, padx=12, pady=(0, 12), sticky="ew") + self.notes_box.bind("", lambda e: self.save_task()) + + # 4. Due Date Field + ctk.CTkLabel( + self.scroll_container, + text="Due Date (YYYY-MM-DD):", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=6, column=0, padx=12, pady=(4, 2), sticky="w") + + self.due_date_entry = ctk.CTkEntry( + self.scroll_container, + font=ctk.CTkFont(size=13), + text_color=TEXT_PRIMARY, + placeholder_text="YYYY-MM-DD", + fg_color=BG_COLOR, + border_color=BORDER_COLOR, + border_width=1, + corner_radius=6, + ) + self.due_date_entry.grid(row=7, column=0, padx=12, pady=(0, 12), sticky="ew") + self.due_date_entry.bind("", lambda e: self.save_task()) + self.due_date_entry.bind("", lambda e: self.focus_set()) + + # 5. Eisenhower Quadrant Option Menu + ctk.CTkLabel( + self.scroll_container, + text="Matrix Quadrant Override:", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=TEXT_SECONDARY, + anchor="w", + ).grid(row=8, column=0, padx=12, pady=(4, 2), sticky="w") + + self.quadrant_menu = ctk.CTkOptionMenu( + self.scroll_container, + values=list(_QUADRANT_DISPLAY_TO_KEY.keys()), + command=self._on_quadrant_changed, + button_color=BORDER_COLOR, + button_hover_color=TEXT_SECONDARY, + fg_color=BG_COLOR, + text_color=TEXT_PRIMARY, + corner_radius=6, + ) + self.quadrant_menu.grid(row=9, column=0, padx=12, pady=(0, 12), sticky="ew") + + # 6. Status Checkbox + self.status_var = tk.StringVar(value="pending") + self.status_checkbox = ctk.CTkCheckBox( + self.scroll_container, + text="Mark Done", + font=ctk.CTkFont(size=12, weight="bold"), + variable=self.status_var, + onvalue="completed", + offvalue="pending", + command=self.save_task, + text_color=TEXT_PRIMARY, + fg_color=BORDER_COLOR, + hover_color=TEXT_SECONDARY, + border_color=TEXT_SECONDARY, + corner_radius=4, + ) + self.status_checkbox.grid(row=10, column=0, padx=12, pady=(4, 16), sticky="w") + + # Divider + divider = ctk.CTkFrame(self.scroll_container, height=1, fg_color=BORDER_COLOR) + divider.grid(row=11, column=0, padx=12, pady=(0, 16), sticky="ew") + + # Timestamps Labels + self.created_label = ctk.CTkLabel( + self.scroll_container, + text="Created: --", + font=ctk.CTkFont(size=10), + text_color=TEXT_SECONDARY, + anchor="w", + ) + self.created_label.grid(row=12, column=0, padx=12, pady=(0, 4), sticky="w") + + self.updated_label = ctk.CTkLabel( + self.scroll_container, + text="Updated: --", + font=ctk.CTkFont(size=10), + text_color=TEXT_SECONDARY, + anchor="w", + ) + self.updated_label.grid(row=13, column=0, padx=12, pady=0, sticky="w") + + def load_task(self, task_id: int) -> None: + """Fetch task details and populate fields. + + Args: + task_id (int): Database identifier of task to display. + """ + self._loading = True + self.task_id = task_id + + task_record = self._db.get_task(task_id) + if not task_record: + logger.error("Failed to load task ID %d from database.", task_id) + self._loading = False + return + + task, matrix = task_record + + # Excerpt loading + excerpt = self._db.get_transcript_excerpt_for_task(task_id) + self.excerpt_box.configure(state="normal") + self.excerpt_box.delete("1.0", "end") + self.excerpt_box.insert("1.0", excerpt) + self.excerpt_box.configure(state="disabled") + + # Populate Entries + self.title_entry.delete(0, "end") + self.title_entry.insert(0, task.title or "") + + self.notes_box.delete("1.0", "end") + self.notes_box.insert("1.0", task.notes or "") + + self.due_date_entry.delete(0, "end") + self.due_date_entry.insert(0, task.due_date or "") + + self.status_var.set(task.status) + + # Set Quadrant Display + display_name = _QUADRANT_KEY_TO_DISPLAY.get(matrix.quadrant, "Schedule") + self.quadrant_menu.set(display_name) + + # Set Timestamps + created_str = self._format_timestamp(task.created_at) + updated_str = self._format_timestamp(task.updated_at) + self.created_label.configure(text=f"Created: {created_str}") + self.updated_label.configure(text=f"Updated: {updated_str}") + + # Keep current copy for delta detection + self._current_title = task.title or "" + self._current_notes = task.notes or "" + self._current_due_date = task.due_date or "" + self._current_quadrant = matrix.quadrant or "" + self._current_status = task.status + + self._loading = False + + def save_task(self) -> None: + """Compare inputs with database, persisting modifications on difference.""" + if self._loading or self.task_id is None: + return + + title = self.title_entry.get().strip() + notes = self.notes_box.get("1.0", "end-1c").strip() + due_date = self.due_date_entry.get().strip() + status = self.status_var.get() + + # Check if anything changed + title_changed = title != self._current_title + notes_changed = notes != self._current_notes + due_changed = due_date != self._current_due_date + status_changed = status != self._current_status + + if title_changed or notes_changed or due_changed or status_changed: + try: + self._db.update_task( + task_id=self.task_id, + title=title, + notes=notes, + due_date=due_date, + status=status, + ) + logger.info("Task ID %d updated successfully.", self.task_id) + + # Update delta check variables + self._current_title = title + self._current_notes = notes + self._current_due_date = due_date + self._current_status = status + + # Refresh timestamps (re-read task) + task_record = self._db.get_task(self.task_id) + if task_record: + updated_str = self._format_timestamp(task_record[0].updated_at) + self.updated_label.configure(text=f"Updated: {updated_str}") + + # Notify main window to redraw grids + self.on_save() + except Exception as e: + logger.error("Failed to auto-save task ID %d: %s", self.task_id, e) + + def _on_quadrant_changed(self, value: str) -> None: + """Handle option menu selection, triggering manual override immediately. + + Args: + value (str): Text selected in OptionMenu list. + """ + if self._loading or self.task_id is None: + return + + target_quadrant = _QUADRANT_DISPLAY_TO_KEY.get(value, "schedule") + if target_quadrant != self._current_quadrant: + try: + self._db.override_matrix_quadrant(self.task_id, target_quadrant) + logger.info( + "Task ID %d quadrant overridden manually to '%s'.", + self.task_id, + target_quadrant, + ) + self._current_quadrant = target_quadrant + self.on_save() + except Exception as e: + logger.error("Failed to override matrix quadrant: %s", e) + + def close_panel(self) -> None: + """Auto-save any pending changes and invoke the close event hook.""" + self.save_task() + self.on_close() + + def _format_timestamp(self, ts_str: str | None) -> str: + """Convert raw SQLite ISO timestamp string to user friendly readable text. + + Args: + ts_str (str): Raw timestamp from database record. + + Returns: + str: Reformatted human readable timestamp. + """ + if not ts_str: + return "--" + try: + dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") + return dt.strftime("%b %d, %H:%M") + except ValueError: + return ts_str diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index 0d57fbf..0d9bb99 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -154,7 +154,7 @@ def test_tasks_crud(temp_db_path: Path) -> None: title = "Complete report writing" notes = "Needs to be finished before Friday morning" due_date = "2026-07-17" - quadrant = "urgent_important" + quadrant = "do_first" task, matrix = db.create_task(title, notes, due_date, quadrant) @@ -191,18 +191,18 @@ def test_tasks_crud(temp_db_path: Path) -> None: assert updated[0].status == "completed" # Check tasks by quadrant - urgent_tasks = db.get_tasks_in_quadrant("urgent_important") + urgent_tasks = db.get_tasks_in_quadrant("do_first") assert len(urgent_tasks) == 1 assert urgent_tasks[0][0].id == task.id - empty_quadrant = db.get_tasks_in_quadrant("neither") + empty_quadrant = db.get_tasks_in_quadrant("eliminate") assert len(empty_quadrant) == 0 # Override quadrant - db.override_matrix_quadrant(task.id, "neither") + db.override_matrix_quadrant(task.id, "eliminate") overridden = db.get_task(task.id) assert overridden is not None - assert overridden[1].quadrant == "neither" + assert overridden[1].quadrant == "eliminate" assert overridden[1].user_override # Delete task diff --git a/tests/unit/test_ui_task_detail.py b/tests/unit/test_ui_task_detail.py new file mode 100644 index 0000000..27699d2 --- /dev/null +++ b/tests/unit/test_ui_task_detail.py @@ -0,0 +1,184 @@ +"""Unit tests for the TaskDetailPanel sidebar configuration component.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from taskify.storage.database import DatabaseManager +from taskify.storage.models import MatrixEntry, TaskRecord +from taskify.ui.main_window import MainWindow, TaskCard +from taskify.ui.task_detail_panel import TaskDetailPanel + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> Path: + return tmp_path / "test.db" + + +@pytest.fixture +def db_manager(temp_db_path: Path) -> DatabaseManager: + db = DatabaseManager(temp_db_path) + yield db + db.close() + + +@pytest.fixture +def mock_parent() -> MagicMock: + parent = MagicMock() + parent.refresh_all_quadrants = MagicMock() + parent.close_task_detail = MagicMock() + return parent + + +@pytest.fixture +def detail_panel(mock_parent, db_manager) -> TaskDetailPanel: + panel = TaskDetailPanel( + master=mock_parent, + db_manager=db_manager, + on_save_callback=mock_parent.refresh_all_quadrants, + on_close_callback=mock_parent.close_task_detail, + ) + return panel + + +def test_detail_panel_load(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + # 1. Insert a mock task + task, matrix = db_manager.create_task( + title="Test Task Title", + notes="Important extra notes", + due_date="2026-07-20", + quadrant="schedule", + ) + + # 2. Insert mock transcripts to generate context + # Create session + db_manager.create_session("session1", 1000.0) + from taskify.storage.models import TranscriptSegment + db_manager.insert_transcripts([ + TranscriptSegment( + session_id="session1", + text="Need to finish test task title", + confidence=0.95, + start_time=1001.0, + end_time=1005.0 + ) + ]) + + # Load task into panel + detail_panel.load_task(task.id) + + # Verify UI values are populated + assert detail_panel.title_entry.get() == "Test Task Title" + assert detail_panel.notes_box.get() == "Important extra notes" + assert detail_panel.due_date_entry.get() == "2026-07-20" + assert detail_panel.status_var.get() == "pending" + + +def test_detail_panel_auto_save_on_blur(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + # Insert task + task, matrix = db_manager.create_task( + title="Original Title", + notes="Original Notes", + due_date="2026-07-18", + quadrant="do_first", + ) + + # Load it + detail_panel.load_task(task.id) + + # Edit fields + detail_panel.title_entry.delete() + detail_panel.title_entry.insert(0, "Updated Title") + + detail_panel.notes_box.delete() + detail_panel.notes_box.insert("1.0", "Updated Notes") + + detail_panel.due_date_entry.delete() + detail_panel.due_date_entry.insert(0, "2026-07-22") + + detail_panel.status_var.set("completed") + + # Trigger save (simulate FocusOut/blur) + detail_panel.save_task() + + # Re-fetch from database to verify persistence + updated_data = db_manager.get_task(task.id) + assert updated_data is not None + updated_task, updated_matrix = updated_data + + assert updated_task.title == "Updated Title" + assert updated_task.notes == "Updated Notes" + assert updated_task.due_date == "2026-07-22" + assert updated_task.status == "completed" + assert detail_panel.on_save.called + + +def test_detail_panel_quadrant_override(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + task, matrix = db_manager.create_task( + title="Heuristic Task", + notes="...", + due_date="", + quadrant="schedule", + ) + + detail_panel.load_task(task.id) + + # Trigger dropdown change + detail_panel._on_quadrant_changed("Do First") + + # Verify immediate override and user_override = 1 in database + updated_data = db_manager.get_task(task.id) + assert updated_data is not None + updated_task, updated_matrix = updated_data + + assert updated_matrix.quadrant == "do_first" + assert updated_matrix.user_override is True + assert detail_panel.on_save.called + + +def test_detail_panel_close(detail_panel: TaskDetailPanel, db_manager: DatabaseManager) -> None: + task, matrix = db_manager.create_task( + title="Closing Task", + notes="", + due_date="", + quadrant="eliminate", + ) + + detail_panel.load_task(task.id) + detail_panel.title_entry.delete() + detail_panel.title_entry.insert(0, "Closed Title") + + # Close panel + detail_panel.close_panel() + + # Verify save was triggered + updated_data = db_manager.get_task(task.id) + assert updated_data is not None + assert updated_data[0].title == "Closed Title" + assert detail_panel.on_close.called + + +def test_task_card_left_click_opens_details(db_manager: DatabaseManager) -> None: + task = TaskRecord( + id=42, + title="Left Click Task", + notes="Test details display", + due_date="2026-07-25", + status="pending", + ) + + mock_on_action = MagicMock() + mock_scroll = MagicMock() + + # Create task card + card = TaskCard(master=mock_scroll, task=task, on_action=mock_on_action) + + # Simulate mouse left-click on the card + card._on_click(MagicMock()) + + # Verify edit callback was invoked with task id + mock_on_action.assert_called_once_with("edit", 42) From 7fd51d4b8d8b9207d4841c587cb4ff7f84d6f0cd Mon Sep 17 00:00:00 2001 From: Nihal Rodge Date: Sat, 18 Jul 2026 13:11:34 +0530 Subject: [PATCH 4/6] feat(infra): implement structured application logging and rotation (#15) --- src/taskify/audio/capture.py | 31 +++++++ src/taskify/cli/main.py | 10 +++ src/taskify/llm/ollama_backend.py | 3 + src/taskify/logging_config.py | 105 ++++++++++++++++++++++ src/taskify/ui/main_window.py | 26 ++++++ tests/unit/test_logging.py | 143 ++++++++++++++++++++++++++++++ 6 files changed, 318 insertions(+) create mode 100644 src/taskify/logging_config.py create mode 100644 tests/unit/test_logging.py diff --git a/src/taskify/audio/capture.py b/src/taskify/audio/capture.py index 3d2d98e..2b57efa 100644 --- a/src/taskify/audio/capture.py +++ b/src/taskify/audio/capture.py @@ -1,6 +1,7 @@ """Microphone audio capture module with RMS-based silence detection.""" import collections +import logging import queue import sys from typing import Any @@ -10,6 +11,8 @@ from taskify.config import Settings +logger = logging.getLogger(__name__) + class AudioCapture: """Handles cross-platform audio stream capture and real-time silence filtering.""" @@ -140,6 +143,12 @@ def _audio_callback( if self._state == 0: # SILENT state if rms >= self._silence_threshold: # Transition to ACTIVE + logger.info( + "AudioCapture: speech activity detected (RMS: %.4f >= %.4f). " + "Transitioning to ACTIVE state.", + rms, + self._silence_threshold, + ) self._state = 1 self._silence_counter = 0 @@ -149,20 +158,42 @@ def _audio_callback( self._queue.put(audio_data) else: # Retain sliding window history + logger.debug( + "AudioCapture: silent chunk ignored (RMS: %.4f < %.4f).", + rms, + self._silence_threshold, + ) self._preroll_buffer.append(audio_data) else: # ACTIVE state if rms < self._silence_threshold: self._silence_counter += 1 if self._silence_counter >= self._silence_limit: # Transition to SILENT + logger.info( + "AudioCapture: silence duration limit met (counter: %d/%d). " + "Transitioning to SILENT state.", + self._silence_counter, + self._silence_limit, + ) self._state = 0 self._silence_counter = 0 self._preroll_buffer.clear() self._preroll_buffer.append(audio_data) else: + logger.debug( + "AudioCapture: active chunk recorded with low energy " + "(RMS: %.4f, counter: %d/%d).", + rms, + self._silence_counter, + self._silence_limit, + ) self._queue.put(audio_data) else: self._silence_counter = 0 + logger.debug( + "AudioCapture: recording active chunk (RMS: %.4f).", + rms, + ) self._queue.put(audio_data) def start(self) -> None: diff --git a/src/taskify/cli/main.py b/src/taskify/cli/main.py index 55b8fa2..dc1388a 100644 --- a/src/taskify/cli/main.py +++ b/src/taskify/cli/main.py @@ -10,6 +10,16 @@ @click.pass_context def main(ctx: click.Context, version: bool) -> None: """Taskify: A fully offline, privacy-first, voice-driven task manager.""" + # Initialize unified log configurations on startup + try: + from taskify.config import load_settings + from taskify.logging_config import configure_logging + + settings = load_settings() + configure_logging(settings) + except Exception: + pass + if version: click.echo(f"Taskify version {__version__}") ctx.exit(0) diff --git a/src/taskify/llm/ollama_backend.py b/src/taskify/llm/ollama_backend.py index 18548c6..ac82bc1 100644 --- a/src/taskify/llm/ollama_backend.py +++ b/src/taskify/llm/ollama_backend.py @@ -168,12 +168,15 @@ def _generate(self, prompt: str) -> str | None: "options": {"temperature": 0.1}, } + logger.debug("Ollama request prompt:\n%s", prompt) + for attempt in range(1, self._retries + 1): try: resp = self._client.post(url, json=payload) resp.raise_for_status() data = resp.json() response_text: str = data.get("response", "") + logger.debug("Ollama response:\n%s", response_text) return response_text except httpx.HTTPStatusError as exc: logger.warning( diff --git a/src/taskify/logging_config.py b/src/taskify/logging_config.py new file mode 100644 index 0000000..8357727 --- /dev/null +++ b/src/taskify/logging_config.py @@ -0,0 +1,105 @@ +"""Structured logging configurations with rotation and dynamic session contexts.""" + +from __future__ import annotations + +import contextvars +import logging +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from taskify.config import Settings + +# Thread-safe context variable to store the current capture session ID +session_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar( + "session_id", default="N/A" +) + + +class SessionIDFilter(logging.Filter): + """Injects the active session ID into log records for structured output.""" + + def filter(self, record: logging.LogRecord) -> bool: + """Add session_id attribute from ContextVar to the log record.""" + record.session_id = session_id_ctx.get() + return True + + +def set_active_session_id(session_id: str | None) -> None: + """Set the active recording session identifier in context. + + Args: + session_id (str, optional): Target UUID session string or None to reset. + """ + session_id_ctx.set(session_id if session_id else "N/A") + + +def configure_logging(settings: Settings) -> None: + """Initialize structured console and rotating file logging handlers. + + Args: + settings (Settings): Active configuration blocks containing levels/paths. + """ + # 1. Clear existing handlers on the root logger to prevent duplicate logs + root_logger = logging.getLogger() + for handler in list(root_logger.handlers): + root_logger.removeHandler(handler) + + # 2. Determine base log levels + global_level_str = settings.logging.level.upper() + global_level = getattr(logging, global_level_str, logging.INFO) + + # If debug_llm is enabled, root accepts DEBUG so they propagate to handlers + if settings.logging.debug_llm and global_level > logging.DEBUG: + root_logger.setLevel(logging.DEBUG) + + # Restrict other subsystems to the global level + logging.getLogger("taskify.audio").setLevel(global_level) + logging.getLogger("taskify.stt").setLevel(global_level) + logging.getLogger("taskify.storage").setLevel(global_level) + logging.getLogger("taskify.pipeline").setLevel(global_level) + logging.getLogger("taskify.ui").setLevel(global_level) + # Explicity debug LLM + logging.getLogger("taskify.llm").setLevel(logging.DEBUG) + else: + root_logger.setLevel(global_level) + + # 3. Formatter including module, threadName, and session_id context + fmt_str = ( + "%(asctime)s [%(levelname)s] [%(module)s:%(threadName)s] " + "[Session: %(session_id)s] %(message)s" + ) + formatter = logging.Formatter(fmt_str) + session_filter = SessionIDFilter() + + # 4. Console Handler (Write INFO+ or DEBUG if requested globally) + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(global_level) + console_handler.setFormatter(formatter) + console_handler.addFilter(session_filter) + root_logger.addHandler(console_handler) + + # 5. Rotating File Handler (10 MB per file, max 5 backups) + log_dir = settings.storage.log_dir + if not log_dir: + # Fallback default path + log_dir = Path.home() / ".local" / "share" / "taskify" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "app.log" + + try: + file_handler = RotatingFileHandler( + log_path, + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler.setLevel(global_level) + file_handler.setFormatter(formatter) + file_handler.addFilter(session_filter) + root_logger.addHandler(file_handler) + except OSError as e: + # Gracefully log console warning if file write is locked/forbidden + logging.warning("Failed to configure rotating file handler: %s", e) diff --git a/src/taskify/ui/main_window.py b/src/taskify/ui/main_window.py index 5fa7f67..097a61a 100644 --- a/src/taskify/ui/main_window.py +++ b/src/taskify/ui/main_window.py @@ -5,6 +5,7 @@ import logging import time import tkinter as tk +import uuid from datetime import datetime from typing import TYPE_CHECKING @@ -264,6 +265,7 @@ def __init__( # Recording state self._is_recording = False + self._session_id: str | None = None self._session_start: float | None = None self._timer_after_id: str | None = None self._level_after_id: str | None = None @@ -474,6 +476,19 @@ def toggle_recording(self) -> None: def _start_recording(self) -> None: """Open AudioCapture stream and begin session timer.""" + session_id = str(uuid.uuid4()) + self._session_id = session_id + self._session_start_epoch = time.time() + + try: + self._db.create_session(session_id, self._session_start_epoch) + except Exception as exc: + logger.error("Failed to create database session record: %s", exc) + + # Set active session ID for formatters + from taskify.logging_config import set_active_session_id + set_active_session_id(session_id) + try: if AudioCapture is None: raise RuntimeError("AudioCapture unavailable (portaudio missing?)") @@ -504,6 +519,17 @@ def _stop_recording(self) -> None: """Close AudioCapture stream and cancel timer.""" self._is_recording = False + if self._session_id is not None: + try: + self._db.close_session(self._session_id, time.time()) + except Exception as exc: + logger.error("Failed to close database session record: %s", exc) + self._session_id = None + + # Reset active session ID in context formatter + from taskify.logging_config import set_active_session_id + set_active_session_id(None) + if self._audio_capture is not None: try: self._audio_capture.stop() diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..31056f3 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,143 @@ +"""Unit tests for structured logging configuration and custom session filtering.""" + +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from taskify.config import ( + AudioSettings, + LLMSettings, + LoggingSettings, + Settings, + STTSettings, + StorageSettings, +) +from taskify.logging_config import ( + SessionIDFilter, + configure_logging, + set_active_session_id, +) + + +@pytest.fixture +def dummy_settings(tmp_path: Path) -> Settings: + return Settings( + audio=AudioSettings( + device="default", + sample_rate=16000, + chunk_duration_ms=200, + silence_threshold=0.01, + silence_duration_s=1.5, + ), + stt=STTSettings( + engine="vosk", + vosk_model="en-small", + whisper_model="base", + language="en", + ), + llm=LLMSettings( + backend="ollama", + ollama_host="http://localhost:11434", + ollama_model="phi4-mini", + extraction_interval_s=300, + ), + storage=StorageSettings( + data_dir=tmp_path / "data", + log_dir=tmp_path / "logs", + ), + logging=LoggingSettings( + level="DEBUG", + debug_llm=True, + ), + ) + + +def test_session_id_filter_defaults_to_na() -> None: + # Ensure default session ID is N/A + set_active_session_id(None) + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="test_path.py", + lineno=10, + msg="Test message", + args=(), + exc_info=None, + ) + filtr = SessionIDFilter() + assert filtr.filter(record) + assert record.session_id == "N/A" + + +def test_session_id_filter_updates_via_set_active() -> None: + # Set explicit session ID and check record updates + set_active_session_id("uuid-1234-5678") + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="test_path.py", + lineno=10, + msg="Test message", + args=(), + exc_info=None, + ) + filtr = SessionIDFilter() + assert filtr.filter(record) + assert record.session_id == "uuid-1234-5678" + + # Reset + set_active_session_id(None) + filtr.filter(record) + assert record.session_id == "N/A" + + +def test_configure_logging_creates_handlers(dummy_settings: Settings) -> None: + configure_logging(dummy_settings) + + root_logger = logging.getLogger() + assert len(root_logger.handlers) >= 2 + + # Check that console and rotating file handlers are added + handlers = root_logger.handlers + has_console = any(isinstance(h, logging.StreamHandler) for h in handlers) + has_file = any(isinstance(h, RotatingFileHandler) for h in handlers) + + assert has_console + assert has_file + + # Clean up handlers to prevent side effects in other tests + for h in list(root_logger.handlers): + root_logger.removeHandler(h) + + +def test_rotating_file_handler_rotation(tmp_path: Path) -> None: + # Set up small rotation log to verify rotation on write threshold + log_file = tmp_path / "test_rotation.log" + handler = RotatingFileHandler( + log_file, + maxBytes=100, # Tiny limit to force immediate rotation + backupCount=2, + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + + logger = logging.getLogger("test_rotator") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Write logs exceeding 100 bytes + logger.info("A" * 60) + logger.info("B" * 60) + logger.info("C" * 60) + + handler.close() + logger.removeHandler(handler) + + # Check that backup rotation files were generated + assert log_file.exists() + assert (tmp_path / "test_rotation.log.1").exists() From 991822d6dc4774766a5e7d6f05a21a16317d6250 Mon Sep 17 00:00:00 2001 From: Nihal Rodge Date: Sat, 18 Jul 2026 13:49:17 +0530 Subject: [PATCH 5/6] feat(infra): add PyInstaller spec and build script for standalone execution (#16) --- Makefile | 18 ++++++++ packaging/README.md | 45 ++++++++++++++++++++ packaging/build.py | 55 ++++++++++++++++++++++++ packaging/taskify.spec | 95 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 Makefile create mode 100644 packaging/README.md create mode 100644 packaging/build.py create mode 100644 packaging/taskify.spec diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..df2176d --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# Taskify Makefile target shortcuts + +.PHONY: dist clean test lint typecheck + +dist: + python packaging/build.py + +clean: + python -c "import shutil; [shutil.rmtree(p, ignore_errors=True) for p in ('build', 'dist')]" + +test: + pytest tests/unit/ -v + +lint: + ruff check src/ --fix + +typecheck: + mypy src/ diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..b01235d --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,45 @@ +# Taskify standalone packaging instructions + +This directory holds the specifications and tooling to bundle the Python codebase, CustomTkinter frontend styling, and dependency components into a single standalone binary. + +## Requirements + +Ensure development dependencies are installed: +```bash +pip install -e .[dev] +``` + +## Building stand-alone executables + +To compile the binary for your active platform: + +```bash +python packaging/build.py +``` + +Or run via the Makefile: +```bash +make dist +``` + +Upon success, PyInstaller generates output directories: +- `build/`: Temporary files generated during build time. +- `dist/`: Stands for distribution. Contains the single executable `taskify` (or `taskify.exe` on Windows). + +## Local model dependency setup + +Because Taskify is a fully offline application, speech-to-text models must be available locally. The compiled binary does not include model directories by default to save executable file footprint. + +If model directories are missing, the application downloads defaults to the user directory on first launch, or they can be downloaded manually via: + +```bash +# Download default Vosk english model +dist/taskify download-model --engine vosk + +# Download default Whisper model +dist/taskify download-model --engine whisper +``` + +Offline models are stored at: +- **Vosk:** `~/.local/share/taskify/models/vosk/` (on Windows `~` refers to `C:\Users\`) +- **Whisper:** standard cache path managed by `huggingface_hub`. diff --git a/packaging/build.py b/packaging/build.py new file mode 100644 index 0000000..d599197 --- /dev/null +++ b/packaging/build.py @@ -0,0 +1,55 @@ +"""Programmatic execution of PyInstaller builds for standalone executables.""" + +import os +import subprocess +import sys +from pathlib import Path + + +def main() -> None: + """Execute the PyInstaller command pointing to packaging spec file.""" + workspace_dir = Path(__file__).parent.parent.resolve() + spec_file = workspace_dir / "packaging" / "taskify.spec" + + if not spec_file.exists(): + print(f"Error: Specification file not found at: {spec_file}", file=sys.stderr) + sys.exit(1) + + print("=== Taskify Packaging Builder ===") + print(f"Targeting Specification: {spec_file}") + print(f"Working Directory: {workspace_dir}") + + # Build the PyInstaller command list + cmd = [ + "pyinstaller", + "--clean", + "--noconfirm", + "--distpath", + str(workspace_dir / "dist"), + "--workpath", + str(workspace_dir / "build"), + str(spec_file), + ] + + print(f"Executing: {' '.join(cmd)}") + try: + # Run pyinstaller build process + result = subprocess.run( + cmd, cwd=str(workspace_dir), check=True, capture_output=False + ) + print("=== Build process finished successfully. ===") + sys.exit(result.returncode) + except subprocess.CalledProcessError as e: + print(f"Error: PyInstaller build failed with status {e.returncode}", file=sys.stderr) + sys.exit(e.returncode) + except FileNotFoundError: + print( + "Error: PyInstaller command not found. Please install the dev packages: " + "pip install -e .[dev]", + file=sys.stderr, + ) + sys.exit(127) + + +if __name__ == "__main__": + main() diff --git a/packaging/taskify.spec b/packaging/taskify.spec new file mode 100644 index 0000000..2f4bf94 --- /dev/null +++ b/packaging/taskify.spec @@ -0,0 +1,95 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import sys +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None + +# Locate customtkinter package installation path programmatically +import customtkinter +customtkinter_path = os.path.dirname(customtkinter.__file__) + +# Collect taskify assets (default configurations, schemas, sql scripts) +taskify_datas = collect_data_files("taskify") + +# Aggregate all data files to bundle inside the executable +datas = taskify_datas + [ + (customtkinter_path, "customtkinter") +] + +# Ensure dynamic imports are collected by PyInstaller +hiddenimports = collect_submodules("taskify") + [ + "sounddevice", + "soundfile", + "numpy", + "vosk", + "click", + "customtkinter", +] + +# Resolve script entrypoint relative to workspace root +entrypoint = os.path.abspath( + os.path.join(os.path.dirname("__file__"), "src", "taskify", "__main__.py") +) + +a = Analysis( + [entrypoint], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "torch", + "tensorflow", + "scipy", + "pandas", + "matplotlib", + "pygame", + "moviepy", + "sklearn", + "lxml", + "nltk", + "pyarrow", + "numba", + "llvmlite", + "openpyxl", + "yt_dlp", + "IPython", + "jedi", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name="taskify", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # Standard console is required for Click CLI commands output + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) From a150355cd6cc1603d8844f9cf58af2b0793484a5 Mon Sep 17 00:00:00 2001 From: Nihal Rodge Date: Sat, 18 Jul 2026 13:52:00 +0530 Subject: [PATCH 6/6] feat(infra): configure GitHub Actions CI/CD workflows (#17) --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++++++++++++++++ .gitignore | 5 ++++ pyproject.toml | 1 + requirements-dev.txt | 1 + 4 files changed, 66 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b1794e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [ main, development ] + pull_request: + branches: [ main, development ] + +jobs: + test: + name: test (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest ] + python-version: [ "3.11", "3.12" ] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: "pyproject.toml" + + - name: Install Linux dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libportaudio2 libasound2-dev + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev,nlp,whisper] + + - name: Run Ruff Linter + run: ruff check src/ + + - name: Run Mypy Type Checker + run: mypy src/ + + - name: Run Safety Check + run: safety check + + - name: Run Pytest with Coverage + # GUI tests might require mock display on Ubuntu. Let's use xvfb on Ubuntu runner. + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get install -y xvfb + xvfb-run --server-args="-screen 0 1024x768x24" pytest tests/unit/ -v --cov=src --cov-fail-under=80 --cov-report=xml + + - name: Run Pytest with Coverage (Windows) + if: matrix.os == 'windows-latest' + run: pytest tests/unit/ -v --cov=src --cov-fail-under=80 --cov-report=xml diff --git a/.gitignore b/.gitignore index ba3d88c..9c30439 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ models/ logs/ *.log .gemini/ +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/pyproject.toml b/pyproject.toml index 1bf0799..c8616e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "ruff>=0.1.0", "mypy>=1.5.0", "pyinstaller>=6.1.0", + "safety>=2.3.0", ] [project.scripts] diff --git a/requirements-dev.txt b/requirements-dev.txt index 09fa59d..103e262 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ pytest-cov>=4.1.0 ruff>=0.1.0 mypy>=1.5.0 pyinstaller>=6.1.0 +safety>=2.3.0