1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
"""Filterable local branch picker."""
from __future__ import annotations
from pygittools.tui.ucurses import ( # type: ignore[import-untyped]
A_BOLD,
A_REVERSE,
KEY_BACKSPACE,
KEY_DOWN,
KEY_ENTER,
KEY_RESIZE,
KEY_UP,
)
from pygittools.tui.branches import (
checkout_branch,
create_branch_from_current,
current_branch_name,
list_local_branches,
)
from pygittools.tui.draw import draw_line
from pygittools.tui.types import PageAction, PageContext, PageResult
_FILTER_PREFIX = "Filter: "
class BranchPickerPage:
title = "branches"
def __init__(self) -> None:
self._ctx: PageContext | None = None
self._filter = ""
self._branches: list[str] = []
self._filtered: list[str] = []
self._cursor = 0
self._scroll_offset = 0
self._error: str | None = None
def on_enter(self, ctx: PageContext) -> None:
self._ctx = ctx
self._filter = ""
self._error = None
self._cursor = 0
self._scroll_offset = 0
self._reload_branches()
def status_text(self) -> str:
if self._error:
return self._error
if not self._filtered:
if self._filter.strip():
return "Enter create branch — type name — Esc back"
return "No matching branches — type to filter, Esc back"
return "Enter checkout — type to filter — j/k move — Esc back"
def draw(self, stdscr: int, height: int, width: int) -> None:
del stdscr
if height <= 0:
return
current = current_branch_name(self._ctx.repo) if self._ctx is not None else ""
draw_line(0, 0, "Switch branch", width, A_BOLD)
if height > 1:
draw_line(1, 0, self._filter_label(width), width, A_REVERSE if self._cursor == 0 else 0)
list_top = 2
list_height = height - list_top
if list_height <= 0:
return
self._ensure_cursor_visible(list_top, list_height)
for view_row in range(list_height):
index = self._scroll_offset + view_row
if index >= len(self._filtered):
break
branch = self._filtered[index]
marker = "* " if branch == current else " "
row_index = index + 1
attr = A_REVERSE if self._cursor == row_index else 0
draw_line(list_top + view_row, 0, f"{marker}{branch}", width, attr)
def handle_key(self, key: int) -> PageResult:
if key in (ord("q"), ord("Q"), 27):
return PageResult(action=PageAction.POP)
if key == KEY_RESIZE:
return PageResult()
if self._error is not None:
self._error = None
ctx = self._ctx
if ctx is None:
return PageResult()
max_cursor = max(0, len(self._filtered))
if key in (KEY_UP, ord("k"), ord("K")):
self._cursor = max(0, self._cursor - 1)
return PageResult()
if key in (KEY_DOWN, ord("j"), ord("J")):
self._cursor = min(max_cursor, self._cursor + 1)
return PageResult()
if self._cursor == 0:
if key in (KEY_BACKSPACE, 127, 8):
self._filter = self._filter[:-1]
self._apply_filter()
return PageResult()
if 32 <= key <= 126:
self._filter += chr(key)
self._apply_filter()
return PageResult()
if key in (KEY_ENTER, 10, 13):
if not self._filtered:
return self._try_create_branch(ctx)
self._cursor = 1
return PageResult()
if key in (KEY_ENTER, 10, 13) and self._cursor > 0:
branch = self._filtered[self._cursor - 1]
ok, error = checkout_branch(ctx.repo, branch)
if ok:
return PageResult(action=PageAction.POP)
self._error = error
return PageResult()
return PageResult()
def _try_create_branch(self, ctx: PageContext) -> PageResult:
ok, error = create_branch_from_current(ctx.repo, self._filter)
if ok:
return PageResult(action=PageAction.POP)
self._error = error
return PageResult()
def _reload_branches(self) -> None:
if self._ctx is None:
self._branches = []
self._filtered = []
return
self._branches = list_local_branches(self._ctx.repo)
self._apply_filter()
def _apply_filter(self) -> None:
needle = self._filter.casefold()
if needle:
self._filtered = [branch for branch in self._branches if needle in branch.casefold()]
else:
self._filtered = list(self._branches)
max_cursor = max(0, len(self._filtered))
self._cursor = min(self._cursor, max_cursor)
def _filter_label(self, width: int) -> str:
max_text = max(0, width - len(_FILTER_PREFIX) - 1)
text = self._filter[-max_text:] if len(self._filter) > max_text else self._filter
label = f"{_FILTER_PREFIX}{text}"
if self._cursor == 0:
label += "_"
return label[:width]
def _ensure_cursor_visible(self, list_top: int, list_height: int) -> None:
del list_top
if self._cursor <= 0:
self._scroll_offset = 0
return
list_index = self._cursor - 1
if list_index < self._scroll_offset:
self._scroll_offset = list_index
elif list_index >= self._scroll_offset + list_height:
self._scroll_offset = list_index - list_height + 1
def branch_picker_page() -> BranchPickerPage:
return BranchPickerPage()