add x selections
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user