gitux 0.1.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.
gitux/ui/gitux.tcss ADDED
@@ -0,0 +1,323 @@
1
+ Screen {
2
+ background: #13131b;
3
+ }
4
+
5
+ * {
6
+ color: #e4e1ed;
7
+ }
8
+
9
+ #top-bar {
10
+ dock: top;
11
+ height: 1;
12
+ background: #1f1f27;
13
+ color: #e4e1ed;
14
+ padding: 0 1;
15
+ text-style: bold;
16
+ }
17
+
18
+ #stats-bar {
19
+ dock: top;
20
+ height: 1;
21
+ background: #1f1f27;
22
+ color: #c7c4d7;
23
+ padding: 0 1;
24
+ }
25
+
26
+ #main-content {
27
+ height: 1fr;
28
+ layout: vertical;
29
+ }
30
+
31
+ #bento-top {
32
+ height: 1fr;
33
+ layout: horizontal;
34
+ }
35
+
36
+ #files-block {
37
+ width: 1fr;
38
+ min-width: 30;
39
+ }
40
+
41
+ #commits-block {
42
+ height: 1fr;
43
+ }
44
+
45
+ #commit-log {
46
+ height: 1fr;
47
+ overflow-y: auto;
48
+ overflow-x: hidden;
49
+ padding: 0 1;
50
+ color: #e4e1ed;
51
+ background: transparent;
52
+ scrollbar-visibility: hidden;
53
+ }
54
+
55
+ .block-active {
56
+ border: solid #c0c1ff;
57
+ background: #0d0d15;
58
+ }
59
+
60
+ .block-inactive {
61
+ border: solid #464554;
62
+ background: #0d0d15;
63
+ }
64
+
65
+ .block-header {
66
+ height: 1;
67
+ background: #34343d;
68
+ padding: 0 1;
69
+ text-style: bold;
70
+ }
71
+
72
+ .block-active .block-header {
73
+ color: #e4e1ed;
74
+ }
75
+
76
+ .block-inactive .block-header {
77
+ color: #c7c4d7;
78
+ }
79
+
80
+ #changed-files {
81
+ height: 1fr;
82
+ overflow-y: auto;
83
+ overflow-x: hidden;
84
+ padding: 0 1;
85
+ background: transparent;
86
+ scrollbar-visibility: hidden;
87
+ }
88
+
89
+ .staged-section {
90
+ color: #c0c1ff;
91
+ }
92
+
93
+ .unstaged-section {
94
+ color: #ffb783;
95
+ }
96
+
97
+ .file-status-M { color: #ffb783; }
98
+ .file-status-A { color: #10b981; }
99
+ .file-status-D { color: #ffb4ab; }
100
+ .file-status-R { color: #c0c1ff; }
101
+ .file-status-C { color: #c0c1ff; }
102
+ .file-status-unknown { color: #908fa0; }
103
+
104
+ #diff-viewer {
105
+ height: 1fr;
106
+ overflow-y: auto;
107
+ padding: 0 1;
108
+ background: transparent;
109
+ scrollbar-visibility: hidden;
110
+ }
111
+
112
+ GituxFooter {
113
+ dock: bottom;
114
+ height: 1;
115
+ background: #1f1f27;
116
+ color: #c7c4d7;
117
+ padding: 0 1;
118
+ }
119
+
120
+ #commit-modal {
121
+ background: #34343d;
122
+ border: solid #c0c1ff;
123
+ color: #e4e1ed;
124
+ width: 60%;
125
+ height: 13;
126
+ }
127
+
128
+ #help-container {
129
+ background: #34343d;
130
+ border: solid #c0c1ff;
131
+ color: #e4e1ed;
132
+ width: 64;
133
+ max-width: 90%;
134
+ max-height: 85%;
135
+ padding: 1 2;
136
+ overflow-y: auto;
137
+ }
138
+
139
+ #help-title {
140
+ text-style: bold;
141
+ color: #c0c1ff;
142
+ text-align: center;
143
+ margin: 0 0 1 0;
144
+ content-align: center middle;
145
+ height: 1;
146
+ }
147
+
148
+ #help-body {
149
+ color: #e4e1ed;
150
+ }
151
+
152
+ #help-body .key {
153
+ color: #ffb783;
154
+ text-style: bold;
155
+ }
156
+
157
+ #help-close-hint {
158
+ text-style: italic;
159
+ color: #c7c4d7;
160
+ text-align: center;
161
+ margin: 1 0 0 0;
162
+ height: 1;
163
+ }
164
+
165
+ CommitScreen {
166
+ background: #13131b 85%;
167
+ align: center middle;
168
+ height: 13;
169
+ }
170
+
171
+ #commit-modal-type {
172
+ height: 1;
173
+ text-align: center;
174
+ color: #ffb783;
175
+ margin: 0 0 1 0;
176
+ }
177
+
178
+ #commit-modal-title {
179
+ text-style: bold;
180
+ color: #c0c1ff;
181
+ margin: 0 0 1 0;
182
+ height: 1;
183
+ text-align: center;
184
+ }
185
+
186
+ #commit-modal-input {
187
+ width: 100%;
188
+ height: 3;
189
+ background: #1f1f27;
190
+ color: #e4e1ed;
191
+ border: solid #464554;
192
+ padding: 0 1;
193
+ margin: 0 0 1 0;
194
+ }
195
+
196
+ #commit-modal-input:focus {
197
+ border: solid #c0c1ff;
198
+ }
199
+
200
+ #commit-modal-input .input--cursor {
201
+ color: #13131b;
202
+ background: #c0c1ff;
203
+ }
204
+
205
+ #commit-modal-buttons {
206
+ layout: horizontal;
207
+ height: 3;
208
+ align: center middle;
209
+ }
210
+
211
+ #commit-modal-btn-commit {
212
+ width: 16;
213
+ min-width: 16;
214
+ max-width: 16;
215
+ margin: 0 1 0 0;
216
+ background: #10b981;
217
+ color: #0d0d15;
218
+ text-style: bold;
219
+ border: none;
220
+ }
221
+
222
+ #commit-modal-btn-commit:hover {
223
+ background: #34d399;
224
+ }
225
+
226
+ #commit-modal-btn-commit:disabled {
227
+ background: #1f1f27;
228
+ color: #c7c4d7;
229
+ }
230
+
231
+ #commit-modal-btn-cancel {
232
+ width: 16;
233
+ min-width: 16;
234
+ max-width: 16;
235
+ background: transparent;
236
+ color: #908fa0;
237
+ text-style: bold;
238
+ border: none;
239
+ }
240
+
241
+ #commit-modal-btn-cancel:hover {
242
+ background: #1f1f27;
243
+ color: #e4e1ed;
244
+ }
245
+
246
+ HelpScreen {
247
+ background: #13131b 80%;
248
+ align: center middle;
249
+ }
250
+
251
+ BranchScreen {
252
+ background: #13131b 85%;
253
+ align: center middle;
254
+ }
255
+
256
+ #branch-modal {
257
+ background: #34343d;
258
+ border: solid #c0c1ff;
259
+ color: #e4e1ed;
260
+ width: 50;
261
+ max-width: 90%;
262
+ height: auto;
263
+ padding: 1 2;
264
+ }
265
+
266
+ #branch-modal-title {
267
+ text-style: bold;
268
+ color: #c0c1ff;
269
+ text-align: center;
270
+ height: 1;
271
+ margin: 0 0 1 0;
272
+ }
273
+
274
+ #branch-option-list {
275
+ background: #1f1f27;
276
+ color: #e4e1ed;
277
+ border: solid #464554;
278
+ height: auto;
279
+ max-height: 60%;
280
+ }
281
+
282
+ #branch-option-list > .option-list--option-highlighted {
283
+ background: #3a4a5f;
284
+ color: #e4e1ed;
285
+ }
286
+
287
+ #branch-modal-hint {
288
+ text-style: italic;
289
+ color: #c7c4d7;
290
+ text-align: center;
291
+ height: 1;
292
+ margin: 1 0 0 0;
293
+ }
294
+
295
+ Toast {
296
+ background: #1f1f27;
297
+ border: solid #464554;
298
+ padding: 0 1;
299
+ }
300
+
301
+ Toast.-information {
302
+ border: solid #10b981;
303
+ }
304
+
305
+ Toast.-information .toast-title {
306
+ color: #10b981;
307
+ }
308
+
309
+ Toast.-error {
310
+ border: solid #ffb4ab;
311
+ }
312
+
313
+ Toast.-error .toast-title {
314
+ color: #ffb4ab;
315
+ }
316
+
317
+ Toast.-warning {
318
+ border: solid #ffb783;
319
+ }
320
+
321
+ Toast.-warning .toast-title {
322
+ color: #ffb783;
323
+ }
@@ -0,0 +1,24 @@
1
+ from gitux.ui.widgets.block_container import BlockContainer
2
+ from gitux.ui.widgets.branch_screen import BranchScreen
3
+ from gitux.ui.widgets.changed_files import ChangedFilesPanel
4
+ from gitux.ui.widgets.commit_log import CommitLogWidget, extract_commit_hash
5
+ from gitux.ui.widgets.commit_screen import CommitScreen
6
+ from gitux.ui.widgets.diff_viewer import DiffViewerWidget
7
+ from gitux.ui.widgets.gitux_footer import GituxFooter
8
+ from gitux.ui.widgets.header import TopAppBar
9
+ from gitux.ui.widgets.help_screen import HelpScreen
10
+ from gitux.ui.widgets.repo_stats_bar import RepoStatsBar
11
+
12
+ __all__ = [
13
+ "BlockContainer",
14
+ "BranchScreen",
15
+ "ChangedFilesPanel",
16
+ "CommitLogWidget",
17
+ "CommitScreen",
18
+ "DiffViewerWidget",
19
+ "GituxFooter",
20
+ "HelpScreen",
21
+ "RepoStatsBar",
22
+ "TopAppBar",
23
+ "extract_commit_hash",
24
+ ]
@@ -0,0 +1,61 @@
1
+ """Block container — wraps a content widget with a styled header and active state."""
2
+
3
+ from typing import override
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.widget import Widget
8
+ from textual.widgets import Static
9
+
10
+
11
+ class BlockContainer(Vertical):
12
+ """Bento-grid block that wraps a content widget with a styled title header.
13
+
14
+ Supports active/inactive visual states via CSS classes (``.block-active`` /
15
+ ``.block-inactive``) for keyboard focus indication.
16
+
17
+ Used to wrap ChangedFilesPanel, CommitLogWidget, and DiffViewerWidget in the
18
+ bento layout.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ title: str,
24
+ subtitle: str = "",
25
+ content_widget: Widget | None = None,
26
+ name: str | None = None,
27
+ id: str | None = None,
28
+ classes: str | None = None,
29
+ disabled: bool = False,
30
+ ) -> None:
31
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
32
+ self._title: str = title
33
+ self._subtitle: str = subtitle
34
+ self._content_widget: Widget | None = content_widget
35
+ self._active: bool = False
36
+ _ = self.set_class(True, "block-inactive")
37
+
38
+ @override
39
+ def compose(self) -> ComposeResult:
40
+ """Yield the header static and the wrapped content widget."""
41
+ yield Static(self._render_header(), classes="block-header")
42
+ if self._content_widget:
43
+ yield self._content_widget
44
+
45
+ def set_active(self, active: bool) -> None:
46
+ """Toggle the active state, which changes the border styling."""
47
+ self._active = active
48
+ _ = self.set_class(active, "block-active")
49
+ _ = self.set_class(not active, "block-inactive")
50
+
51
+ def set_header_subtitle(self, subtitle: str) -> None:
52
+ """Update the subtitle text in the block header in-place."""
53
+ self._subtitle = subtitle
54
+ header = self.query_one(".block-header", Static)
55
+ header.update(self._render_header())
56
+
57
+ def _render_header(self) -> str:
58
+ """Render the header line: ``Title / subtitle`` or just the title."""
59
+ if self._subtitle:
60
+ return f"{self._title} / {self._subtitle}"
61
+ return self._title
@@ -0,0 +1,54 @@
1
+ """Branch switch modal — lists local branches and switches on selection."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.containers import Vertical
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import OptionList, Static
8
+
9
+ from gitux.git import GitError
10
+ from gitux.presenter.repo_presenter import RepoPresenter
11
+ from typing import override
12
+
13
+
14
+ class BranchScreen(ModalScreen[str]):
15
+ """Modal listing local branches; Enter switches, Escape cancels."""
16
+
17
+ BINDINGS: list[Binding] = [Binding("escape", "cancel", "Cancel")]
18
+
19
+ def __init__(self, presenter: RepoPresenter) -> None:
20
+ super().__init__()
21
+ self.presenter: RepoPresenter = presenter
22
+
23
+ @override
24
+ def compose(self) -> ComposeResult:
25
+ """Build the modal: title, branch list, and hint line."""
26
+ with Vertical(id="branch-modal"):
27
+ yield Static("Switch Branch", id="branch-modal-title")
28
+ yield OptionList(id="branch-option-list")
29
+ yield Static("Enter: switch \u2022 Esc: cancel", id="branch-modal-hint")
30
+
31
+ def on_mount(self) -> None:
32
+ """Populate the branch list and focus it."""
33
+ option_list = self.query_one("#branch-option-list", OptionList)
34
+ branches = self.presenter.get_branches()
35
+ for name in branches:
36
+ _ = option_list.add_option(name)
37
+ if not branches:
38
+ _ = self.query_one("#branch-modal-hint", Static).update("No branches found")
39
+ else:
40
+ _ = option_list.focus()
41
+
42
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
43
+ """Switch to the selected branch and dismiss, or notify on error."""
44
+ branch = str(event.option.prompt)
45
+ try:
46
+ self.presenter.switch_branch(branch)
47
+ except GitError as exc:
48
+ _ = self.notify(str(exc), severity="error")
49
+ return
50
+ _ = self.dismiss(branch)
51
+
52
+ def action_cancel(self) -> None:
53
+ """Dismiss the modal without selecting a branch."""
54
+ _ = self.dismiss(None)
@@ -0,0 +1,187 @@
1
+ """Changed files panel — staged/unstaged file list with cursor navigation."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from rich.text import Text
6
+ from textual.widgets import RichLog
7
+
8
+ from gitux.domain import FileStatus
9
+
10
+
11
+ _STATUS_COLORS: dict[str, str] = {
12
+ "M": "#ffb783",
13
+ "A": "#10b981",
14
+ "D": "#ffb4ab",
15
+ "R": "#c0c1ff",
16
+ "C": "#c0c1ff",
17
+ "?": "#908fa0",
18
+ "!": "#c7c4d7",
19
+ }
20
+
21
+ _MAX_PATH_CHARS: int = 40
22
+
23
+
24
+ class ChangedFilesPanel(RichLog):
25
+ """File listing panel with staged and unstaged sections."""
26
+
27
+ can_focus = False
28
+
29
+ def __init__(self, **kwargs) -> None:
30
+ super().__init__(
31
+ highlight=False, markup=False, auto_scroll=False, max_lines=None, **kwargs
32
+ )
33
+ self._files: list[FileStatus] = []
34
+ self._cursor_index: int = 0
35
+ self._on_toggle: Callable[[FileStatus], None] | None = None
36
+ self._on_selection_change: Callable[[], None] | None = None
37
+ self._on_stage_all: Callable[[], None] | None = None
38
+ self._on_unstage_all: Callable[[], None] | None = None
39
+
40
+ @property
41
+ def selected_file(self) -> FileStatus | None:
42
+ """Return the :class:`FileStatus` under the cursor, or ``None``."""
43
+ all_files = self._all_files_flat
44
+ if 0 <= self._cursor_index < len(all_files):
45
+ return all_files[self._cursor_index]
46
+ return None
47
+
48
+ @property
49
+ def _all_files_flat(self) -> list[FileStatus]:
50
+ """Staged files followed by unstaged files, in cursor order."""
51
+ staged = [f for f in self._files if f.is_staged]
52
+ unstaged = [f for f in self._files if f.is_unstaged]
53
+ return staged + unstaged
54
+
55
+ def set_files(self, files: list[FileStatus]) -> None:
56
+ """Replace the file list and reset cursor if out of bounds."""
57
+ self._files = files
58
+ total = len(self._all_files_flat)
59
+ if self._cursor_index >= total:
60
+ self._cursor_index = max(0, total - 1)
61
+ self._refresh_content()
62
+ self._scroll_to_cursor()
63
+
64
+ def set_on_toggle(self, callback: Callable[[FileStatus], None]) -> None:
65
+ """Register a callback invoked when the user toggles a file's staged state."""
66
+ self._on_toggle = callback
67
+
68
+ def set_on_selection_change(self, callback: Callable[[], None]) -> None:
69
+ """Register a callback invoked when the cursor moves to a different file."""
70
+ self._on_selection_change = callback
71
+
72
+ def set_on_stage_all(self, callback: Callable[[], None]) -> None:
73
+ """Register a callback invoked when the user triggers stage-all."""
74
+ self._on_stage_all = callback
75
+
76
+ def set_on_unstage_all(self, callback: Callable[[], None]) -> None:
77
+ """Register a callback invoked when the user triggers unstage-all."""
78
+ self._on_unstage_all = callback
79
+
80
+ def action_cursor_down(self) -> None:
81
+ """Move cursor down to the next file in the flat list."""
82
+ all_files = self._all_files_flat
83
+ if self._cursor_index < len(all_files) - 1:
84
+ self._cursor_index += 1
85
+ self._refresh_content()
86
+ self._scroll_to_cursor()
87
+ if self._on_selection_change:
88
+ self._on_selection_change()
89
+
90
+ def action_cursor_up(self) -> None:
91
+ """Move cursor up to the previous file in the flat list."""
92
+ if self._cursor_index > 0:
93
+ self._cursor_index -= 1
94
+ self._refresh_content()
95
+ self._scroll_to_cursor()
96
+ if self._on_selection_change:
97
+ self._on_selection_change()
98
+
99
+ def action_toggle_file(self) -> None:
100
+ """Stage or unstage the file under the cursor."""
101
+ file = self.selected_file
102
+ if file and self._on_toggle:
103
+ self._on_toggle(file)
104
+
105
+ def action_stage_all(self) -> None:
106
+ """Stage all currently unstaged files."""
107
+ if self._on_stage_all:
108
+ self._on_stage_all()
109
+
110
+ def action_unstage_all(self) -> None:
111
+ """Unstage all currently staged files."""
112
+ if self._on_unstage_all:
113
+ self._on_unstage_all()
114
+
115
+ def _refresh_content(self) -> None:
116
+ """Clear the widget and re-render the full file list."""
117
+ _ = self.clear()
118
+ _ = self.write(self._render_files())
119
+
120
+ def _render_files(self) -> Text:
121
+ """Build the full layout with STAGED and UNSTAGED sections."""
122
+ text = Text(no_wrap=True)
123
+ staged = [f for f in self._files if f.is_staged]
124
+ unstaged = [f for f in self._files if f.is_unstaged]
125
+
126
+ text.append(" \u25a0 STAGED", style="bold #c0c1ff")
127
+ text.append("\n")
128
+
129
+ if staged:
130
+ text.append(self._build_file_entries(staged, 0))
131
+ else:
132
+ text.append(" No changed files\n", style="italic #c7c4d7")
133
+
134
+ text.append(" \u25a0 UNSTAGED", style="bold #ffb783")
135
+ text.append("\n")
136
+
137
+ if unstaged:
138
+ text.append(self._build_file_entries(unstaged, len(staged)))
139
+ else:
140
+ text.append(" No changed files\n", style="italic #c7c4d7")
141
+
142
+ return text
143
+
144
+ def _build_file_entries(self, files: list[FileStatus], start_index: int) -> Text:
145
+ """Build rich text entries for a slice of files, marking the cursor row."""
146
+ text = Text()
147
+ for i, f in enumerate(files):
148
+ linear_idx = start_index + i
149
+ is_cursor = linear_idx == self._cursor_index
150
+ status = f.display_status
151
+ color = _STATUS_COLORS.get(status, "#e4e1ed")
152
+
153
+ if is_cursor:
154
+ text.append(" \u276f", style="bold #c0c1ff")
155
+ else:
156
+ text.append(" ")
157
+
158
+ text.append(f" {status}", style=f"bold {color}")
159
+ text.append(f" {self._truncate_path(f.display_path)}", style="#e4e1ed")
160
+ text.append("\n")
161
+
162
+ return text
163
+
164
+ def _truncate_path(self, display_path: str) -> str:
165
+ """Truncate a rendered path to ``_MAX_PATH_CHARS`` with a trailing ``..``."""
166
+ if len(display_path) > _MAX_PATH_CHARS:
167
+ return display_path[:_MAX_PATH_CHARS - 2] + ".."
168
+ return display_path
169
+
170
+ def _row_for_index(self, index: int) -> int:
171
+ """Rendered-line offset of flat cursor ``index`` (accounts for section headers)."""
172
+ all_files = self._all_files_flat
173
+ if not all_files:
174
+ return 0
175
+ staged_count = len([f for f in self._files if f.is_staged])
176
+ if index < staged_count:
177
+ return 1 + index
178
+ if staged_count > 0:
179
+ return index + 2
180
+ return index + 3
181
+
182
+ def _scroll_to_cursor(self) -> None:
183
+ """Ensure the cursor row is visible; no-op when the file list is empty."""
184
+ if not self._all_files_flat:
185
+ return
186
+ target = 0 if self._cursor_index == 0 else self._row_for_index(self._cursor_index)
187
+ self.scroll_to(y=target, animate=False)