wrkmon 1.0.1__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.
@@ -0,0 +1,258 @@
1
+ """Search view container for wrkmon."""
2
+
3
+ import logging
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Vertical
6
+ from textual.widgets import Static, Input, ListView, ListItem, Label
7
+ from textual.binding import Binding
8
+ from textual.events import Key
9
+ from textual import on
10
+
11
+ from wrkmon.core.youtube import SearchResult
12
+ from wrkmon.ui.messages import TrackSelected, TrackQueued, StatusMessage
13
+ from wrkmon.ui.widgets.result_item import ResultItem
14
+
15
+ logger = logging.getLogger("wrkmon.search")
16
+
17
+
18
+ class LoadMoreItem(ListItem):
19
+ """Special list item for loading more results."""
20
+
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+ self.is_load_more = True
24
+
25
+ def compose(self):
26
+ yield Label(" >>> Load More Results <<<", classes="load-more")
27
+
28
+
29
+ class SearchView(Vertical):
30
+ """Main search view - search YouTube and display results."""
31
+
32
+ BINDINGS = [
33
+ Binding("a", "queue_selected", "Add to Queue", show=True),
34
+ Binding("escape", "clear_search", "Clear", show=True),
35
+ Binding("/", "focus_search", "Search", show=True),
36
+ Binding("space", "play_selected", "Play", show=False, priority=True),
37
+ Binding("r", "toggle_repeat", "Repeat", show=True),
38
+ ]
39
+
40
+ def __init__(self, **kwargs) -> None:
41
+ super().__init__(**kwargs)
42
+ self.results: list[SearchResult] = []
43
+ self._is_searching = False
44
+ self._current_query = ""
45
+ self._load_more_offset = 0
46
+ self._batch_size = 15
47
+
48
+ def compose(self) -> ComposeResult:
49
+ yield Static("SEARCH", id="view-title")
50
+
51
+ with Vertical(id="search-container"):
52
+ yield Input(
53
+ placeholder="Search processes...",
54
+ id="search-input",
55
+ )
56
+
57
+ yield Static(
58
+ " # Process PID Duration Status",
59
+ id="list-header",
60
+ )
61
+
62
+ yield ListView(id="results-list")
63
+ yield Static("Type to search", id="status-bar")
64
+
65
+ def on_mount(self) -> None:
66
+ """Focus the search input on mount."""
67
+ self.query_one("#search-input", Input).focus()
68
+
69
+ @on(Input.Submitted, "#search-input")
70
+ async def handle_search(self, event: Input.Submitted) -> None:
71
+ """Execute search when Enter is pressed."""
72
+ query = event.value.strip()
73
+ if not query or self._is_searching:
74
+ return
75
+
76
+ self._is_searching = True
77
+ self._update_status("Searching...")
78
+ self._current_query = query
79
+ self._load_more_offset = 0
80
+
81
+ try:
82
+ # Access the app's YouTube client
83
+ youtube = self.app.youtube
84
+ self.results = await youtube.search(query, max_results=self._batch_size)
85
+ self._display_results(show_load_more=True)
86
+ except Exception as e:
87
+ self._update_status(f"Search failed: {e}")
88
+ self.post_message(StatusMessage(f"Search error: {e}", "error"))
89
+ finally:
90
+ self._is_searching = False
91
+
92
+ async def _load_more_results(self) -> None:
93
+ """Load more search results."""
94
+ if not self._current_query or self._is_searching:
95
+ return
96
+
97
+ self._is_searching = True
98
+ self._update_status("Loading more...")
99
+
100
+ try:
101
+ youtube = self.app.youtube
102
+ self._load_more_offset += self._batch_size
103
+ # Search with offset by fetching more and skipping existing
104
+ new_results = await youtube.search(
105
+ self._current_query,
106
+ max_results=self._batch_size + self._load_more_offset
107
+ )
108
+ # Get only the new results we don't have yet
109
+ if len(new_results) > len(self.results):
110
+ self.results = new_results
111
+ self._display_results(show_load_more=True)
112
+ else:
113
+ self._update_status(f"No more results | Found {len(self.results)} total")
114
+ except Exception as e:
115
+ self._update_status(f"Load more failed: {e}")
116
+ finally:
117
+ self._is_searching = False
118
+
119
+ def _display_results(self, show_load_more: bool = False) -> None:
120
+ """Display search results in the list."""
121
+ list_view = self.query_one("#results-list", ListView)
122
+ list_view.clear()
123
+
124
+ if not self.results:
125
+ self._update_status("No results found")
126
+ return
127
+
128
+ for i, result in enumerate(self.results, 1):
129
+ list_view.append(ResultItem(result, i))
130
+
131
+ # Add "Load More" option at the end
132
+ if show_load_more:
133
+ list_view.append(LoadMoreItem())
134
+
135
+ # Build status with repeat indicator
136
+ repeat_status = self._get_repeat_status()
137
+ status = f"Found {len(self.results)} | Enter/Space=Play A=Queue R=Repeat{repeat_status}"
138
+ self._update_status(status)
139
+ list_view.focus()
140
+
141
+ def _get_repeat_status(self) -> str:
142
+ """Get repeat mode status string."""
143
+ try:
144
+ mode = self.app.queue.repeat_mode
145
+ if mode == "one":
146
+ return " [REPEAT ONE]"
147
+ elif mode == "all":
148
+ return " [REPEAT ALL]"
149
+ except Exception:
150
+ pass
151
+ return ""
152
+
153
+ def _update_status(self, message: str) -> None:
154
+ """Update the status bar."""
155
+ try:
156
+ self.query_one("#status-bar", Static).update(message)
157
+ except Exception:
158
+ pass
159
+
160
+ def _get_selected(self) -> SearchResult | None:
161
+ """Get the currently selected result."""
162
+ list_view = self.query_one("#results-list", ListView)
163
+ if list_view.highlighted_child is None:
164
+ return None
165
+
166
+ item = list_view.highlighted_child
167
+ if isinstance(item, ResultItem):
168
+ return item.result
169
+ return None
170
+
171
+ def action_clear_search(self) -> None:
172
+ """Clear the search input."""
173
+ search_input = self.query_one("#search-input", Input)
174
+ if search_input.value:
175
+ search_input.value = ""
176
+ search_input.focus()
177
+
178
+ @on(ListView.Selected, "#results-list")
179
+ async def handle_result_selected(self, event: ListView.Selected) -> None:
180
+ """Handle Enter key on a result - play it or load more."""
181
+ if isinstance(event.item, LoadMoreItem):
182
+ await self._load_more_results()
183
+ elif isinstance(event.item, ResultItem):
184
+ result = event.item.result
185
+ self.post_message(TrackSelected(result))
186
+ repeat_status = self._get_repeat_status()
187
+ self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
188
+
189
+ def action_play_selected(self) -> None:
190
+ """Play the selected track (Space key)."""
191
+ list_view = self.query_one("#results-list", ListView)
192
+ if not list_view.has_focus:
193
+ return
194
+ result = self._get_selected()
195
+ if result:
196
+ self.post_message(TrackSelected(result))
197
+ repeat_status = self._get_repeat_status()
198
+ self._update_status(f"Playing: {result.title[:40]}...{repeat_status}")
199
+
200
+ def action_toggle_repeat(self) -> None:
201
+ """Cycle repeat mode (R key)."""
202
+ try:
203
+ mode = self.app.queue.cycle_repeat()
204
+ mode_names = {"none": "OFF", "one": "ONE", "all": "ALL"}
205
+ repeat_status = self._get_repeat_status()
206
+ count = len(self.results) if self.results else 0
207
+ self._update_status(f"Repeat: {mode_names[mode]} | Found {count}{repeat_status}")
208
+ except Exception:
209
+ pass
210
+
211
+ def action_queue_selected(self) -> None:
212
+ """Add selected track to queue."""
213
+ logger.info("=== 'a' PRESSED: action_queue_selected ===")
214
+ result = self._get_selected()
215
+ logger.info(f" Selected result: {result}")
216
+ if result:
217
+ logger.info(f" Posting TrackQueued for: {result.title}")
218
+ self.post_message(TrackQueued(result))
219
+ repeat_status = self._get_repeat_status()
220
+ self._update_status(f"Queued: {result.title[:40]}...{repeat_status}")
221
+ else:
222
+ logger.warning(" No result selected!")
223
+
224
+ def focus_input(self) -> None:
225
+ """Focus the search input (called from parent)."""
226
+ self.query_one("#search-input", Input).focus()
227
+
228
+ def action_focus_search(self) -> None:
229
+ """Focus the search input (/ key)."""
230
+ self.focus_input()
231
+
232
+ def on_key(self, event: Key) -> None:
233
+ """Handle key events - up at top of list goes to search, down from search goes to list."""
234
+ if event.key == "up":
235
+ list_view = self.query_one("#results-list", ListView)
236
+ # If list is focused and at top (index 0), go to search input
237
+ if list_view.has_focus and list_view.index == 0:
238
+ self.focus_input()
239
+ event.prevent_default()
240
+ event.stop()
241
+ elif event.key == "down":
242
+ search_input = self.query_one("#search-input", Input)
243
+ # If search input is focused, go to list
244
+ if search_input.has_focus:
245
+ list_view = self.query_one("#results-list", ListView)
246
+ if len(list_view.children) > 0:
247
+ list_view.focus()
248
+ list_view.index = 0
249
+ event.prevent_default()
250
+ event.stop()
251
+
252
+ def focus_list(self) -> None:
253
+ """Focus the results list (called from parent)."""
254
+ list_view = self.query_one("#results-list", ListView)
255
+ if len(list_view.children) > 0:
256
+ list_view.focus()
257
+ else:
258
+ self.query_one("#search-input", Input).focus()
@@ -0,0 +1,7 @@
1
+ """Reusable widgets for wrkmon TUI."""
2
+
3
+ from wrkmon.ui.widgets.header import HeaderBar
4
+ from wrkmon.ui.widgets.player_bar import PlayerBar
5
+ from wrkmon.ui.widgets.result_item import ResultItem
6
+
7
+ __all__ = ["HeaderBar", "PlayerBar", "ResultItem"]
@@ -0,0 +1,59 @@
1
+ """Header bar widget for wrkmon."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal
5
+ from textual.reactive import reactive
6
+ from textual.widgets import Static
7
+
8
+ from wrkmon.utils.stealth import get_stealth
9
+
10
+
11
+ class HeaderBar(Static):
12
+ """Application header with title and fake system stats."""
13
+
14
+ cpu = reactive(23)
15
+ mem = reactive(45)
16
+
17
+ def __init__(self, **kwargs) -> None:
18
+ super().__init__(**kwargs)
19
+ self._stealth = get_stealth()
20
+
21
+ def compose(self) -> ComposeResult:
22
+ with Horizontal(id="header-inner"):
23
+ yield Static("WRKMON", id="app-title")
24
+ yield Static("", id="current-view")
25
+ yield Static(self._format_stats(), id="sys-stats")
26
+
27
+ def _format_stats(self) -> str:
28
+ return f"CPU:{self.cpu:>3}% MEM:{self.mem:>3}%"
29
+
30
+ def on_mount(self) -> None:
31
+ """Start periodic stats updates."""
32
+ self.set_interval(3.0, self._update_stats)
33
+
34
+ def _update_stats(self) -> None:
35
+ """Update fake system stats."""
36
+ self.cpu = self._stealth.get_fake_cpu()
37
+ self.mem = self._stealth.get_fake_memory()
38
+
39
+ def watch_cpu(self) -> None:
40
+ """React to CPU changes."""
41
+ self._refresh_stats()
42
+
43
+ def watch_mem(self) -> None:
44
+ """React to memory changes."""
45
+ self._refresh_stats()
46
+
47
+ def _refresh_stats(self) -> None:
48
+ """Update the stats display."""
49
+ try:
50
+ self.query_one("#sys-stats", Static).update(self._format_stats())
51
+ except Exception:
52
+ pass
53
+
54
+ def set_view_name(self, name: str) -> None:
55
+ """Update the current view indicator."""
56
+ try:
57
+ self.query_one("#current-view", Static).update(f"/{name.upper()}")
58
+ except Exception:
59
+ pass
@@ -0,0 +1,129 @@
1
+ """Player bar widget - persistent playback controls at the bottom."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.reactive import reactive
6
+ from textual.widgets import Static, ProgressBar
7
+
8
+ from wrkmon.utils.stealth import get_stealth
9
+
10
+
11
+ class PlayerBar(Static):
12
+ """Persistent player bar showing current track and playback controls."""
13
+
14
+ # Reactive state
15
+ title = reactive("No process running")
16
+ is_playing = reactive(False)
17
+ position = reactive(0.0)
18
+ duration = reactive(0.0)
19
+ volume = reactive(80)
20
+ status_text = reactive("") # For showing errors/buffering
21
+ repeat_mode = reactive("none") # none, one, all
22
+
23
+ def __init__(self, **kwargs) -> None:
24
+ super().__init__(**kwargs)
25
+ self._stealth = get_stealth()
26
+
27
+ def compose(self) -> ComposeResult:
28
+ with Vertical(id="player-bar-inner"):
29
+ # Now playing row
30
+ with Horizontal(id="now-playing-row"):
31
+ yield Static("NOW", id="now-label", classes="label")
32
+ yield Static(self._get_status_icon(), id="play-status")
33
+ yield Static(self.title, id="track-title")
34
+ yield Static("", id="repeat-indicator")
35
+
36
+ # Progress row
37
+ with Horizontal(id="progress-row"):
38
+ yield Static(self._format_time(0), id="time-current")
39
+ yield ProgressBar(total=100, show_percentage=False, id="progress")
40
+ yield Static(self._format_time(0), id="time-total")
41
+
42
+ # Volume row
43
+ with Horizontal(id="volume-row"):
44
+ yield Static("VOL", id="vol-label", classes="label")
45
+ yield ProgressBar(total=100, show_percentage=False, id="volume")
46
+ yield Static(f"{self.volume}%", id="vol-value")
47
+
48
+ def _get_status_icon(self) -> str:
49
+ return "▶" if self.is_playing else "■"
50
+
51
+ def _format_time(self, seconds: float) -> str:
52
+ return self._stealth.format_duration(seconds)
53
+
54
+ def _format_title(self, title: str) -> str:
55
+ return self._stealth.get_fake_process_name(title)
56
+
57
+ # Watchers for reactive properties
58
+ def watch_title(self, new_title: str) -> None:
59
+ """Update title display."""
60
+ try:
61
+ display_title = self._format_title(new_title) if new_title else "No process running"
62
+ self.query_one("#track-title", Static).update(display_title)
63
+ except Exception:
64
+ pass
65
+
66
+ def watch_is_playing(self) -> None:
67
+ """Update play/pause icon."""
68
+ try:
69
+ self.query_one("#play-status", Static).update(self._get_status_icon())
70
+ except Exception:
71
+ pass
72
+
73
+ def watch_position(self, new_pos: float) -> None:
74
+ """Update progress bar and time."""
75
+ try:
76
+ self.query_one("#time-current", Static).update(self._format_time(new_pos))
77
+ if self.duration > 0:
78
+ progress = (new_pos / self.duration) * 100
79
+ self.query_one("#progress", ProgressBar).update(progress=progress)
80
+ except Exception:
81
+ pass
82
+
83
+ def watch_duration(self, new_dur: float) -> None:
84
+ """Update total duration display."""
85
+ try:
86
+ self.query_one("#time-total", Static).update(self._format_time(new_dur))
87
+ except Exception:
88
+ pass
89
+
90
+ def watch_volume(self, new_vol: int) -> None:
91
+ """Update volume display."""
92
+ try:
93
+ self.query_one("#volume", ProgressBar).update(progress=new_vol)
94
+ self.query_one("#vol-value", Static).update(f"{new_vol}%")
95
+ except Exception:
96
+ pass
97
+
98
+ def watch_repeat_mode(self, new_mode: str) -> None:
99
+ """Update repeat indicator."""
100
+ try:
101
+ indicator = ""
102
+ if new_mode == "one":
103
+ indicator = "[R1]"
104
+ elif new_mode == "all":
105
+ indicator = "[RA]"
106
+ self.query_one("#repeat-indicator", Static).update(indicator)
107
+ except Exception:
108
+ pass
109
+
110
+ def update_playback(
111
+ self,
112
+ title: str | None = None,
113
+ is_playing: bool | None = None,
114
+ position: float | None = None,
115
+ duration: float | None = None,
116
+ ) -> None:
117
+ """Batch update playback state."""
118
+ if title is not None:
119
+ self.title = title
120
+ if is_playing is not None:
121
+ self.is_playing = is_playing
122
+ if position is not None:
123
+ self.position = position
124
+ if duration is not None:
125
+ self.duration = duration
126
+
127
+ def set_volume(self, volume: int) -> None:
128
+ """Update volume display."""
129
+ self.volume = max(0, min(100, volume))
@@ -0,0 +1,98 @@
1
+ """Result item widget for displaying search results, queue items, etc."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.widgets import ListItem, Label
5
+
6
+ from wrkmon.core.youtube import SearchResult
7
+ from wrkmon.utils.stealth import get_stealth
8
+
9
+
10
+ class ResultItem(ListItem):
11
+ """A list item representing a search result or track."""
12
+
13
+ def __init__(
14
+ self,
15
+ result: SearchResult,
16
+ index: int,
17
+ status: str = "READY",
18
+ show_duration: bool = True,
19
+ **kwargs,
20
+ ) -> None:
21
+ super().__init__(**kwargs)
22
+ self.result = result
23
+ self.index = index
24
+ self.status = status
25
+ self.show_duration = show_duration
26
+ self._stealth = get_stealth()
27
+
28
+ def compose(self) -> ComposeResult:
29
+ process_name = self._stealth.get_fake_process_name(self.result.title)
30
+ pid = self._stealth.get_fake_pid()
31
+
32
+ if self.show_duration:
33
+ duration = self._stealth.format_duration(self.result.duration)
34
+ text = f"{self.index:>2} {process_name:<38} {pid:>5} {duration:>7} {self.status}"
35
+ else:
36
+ text = f"{self.index:>2} {process_name:<42} {pid:>5} {self.status}"
37
+
38
+ yield Label(text, classes="result-text")
39
+
40
+
41
+ class QueueItem(ListItem):
42
+ """A list item representing a queue entry."""
43
+
44
+ def __init__(
45
+ self,
46
+ title: str,
47
+ duration: int,
48
+ index: int,
49
+ is_current: bool = False,
50
+ **kwargs,
51
+ ) -> None:
52
+ super().__init__(**kwargs)
53
+ self.title = title
54
+ self.duration = duration
55
+ self.index = index
56
+ self.is_current = is_current
57
+ self._stealth = get_stealth()
58
+
59
+ def compose(self) -> ComposeResult:
60
+ process_name = self._stealth.get_fake_process_name(self.title)
61
+ duration = self._stealth.format_duration(self.duration)
62
+ status = "RUNNING" if self.is_current else "QUEUED"
63
+ marker = "▶" if self.is_current else " "
64
+
65
+ text = f"{marker} {self.index:>2} {process_name:<40} {duration:>7} {status}"
66
+ yield Label(text, classes="queue-text")
67
+
68
+
69
+ class HistoryItem(ListItem):
70
+ """A list item representing a history entry."""
71
+
72
+ def __init__(
73
+ self,
74
+ title: str,
75
+ duration: int,
76
+ play_count: int,
77
+ last_played: str,
78
+ index: int,
79
+ video_id: str,
80
+ channel: str,
81
+ **kwargs,
82
+ ) -> None:
83
+ super().__init__(**kwargs)
84
+ self.title = title
85
+ self.duration = duration
86
+ self.play_count = play_count
87
+ self.last_played = last_played
88
+ self.index = index
89
+ self.video_id = video_id
90
+ self.channel = channel
91
+ self._stealth = get_stealth()
92
+
93
+ def compose(self) -> ComposeResult:
94
+ process_name = self._stealth.get_fake_process_name(self.title)[:32]
95
+ duration = self._stealth.format_duration(self.duration)
96
+
97
+ text = f"{self.index:>2} {process_name:<34} {duration:>7} {self.play_count:>3}x {self.last_played}"
98
+ yield Label(text, classes="history-text")
@@ -0,0 +1,6 @@
1
+ """Utility modules for wrkmon."""
2
+
3
+ from wrkmon.utils.config import Config
4
+ from wrkmon.utils.stealth import StealthManager
5
+
6
+ __all__ = ["Config", "StealthManager"]