meeting-noter 1.3.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of meeting-noter might be problematic. Click here for more details.
- meeting_noter/__init__.py +1 -1
- meeting_noter/cli.py +61 -0
- meeting_noter/daemon.py +38 -0
- meeting_noter/mic_monitor.py +29 -1
- meeting_noter/ui/__init__.py +5 -0
- meeting_noter/ui/app.py +68 -0
- meeting_noter/ui/screens/__init__.py +17 -0
- meeting_noter/ui/screens/logs.py +166 -0
- meeting_noter/ui/screens/main.py +346 -0
- meeting_noter/ui/screens/recordings.py +241 -0
- meeting_noter/ui/screens/search.py +191 -0
- meeting_noter/ui/screens/settings.py +184 -0
- meeting_noter/ui/screens/viewer.py +116 -0
- meeting_noter/ui/styles/app.tcss +257 -0
- meeting_noter/ui/widgets/__init__.py +1 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-2.0.0.dist-info}/METADATA +2 -1
- {meeting_noter-1.3.0.dist-info → meeting_noter-2.0.0.dist-info}/RECORD +20 -9
- {meeting_noter-1.3.0.dist-info → meeting_noter-2.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-2.0.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-2.0.0.dist-info}/top_level.txt +0 -0
meeting_noter/__init__.py
CHANGED
meeting_noter/cli.py
CHANGED
|
@@ -804,6 +804,25 @@ def watcher(foreground: bool):
|
|
|
804
804
|
click.echo("Use 'meeting-noter shutdown' to stop.")
|
|
805
805
|
|
|
806
806
|
|
|
807
|
+
def _disable_app_nap():
|
|
808
|
+
"""Disable App Nap for this process to prevent macOS from throttling it.
|
|
809
|
+
|
|
810
|
+
App Nap can suspend background processes, causing meeting detection to fail
|
|
811
|
+
after long idle periods.
|
|
812
|
+
"""
|
|
813
|
+
try:
|
|
814
|
+
from Foundation import NSProcessInfo
|
|
815
|
+
info = NSProcessInfo.processInfo()
|
|
816
|
+
# Disable App Nap and sudden termination
|
|
817
|
+
# NSActivityUserInitiated keeps the process responsive
|
|
818
|
+
info.beginActivityWithOptions_reason_(
|
|
819
|
+
0x00FFFFFF, # NSActivityUserInitiatedAllowingIdleSystemSleep
|
|
820
|
+
"Meeting detection watcher"
|
|
821
|
+
)
|
|
822
|
+
except Exception:
|
|
823
|
+
pass # Not critical if this fails
|
|
824
|
+
|
|
825
|
+
|
|
807
826
|
def _run_watcher_loop():
|
|
808
827
|
"""Run the watcher loop (foreground)."""
|
|
809
828
|
import time
|
|
@@ -813,6 +832,9 @@ def _run_watcher_loop():
|
|
|
813
832
|
from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
|
|
814
833
|
from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
|
|
815
834
|
|
|
835
|
+
# Disable App Nap to prevent macOS from throttling this process
|
|
836
|
+
_disable_app_nap()
|
|
837
|
+
|
|
816
838
|
# Write PID file
|
|
817
839
|
WATCHER_PID_FILE.write_text(str(os.getpid()))
|
|
818
840
|
atexit.register(lambda: WATCHER_PID_FILE.unlink(missing_ok=True))
|
|
@@ -824,8 +846,23 @@ def _run_watcher_loop():
|
|
|
824
846
|
mic_monitor = MicrophoneMonitor()
|
|
825
847
|
current_meeting_name = None
|
|
826
848
|
|
|
849
|
+
# Heartbeat tracking - log every 30 minutes to confirm watcher is alive
|
|
850
|
+
last_heartbeat = time.time()
|
|
851
|
+
heartbeat_interval = 30 * 60 # 30 minutes
|
|
852
|
+
|
|
827
853
|
try:
|
|
828
854
|
while True:
|
|
855
|
+
# Periodic heartbeat to confirm watcher is running
|
|
856
|
+
now = time.time()
|
|
857
|
+
if now - last_heartbeat >= heartbeat_interval:
|
|
858
|
+
# Write to log file so user can verify watcher is alive
|
|
859
|
+
log_path = Path.home() / ".meeting-noter.log"
|
|
860
|
+
with open(log_path, "a") as f:
|
|
861
|
+
from datetime import datetime
|
|
862
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
863
|
+
f.write(f"{timestamp} Watcher heartbeat - still monitoring for meetings\n")
|
|
864
|
+
last_heartbeat = now
|
|
865
|
+
|
|
829
866
|
mic_started, mic_stopped, app_name = mic_monitor.check()
|
|
830
867
|
|
|
831
868
|
is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
|
|
@@ -990,6 +1027,30 @@ def open_folder(what: str):
|
|
|
990
1027
|
click.echo(f"Opened: {path}")
|
|
991
1028
|
|
|
992
1029
|
|
|
1030
|
+
@cli.command()
|
|
1031
|
+
def ui():
|
|
1032
|
+
"""Launch the Terminal User Interface.
|
|
1033
|
+
|
|
1034
|
+
Opens an interactive TUI for managing recordings, searching transcripts,
|
|
1035
|
+
and configuring settings.
|
|
1036
|
+
|
|
1037
|
+
\b
|
|
1038
|
+
Examples:
|
|
1039
|
+
meeting-noter ui # Launch the TUI
|
|
1040
|
+
|
|
1041
|
+
\b
|
|
1042
|
+
Keyboard shortcuts:
|
|
1043
|
+
1-5 Switch between screens
|
|
1044
|
+
q Quit
|
|
1045
|
+
? Help
|
|
1046
|
+
r Start/stop recording (on dashboard)
|
|
1047
|
+
"""
|
|
1048
|
+
from meeting_noter.ui import MeetingNoterApp
|
|
1049
|
+
|
|
1050
|
+
app = MeetingNoterApp()
|
|
1051
|
+
app.run()
|
|
1052
|
+
|
|
1053
|
+
|
|
993
1054
|
@cli.command()
|
|
994
1055
|
@click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
|
|
995
1056
|
def completion(shell: str):
|
meeting_noter/daemon.py
CHANGED
|
@@ -174,6 +174,7 @@ def _run_capture_loop(
|
|
|
174
174
|
from meeting_noter.audio.encoder import RecordingSession
|
|
175
175
|
|
|
176
176
|
config = get_config()
|
|
177
|
+
saved_filepath = None # Track saved file for auto-transcription
|
|
177
178
|
|
|
178
179
|
# Live transcription (imported here to avoid loading Whisper before fork)
|
|
179
180
|
live_transcriber = None
|
|
@@ -306,6 +307,23 @@ def _run_capture_loop(
|
|
|
306
307
|
filepath, duration = session.stop()
|
|
307
308
|
if filepath:
|
|
308
309
|
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
310
|
+
saved_filepath = filepath
|
|
311
|
+
# Auto-transcribe if enabled
|
|
312
|
+
if config.auto_transcribe:
|
|
313
|
+
print("Auto-transcribing...")
|
|
314
|
+
sys.stdout.flush()
|
|
315
|
+
try:
|
|
316
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
317
|
+
transcribe_file(
|
|
318
|
+
str(filepath),
|
|
319
|
+
output_dir,
|
|
320
|
+
config.whisper_model,
|
|
321
|
+
config.transcripts_dir,
|
|
322
|
+
)
|
|
323
|
+
print("Transcription complete.")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
print(f"Transcription error: {e}")
|
|
326
|
+
sys.stdout.flush()
|
|
309
327
|
else:
|
|
310
328
|
print("Recording discarded (too short)")
|
|
311
329
|
silence_detector.reset()
|
|
@@ -313,6 +331,8 @@ def _run_capture_loop(
|
|
|
313
331
|
|
|
314
332
|
except Exception as e:
|
|
315
333
|
print(f"Error in capture loop: {e}")
|
|
334
|
+
import sys
|
|
335
|
+
sys.stdout.flush()
|
|
316
336
|
finally:
|
|
317
337
|
capture.stop()
|
|
318
338
|
|
|
@@ -325,6 +345,24 @@ def _run_capture_loop(
|
|
|
325
345
|
filepath, duration = session.stop()
|
|
326
346
|
if filepath:
|
|
327
347
|
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
348
|
+
saved_filepath = filepath
|
|
349
|
+
# Auto-transcribe if enabled
|
|
350
|
+
if config.auto_transcribe:
|
|
351
|
+
print("Auto-transcribing...")
|
|
352
|
+
import sys
|
|
353
|
+
sys.stdout.flush()
|
|
354
|
+
try:
|
|
355
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
356
|
+
transcribe_file(
|
|
357
|
+
str(filepath),
|
|
358
|
+
output_dir,
|
|
359
|
+
config.whisper_model,
|
|
360
|
+
config.transcripts_dir,
|
|
361
|
+
)
|
|
362
|
+
print("Transcription complete.")
|
|
363
|
+
except Exception as e:
|
|
364
|
+
print(f"Transcription error: {e}")
|
|
365
|
+
sys.stdout.flush()
|
|
328
366
|
|
|
329
367
|
print("Daemon stopped.")
|
|
330
368
|
|
meeting_noter/mic_monitor.py
CHANGED
|
@@ -36,11 +36,21 @@ class _AudioObjectPropertyAddress(Structure):
|
|
|
36
36
|
_core_audio = None
|
|
37
37
|
_AudioObjectGetPropertyDataSize = None
|
|
38
38
|
_AudioObjectGetPropertyData = None
|
|
39
|
+
_coreaudio_init_time = 0 # Track when CoreAudio was initialized
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reset_coreaudio():
|
|
43
|
+
"""Reset CoreAudio framework (for reinitializing after long idle)."""
|
|
44
|
+
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
|
|
45
|
+
_core_audio = None
|
|
46
|
+
_AudioObjectGetPropertyDataSize = None
|
|
47
|
+
_AudioObjectGetPropertyData = None
|
|
48
|
+
_coreaudio_init_time = 0
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
def _init_coreaudio():
|
|
42
52
|
"""Initialize CoreAudio framework."""
|
|
43
|
-
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData
|
|
53
|
+
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
|
|
44
54
|
|
|
45
55
|
if _core_audio is not None:
|
|
46
56
|
return True
|
|
@@ -60,11 +70,26 @@ def _init_coreaudio():
|
|
|
60
70
|
]
|
|
61
71
|
_AudioObjectGetPropertyData.restype = c_int32
|
|
62
72
|
|
|
73
|
+
_coreaudio_init_time = time.time()
|
|
63
74
|
return True
|
|
64
75
|
except Exception:
|
|
65
76
|
return False
|
|
66
77
|
|
|
67
78
|
|
|
79
|
+
# Reinitialize CoreAudio every 30 minutes to prevent stale handles
|
|
80
|
+
_COREAUDIO_REFRESH_INTERVAL = 30 * 60
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _maybe_refresh_coreaudio():
|
|
84
|
+
"""Reinitialize CoreAudio if it's been too long since last init.
|
|
85
|
+
|
|
86
|
+
This prevents stale CoreAudio handles after system sleep or long idle.
|
|
87
|
+
"""
|
|
88
|
+
global _coreaudio_init_time
|
|
89
|
+
if _core_audio is not None and time.time() - _coreaudio_init_time > _COREAUDIO_REFRESH_INTERVAL:
|
|
90
|
+
_reset_coreaudio()
|
|
91
|
+
|
|
92
|
+
|
|
68
93
|
def is_mic_in_use_by_another_app() -> bool:
|
|
69
94
|
"""Check if the microphone is being used by another application.
|
|
70
95
|
|
|
@@ -74,6 +99,9 @@ def is_mic_in_use_by_another_app() -> bool:
|
|
|
74
99
|
Returns:
|
|
75
100
|
True if another app is using the microphone
|
|
76
101
|
"""
|
|
102
|
+
# Refresh CoreAudio if it's been too long (prevents stale handles)
|
|
103
|
+
_maybe_refresh_coreaudio()
|
|
104
|
+
|
|
77
105
|
if not _init_coreaudio():
|
|
78
106
|
return False
|
|
79
107
|
|
meeting_noter/ui/app.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Main Textual application for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.widgets import Footer, Header
|
|
8
|
+
|
|
9
|
+
from meeting_noter.ui.screens.main import MainScreen
|
|
10
|
+
from meeting_noter.ui.screens.recordings import RecordingsScreen
|
|
11
|
+
from meeting_noter.ui.screens.search import SearchScreen
|
|
12
|
+
from meeting_noter.ui.screens.settings import SettingsScreen
|
|
13
|
+
from meeting_noter.ui.screens.logs import LogsScreen
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MeetingNoterApp(App):
|
|
17
|
+
"""Meeting Noter Terminal UI Application."""
|
|
18
|
+
|
|
19
|
+
TITLE = "Meeting Noter"
|
|
20
|
+
CSS_PATH = "styles/app.tcss"
|
|
21
|
+
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("q", "quit", "Quit"),
|
|
24
|
+
Binding("question_mark", "help", "Help", key_display="?"),
|
|
25
|
+
Binding("1", "switch_screen('main')", "Dashboard", show=True),
|
|
26
|
+
Binding("2", "switch_screen('recordings')", "Recordings", show=True),
|
|
27
|
+
Binding("3", "switch_screen('search')", "Search", show=True),
|
|
28
|
+
Binding("4", "switch_screen('settings')", "Settings", show=True),
|
|
29
|
+
Binding("5", "switch_screen('logs')", "Logs", show=True),
|
|
30
|
+
Binding("escape", "go_back", "Back", show=False),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
SCREENS = {
|
|
34
|
+
"main": MainScreen,
|
|
35
|
+
"recordings": RecordingsScreen,
|
|
36
|
+
"search": SearchScreen,
|
|
37
|
+
"settings": SettingsScreen,
|
|
38
|
+
"logs": LogsScreen,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def compose(self) -> ComposeResult:
|
|
42
|
+
"""Compose the app layout."""
|
|
43
|
+
yield Header()
|
|
44
|
+
yield Footer()
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
"""Handle app mount - show the main screen."""
|
|
48
|
+
self.push_screen("main")
|
|
49
|
+
|
|
50
|
+
def action_switch_screen(self, screen_name: str) -> None:
|
|
51
|
+
"""Switch to a named screen."""
|
|
52
|
+
# Pop all screens except the base and push the new one
|
|
53
|
+
while len(self.screen_stack) > 1:
|
|
54
|
+
self.pop_screen()
|
|
55
|
+
self.push_screen(screen_name)
|
|
56
|
+
|
|
57
|
+
def action_go_back(self) -> None:
|
|
58
|
+
"""Go back to the previous screen."""
|
|
59
|
+
if len(self.screen_stack) > 1:
|
|
60
|
+
self.pop_screen()
|
|
61
|
+
|
|
62
|
+
def action_help(self) -> None:
|
|
63
|
+
"""Show help information."""
|
|
64
|
+
self.notify(
|
|
65
|
+
"Keys: 1-5 switch screens, q quit, ? help",
|
|
66
|
+
title="Meeting Noter Help",
|
|
67
|
+
timeout=5,
|
|
68
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""UI Screens for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from meeting_noter.ui.screens.main import MainScreen
|
|
4
|
+
from meeting_noter.ui.screens.recordings import RecordingsScreen
|
|
5
|
+
from meeting_noter.ui.screens.search import SearchScreen
|
|
6
|
+
from meeting_noter.ui.screens.viewer import ViewerScreen
|
|
7
|
+
from meeting_noter.ui.screens.settings import SettingsScreen
|
|
8
|
+
from meeting_noter.ui.screens.logs import LogsScreen
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MainScreen",
|
|
12
|
+
"RecordingsScreen",
|
|
13
|
+
"SearchScreen",
|
|
14
|
+
"ViewerScreen",
|
|
15
|
+
"SettingsScreen",
|
|
16
|
+
"LogsScreen",
|
|
17
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Logs viewer screen for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Container, Horizontal
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Button, Label, RichLog, Static, Switch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LOG_FILE = Path.home() / ".meeting-noter.log"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LogsScreen(Screen):
|
|
17
|
+
"""Logs viewer screen."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("l", "toggle_follow", "Follow"),
|
|
21
|
+
("c", "clear_display", "Clear"),
|
|
22
|
+
("r", "refresh", "Refresh"),
|
|
23
|
+
("escape", "go_back", "Back"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self._follow_mode = True
|
|
29
|
+
self._last_size = 0
|
|
30
|
+
|
|
31
|
+
def compose(self) -> ComposeResult:
|
|
32
|
+
"""Compose the logs screen layout."""
|
|
33
|
+
yield Container(
|
|
34
|
+
Static("[bold]Logs[/bold]", classes="title"),
|
|
35
|
+
Label(f"[dim]{LOG_FILE}[/dim]", id="log-path"),
|
|
36
|
+
Static("", classes="spacer"),
|
|
37
|
+
RichLog(id="log-content", highlight=True, markup=True),
|
|
38
|
+
Static("", classes="spacer"),
|
|
39
|
+
Horizontal(
|
|
40
|
+
Label("Follow: "),
|
|
41
|
+
Switch(value=True, id="follow-switch"),
|
|
42
|
+
Button("Refresh", id="refresh-btn"),
|
|
43
|
+
Button("Clear", id="clear-btn"),
|
|
44
|
+
Button("Back", id="back-btn"),
|
|
45
|
+
classes="button-row",
|
|
46
|
+
),
|
|
47
|
+
id="logs-container",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def on_mount(self) -> None:
|
|
51
|
+
"""Start log refresh on mount."""
|
|
52
|
+
self._load_logs()
|
|
53
|
+
self.set_interval(1.0, self._check_for_updates)
|
|
54
|
+
|
|
55
|
+
def _load_logs(self, lines: int = 100) -> None:
|
|
56
|
+
"""Load the last N lines from the log file."""
|
|
57
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
58
|
+
|
|
59
|
+
if not LOG_FILE.exists():
|
|
60
|
+
log_content.write("[yellow]No log file found[/yellow]")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
content = LOG_FILE.read_text()
|
|
65
|
+
all_lines = content.splitlines()
|
|
66
|
+
recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
|
67
|
+
|
|
68
|
+
log_content.clear()
|
|
69
|
+
for line in recent_lines:
|
|
70
|
+
# Color-code different log levels
|
|
71
|
+
if "Error" in line or "error" in line:
|
|
72
|
+
log_content.write(f"[red]{line}[/red]")
|
|
73
|
+
elif "Warning" in line or "warning" in line:
|
|
74
|
+
log_content.write(f"[yellow]{line}[/yellow]")
|
|
75
|
+
elif "Recording started" in line:
|
|
76
|
+
log_content.write(f"[green]{line}[/green]")
|
|
77
|
+
elif "Recording saved" in line:
|
|
78
|
+
log_content.write(f"[cyan]{line}[/cyan]")
|
|
79
|
+
else:
|
|
80
|
+
log_content.write(line)
|
|
81
|
+
|
|
82
|
+
self._last_size = LOG_FILE.stat().st_size
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
log_content.write(f"[red]Error reading log: {e}[/red]")
|
|
86
|
+
|
|
87
|
+
def _check_for_updates(self) -> None:
|
|
88
|
+
"""Check for new log content if in follow mode."""
|
|
89
|
+
if not self._follow_mode:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
follow_switch = self.query_one("#follow-switch", Switch)
|
|
93
|
+
self._follow_mode = follow_switch.value
|
|
94
|
+
|
|
95
|
+
if not self._follow_mode:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if not LOG_FILE.exists():
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
current_size = LOG_FILE.stat().st_size
|
|
103
|
+
if current_size > self._last_size:
|
|
104
|
+
# Read new content
|
|
105
|
+
with open(LOG_FILE, "r") as f:
|
|
106
|
+
f.seek(self._last_size)
|
|
107
|
+
new_content = f.read()
|
|
108
|
+
|
|
109
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
110
|
+
for line in new_content.splitlines():
|
|
111
|
+
if line.strip():
|
|
112
|
+
if "Error" in line or "error" in line:
|
|
113
|
+
log_content.write(f"[red]{line}[/red]")
|
|
114
|
+
elif "Warning" in line or "warning" in line:
|
|
115
|
+
log_content.write(f"[yellow]{line}[/yellow]")
|
|
116
|
+
elif "Recording started" in line:
|
|
117
|
+
log_content.write(f"[green]{line}[/green]")
|
|
118
|
+
elif "Recording saved" in line:
|
|
119
|
+
log_content.write(f"[cyan]{line}[/cyan]")
|
|
120
|
+
else:
|
|
121
|
+
log_content.write(line)
|
|
122
|
+
|
|
123
|
+
self._last_size = current_size
|
|
124
|
+
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
129
|
+
"""Handle button presses."""
|
|
130
|
+
button_id = event.button.id
|
|
131
|
+
|
|
132
|
+
if button_id == "refresh-btn":
|
|
133
|
+
self.action_refresh()
|
|
134
|
+
elif button_id == "clear-btn":
|
|
135
|
+
self.action_clear_display()
|
|
136
|
+
elif button_id == "back-btn":
|
|
137
|
+
self.action_go_back()
|
|
138
|
+
|
|
139
|
+
def on_switch_changed(self, event: Switch.Changed) -> None:
|
|
140
|
+
"""Handle follow switch changes."""
|
|
141
|
+
if event.switch.id == "follow-switch":
|
|
142
|
+
self._follow_mode = event.value
|
|
143
|
+
if event.value:
|
|
144
|
+
self.notify("Follow mode enabled")
|
|
145
|
+
else:
|
|
146
|
+
self.notify("Follow mode disabled")
|
|
147
|
+
|
|
148
|
+
def action_toggle_follow(self) -> None:
|
|
149
|
+
"""Toggle follow mode."""
|
|
150
|
+
switch = self.query_one("#follow-switch", Switch)
|
|
151
|
+
switch.value = not switch.value
|
|
152
|
+
|
|
153
|
+
def action_clear_display(self) -> None:
|
|
154
|
+
"""Clear the log display."""
|
|
155
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
156
|
+
log_content.clear()
|
|
157
|
+
self.notify("Display cleared")
|
|
158
|
+
|
|
159
|
+
def action_refresh(self) -> None:
|
|
160
|
+
"""Refresh the log display."""
|
|
161
|
+
self._load_logs()
|
|
162
|
+
self.notify("Refreshed")
|
|
163
|
+
|
|
164
|
+
def action_go_back(self) -> None:
|
|
165
|
+
"""Go back to the previous screen."""
|
|
166
|
+
self.app.pop_screen()
|