layout of panels tweaked

This commit is contained in:
Tim Bendt
2025-05-04 14:50:58 -06:00
parent 08eb4ee0cf
commit b26674ff4e
9 changed files with 201 additions and 54 deletions

View File

@@ -97,6 +97,9 @@ def synchronize_maildir(maildir_path, headers):
) )
if response.status_code != 201: # 201 Created indicates success if response.status_code != 201: # 201 Created indicates success
print(f"Failed to move message to 'Archive': {message_id}, {response.status_code}, {response.text}") print(f"Failed to move message to 'Archive': {message_id}, {response.status_code}, {response.text}")
if response.status_code == 404:
os.remove(filepath) # Remove the file from local archive if not found on server
# Save the current sync timestamp # Save the current sync timestamp
save_sync_timestamp() save_sync_timestamp()
@@ -311,7 +314,7 @@ new_files = set(glob.glob(os.path.join(new_dir, '*.eml')))
cur_files = set(glob.glob(os.path.join(cur_dir, '*.eml'))) cur_files = set(glob.glob(os.path.join(cur_dir, '*.eml')))
for filename in Set.union(cur_files, new_files): for filename in Set.union(cur_files, new_files):
message_id = filename.split('.')[0] # Extract the Message-ID from the filename message_id = filename.split('.')[0].split('/')[-1] # Extract the Message-ID from the filename
if (message_id not in inbox_msg_ids): if (message_id not in inbox_msg_ids):
print(f"Deleting {filename} from inbox") print(f"Deleting {filename} from inbox")
os.remove(filename) os.remove(filename)

View File

@@ -3,10 +3,12 @@ from maildir_gtd.screens.OpenMessage import OpenMessageScreen
def action_open(app) -> None: def action_open(app) -> None:
"""Show the input modal for opening a specific message by ID.""" """Show the input modal for opening a specific message by ID."""
def check_id(message_id: str) -> bool: def check_id(message_id: str | None) -> bool:
try: try:
int(message_id) int(message_id)
app.show_message(message_id) app.show_message(message_id)
if (message_id is not None and message_id > 0):
app.show_message(message_id)
except ValueError: except ValueError:
app.bell() app.bell()
app.show_status("Invalid message ID. Please enter an integer.", severity="error") app.show_status("Invalid message ID. Please enter an integer.", severity="error")

View File

