diff --git a/src/app.py b/src/app.py index d224c15..835c2bd 100644 --- a/src/app.py +++ b/src/app.py @@ -1,4 +1,3 @@ -# app.py from textual.app import App, ComposeResult from textual.screen import Screen from .ui.views.nest import NewFileDialog diff --git a/src/config/theme.tcss b/src/config/theme.tcss index 88be2fb..1c4f9a2 100644 --- a/src/config/theme.tcss +++ b/src/config/theme.tcss @@ -29,7 +29,6 @@ MainMenu { height: 100%; overflow: hidden; padding: 1; - transition: width 0.10s; } @@ -80,8 +79,8 @@ MenuItem:focus { .main-container { width: 100%; height: 100%; + background: $surface-darken-2; layout: horizontal; - overflow: hidden; } @@ -562,19 +561,23 @@ DashboardCard { width: auto; align: right middle; padding: 0; + text-style: none; +} + +.filter-btn:hover { + background: $surface-lighten-1; + color: $text; + height: 2; } .filter-btn { background: transparent; min-width: 4; - height: 3; + height: 2; padding: 0 1; margin-left: 1; border: none; -} - -.filter-btn:hover { - background: $surface-lighten-1; + text-style: none; } .filter-btn.active { @@ -667,13 +670,6 @@ FilterableDirectoryTree:focus { height: 100%; } -.main-container { - width: 100%; - height: 100%; - layout: horizontal; - -} - .new-file-btn { width: 4; height: 3; @@ -1049,14 +1045,15 @@ SpotifyPlayer Horizontal { background: $surface-darken-2; padding: 1; dock: right; + border-left: solid $accent; } -/* Main Content Area */ .main-content { width: 1fr; height: 100%; padding: 1; background: $surface-darken-2; + layout: horizontal; } /* Ensure the playlists section fills available space */ @@ -1157,6 +1154,10 @@ SpotifyView { color: $accent; } +.track-title:hover { + color: $accent; +} + .result-artist:hover { background: transparent; color: $accent; @@ -1172,8 +1173,9 @@ SpotifyView { } .tracks-scroll { - height: 100%; - border: solid $background; + height: 1fr; + overflow-y: auto; + width: 100%; } .result-artist { @@ -1265,6 +1267,7 @@ SettingsView { padding: 1 2; align: center middle; layout: grid; /* Add this as well */ + overflow: auto; } .settings-layout { @@ -1284,6 +1287,8 @@ SettingsView { width: 100%; height: 100%; padding: 1 2; + padding-left: 10; + overflow: auto; } .setting-button { @@ -1344,8 +1349,80 @@ ThemeButton:focus { grid-size: 2; grid-columns: 1fr 1fr; grid-rows: auto; - grid-gutter: 2 1; /* Reduced gutter, horizontal spacing only */ - padding: 0; /* Remove padding */ - align: center middle; /* Center the entire grid */ - margin-right: 44; /* Add right margin to center the grid */ + grid-gutter: 2 1; + padding: 0; + align: center middle; + margin-right: 44; + overflow: auto; +} + +.playlist-view { + width: 33%; + height: 100%; + dock: left; + border-right: solid $secondary; + +} + +.recently-played-view { + width: 33%; + dock: right; + height: 100%; +} + +.search-view { + width: 100%; + height: 100%; + layout: vertical; + border-right: solid $secondary; +} + + +.results-section-header { + dock: top; + padding: 1; + background: $surface-darken-2; + color: $text; + text-style: bold; + border-bottom: solid $primary; +} +.search-input { + width: 100%; + height: 3; + padding: 1; + background: $primary 19%; + border: none; + color: $text; +} + +.search-input:focus { + background: white 20%; + color: $text; +} + +.search-results-area { + width: 100%; + height: 1fr; +} + +LoadingScreen { + background: $surface-darken-2; +} + +.loading-container { + width: 100%; + height: 100%; + align: center middle; + dock: right; +} + +.loading-text { + color: $text; + text-align: center; + margin-bottom: 1; +} + +.loading-animation { + color: $accent; + text-align: center; } \ No newline at end of file diff --git a/src/core/database/tick_db.py b/src/core/database/tick_db.py index 95bffce..c67a359 100644 --- a/src/core/database/tick_db.py +++ b/src/core/database/tick_db.py @@ -41,6 +41,13 @@ def _create_tables(self) -> None: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + """) cursor.execute("SELECT id, due_time FROM tasks") tasks = cursor.fetchall() @@ -66,7 +73,23 @@ def add_task(self, title: str, due_date: str, due_time: str, description: str = """, (title, description, due_date, formatted_time)) conn.commit() return cursor.lastrowid or 0 - + + def is_first_launch(self) -> bool: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM settings WHERE key = 'first_launch'") + result = cursor.fetchone() + return result is None + + def mark_first_launch_complete(self) -> None: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO settings (key, value) + VALUES ('first_launch', 'completed') + """) + conn.commit() + def get_tasks_for_date(self, date: str) -> List[Dict[str, Any]]: with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row diff --git a/src/ui/screens/home.py b/src/ui/screens/home.py index f4818cf..cca6053 100644 --- a/src/ui/screens/home.py +++ b/src/ui/screens/home.py @@ -1,4 +1,3 @@ -# home.py from textual.app import ComposeResult from textual.containers import Container, ScrollableContainer from textual.screen import Screen @@ -14,6 +13,8 @@ from typing import Optional from ..views.pomodoro import PomodoroView from ..views.spotify import SpotifyView +from .loading_screen import LoadingScreen +import asyncio class MenuItem(Button): @@ -140,52 +141,105 @@ def on_button_pressed(self, event: Button.Pressed) -> None: content_container = self.query_one("#content") button_id = event.button.id menu = self.query_one("MainMenu") - - content_container.remove_children() - + + menu.add_class("hidden") + menu.styles.display = "none" + try: if button_id == "menu_home": - menu.add_class("hidden") - menu.styles.display = "none" - home_view = WelcomeView() - content_container.mount(home_view) - try: - tab = home_view.query("TabButton").first() - if tab: - tab.focus() - except Exception: - home_view.focus() - + loading = LoadingScreen(animation_style=0) + content_container.remove_children() + content_container.mount(loading) + + async def load_home(): + await loading.start_loading(0.2) + content_container.remove_children() + home_view = WelcomeView() + content_container.mount(home_view) + try: + tab = home_view.query("TabButton").first() + if tab: + tab.focus() + except Exception: + home_view.focus() + + asyncio.create_task(load_home()) + elif button_id == "menu_calendar": - menu.add_class("hidden") - menu.styles.display = "none" - calendar_view = CalendarView() - content_container.mount(calendar_view) - try: - if hasattr(calendar_view, 'get_initial_focus'): - initial_focus = calendar_view.get_initial_focus() - if initial_focus: - initial_focus.focus() - except Exception: - calendar_view.focus() + loading = LoadingScreen(animation_style=1) + content_container.remove_children() + content_container.mount(loading) + + async def load_calendar(): + await loading.start_loading(0) + content_container.remove_children() + calendar_view = CalendarView() + content_container.mount(calendar_view) + try: + if hasattr(calendar_view, 'get_initial_focus'): + initial_focus = calendar_view.get_initial_focus() + if initial_focus: + initial_focus.focus() + except Exception: + calendar_view.focus() + + asyncio.create_task(load_calendar()) + elif button_id == "menu_nest": - menu.add_class("hidden") - nest_view = NestView() - content_container.mount(nest_view) + loading = LoadingScreen(animation_style=2) + content_container.remove_children() + content_container.mount(loading) + + async def load_nest(): + await loading.start_loading(0) + content_container.remove_children() + nest_view = NestView() + content_container.mount(nest_view) + + asyncio.create_task(load_nest()) + elif button_id == "menu_pomodoro": - menu.add_class("hidden") - pomo_view = PomodoroView() - content_container.mount(pomo_view) + loading = LoadingScreen(animation_style=3) + content_container.remove_children() + content_container.mount(loading) + + async def load_pomodoro(): + await loading.start_loading(0) + content_container.remove_children() + pomo_view = PomodoroView() + content_container.mount(pomo_view) + + asyncio.create_task(load_pomodoro()) + elif button_id == "menu_spotify": - menu.add_class("hidden") - spotify_view = SpotifyView() - content_container.mount(spotify_view) + loading = LoadingScreen(animation_style=0) + content_container.remove_children() + content_container.mount(loading) + + async def load_spotify(): + await loading.start_loading(2.0) + content_container.remove_children() + spotify_view = SpotifyView() + content_container.mount(spotify_view) + + asyncio.create_task(load_spotify()) + elif button_id == "menu_settings": - menu.add_class("hidden") - settings_view = SettingsView() - content_container.mount(settings_view) + loading = LoadingScreen(animation_style=1) + content_container.remove_children() + content_container.mount(loading) + + async def load_settings(): + await loading.start_loading(0) + content_container.remove_children() + settings_view = SettingsView() + content_container.mount(settings_view) + + asyncio.create_task(load_settings()) + elif button_id == "menu_exit": self.action_quit_app() + except Exception as e: self.notify(f"Error: {str(e)}") @@ -221,3 +275,38 @@ def action_focus_next(self) -> None: def get_initial_focus(self) -> Optional[Widget]: return self.query_one("MenuItem") + + + async def _switch_view(self, view_class, *args, **kwargs): + content_container = self.query_one("#content") + content_container.remove_children() + + loading = LoadingScreen(animation_style=0) + content_container.mount(loading) + + new_view = view_class(*args, **kwargs) + + loading_task = asyncio.create_task(loading.start_loading(0.3)) + + try: + await loading_task + + content_container.remove_children() + content_container.mount(new_view) + + try: + if isinstance(new_view, WelcomeView): + tab = new_view.query("TabButton").first() + if tab: + tab.focus() + elif hasattr(new_view, 'get_initial_focus'): + initial_focus = new_view.get_initial_focus() + if initial_focus: + initial_focus.focus() + else: + new_view.focus() + except Exception: + new_view.focus() + + except Exception as e: + self.notify(f"Error: {str(e)}") \ No newline at end of file diff --git a/src/ui/screens/loading_screen.py b/src/ui/screens/loading_screen.py new file mode 100644 index 0000000..f82cf2c --- /dev/null +++ b/src/ui/screens/loading_screen.py @@ -0,0 +1,49 @@ +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import Static +from textual.message import Message +import asyncio + +class LoadingScreen(Container): + + class LoadingComplete(Message): + pass + + ANIMATIONS = [ + "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", + "▁▂▃▄▅▆▇█▇▆▅▄▃▂▁", + "⣾⣽⣻⢿⡿⣟⣯⣷", + "←↖↑↗→↘↓↙" + ] + + def __init__(self, animation_style: int = 0) -> None: + super().__init__() + self.animation = self.ANIMATIONS[animation_style] + self.frame = 0 + self.running = True + + def compose(self) -> ComposeResult: + yield Container( + Static("Loading...", id="loading-text", classes="loading-text"), + Static("", id="loading-animation", classes="loading-animation"), + classes="loading-container" + ) + + async def animate(self) -> None: + animation_widget = self.query_one("#loading-animation") + while self.running: + self.frame = (self.frame + 1) % len(self.animation) + animation_widget.update(self.animation[self.frame]) + await asyncio.sleep(0.1) + + def on_mount(self) -> None: + self.styles.align_horizontal = "center" + self.styles.align_vertical = "middle" + self.styles.width = "100%" + self.styles.height = "100%" + asyncio.create_task(self.animate()) + + async def start_loading(self, duration: float = 2.0) -> None: + await asyncio.sleep(duration) + self.running = False + self.post_message(self.LoadingComplete()) diff --git a/src/ui/views/nest.py b/src/ui/views/nest.py index 4fd30be..bcd375a 100644 --- a/src/ui/views/nest.py +++ b/src/ui/views/nest.py @@ -627,7 +627,6 @@ async def action_new_file(self) -> None: def open_file(self, filepath: str) -> None: - """Open a file in the editor.""" try: with open(filepath, 'r', encoding='utf-8') as file: content = file.read() diff --git a/src/ui/views/pomodoro.py b/src/ui/views/pomodoro.py index 7740fbf..4a3e870 100644 --- a/src/ui/views/pomodoro.py +++ b/src/ui/views/pomodoro.py @@ -1,4 +1,3 @@ -# pomodoro.py - Pomodoro timer view from textual.app import ComposeResult, App from textual.containers import Container, Vertical, Horizontal from textual.widgets import Button, Static, Input, Label diff --git a/src/ui/views/spotify.py b/src/ui/views/spotify.py index 02c18b9..fc12d7a 100644 --- a/src/ui/views/spotify.py +++ b/src/ui/views/spotify.py @@ -1,8 +1,8 @@ -# spotify.py from textual.app import ComposeResult from textual.containers import Container, Horizontal, ScrollableContainer from textual.widgets import Button, Static, Input from textual import work +from textual.worker import get_current_worker import asyncio from ...core.database.tick_db import CalendarDB from textual.binding import Binding @@ -51,18 +51,15 @@ def __init__(self, db: CalendarDB): self._try_restore_session() def _try_restore_session(self) -> bool: - """Try to restore a previous session from stored tokens.""" stored_tokens = self.db.get_spotify_tokens() if not stored_tokens: return False - # check if current token is still valid expiry = datetime.fromisoformat(stored_tokens['token_expiry']) if expiry > datetime.now(): self.spotify_client = spotipy.Spotify(auth=stored_tokens['access_token']) return True - # token expired, try to refresh try: token_info = self.sp_oauth.refresh_access_token(stored_tokens['refresh_token']) if token_info: @@ -105,33 +102,8 @@ def start_auth(self) -> bool: return False return False -class SearchResult(Static): - class Selected(Message): - def __init__(self, result_id: str, result_type: str) -> None: - self.result_id = result_id - self.result_type = result_type - super().__init__() - - def __init__(self, title: str, result_id: str, result_type: str, artist: str = "") -> None: - super().__init__() - self.title = title - self.result_id = result_id - self.result_type = result_type - self.artist = artist - - def compose(self) -> ComposeResult: - self.classes = "spotify-track-button" - yield Static(f"{'🎵' if self.result_type == 'track' else '📁'} {self.title}") - if self.artist: - yield Static(f" by {self.artist}", classes="result-artist") - - def on_click(self) -> None: - self.post_message(self.Selected(self.result_id, self.result_type)) - self.notify(f"Now playing - {self.title}{f' by {self.artist}' if self.artist else ''}") - class SpotifyPlayer(Container): def notify_current_track(self, spotify_client): - """Helper method to notify current track info.""" try: current = spotify_client.current_playback() if current and current.get('item'): @@ -184,7 +156,7 @@ async def on_button_pressed(self, event: Button.Pressed): self.notify("No playlist context found. Try selecting a track from a playlist.", severity="warning") return spotify_client.next_track() - await asyncio.sleep(0.5) # Wait for track change + await asyncio.sleep(0.5) self.notify_current_track(spotify_client) except Exception as e: print(f"Next track error: {str(e)}") @@ -197,7 +169,7 @@ async def on_button_pressed(self, event: Button.Pressed): self.notify("No playlist context found. Try selecting a track from a playlist.", severity="warning") return spotify_client.previous_track() - await asyncio.sleep(0.5) # Wait for track change + await asyncio.sleep(0.5) self.notify_current_track(spotify_client) except Exception as e: print(f"Previous track error: {str(e)}") @@ -233,13 +205,9 @@ def compose(self) -> ComposeResult: classes="search-container" ) + class SearchResult(Static): - """ - A widget representing a search result (track or playlist) in the Spotify interface. - """ - class Selected(Message): - """Message emitted when a search result is selected.""" def __init__(self, result_id: str, result_type: str, position: int = None) -> None: self.result_id = result_id self.result_type = result_type @@ -254,15 +222,6 @@ def __init__( artist: str = "", position: int = None ) -> None: - """Initialize a new search result widget. - - Args: - title: The title of the track or playlist - result_id: The Spotify ID of the track or playlist - result_type: Either 'track' or 'playlist' - artist: The artist name (for tracks only) - position: The position of this track in its playlist (optional) - """ super().__init__() self.title = title self.result_id = result_id @@ -271,14 +230,13 @@ def __init__( self.position = position def compose(self) -> ComposeResult: - """Compose the widget's view.""" self.classes = "spotify-track-button" - yield Static(f"{'🎵' if self.result_type == 'track' else '📁'} {self.title}") - if self.artist: - yield Static(f" by {self.artist}", classes="result-artist") + if self.result_type == 'track': + yield Static(f"🎵 {self.title} - {self.artist}") + else: + yield Static(f"📁 {self.title}") def on_click(self) -> None: - """Handle click events by posting a Selected message.""" self.post_message(self.Selected( self.result_id, self.result_type, @@ -292,13 +250,11 @@ def compose(self) -> ComposeResult: classes="playlists-scroll" ) - # debug log to test pulling of user playlists def load_playlists(self, spotify_client): with open("spotify_debug.log", "a") as f: f.write("\n--- Starting playlist load ---\n") if spotify_client: try: - # Try to ensure client is valid first f.write("Testing connection...\n") spotify_client.current_user() @@ -339,6 +295,39 @@ def load_playlists(self, spotify_client): else: f.write("No Spotify client available!\n") return False + +class RecentlyPlayedView(Container): + def compose(self) -> ComposeResult: + yield Static("Recently Played", id="recently-played-title", classes="content-header-cont") + yield ScrollableContainer( + id="recently-played-container", + classes="tracks-scroll" + ) + + def load_recent_tracks(self, spotify_client) -> None: + if not spotify_client: + self.notify("No spotify client") + return + + try: + results = spotify_client.current_user_recently_played(limit=20) + tracks_container = self.query_one("#recently-played-container") + if tracks_container: + tracks_container.remove_children() + + for i, item in enumerate(results['items']): + track = item['track'] + artist_names = ", ".join(artist['name'] for artist in track['artists']) + tracks_container.mount(SearchResult( + track['name'], + track['id'], + 'track', + artist_names, + position=i + )) + except Exception as e: + self.notify(f"Error loading recent tracks: {str(e)}") + self.query_one("#recently-played-title").update("Error loading recent tracks") class PlaylistView(Container): def __init__(self) -> None: @@ -346,7 +335,6 @@ def __init__(self) -> None: self.current_playlist_id = None def compose(self) -> ComposeResult: - """Create the playlist view components.""" yield Static("Select a playlist", id="playlist-title", classes="content-header-cont") yield ScrollableContainer( id="tracks-container", @@ -368,11 +356,12 @@ def load_playlist(self, spotify_client, playlist_id: str) -> None: self.query_one("#playlist-title").update("Liked Songs") for i, item in enumerate(results['items']): track_info = item['track'] + artist_names = ", ".join(artist['name'] for artist in track_info['artists']) tracks_container.mount(SearchResult( track_info['name'], track_info['id'], 'track', - ", ".join(artist['name'] for artist in track_info['artists']), + artist_names, position=i )) else: @@ -381,20 +370,116 @@ def load_playlist(self, spotify_client, playlist_id: str) -> None: for i, item in enumerate(playlist['tracks']['items']): track_info = item['track'] if track_info: + artist_names = ", ".join(artist['name'] for artist in track_info['artists']) tracks_container.mount(SearchResult( track_info['name'], track_info['id'], 'track', - ", ".join(artist['name'] for artist in track_info['artists']), + artist_names, position=i )) except Exception as e: print(f"Error loading playlist: {e}") self.query_one("#playlist-title").update("Error loading playlist") +class SearchView(Container): + def compose(self) -> ComposeResult: + yield Static("Search", id="search-title", classes="content-header-cont") + yield Input(placeholder="Search tracks and playlists...", id="search-input", classes="search-input") + yield Container( + Static("Results", classes="results-section-header"), + ScrollableContainer( + id="search-results-container", + classes="tracks-scroll" + ), + classes="search-results-area" + ) + def on_mount(self) -> None: + self._search_id = 0 + self._current_worker = None + def on_input_changed(self, event: Input.Changed) -> None: + query = event.value.strip() + + if not query: + results_container = self.query_one("#search-results-container") + if results_container: + results_container.remove_children() + return + self._search_id += 1 + + self.run_search(query, self._search_id) + @work + async def run_search(self, query: str, search_id: int) -> None: + await asyncio.sleep(0.5) + + if search_id != self._search_id: + return + spotify_client = self.app.get_spotify_client() + if not spotify_client: + return + worker = get_current_worker() + + try: + results = spotify_client.search(q=query, type='track,playlist', limit=10) + + if worker.is_cancelled: + return + + results_container = self.query_one("#search-results-container") + if results_container: + results_container.remove_children() + if results.get('tracks', {}).get('items'): + for track in results['tracks']['items']: + if worker.is_cancelled: + return + results_container.mount(SearchResult( + track['name'], + track['id'], + 'track', + ", ".join(artist['name'] for artist in track['artists']) + )) + if results.get('playlists', {}).get('items'): + for playlist in results['playlists']['items']: + if worker.is_cancelled: + return + results_container.mount(SearchResult( + playlist['name'], + playlist['id'], + 'playlist' + )) + except spotipy.exceptions.SpotifyException as e: + if not worker.is_cancelled and search_id == self._search_id: + print(f"Spotify API error during search: {str(e)}") + self.app.notify("Unable to complete search", severity="error") + except Exception as e: + if not worker.is_cancelled and search_id == self._search_id: + print(f"Non-critical search error (handled): {str(e)}") + class MainContent(Container): def compose(self) -> ComposeResult: - yield PlaylistView() + yield Container( + PlaylistView(), + classes="playlist-view" + ) + yield Container( + RecentlyPlayedView(), + classes="recently-played-view" + ) + yield Container( + SearchView(), + classes="search-view" + ) + + def on_mount(self) -> None: + print("MainContent mounted") + if self.app.get_spotify_client(): + print("Spotify client found, loading recent tracks") + recently_played = self.query_one(RecentlyPlayedView) + if recently_played: + print("Found RecentlyPlayedView, loading tracks...") + recently_played.load_recent_tracks(self.app.get_spotify_client()) + else: + print("Failed to find RecentlyPlayedView") class SpotifyView(Container): BINDINGS = [ @@ -404,16 +489,14 @@ class SpotifyView(Container): def __init__(self): super().__init__() self.auth = SpotifyAuth(self.app.db) - - # If we restored a session, update the UI if self.auth.spotify_client: self.app.set_spotify_auth(self.auth) - # We'll need to call this after mount self.call_after_refresh = True else: self.call_after_refresh = False def on_mount(self) -> None: + self._search_id = 0 if self.call_after_refresh: library_section = self.query_one(LibrarySection) library_section.load_playlists(self.auth.spotify_client) @@ -421,7 +504,7 @@ def on_mount(self) -> None: def compose(self) -> ComposeResult: yield SpotifyPlayer() yield Container( - Static("Spotify - Connected 🟢", id="status-bar-title", classes="status-item"), + Static(f"Spotify - {'Connected 🟢' if self.auth.spotify_client else 'Not Connected 🔴'}", id="status-bar-title", classes="status-item"), classes="status-bar" ) yield Container( @@ -450,10 +533,9 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "auth-btn": self.notify("Starting Spotify authentication...") self.action_authenticate() - self.app.set_spotify_auth(self.auth) + self.app.set_spotify_auth(self.auth) event.stop() - @work async def do_auth(self): self.auth.start_auth() @@ -465,11 +547,23 @@ async def do_auth(self): self.auth.spotify_client = spotipy.Spotify(auth=token_info["access_token"]) library_section = self.query_one(LibrarySection) library_section.load_playlists(self.auth.spotify_client) + + main_content = self.query_one(MainContent) + recently_played = main_content.query_one(RecentlyPlayedView) + if recently_played: + recently_played.load_recent_tracks(self.auth.spotify_client) + self.notify("Successfully connected to Spotify!") + status_bar = self.query_one("#status-bar-title") + status_bar.update("Spotify - Connected 🟢") else: self.notify("Failed to authenticate with Spotify", severity="error") + status_bar = self.query_one("#status-bar-title") + status_bar.update("Spotify - Not Connected 🔴") except: self.notify("Failed to authenticate with Spotify", severity="error") + status_bar = self.query_one("#status-bar-title") + status_bar.update("Spotify - Not Connected 🔴") def action_authenticate(self): self.do_auth() @@ -483,42 +577,66 @@ def on_input_changed(self, event: Input.Changed) -> None: query = event.value.strip() if not query: - self.query_one(SearchResult).query_one("#results-container").remove_children() + try: + results_container = self.query_one(SearchResult).query_one("#search-results-container") + if results_container: + results_container.remove_children() + except: + pass + return + + self._search_id += 1 + self.run_search(query, self._search_id) + + @work + async def run_search(self, query: str, search_id: int) -> None: + await asyncio.sleep(0.5) + + if search_id != self._search_id: + return + spotify_client = self.app.get_spotify_client() + if not spotify_client: return + worker = get_current_worker() try: - results = self.auth.spotify_client.search(q=query, type='track,playlist', limit=5) - - results_container = self.query_one(SearchResult).query_one("#results-container") - results_container.remove_children() + results = spotify_client.search(q=query, type='track,playlist', limit=10) - if results['tracks']['items']: - results_container.mount(Static("Songs", classes="results-section-header")) - for track in results['tracks']['items']: - results_container.mount(SearchResult( - track['name'], - track['id'], - 'track', - ", ".join(artist['name'] for artist in track['artists']) - )) + if worker.is_cancelled: + return - if results['playlists']['items']: - results_container.mount(Static("Playlists", classes="results-section-header")) - for playlist in results['playlists']['items']: - results_container.mount(SearchResult( - playlist['name'], - playlist['id'], - 'playlist' - )) + results_container = self.query_one("#search-results-container") + if results_container: + results_container.remove_children() + if results.get('tracks', {}).get('items'): + for track in results['tracks']['items']: + if worker.is_cancelled: + return + results_container.mount(SearchResult( + track['name'], + track['id'], + 'track', + ", ".join(artist['name'] for artist in track['artists']) + )) + if results.get('playlists', {}).get('items'): + for playlist in results['playlists']['items']: + if worker.is_cancelled: + return + results_container.mount(SearchResult( + playlist['name'], + playlist['id'], + 'playlist' + )) except Exception as e: - print(f"Search error: {e}") - self.notify("Search failed", severity="error") + if not worker.is_cancelled and search_id == self._search_id: + print(f"Search error: {str(e)}") def on_playlist_item_selected(self, message: PlaylistItem.Selected) -> None: playlist_view = self.query_one(PlaylistView) playlist_view.load_playlist(self.auth.spotify_client, message.playlist_id) - def on_search_result_selected(self, message: SearchResult.Selected) -> None: + @work + async def on_search_result_selected(self, message: SearchResult.Selected) -> None: if message.result_type == 'playlist': playlist_view = self.query_one(PlaylistView) playlist_view.load_playlist(self.auth.spotify_client, message.result_id) @@ -531,26 +649,23 @@ def on_search_result_selected(self, message: SearchResult.Selected) -> None: active_device = next((d for d in devices['devices'] if d['is_active']), devices['devices'][0]) - # Get the current playlist context if we're in one playlist_view = self.query_one(PlaylistView) current_playlist_id = playlist_view.current_playlist_id if current_playlist_id and current_playlist_id != "liked_songs": - # If we have a position, use it directly if message.position is not None: self.auth.spotify_client.start_playback( device_id=active_device['id'], context_uri=f"spotify:playlist:{current_playlist_id}", offset={"position": message.position} ) - # Get current playing track info + await asyncio.sleep(0.5) current = self.auth.spotify_client.current_playback() if current and current.get('item'): track = current['item'] artist_names = ", ".join(artist['name'] for artist in track['artists']) self.notify(f"Now playing - {track['name']} by {artist_names}") else: - # Otherwise fall back to searching for the track in the playlist playlist = self.auth.spotify_client.playlist(current_playlist_id) track_uris = [track['track']['uri'] for track in playlist['tracks']['items'] if track['track']] @@ -561,20 +676,36 @@ def on_search_result_selected(self, message: SearchResult.Selected) -> None: context_uri=f"spotify:playlist:{current_playlist_id}", offset={"position": track_index} ) + await asyncio.sleep(0.5) + current = self.auth.spotify_client.current_playback() + if current and current.get('item'): + track = current['item'] + artist_names = ", ".join(artist['name'] for artist in track['artists']) + self.notify(f"Now playing - {track['name']} by {artist_names}") except ValueError: - # Track not found in current playlist, play individually self.auth.spotify_client.start_playback( device_id=active_device['id'], uris=[f"spotify:track:{message.result_id}"] ) + await asyncio.sleep(0.5) + current = self.auth.spotify_client.current_playback() + if current and current.get('item'): + track = current['item'] + artist_names = ", ".join(artist['name'] for artist in track['artists']) + self.notify(f"Now playing - {track['name']} by {artist_names}") else: - # No playlist context or in Liked Songs, play track directly self.auth.spotify_client.start_playback( device_id=active_device['id'], uris=[f"spotify:track:{message.result_id}"] ) + await asyncio.sleep(0.5) + current = self.auth.spotify_client.current_playback() + if current and current.get('item'): + track = current['item'] + artist_names = ", ".join(artist['name'] for artist in track['artists']) + self.notify(f"Now playing - {track['name']} by {artist_names}") except Exception as e: - self.notify(f"Playback error: {str(e)}", severity="error") + self.notify(f"Playback error: {str(e)}", severity="error") def action_focus_search(self) -> None: search_input = self.query_one("Input") @@ -587,4 +718,9 @@ def action_refresh(self) -> None: playlist_view = self.query_one(PlaylistView) if playlist_view.current_playlist_id: - playlist_view.load_playlist(self.auth.spotify_client, playlist_view.current_playlist_id) \ No newline at end of file + playlist_view.load_playlist(self.auth.spotify_client, playlist_view.current_playlist_id) + + main_content = self.query_one(MainContent) + recently_played = main_content.query_one(RecentlyPlayedView) + if recently_played: + recently_played.load_recent_tracks(self.auth.spotify_client) \ No newline at end of file diff --git a/src/ui/views/welcome.py b/src/ui/views/welcome.py index 0fb4ed6..8bac130 100644 --- a/src/ui/views/welcome.py +++ b/src/ui/views/welcome.py @@ -150,7 +150,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: event.stop() spotify_client.previous_track() self.poll_spotify_now_playing() - # Wait briefly for track info to update playback = spotify_client.current_playback() if playback and playback.get("item"): track_name = playback["item"]["name"] @@ -160,7 +159,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: event.stop() spotify_client.next_track() self.poll_spotify_now_playing() - # Wait briefly for track info to update playback = spotify_client.current_playback() if playback and playback.get("item"): track_name = playback["item"]["name"] @@ -207,7 +205,7 @@ def compose(self) -> ComposeResult: with Container(classes="tasks-card"): with DashboardCard("Today's Tasks"): with Vertical(id="today-tasks-list", classes="tasks-list"): - yield Static("Loading tasks...", classes="empty-schedule") + yield Static("No Tasks - Head over to your calendar to add some!", classes="empty-schedule") with Container(classes="right-column"): with Grid(classes="right-top-grid"): @@ -288,7 +286,6 @@ def fetch_and_cache_quotes(self): except requests.RequestException as e: print(f"Error fetching quotes: {e}") - # Add method to update Now Playing info def update_now_playing(self, track_name: str, artist_name: str) -> None: now_playing = self.query_one(NowPlayingCard) if now_playing: @@ -315,16 +312,16 @@ class WelcomeContent(Container): } """ - def compose(self): + def compose(self) -> ComposeResult: yield WelcomeMessage("║ Welcome to TICK ║") yield WelcomeMessage("") - yield WelcomeMessage("For detailed instructions, reference the docs on the GitHub | https://github.com/cachebag/tick ") + yield WelcomeMessage("For detailed instructions, [yellow]reference the docs on the GitHub[/yellow] | [link=https://github.com/cachebag/tick]https://github.com/cachebag/tick[/link]") yield WelcomeMessage("") yield WelcomeMessage("Navigation:") - yield WelcomeMessage("• Use ↑/↓ arrows to navigate the menu") - yield WelcomeMessage("• Press Enter to select a menu item") - yield WelcomeMessage("• Press Ctrl+Q to quit") - yield WelcomeMessage("• Press Esc to toggle the menu") + yield WelcomeMessage("• Use [red]↑/↓ ←/→ [/red] arrows to navigate the program") + yield WelcomeMessage("• Press [red]Enter[/red] to select an item") + yield WelcomeMessage("• Press [red]Ctrl+Q[/red] to quit") + yield WelcomeMessage("• Press [red]Esc[/red] to toggle the main menu options") yield WelcomeMessage("") yield WelcomeMessage("Select an option from the menu to begin (you can use your mouse too, we don't judge.)") @@ -393,18 +390,33 @@ def compose(self) -> ComposeResult: yield WelcomeContent() def on_mount(self) -> None: - today_tab = self.query_one("TabButton#tab_today") - today_tab.toggle_active(True) - today_tab.focus() + is_first_time = self.app.db.is_first_launch() - welcome_content = self.query_one(WelcomeContent) - welcome_content.styles.display = "none" - today_content = self.query_one(TodayContent) - today_content.styles.display = "block" + today_tab = self.query_one("TabButton#tab_today") + welcome_tab = self.query_one("TabButton#tab_welcome") - today = datetime.now().strftime('%Y-%m-%d') - tasks = self.app.db.get_tasks_for_date(today) - today_content.mount_tasks(tasks) + if is_first_time: + welcome_tab.toggle_active(True) + welcome_tab.focus() + + welcome_content = self.query_one(WelcomeContent) + welcome_content.styles.display = "block" + today_content = self.query_one(TodayContent) + today_content.styles.display = "none" + + self.app.db.mark_first_launch_complete() + else: + today_tab.toggle_active(True) + today_tab.focus() + + welcome_content = self.query_one(WelcomeContent) + welcome_content.styles.display = "none" + today_content = self.query_one(TodayContent) + today_content.styles.display = "block" + + today = datetime.now().strftime('%Y-%m-%d') + tasks = self.app.db.get_tasks_for_date(today) + today_content.mount_tasks(tasks) def get_initial_focus(self) -> Optional[Widget]: return self.query_one(TabButton, id="tab_today")