add x selections

This commit is contained in:
Tim Bendt
2025-07-02 23:13:15 -04:00
parent 0bbb9e6cd4
commit c8f9a22401
4 changed files with 244 additions and 36 deletions

View File

@@ -28,7 +28,7 @@ async def archive_current(app):
if success: if success:
app.show_status(f"Message {current_message_id} archived.", "success") app.show_status(f"Message {current_message_id} archived.", "success")
app.message_store.remove_envelope(current_message_id) app.message_store.remove_envelope(current_message_id)
app.refresh_list_view() app.refresh_list_view_items()
# Select the next available message if it exists # Select the next available message if it exists
if next_id is not None and next_idx is not None: if next_id is not None and next_idx is not None:

View File

@@ -68,6 +68,7 @@ class EmailViewerApp(App):
total_messages: Reactive[int] = reactive(0) total_messages: Reactive[int] = reactive(0)
status_title = reactive("Message View") status_title = reactive("Message View")
sort_order_ascending: Reactive[bool] = reactive(True) sort_order_ascending: Reactive[bool] = reactive(True)
selected_messages: Reactive[set[int]] = reactive(set())
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)
@@ -117,6 +118,8 @@ class EmailViewerApp(App):
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"),
Binding("s", "toggle_sort_order", "Toggle Sort Order"), Binding("s", "toggle_sort_order", "Toggle Sort Order"),
Binding("x", "toggle_selection", "Toggle selection"),
Binding("escape", "clear_selection", "Clear selection"),
] ]
) )
@@ -158,7 +161,9 @@ class EmailViewerApp(App):
self.action_oldest() self.action_oldest()
def compute_status_title(self): def compute_status_title(self):
return f"✉️ Message ID: {self.current_message_id} " metadata = self.message_store.get_metadata(self.current_message_id)
message_date = metadata["date"] if metadata else "N/A"
return f"✉️ Message ID: {self.current_message_id} | Date: {message_date}"
def watch_status_title(self, old_status_title: str, new_status_title: str) -> None: def watch_status_title(self, old_status_title: str, new_status_title: str) -> None:
self.query_one(ContentContainer).border_title = new_status_title self.query_one(ContentContainer).border_title = new_status_title
@@ -176,11 +181,23 @@ class EmailViewerApp(App):
if new_index > self.total_messages: if new_index > self.total_messages:
new_index = self.total_messages new_index = self.total_messages
self.current_message_index = new_index self.current_message_index = new_index
self.query_one(
"#envelopes_list" self._update_list_view_subtitle()
).border_subtitle = f"[b]{new_index}[/b]/{self.total_messages}"
self.query_one("#envelopes_list").index = new_index self.query_one("#envelopes_list").index = new_index
def watch_selected_messages(self, old_messages: set[int], new_messages: set[int]) -> None:
self._update_list_view_subtitle()
def _update_list_view_subtitle(self) -> None:
subtitle = f"[b]{self.current_message_index}[/b]/{self.total_messages}"
if self.selected_messages:
subtitle = f"(✓{len(self.selected_messages)}) {subtitle}"
self.query_one("#envelopes_list").border_subtitle = subtitle
def watch_total_messages(self, old_total: int, new_total: int) -> None:
"""Called when the total_messages reactive attribute changes."""
self._update_list_view_subtitle()
def watch_reload_needed( def watch_reload_needed(
self, old_reload_needed: bool, new_reload_needed: bool self, old_reload_needed: bool, new_reload_needed: bool
) -> None: ) -> None:
@@ -221,7 +238,7 @@ class EmailViewerApp(App):
# cc=metadata["cc"].get("addr", "") if "cc" in metadata else "", # cc=metadata["cc"].get("addr", "") if "cc" in metadata else "",
# ) # )
list_view = self.query_one("#envelopes_list") list_view = self.query_one("#envelopes_list", ListView)
if list_view.index != metadata["index"]: if list_view.index != metadata["index"]:
list_view.index = metadata["index"] list_view.index = metadata["index"]
else: else:
@@ -230,18 +247,39 @@ class EmailViewerApp(App):
def on_list_view_selected(self, event: ListView.Selected) -> None: def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Called when an item in the list view is selected.""" """Called when an item in the list view is selected."""
current_item = self.message_store.envelopes[event.list_view.index] if event.list_view.index is None:
return
selected_index = event.list_view.index
current_item = self.message_store.envelopes[selected_index]
if current_item is None or current_item.get("type") == "header": if current_item is None or current_item.get("type") == "header":
return return
message_id = int(current_item["id"]) message_id = int(current_item["id"])
self.current_message_id = message_id self.current_message_id = message_id
self.current_message_index = event.list_view.index self.current_message_index = selected_index
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
"""Called when an item in the list view is highlighted (e.g., via arrow keys)."""
if event.list_view.index is None:
return
highlighted_index = event.list_view.index
current_item = self.message_store.envelopes[highlighted_index]
if current_item is None or current_item.get("type") == "header":
return
message_id = int(current_item["id"])
self.current_message_id = message_id
self.current_message_index = highlighted_index
@work(exclusive=False) @work(exclusive=False)
async def fetch_envelopes(self) -> None: async def fetch_envelopes(self) -> None:
msglist = self.query_one("#envelopes_list") msglist = self.query_one("#envelopes_list", ListView)
try: try:
msglist.loading = True msglist.loading = True
@@ -254,7 +292,7 @@ class EmailViewerApp(App):
self.total_messages = self.message_store.total_messages self.total_messages = self.message_store.total_messages
# Use the centralized refresh method to update the ListView # Use the centralized refresh method to update the ListView
self.refresh_list_view() self._populate_list_view()
# Restore the current index # Restore the current index
msglist.index = self.current_message_index msglist.index = self.current_message_index
@@ -321,35 +359,119 @@ class EmailViewerApp(App):
finally: finally:
folders_list.loading = False folders_list.loading = False
def refresh_list_view(self) -> None: def _populate_list_view(self) -> None:
"""Refresh the ListView to ensure it matches the MessageStore exactly.""" """Populate the ListView with new items. This clears existing items."""
envelopes_list = self.query_one("#envelopes_list") envelopes_list = self.query_one("#envelopes_list", ListView)
envelopes_list.clear() envelopes_list.clear()
for item in self.message_store.envelopes: for item in self.message_store.envelopes:
if item and item.get("type") == "header": if item and item.get("type") == "header":
envelopes_list.append( envelopes_list.append(
ListItem( ListItem(
Label( Horizontal(
item["label"], Label("", classes="checkbox"), # Hidden checkbox for header
classes="group_header", Label(
markup=False, item["label"],
classes="group_header",
markup=False,
),
classes="envelope_item_row"
) )
) )
) )
elif item: # Check if not None elif item: # Check if not None
envelopes_list.append( # Extract sender and date
ListItem( sender_name = item.get("from", {}).get("name", item.get("from", {}).get("addr", "Unknown"))
Label( if not sender_name:
str(item.get("subject", "")).strip(), sender_name = item.get("from", {}).get("addr", "Unknown")
classes="email_subject",
markup=False, # Truncate sender name
) max_sender_len = 25 # Adjust as needed
if len(sender_name) > max_sender_len:
sender_name = sender_name[:max_sender_len-3] + "..."
message_date_str = item.get("date", "")
formatted_date = ""
if message_date_str:
try:
# Parse the date string, handling potential timezone info
dt_object = datetime.fromisoformat(message_date_str)
formatted_date = dt_object.strftime("%m/%d %H:%M")
except ValueError:
formatted_date = "Invalid Date"
list_item = ListItem(
Vertical(
Horizontal(
Label("", classes="checkbox"), # Placeholder for checkbox
Label(sender_name, classes="sender_name"),
Label(formatted_date, classes="message_date"),
classes="envelope_header_row"
),
Horizontal(
Label(
str(item.get("subject", "")).strip(),
classes="email_subject",
markup=False,
)
),
classes="envelope_item_row"
) )
) )
envelopes_list.append(list_item)
self.refresh_list_view_items() # Initial refresh of item states
# Update total messages count def refresh_list_view_items(self) -> None:
self.total_messages = self.message_store.total_messages """Update the visual state of existing ListItems without clearing the list."""
envelopes_list = self.query_one("#envelopes_list", ListView)
for i, list_item in enumerate(envelopes_list.children):
if isinstance(list_item, ListItem):
item_data = self.message_store.envelopes[i]
# Find the checkbox label within the ListItem's children
checkbox_label = None
for child in list_item.walk_children():
if isinstance(child, Label) and "checkbox" in child.classes:
checkbox_label = child
break
if checkbox_label:
if item_data and item_data.get("type") != "header":
message_id = int(item_data["id"])
is_selected = message_id in self.selected_messages
checkbox_label.update("\uf4a7" if is_selected else "\ue640")
checkbox_label.display = True # Always display checkbox
list_item.highlighted = is_selected
# Update sender and date labels
sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown"))
if not sender_name:
sender_name = item_data.get("from", {}).get("addr", "Unknown")
max_sender_len = 25
if len(sender_name) > max_sender_len:
sender_name = sender_name[:max_sender_len-3] + "..."
list_item.query_one(".sender_name", Label).update(sender_name)
message_date_str = item_data.get("date", "")
formatted_date = ""
if message_date_str:
try:
dt_object = datetime.fromisoformat(message_date_str)
formatted_date = dt_object.strftime("%m/%d %H:%M")
except ValueError:
formatted_date = "Invalid Date"
list_item.query_one(".message_date", Label).update(formatted_date)
else:
# For header items, checkbox should be unchecked and visible
checkbox_label.update("\ue640") # Always unchecked for headers
checkbox_label.display = True # Always display checkbox
list_item.highlighted = False # Headers are never highlighted for selection
# Update total messages count (this is still fine here)
# self.total_messages = self.message_store.total_messages
def show_message(self, message_id: int, new_index=None) -> None: def show_message(self, message_id: int, new_index=None) -> None:
if new_index: if new_index:
@@ -390,8 +512,6 @@ class EmailViewerApp(App):
self.current_message_id = next_id self.current_message_id = next_id
self.current_message_index = next_idx self.current_message_index = next_idx
self.fetch_envelopes() if self.reload_needed else None
def action_previous(self) -> None: def action_previous(self) -> None:
if not self.current_message_index >= 0: if not self.current_message_index >= 0:
return return
@@ -403,8 +523,6 @@ class EmailViewerApp(App):
self.current_message_id = prev_id self.current_message_id = prev_id
self.current_message_index = prev_idx self.current_message_index = prev_idx
self.fetch_envelopes() if self.reload_needed else None
async def action_delete(self) -> None: async def action_delete(self) -> None:
"""Delete the current message and update UI consistently.""" """Delete the current message and update UI consistently."""
# Call the delete_current function which uses our Himalaya client module # Call the delete_current function which uses our Himalaya client module
@@ -413,9 +531,19 @@ class EmailViewerApp(App):
async def action_archive(self) -> None: async def action_archive(self) -> None:
"""Archive the current message and update UI consistently.""" """Archive the current message and update UI consistently."""
# Call the archive_current function which uses our Himalaya client module if self.selected_messages:
worker = archive_current(self) message_ids = [str(msg_id) for msg_id in self.selected_messages]
await worker.wait() _, success = await himalaya_client.archive_messages(message_ids)
if success:
self.show_status(f"{len(message_ids)} messages archived.")
self.selected_messages.clear()
self.fetch_envelopes()
else:
self.show_status("Failed to archive messages.", "error")
else:
# Call the archive_current function which uses our Himalaya client module
worker = archive_current(self)
await worker.wait()
def action_open(self) -> None: def action_open(self) -> None:
action_open(self) action_open(self)
@@ -440,9 +568,38 @@ class EmailViewerApp(App):
self.query_one("#main_content").scroll_page_up() self.query_one("#main_content").scroll_page_up()
def action_quit(self) -> None: def action_quit(self) -> None:
"""Quit the application."""
self.exit() self.exit()
def action_toggle_selection(self) -> None:
"""Toggle selection for the current message."""
current_item_data = self.message_store.envelopes[self.current_message_index]
if current_item_data and current_item_data.get("type") != "header":
message_id = int(current_item_data["id"])
if message_id in self.selected_messages:
self.selected_messages.remove(message_id)
else:
self.selected_messages.add(message_id)
# Manually update the current ListItem
envelopes_list = self.query_one("#envelopes_list", ListView)
current_list_item = envelopes_list.children[self.current_message_index]
if isinstance(current_list_item, ListItem):
checkbox_label = None
for child in current_list_item.walk_children():
if isinstance(child, Label) and "checkbox" in child.classes:
checkbox_label = child
break
if checkbox_label:
checkbox_label.update("\uf4a7" if message_id in self.selected_messages else "\ue640")
current_list_item.highlighted = (message_id in self.selected_messages)
self._update_list_view_subtitle()
def action_clear_selection(self) -> None:
"""Clear all selected messages."""
self.selected_messages.clear()
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
self._update_list_view_subtitle()
def action_oldest(self) -> None: def action_oldest(self) -> None:
self.fetch_envelopes() if self.reload_needed else None self.fetch_envelopes() if self.reload_needed else None
self.show_message(self.message_store.get_oldest_id()) self.show_message(self.message_store.get_oldest_id())

View File

@@ -68,7 +68,17 @@ Markdown {
.email_subject { .email_subject {
width: 1fr; width: 1fr;
padding: 0 padding: 0 2;
text-style: bold;
}
.sender_name {
tint: gray 30%;
}
.message_date {
padding: 0 2;
color: $secondary;
} }
.header_key { .header_key {
@@ -103,7 +113,7 @@ Markdown {
background: rgb(50, 50, 56); background: rgb(50, 50, 56);
} }
& > ListItem { & > ListItem {
&.-highlight { &.-highlight, .selection {
color: $block-cursor-blurred-foreground; color: $block-cursor-blurred-foreground;
background: $block-cursor-blurred-background; background: $block-cursor-blurred-background;
text-style: $block-cursor-blurred-text-style; text-style: $block-cursor-blurred-text-style;
@@ -111,6 +121,13 @@ Markdown {
} }
} }
.envelope_item_row {
height: auto;
width: 1fr;
Horizontal {
height: auto;
}
}
#open_message_container, #create_task_container { #open_message_container, #create_task_container {
border: panel $border; border: panel $border;
@@ -160,3 +177,6 @@ ContentContainer {
width: 100%; width: 100%;
height: 1fr; height: 1fr;
} }
.checkbox {
padding-right: 1;
}

View File

@@ -140,6 +140,37 @@ async def archive_message(message_id: int) -> bool:
return False return False
async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]:
"""
Archive multiple messages by their IDs.
Args:
message_ids: A list of message IDs to archive.
Returns:
A tuple containing an optional output string and a boolean indicating success.
"""
try:
ids_str = " ".join(message_ids)
cmd = f"himalaya message move Archives {ids_str}"
process = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
return stdout.decode(), True
else:
logging.error(f"Error archiving messages: {stderr.decode()}")
return None, False
except Exception as e:
logging.error(f"Exception during message archiving: {e}")
return None, False
async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]: async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]:
""" """
Retrieve the content of a message by its ID. Retrieve the content of a message by its ID.