Skip to content

Commit 51d3b3f

Browse files
feat(screens): implement multi-screen TUI with Textual
Add full multi-screen architecture replacing the single-screen approach: Screens: - auth.py: GitHub token entry and validation screen - language.py: locale selection screen - loading.py: async repo clone screen with progress - dashboard.py: main hub showing cloned repo actions - member_list.py: scrollable list of existing members - member_form.py: full member profile form (name, email, socials, open questions) - save_loading.py: async save/commit/push/PR workflow screen - quit_confirm.py: modal confirmation dialog Components: - form_control.py: labelled form field with error display - alias_entry.py: dynamic alias row (input + delete button) - social_entry.py: dynamic social network row (select + URL + delete) - layout.py: shared header/footer widgets
1 parent b16ce8a commit 51d3b3f

12 files changed

Lines changed: 1018 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from textual.containers import Horizontal
2+
from textual.widgets import Button, Input
3+
4+
from ..strings import _
5+
from .form_control import FormControl
6+
7+
8+
class AliasEntry(FormControl):
9+
def __init__(self, index: int) -> None:
10+
self.index = index
11+
self.alias_input = Input(placeholder=_("Alias"))
12+
self.delete_btn = Button(
13+
_("Delete"), id=f"delete_alias_{index}", variant="error"
14+
)
15+
super().__init__(
16+
Horizontal(self.alias_input, self.delete_btn, classes="entry-row")
17+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from textual.app import ComposeResult
2+
from textual.containers import Vertical
3+
from textual.widget import Widget
4+
from textual.widgets import Static
5+
6+
7+
class FormControl(Vertical):
8+
def __init__(
9+
self,
10+
*children: Widget,
11+
label: str = "",
12+
help_text: str = "",
13+
**kwargs,
14+
):
15+
super().__init__(**kwargs)
16+
self.label_text = label
17+
self.help_text_content = help_text
18+
self.children_widgets = children
19+
20+
def compose(self) -> ComposeResult:
21+
if self.label_text:
22+
yield Static(self.label_text, classes="form-label")
23+
24+
yield from self.children_widgets
25+
26+
if self.help_text_content:
27+
yield Static(self.help_text_content, classes="form-help-text")
28+
29+
yield Static("", classes="form-error-text", id="error-msg")
30+
31+
def on_mount(self) -> None:
32+
self.query_one("#error-msg", Static).display = False
33+
34+
def show_error(self, message: str) -> None:
35+
error_static = self.query_one("#error-msg", Static)
36+
error_static.update(message)
37+
error_static.display = True
38+
39+
# Apply has-error class to self
40+
self.add_class("has-error")
41+
42+
def clear_error(self) -> None:
43+
error_static = self.query_one("#error-msg", Static)
44+
error_static.update("")
45+
error_static.display = False
46+
47+
# Remove has-error class from self
48+
self.remove_class("has-error")
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from textual.widgets import Static
2+
3+
from ..strings import _
4+
5+
6+
class AppHeader(Static):
7+
def __init__(self, **kwargs) -> None:
8+
super().__init__(
9+
_("Welcome aboard to Python Perú"),
10+
id="app-header",
11+
**kwargs,
12+
)
13+
14+
15+
class AppFooter(Static):
16+
def __init__(self, **kwargs) -> None:
17+
super().__init__(
18+
_("Proudly built with ❤️ in Perú"),
19+
id="app-footer",
20+
**kwargs,
21+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from textual.containers import Horizontal
2+
from textual.types import NoSelection
3+
from textual.widgets import Button, Input, Select
4+
5+
from ..constants import (
6+
BITBUCKET_OPTION,
7+
FACEBOOK_OPTION,
8+
GITHUB_OPTION,
9+
GITLAB_OPTION,
10+
INSTAGRAM_OPTION,
11+
LINKEDIN_OPTION,
12+
X_OPTION,
13+
YOUTUBE_OPTION,
14+
)
15+
from ..strings import _
16+
from .form_control import FormControl
17+
18+
19+
class SocialEntry(FormControl):
20+
def __init__(self, index: int, value: str | NoSelection) -> None:
21+
self.index = index
22+
self.select = Select(
23+
options=[
24+
GITHUB_OPTION,
25+
GITLAB_OPTION,
26+
BITBUCKET_OPTION,
27+
LINKEDIN_OPTION,
28+
FACEBOOK_OPTION,
29+
INSTAGRAM_OPTION,
30+
X_OPTION,
31+
YOUTUBE_OPTION,
32+
],
33+
prompt=_("Social Network"),
34+
value=value,
35+
)
36+
self.url_input = Input(placeholder=_("Social network URL"))
37+
self.delete_btn = Button(
38+
_("Delete"), id=f"delete_social_{index}", variant="error"
39+
)
40+
41+
super().__init__(
42+
Horizontal(
43+
self.select,
44+
self.url_input,
45+
self.delete_btn,
46+
classes="entry-row",
47+
)
48+
)

src/edit_python_pe/screens/auth.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import logging
2+
3+
import keyring
4+
from textual.app import ComposeResult
5+
from textual.containers import Horizontal, Vertical
6+
from textual.screen import Screen
7+
from textual.widgets import Button, Input, Static
8+
9+
from ..components.layout import AppFooter, AppHeader
10+
from ..strings import _
11+
from .loading import LoadingScreen
12+
from .quit_confirm import QuitConfirmScreen
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class AuthScreen(Screen):
18+
def compose(self) -> ComposeResult:
19+
yield AppHeader()
20+
with Vertical(id="auth-container"):
21+
yield Static(_("Please enter your GitHub personal access token: "))
22+
yield Input(password=True, id="github-token")
23+
with Horizontal(id="auth-actions"):
24+
with Horizontal(id="auth-actions-left"):
25+
yield Button(_("Login"), id="login-btn", variant="primary")
26+
with Horizontal(id="auth-actions-right"):
27+
yield Button(_("Back"), id="auth-back")
28+
yield Button(_("Quit"), id="auth-quit", variant="error")
29+
yield AppFooter()
30+
31+
def on_mount(self) -> None:
32+
try:
33+
token = keyring.get_password("edit-python-pe", "github_token")
34+
if token:
35+
self.query_one("#github-token", Input).value = token
36+
except Exception:
37+
logger.error("Failed to retrieve token from keyring", exc_info=True)
38+
39+
def on_button_pressed(self, event: Button.Pressed) -> None:
40+
if event.button.id == "login-btn":
41+
token = self.query_one("#github-token", Input).value.strip()
42+
if not token:
43+
return
44+
45+
try:
46+
keyring.set_password("edit-python-pe", "github_token", token)
47+
except Exception:
48+
logger.error("Failed to save token to keyring", exc_info=True)
49+
50+
self.app.push_screen(LoadingScreen(token))
51+
elif event.button.id == "auth-quit":
52+
53+
def check_quit(quit_app: bool | None) -> None:
54+
if quit_app:
55+
self.app.exit()
56+
57+
self.app.push_screen(QuitConfirmScreen(), check_quit)
58+
elif event.button.id == "auth-back":
59+
self.app.pop_screen()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from textual.app import ComposeResult
2+
from textual.containers import Vertical
3+
from textual.screen import Screen
4+
from textual.widgets import Button
5+
6+
from ..components.layout import AppFooter, AppHeader
7+
from ..strings import _
8+
from .member_form import MemberFormScreen
9+
from .member_list import MemberListScreen
10+
from .quit_confirm import QuitConfirmScreen
11+
12+
13+
class DashboardScreen(Screen):
14+
BINDINGS = [
15+
("ctrl+q", "quit_app", "Quit"),
16+
]
17+
18+
def compose(self) -> ComposeResult:
19+
yield AppHeader()
20+
with Vertical(id="dashboard-container"):
21+
yield Button(_("Add New Member"), id="dash-add", variant="success")
22+
yield Button(_("Edit Existing Member"), id="dash-edit", variant="primary")
23+
yield Button(_("Quit"), id="dash-quit", variant="error")
24+
yield AppFooter()
25+
26+
def on_button_pressed(self, event: Button.Pressed) -> None:
27+
if event.button.id == "dash-add":
28+
self.app.push_screen(MemberFormScreen())
29+
elif event.button.id == "dash-edit":
30+
self.app.push_screen(MemberListScreen())
31+
elif event.button.id == "dash-quit":
32+
self.action_quit_app()
33+
34+
def action_quit_app(self) -> None:
35+
def check_quit(quit_app: bool | None) -> None:
36+
if quit_app:
37+
self.app.exit(message=_("See you next time!"))
38+
39+
self.app.push_screen(QuitConfirmScreen(), check_quit)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from pathlib import Path
2+
3+
from babel import Locale
4+
from textual.app import ComposeResult
5+
from textual.containers import Horizontal, Vertical
6+
from textual.screen import Screen
7+
from textual.widgets import Button, OptionList, Static
8+
9+
from ..components.layout import AppFooter, AppHeader
10+
from ..strings import _, set_language
11+
from .auth import AuthScreen
12+
from .quit_confirm import QuitConfirmScreen
13+
14+
15+
def get_available_languages() -> dict[int, str]:
16+
locales_dir = Path(__file__).parent.parent / "locale"
17+
langs = ["en"]
18+
if locales_dir.exists():
19+
for item in sorted(locales_dir.iterdir()):
20+
if item.is_dir() and (item / "LC_MESSAGES" / "messages.mo").exists():
21+
langs.append(item.name)
22+
23+
unique_langs = []
24+
for lang in langs:
25+
if lang not in unique_langs:
26+
unique_langs.append(lang)
27+
28+
return dict(enumerate(unique_langs))
29+
30+
31+
class LanguageScreen(Screen):
32+
def compose(self) -> ComposeResult:
33+
self.lang_map = get_available_languages()
34+
options = []
35+
for _idx, lang_code in self.lang_map.items():
36+
try:
37+
name = Locale(lang_code).get_display_name(lang_code)
38+
display = str(name or lang_code).title()
39+
except Exception:
40+
display = lang_code
41+
42+
options.append(display)
43+
44+
yield AppHeader()
45+
with Vertical(id="lang-container"):
46+
yield Static(_("Select your language"), id="lang-label")
47+
yield OptionList(
48+
*options,
49+
id="lang-select",
50+
)
51+
with Horizontal(id="lang-actions"):
52+
yield Button(_("Continue"), id="lang-continue", variant="primary")
53+
yield Button(_("Quit"), id="lang-quit", variant="error")
54+
yield AppFooter()
55+
56+
def on_option_list_option_highlighted(
57+
self, event: OptionList.OptionHighlighted
58+
) -> None:
59+
lang_code = (
60+
self.lang_map.get(event.option_index, "en")
61+
if getattr(self, "lang_map", None) and event.option_index is not None
62+
else "en"
63+
)
64+
65+
set_language(lang_code)
66+
67+
# Update labels dynamically
68+
self.query_one("#app-header", Static).update(_("Welcome aboard to Python Perú"))
69+
self.query_one("#lang-label", Static).update(_("Select your language"))
70+
self.query_one("#lang-continue", Button).label = _("Continue")
71+
self.query_one("#lang-quit", Button).label = _("Quit")
72+
self.query_one("#app-footer", Static).update(_("Proudly built with ❤️ in Perú"))
73+
74+
def on_button_pressed(self, event: Button.Pressed) -> None:
75+
if event.button.id == "lang-quit":
76+
77+
def check_quit(quit_app: bool | None) -> None:
78+
if quit_app:
79+
self.app.exit()
80+
81+
self.app.push_screen(QuitConfirmScreen(), check_quit)
82+
elif event.button.id == "lang-continue":
83+
# If the user clicks continue without highlighting an option,
84+
# make sure we set default
85+
opt_list = self.query_one("#lang-select", OptionList)
86+
selected_idx = opt_list.highlighted
87+
lang_code = (
88+
self.lang_map.get(selected_idx, "en")
89+
if getattr(self, "lang_map", None) and selected_idx is not None
90+
else "en"
91+
)
92+
set_language(lang_code)
93+
self.app.push_screen(AuthScreen())

0 commit comments

Comments
 (0)