nexus-tui 0.1.4__py3-none-any.whl → 0.1.5__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,250 @@
1
+ """Screen for selecting a project directory.
2
+
3
+ Allows users to browse potential project directories and choose one as the
4
+ context for a tool execution. Also supports creating new projects.
5
+ """
6
+
7
+
8
+
9
+ from nexus.config import get_project_root
10
+ from nexus.models import Project, Tool
11
+ from typing import Any
12
+ from nexus.widgets.tool_list_item import ProjectListItem
13
+ from textual.app import ComposeResult
14
+ from textual.containers import Container
15
+ from textual.screen import Screen
16
+ from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, LoadingIndicator
17
+ from thefuzz import process
18
+
19
+
20
+ class ProjectPicker(Screen[None]):
21
+ """Screen for selecting a project directory.
22
+
23
+ Displays a searchable list of projects found in the configured root directory.
24
+ Allows creating new projects or selecting an existing one.
25
+
26
+ Attributes:
27
+ selected_tool: The tool that was selected and requires a project context.
28
+ projects: List of discovered projects.
29
+ """
30
+
31
+ def __init__(self, selected_tool: Tool, **kwargs: Any):
32
+ """Initializes the ProjectPicker.
33
+
34
+ Args:
35
+ selected_tool: The tool that was selected and requires a project context.
36
+ **kwargs: Additional arguments passed to the Screen.
37
+ """
38
+ super().__init__(**kwargs)
39
+ self.selected_tool = selected_tool
40
+ self.projects: list[Project] = []
41
+
42
+ BINDINGS = [
43
+ ("down", "cursor_down", "Next Item"),
44
+ ("up", "cursor_up", "Previous Item"),
45
+ ("enter", "select_current", "Select"),
46
+ ]
47
+
48
+ def compose(self) -> ComposeResult:
49
+ """Composes the screen layout.
50
+
51
+ Yields:
52
+ The widget tree for the screen.
53
+ """
54
+ yield Header()
55
+ yield Container(
56
+ Label(f"Select Project for {self.selected_tool.label}", id="title"),
57
+ id="title-container",
58
+ )
59
+ yield Input(placeholder="Search projects...", id="project-search")
60
+ with Container(id="list-container"):
61
+ yield LoadingIndicator(id="loading-spinner")
62
+ yield ListView(id="project-list")
63
+ yield Label(
64
+ "No projects found", id="projects-empty", classes="empty-state hidden"
65
+ )
66
+ yield Footer()
67
+
68
+ async def on_mount(self) -> None:
69
+ """Called when the screen is mounted.
70
+
71
+ Initiates an asynchronous scan of the project root directory.
72
+ """
73
+ self.query_one("#project-list").display = False
74
+ self.query_one("#project-search").focus()
75
+
76
+ root = get_project_root()
77
+ from nexus.app import NexusApp
78
+
79
+ if isinstance(self.app, NexusApp):
80
+ self.projects = await self.app.container.scanner.scan_projects(root)
81
+
82
+ self.populate_list()
83
+ self.query_one("#loading-spinner").display = False
84
+ self.query_one("#project-list").display = True
85
+
86
+ def populate_list(self, filter_text: str = "") -> None:
87
+ """Populates the project list, processing the filter text.
88
+
89
+ Args:
90
+ filter_text: Text to filter project names by.
91
+ """
92
+ project_list = self.query_one("#project-list", ListView)
93
+ project_list.clear()
94
+
95
+ # Add "Create New Project" option
96
+ if not filter_text:
97
+ new_item = ProjectListItem(is_create_new=True)
98
+ project_list.append(new_item)
99
+
100
+ # --- Recents Section ---
101
+ from nexus.app import NexusApp
102
+ if isinstance(self.app, NexusApp):
103
+ recents = self.app.container.state_manager.get_recents()
104
+ if recents:
105
+ # Find project objects for recent paths
106
+ recent_projects = []
107
+ for path_str in recents:
108
+ # Find matching project in discovered list
109
+ match = next((p for p in self.projects if str(p.path) == path_str), None)
110
+ if match:
111
+ recent_projects.append(match)
112
+
113
+ if recent_projects:
114
+ project_list.append(ListItem(Label("[bold yellow]Recent Projects[/]"), classes="list-item"))
115
+ for proj in recent_projects:
116
+ project_list.append(ProjectListItem(project_data=proj))
117
+
118
+ project_list.append(ListItem(Label("[bold blue]All Projects[/]"), classes="list-item"))
119
+
120
+ # Filter out recents from main list to avoid duplication
121
+ recent_paths = {str(p.path) for p in recent_projects}
122
+ for project in self.projects:
123
+ if str(project.path) not in recent_paths:
124
+ project_list.append(ProjectListItem(project_data=project))
125
+ return
126
+
127
+ # --- Fuzzy Search or Default List ---
128
+ if filter_text:
129
+ # Prepare data for fuzzy matching
130
+ choices = {p.name: p for p in self.projects}
131
+ # extract returns list of (choice, score, key)
132
+ results = process.extract(filter_text, choices.keys(), limit=20)
133
+
134
+ for name, score in results:
135
+ if score > 40: # Threshold
136
+ project = choices[name]
137
+ project_list.append(ProjectListItem(project_data=project))
138
+ else:
139
+ # Default lexical sort
140
+ for project in self.projects:
141
+ project_list.append(ProjectListItem(project_data=project))
142
+
143
+ # Check if list is effectively empty
144
+ if not project_list.children:
145
+ project_list.display = False
146
+ empty_lbl = self.query_one("#projects-empty", Label)
147
+ empty_lbl.remove_class("hidden")
148
+ if filter_text:
149
+ empty_lbl.update(f"No projects matching '{filter_text}'")
150
+ else:
151
+ empty_lbl.update("No projects found in root directory.")
152
+ else:
153
+ project_list.display = True
154
+ self.query_one("#projects-empty").add_class("hidden")
155
+
156
+ def on_input_changed(self, event: Input.Changed) -> None:
157
+ """Called when the project search input changes.
158
+
159
+ Args:
160
+ event: The input changed event.
161
+ """
162
+ if event.input.id == "project-search":
163
+ self.populate_list(event.value)
164
+
165
+ def on_input_submitted(self, event: Input.Submitted) -> None:
166
+ """Called when Enter is pressed in the search input.
167
+
168
+ Args:
169
+ event: The input submitted event.
170
+ """
171
+ self.action_select_current()
172
+
173
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
174
+ """Called when a project is selected from the list.
175
+
176
+ Args:
177
+ event: The selection event.
178
+ """
179
+ self._select_item(event.item)
180
+
181
+ def _select_item(self, item: Any) -> None:
182
+ """Internal method to handle item selection logic.
183
+
184
+ Args:
185
+ item: The selected ListItem.
186
+ """
187
+ if not isinstance(item, ProjectListItem):
188
+ return
189
+
190
+ if item.is_create_new:
191
+ from nexus.screens.create_project import CreateProject
192
+
193
+ def on_created(new_project_name: str) -> None:
194
+ self.app.notify(f"Created project: {new_project_name}")
195
+ # Refresh list and try to find the new project
196
+ import asyncio
197
+
198
+ async def refresh() -> None:
199
+ if isinstance(self.app, NexusApp):
200
+ root = get_project_root()
201
+ self.projects = await self.app.container.scanner.scan_projects(root)
202
+ self.populate_list(filter_text=new_project_name)
203
+
204
+ asyncio.create_task(refresh())
205
+
206
+ self.app.push_screen(CreateProject(on_created))
207
+ return
208
+
209
+ project = item.project_data
210
+ if project:
211
+ with self.app.suspend():
212
+ from nexus.app import NexusApp
213
+
214
+ if isinstance(self.app, NexusApp):
215
+ if self.app.container.executor.launch_tool(
216
+ self.selected_tool.command, project.path
217
+ ):
218
+ # Save to Recents
219
+ self.app.container.state_manager.add_recent(str(project.path))
220
+ pass
221
+ else:
222
+ self.app.notify(
223
+ f"Failed to launch {self.selected_tool.label}", severity="error"
224
+ )
225
+ self.app.refresh()
226
+ self.app.pop_screen()
227
+
228
+ def action_cursor_down(self) -> None:
229
+ """Moves selection down in the project list."""
230
+ project_list = self.query_one("#project-list", ListView)
231
+ if project_list.index is None:
232
+ project_list.index = 0
233
+ else:
234
+ project_list.index = min(
235
+ len(project_list.children) - 1, project_list.index + 1
236
+ )
237
+
238
+ def action_cursor_up(self) -> None:
239
+ """Moves selection up in the project list."""
240
+ project_list = self.query_one("#project-list", ListView)
241
+ if project_list.index is None:
242
+ project_list.index = 0
243
+ else:
244
+ project_list.index = max(0, project_list.index - 1)
245
+
246
+ def action_select_current(self) -> None:
247
+ """Selects the currently highlighted item."""
248
+ project_list = self.query_one("#project-list", ListView)
249
+ if project_list.index is not None:
250
+ self._select_item(project_list.children[project_list.index])
@@ -0,0 +1,101 @@
1
+ """Screen for selecting the application theme.
2
+
3
+ Provides a modal list of available themes with live preview capability.
4
+ """
5
+
6
+ from typing import Any, Callable
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Container
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Label, ListItem, ListView
12
+
13
+
14
+ class ThemePicker(ModalScreen[None]):
15
+ """A modal screen for selecting a theme with live preview.
16
+
17
+ Attributes:
18
+ themes: List of available theme CSS classes.
19
+ original_theme: The theme active when the picker was opened.
20
+ on_preview_callback: Callback function to apply a preview theme.
21
+ """
22
+
23
+ CSS_PATH = "../style.tcss"
24
+
25
+ def __init__(
26
+ self, themes: list[str], current_theme: str, on_preview: Callable[[str], None], **kwargs: Any
27
+ ):
28
+ """Initializes the ThemePicker.
29
+
30
+ Args:
31
+ themes: List of available theme CSS class names.
32
+ current_theme: The currently active theme class name.
33
+ on_preview: Callback to receive the selected theme string for preview.
34
+ **kwargs: Additional arguments passed to ModalScreen.
35
+ """
36
+ super().__init__(**kwargs)
37
+ self.themes = themes
38
+ self.original_theme = current_theme
39
+ self.on_preview_callback = on_preview
40
+
41
+ BINDINGS = [
42
+ Binding("escape", "cancel", "Cancel"),
43
+ ]
44
+
45
+ def compose(self) -> ComposeResult:
46
+ """Composes the screen layout.
47
+
48
+ Returns:
49
+ A ComposeResult containing the widget tree.
50
+ """
51
+ with Container(id="theme-picker-dialog"):
52
+ yield Label("Select Theme", id="theme-picker-title")
53
+ yield ListView(id="theme-list")
54
+ yield Label("Esc: Cancel • Enter: Confirm", classes="modal-footer")
55
+
56
+ def on_mount(self) -> None:
57
+ """Called when the screen is mounted.
58
+
59
+ Populates the list of themes.
60
+ """
61
+ list_view = self.query_one("#theme-list", ListView)
62
+ for theme in self.themes:
63
+ # Clean up theme name for display (e.g., "theme-dark" -> "Tokyo Night Dark")
64
+ suffix = theme.replace("theme-", "").title()
65
+ display_name = f"Tokyo Night {suffix}"
66
+ item = ListItem(Label(display_name))
67
+ list_view.append(item)
68
+
69
+ # Select current theme
70
+ try:
71
+ current_index = self.themes.index(self.original_theme)
72
+ list_view.index = current_index
73
+ except ValueError:
74
+ list_view.index = 0
75
+
76
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
77
+ """Previews the confirmed theme when highlighted.
78
+
79
+ Args:
80
+ event: The highlight event.
81
+ """
82
+ if event.list_view.index is not None:
83
+ # Prevent index out of bounds if list changes (unlikely)
84
+ if 0 <= event.list_view.index < len(self.themes):
85
+ new_theme = self.themes[event.list_view.index]
86
+ self.on_preview_callback(new_theme)
87
+
88
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
89
+ """Confirms the selected theme.
90
+
91
+ Args:
92
+ event: The selection event.
93
+ """
94
+ # Theme is already applied by highlight, just close
95
+ self.dismiss()
96
+
97
+ def action_cancel(self) -> None:
98
+ """Reverts the theme choice and closes the picker."""
99
+ self.on_preview_callback(self.original_theme)
100
+ self.dismiss()
101
+