@@ -19,7 +19,7 @@ from textual.widgets import Footer, Static, Label, Markdown, ListView, ListItem
from textual.reactive import reactive, Reactive from textual.reactive import reactive, Reactive
from textual.binding import Binding from textual.binding import Binding
from textual.timer import Timer from textual.timer import Timer
from textual.containers import ScrollableContainer, Grid from textual.containers import ScrollableContainer, Grid, Vertical, Horizontal
from actions.archive import archive_current from actions.archive import archive_current
from actions.delete import delete_current from actions.delete import delete_current
@@ -48,7 +48,6 @@ class EmailViewerApp(App):
"""A simple email viewer app using the Himalaya CLI.""" """A simple email viewer app using the Himalaya CLI."""
CSS_PATH = "email_viewer.tcss" CSS_PATH = "email_viewer.tcss"
title = "Maildir GTD Reader" title = "Maildir GTD Reader"
current_message_id: Reactive[int] = reactive(0) current_message_id: Reactive[int] = reactive(0)
current_message_index: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0)
folder = reactive("INBOX") folder = reactive("INBOX")
@@ -61,6 +60,8 @@ class EmailViewerApp(App):
newest_id: Reactive[int] = reactive(0) newest_id: Reactive[int] = reactive(0)
msg_worker: Worker | None = None msg_worker: Worker | None = None
message_body_cache: dict[int, str] = {} message_body_cache: dict[int, str] = {}
total_messages: Reactive[int] = reactive(0)
status_title = reactive("Message View")
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
yield from super().get_system_commands(screen) yield from super().get_system_commands(screen)
@@ -72,7 +73,7 @@ class EmailViewerApp(App):
yield SystemCommand("Create Task", "Create a task using the task CLI", self.action_create_task) yield SystemCommand("Create Task", "Create a task using the task CLI", self.action_create_task)
yield SystemCommand("Oldest Message", "Show the oldest message", self.action_oldest) yield SystemCommand("Oldest Message", "Show the oldest message", self.action_oldest)
yield SystemCommand("Newest Message", "Show the newest message", self.action_newest) yield SystemCommand("Newest Message", "Show the newest message", self.action_newest)
yield SystemCommand("Reload", "Reload the message list", self.action_fetch_list) yield SystemCommand("Reload", "Reload the message list", self.fetch_envelopes)
BINDINGS = [ BINDINGS = [
Binding("j", "next", "Next message"), Binding("j", "next", "Next message"),
@@ -83,40 +84,60 @@ class EmailViewerApp(App):
Binding("q", "quit", "Quit application"), Binding("q", "quit", "Quit application"),
Binding("h", "toggle_header", "Toggle Envelope Header"), Binding("h", "toggle_header", "Toggle Envelope Header"),
Binding("t", "create_task", "Create Task"), Binding("t", "create_task", "Create Task"),
Binding("ctrl-r", "reload", "Reload message list") Binding("%", "reload", "Reload message list"),
Binding("1", "focus_1", "Focus Accounts Panel"),
Binding("2", "focus_2", "Focus Folders Panel"),
Binding("3", "focus_3", "Focus Envelopes Panel")
] ]
BINDINGS.extend([ BINDINGS.extend([
Binding("down", "scroll_down", "Scroll down"),
Binding("up", "scroll_up", "Scroll up"),
Binding("space", "scroll_page_down", "Scroll page down"), Binding("space", "scroll_page_down", "Scroll page down"),
Binding("b", "scroll_page_up", "Scroll page up") Binding("b", "scroll_page_up", "Scroll page up")
]) ])
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" yield Horizontal(
yield Grid( Vertical(
ListView(ListItem(Label("All emails...")), id="list_view", initial_index=0), ListView(ListItem(Label("All emails...")), id="envelopes_list", classes="list_view", initial_index=0),
ScrollableContainer( ListView(id="accounts_list", classes="list_view"),
StatusTitle().data_bind(EmailViewerApp.current_message_id), ListView(id="folders_list", classes="list_view"),
id="sidebar"
),
ScrollableContainer(
EnvelopeHeader(), EnvelopeHeader(),
Markdown(), Markdown(),
id="main_content", id="main_content"
) ),
) id="outer-wrapper"
)
yield Footer() yield Footer()
async def on_mount(self) -> None: async def on_mount(self) -> None:
self.alert_timer: Timer | None = None # Timer to throttle alerts self.alert_timer: Timer | None = None # Timer to throttle alerts
self.theme = "monokai" self.theme = "monokai"
self.title = "MaildirGTD" self.title = "MaildirGTD"
self.query_one("#main_content").border_title = self.status_title
self.query_one("#envelopes_list").border_title = "\[1] Emails"
self.query_one("#accounts_list").border_title = "\[2] Accounts"
self.query_one("#folders_list").border_title = "\[3] Folders"
# self.query_one(ListView).data_bind(index=EmailViewerApp.current_message_index) # self.query_one(ListView).data_bind(index=EmailViewerApp.current_message_index)
# self.watch(self.query_one(StatusTitle), "current_message_id", update_progress) # self.watch(self.query_one(StatusTitle), "current_message_id", update_progress)
# Fetch the ID of the most recent message using the Himalaya CLI # Fetch the ID of the most recent message using the Himalaya CLI
worker = self.action_fetch_list() self.fetch_accounts()
self.fetch_folders()
worker = self.fetch_envelopes()
await worker.wait() await worker.wait()
self.query_one("#envelopes_list").focus()
self.action_oldest() self.action_oldest()
def compute_status_title(self) -> None:
return f"Message ID: {self.current_message_id} | [b]{self.current_message_index}[/b]/{self.total_messages}"
def watch_status_title(self, old_status_title: str, new_status_title: str) -> None:
self.query_one("#main_content").border_title = new_status_title
def compute_newest_id(self) -> None: def compute_newest_id(self) -> None:
if not self.all_envelopes: if not self.all_envelopes:
return 0 return 0
@@ -146,7 +167,7 @@ class EmailViewerApp(App):
def watch_reload_needed(self, old_reload_needed: bool, new_reload_needed: bool) -> None: def watch_reload_needed(self, old_reload_needed: bool, new_reload_needed: bool) -> None:
logging.info(f"Reload needed: {new_reload_needed}") logging.info(f"Reload needed: {new_reload_needed}")
if (old_reload_needed == False and new_reload_needed == True): if (old_reload_needed == False and new_reload_needed == True):
self.action_fetch_list() self.fetch_envelopes()
def watch_current_message_id(self, old_message_id: int, new_message_id: int) -> None: def watch_current_message_id(self, old_message_id: int, new_message_id: int) -> None:
@@ -165,7 +186,7 @@ class EmailViewerApp(App):
headers.to = envelope['to']['addr'] headers.to = envelope['to']['addr']
headers.date = datetime.strptime(envelope['date'].replace("+00:00", ""), "%Y-%m-%d %H:%M").strftime("%a %b %d %H:%M") headers.date = datetime.strptime(envelope['date'].replace("+00:00", ""), "%Y-%m-%d %H:%M").strftime("%a %b %d %H:%M")
headers.cc = envelope['cc']['addr'] if 'cc' in envelope else "" headers.cc = envelope['cc']['addr'] if 'cc' in envelope else ""
self.query_one(StatusTitle).current_message_index = index # self.query_one(StatusTitle).current_message_index = index
self.query_one(ListView).index = index self.query_one(ListView).index = index
break break
@@ -211,8 +232,8 @@ class EmailViewerApp(App):
logging.error(f"Error fetching message content: {e}") logging.error(f"Error fetching message content: {e}")
@work(exclusive=False) @work(exclusive=False)
async def action_fetch_list(self) -> None: async def fetch_envelopes(self) -> None:
msglist = self.query_one(ListView) msglist = self.query_one("#envelopes_list")
try: try:
msglist.loading = True msglist.loading = True
process = await asyncio.create_subprocess_shell( process = await asyncio.create_subprocess_shell(
@@ -227,8 +248,7 @@ class EmailViewerApp(App):
envelopes = json.loads(stdout.decode()) envelopes = json.loads(stdout.decode())
if envelopes: if envelopes:
self.reload_needed = False self.reload_needed = False
status = self.query_one(StatusTitle) self.total_messages = len(envelopes)
status.total_messages = len(envelopes)
msglist.clear() msglist.clear()
envelopes = sorted(envelopes, key=lambda x: int(x['id'])) envelopes = sorted(envelopes, key=lambda x: int(x['id']))
self.all_envelopes = envelopes self.all_envelopes = envelopes
@@ -243,7 +263,55 @@ class EmailViewerApp(App):
finally: finally:
msglist.loading = False msglist.loading = False
@work(exclusive=False)
async def fetch_accounts(self) -> None:
accounts_list = self.query_one("#accounts_list")
try:
accounts_list.loading = True
process = await asyncio.create_subprocess_shell(
"himalaya account list -o json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
logging.info(f"stdout: {stdout.decode()[0:50]}")
if process.returncode == 0:
import json
accounts = json.loads(stdout.decode())
if accounts:
for account in accounts:
item = ListItem(Label(str(account['name']).strip(), classes="account_name", markup=False))
accounts_list.append(item)
except Exception as e:
self.show_status(f"Error fetching account list: {e}", "error")
finally:
accounts_list.loading = False
@work(exclusive=False)
async def fetch_folders(self) -> None:
folders_list = self.query_one("#folders_list")
folders_list.clear()
folders_list.append(ListItem(Label("INBOX", classes="folder_name", markup=False)))
try:
folders_list.loading = True
process = await asyncio.create_subprocess_shell(
"himalaya folder list -o json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
logging.info(f"stdout: {stdout.decode()[0:50]}")
if process.returncode == 0:
import json
folders = json.loads(stdout.decode())
if folders:
for folder in folders:
item = ListItem(Label(str(folder['name']).strip(), classes="folder_name", markup=False))
folders_list.append(item)
except Exception as e:
self.show_status(f"Error fetching folder list: {e}", "error")
finally:
folders_list.loading = False
def show_message(self, message_id: int) -> None: def show_message(self, message_id: int) -> None:
self.current_message_id = message_id self.current_message_id = message_id
@@ -260,22 +328,22 @@ class EmailViewerApp(App):
def action_next(self) -> None: def action_next(self) -> None:
self.show_message(self.next_id) self.show_message(self.next_id)
self.action_fetch_list() if self.reload_needed else None self.fetch_envelopes() if self.reload_needed else None
def action_previous(self) -> None: def action_previous(self) -> None:
self.action_fetch_list() if self.reload_needed else None self.fetch_envelopes() if self.reload_needed else None
self.show_message(self.previous_id) self.show_message(self.previous_id)
def action_delete(self) -> None: def action_delete(self) -> None:
self.all_envelopes.remove(self.all_envelopes[self.current_message_index]) self.all_envelopes.remove(self.all_envelopes[self.current_message_index])
self.message_body_cache.pop(self.current_message_id, None) self.message_body_cache.pop(self.current_message_id, None)
self.query_one(StatusTitle).total_messages = len(self.all_envelopes) self.total_messages = len(self.all_envelopes)
delete_current(self) delete_current(self)
def action_archive(self) -> None: def action_archive(self) -> None:
self.all_envelopes.remove(self.all_envelopes[self.current_message_index]) self.all_envelopes.remove(self.all_envelopes[self.current_message_index])
self.message_body_cache.pop(self.current_message_id, None) self.message_body_cache.pop(self.current_message_id, None)
self.query_one(StatusTitle).total_messages = len(self.all_envelopes) self.total_messages = len(self.all_envelopes)
archive_current(self) archive_current(self)
def action_open(self) -> None: def action_open(self) -> None:
@@ -306,13 +374,22 @@ class EmailViewerApp(App):
self.exit() self.exit()
def action_oldest(self) -> None: def action_oldest(self) -> None:
self.action_fetch_list() if self.reload_needed else None self.fetch_envelopes() if self.reload_needed else None
self.show_message(self.oldest_id) self.show_message(self.oldest_id)
def action_newest(self) -> None: def action_newest(self) -> None:
self.action_fetch_list() if self.reload_needed else None self.fetch_envelopes() if self.reload_needed else None
self.show_message(self.newest_id) self.show_message(self.newest_id)
def action_focus_1(self) -> None:
self.query_one("#envelopes_list").focus()
def action_focus_2(self) -> None:
self.query_one("#accounts_list").focus()
def action_focus_3(self) -> None:
self.query_one("#folders_list").focus()
if __name__ == "__main__": if __name__ == "__main__":
app = EmailViewerApp() app = EmailViewerApp()
app.run() app.run()

View File

@@ -1,5 +1,37 @@
/* Basic stylesheet for the Textual Email Viewer App */ /* Basic stylesheet for the Textual Email Viewer App */
#main_content, .list_view {
scrollbar-size: 1 1;
border: round rgb(117, 106, 129);
height: 1fr;
}
#sidebar {
width: 1fr
}
#main_content {
width: 2fr;
}
#sidebar:focus-within {
background: $panel;
.list_view:blur {
height: 3;
}
.list_view:focus {
height: 2fr;
}
}
#main_content:focus, .list_view:focus {
border: round $secondary;
background: rgb(55, 53, 57);
border-title-style: bold;
}
Label#task_prompt { Label#task_prompt {
padding: 1; padding: 1;
color: rgb(128,128,128); color: rgb(128,128,128);
@@ -25,7 +57,6 @@ StatusTitle {
EnvelopeHeader { EnvelopeHeader {
dock: top; dock: top;
margin-top: 1;
width: 100%; width: 100%;
max-height: 2; max-height: 2;
tint: $primary 10%; tint: $primary 10%;
@@ -35,21 +66,15 @@ Markdown {
padding: 1 2; padding: 1 2;
} }
ListView {
dock: left;
width: 30%;
height: 100%;
padding: 0;
}
.email_subject { .email_subject {
width: 100%; width: 1fr;
padding: 0 padding: 0
} }
.header_key { .header_key {
tint: gray 20%; tint: gray 20%;
min-width: 10; min-width: 10;
text-style:bold;
} }
.header_value { .header_value {
@@ -57,3 +82,32 @@ ListView {
height: auto; height: auto;
width: auto; width: auto;
} }
.modal_screen {
align: center middle;
margin: 1;
padding: 2;
border: round $border;
background: $panel;
width: auto;
height: auto;
}
#create_task_container {
width: 50%;
height: 50%;
border: heavy $secondary;
layout: horizontal;
align: center middle;
Label {
width: auto;
}
Input {
width: 1fr;
}
}
#envelopes-list ListItem:odd {
background: rgb(25, 24, 26);
}

View File

@@ -1,15 +1,24 @@
from textual import on from textual import on
from textual.app import ComposeResult, Screen from textual.app import ComposeResult
from textual.widgets import Input, Label from textual.screen import ModalScreen
from textual.containers import Horizontal from textual.widgets import Input, Label, Button
from textual.containers import Horizontal, Vertical
class CreateTaskScreen(Screen[str]): class CreateTaskScreen(ModalScreen[str]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Horizontal( yield Vertical(
Label("$>", id="task_prompt"), Horizontal(
Label("task add ", id="task_prompt_label"), Label("$>", id="task_prompt"),
Input(placeholder="arguments", id="task_input") Label("task add ", id="task_prompt_label"),
Input(placeholder="arguments", id="task_input"),
),
Horizontal(
Button("Cancel"),
Button("Submit")
),
id="create_task_container",
classes="modal_screen"
) )
@on(Input.Submitted) @on(Input.Submitted)

View File

@@ -1,22 +1,24 @@
from textual import on from textual import on
from textual.app import ComposeResult, Screen from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Input, Label, Button from textual.widgets import Input, Label, Button
from textual.containers import Horizontal from textual.containers import Container
class OpenMessageScreen(ModalScreen[int | None]):
class OpenMessageScreen(Screen[int]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Horizontal( yield Container(
Label("📨", id="message_label"), Label("📨", id="message_label"),
Input(placeholder="Enter message ID (integer only)", type="integer", id="open_message_input"), Input(placeholder="Enter message ID (integer only)", type="integer", id="open_message_input"),
Button("Open", variant="primary", id="open_message_button") Button("Open", variant="primary", id="open_message_button"),
id="open_message_container",
classes="modal_screen"
) )
@on(Input.Submitted) @on(Input.Submitted)
def handle_message_id(self) -> None: def handle_message_id(self) -> None:
input_widget = self.query_one("#open_message_input", Input) input_widget = self.query_one("#open_message_input", Input)
self.disabled = True message_id = int(input_widget.value if input_widget.value else 0)
self.loading = True
message_id = int(input_widget.value)
self.dismiss(message_id) self.dismiss(message_id)
@on(Input._on_key) @on(Input._on_key)