mirror of https://github.com/gmbrax/Pilgrim.git
				
				
				
			
		
			
				
	
	
		
			1039 lines
		
	
	
		
			40 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			1039 lines
		
	
	
		
			40 KiB
		
	
	
	
		
			Python
		
	
	
	
| import re
 | ||
| from datetime import datetime
 | ||
| from pathlib import Path
 | ||
| from typing import Optional, List
 | ||
| 
 | ||
| from pilgrim.models.entry import Entry
 | ||
| from pilgrim.models.photo import Photo
 | ||
| from pilgrim.models.travel_diary import TravelDiary
 | ||
| from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal
 | ||
| from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal
 | ||
| from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal
 | ||
| from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal
 | ||
| from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal
 | ||
| from textual.app import ComposeResult
 | ||
| from textual.binding import Binding
 | ||
| from textual.containers import Container, Horizontal, Vertical
 | ||
| from textual.screen import Screen
 | ||
| from textual.widgets import Header, Footer, Static, TextArea, OptionList
 | ||
| 
 | ||
| 
 | ||
| class EditEntryScreen(Screen):
 | ||
|     TITLE = "Pilgrim - Edit"
 | ||
| 
 | ||
|     BINDINGS = [
 | ||
|         Binding("ctrl+q", "quit", "Quit"),
 | ||
|         Binding("ctrl+s", "save", "Save"),
 | ||
|         Binding("ctrl+n", "new_entry", "New Entry"),
 | ||
|         Binding("ctrl+shift+n", "next_entry", "Next Entry"),
 | ||
|         Binding("ctrl+shift+p", "prev_entry", "Previous Entry"),
 | ||
|         Binding("ctrl+r", "rename_entry", "Rename Entry"),
 | ||
|         Binding("f8", "toggle_sidebar", "Toggle Photos"),
 | ||
|         Binding("f9", "toggle_focus", "Toggle Focus"),
 | ||
|         Binding("escape", "back_to_list", "Back to List"),
 | ||
|     ]
 | ||
| 
 | ||
|     def __init__(self, diary_id: int = 1):
 | ||
|         super().__init__()
 | ||
|         self.diary_id = diary_id
 | ||
|         self.diary_name = f"Diary {diary_id}"
 | ||
|         self.current_entry_index = 0
 | ||
|         self.entries: List[Entry] = []
 | ||
|         self.is_new_entry = False
 | ||
|         self.has_unsaved_changes = False
 | ||
|         self.new_entry_content = ""
 | ||
|         self.new_entry_title = "New Entry"
 | ||
|         self.next_entry_id = 1
 | ||
|         self._updating_display = False
 | ||
|         self._original_content = ""
 | ||
|         self.is_refreshing = False
 | ||
|         self.sidebar_visible = False
 | ||
|         self.sidebar_focused = False
 | ||
|         self._sidebar_opened_once = False
 | ||
|         self._active_tooltip = None
 | ||
|         self._last_photo_suggestion_notification = None
 | ||
|         self._last_photo_suggestion_type = None
 | ||
|         self._active_notification = None
 | ||
|         self._notification_timer = None
 | ||
|         self.references = []
 | ||
|         self.cached_photos = []
 | ||
| 
 | ||
|         # Main header
 | ||
|         self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header")
 | ||
| 
 | ||
|         # Sub-header widgets
 | ||
|         self.diary_info = Static(f"Diary: {self.diary_name}", id="diary_info", classes="EditEntryScreen-diary-info")
 | ||
|         self.entry_info = Static("Loading...", id="entry_info", classes="EditEntryScreen-entry-info")
 | ||
|         self.status_indicator = Static("Saved", id="status_indicator", classes="EditEntryScreen-status-indicator")
 | ||
| 
 | ||
|         # Sub-header container
 | ||
|         self.sub_header = Horizontal(
 | ||
|             self.diary_info,
 | ||
|             Static(classes="spacer EditEntryScreen-spacer"),
 | ||
|             self.entry_info,
 | ||
|             self.status_indicator,
 | ||
|             id="sub_header",
 | ||
|             classes="EditEntryScreen-sub-header"
 | ||
|         )
 | ||
| 
 | ||
|         # Text area
 | ||
|         self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry")
 | ||
| 
 | ||
|         # Sidebar widgets
 | ||
|         self.sidebar_title = Static("📸 Photos", classes="EditEntryScreen-sidebar-title")
 | ||
|         self.photo_list = OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list")
 | ||
|         self.photo_info = Static("", classes="EditEntryScreen-sidebar-photo-info")
 | ||
|         self.help_text = Static("", classes="EditEntryScreen-sidebar-help")
 | ||
| 
 | ||
|         # Sidebar container: photo list and info in a flexible container, help_text fixed at bottom
 | ||
|         self.sidebar_content = Vertical(
 | ||
|             self.photo_list,
 | ||
|             self.photo_info,
 | ||
|             id="sidebar_content",
 | ||
|             classes="EditEntryScreen-sidebar-content"
 | ||
|         )
 | ||
|         self.sidebar = Vertical(
 | ||
|             self.sidebar_title,
 | ||
|             self.sidebar_content,
 | ||
|             self.help_text,  # Always at the bottom, never scrolls
 | ||
|             id="sidebar",
 | ||
|             classes="EditEntryScreen-sidebar"
 | ||
|         )
 | ||
| 
 | ||
|         # Main container
 | ||
|         self.main = Container(
 | ||
|             self.sub_header,
 | ||
|             self.text_entry,
 | ||
|             id="EditEntryScreen_MainContainer",
 | ||
|             classes="EditEntryScreen-main-container"
 | ||
|         )
 | ||
| 
 | ||
|         # Footer
 | ||
|         self.footer = Footer(classes="EditEntryScreen-footer")
 | ||
| 
 | ||
|     def _update_footer_context(self):
 | ||
|         """Force footer refresh to show updated bindings"""
 | ||
|         self.refresh()
 | ||
| 
 | ||
|     def _get_cursor_position(self) -> tuple:
 | ||
|         """Get the current cursor position for tooltip placement"""
 | ||
|         try:
 | ||
|             # Get cursor position from text area
 | ||
|             cursor_location = self.text_entry.cursor_location
 | ||
|             if cursor_location:
 | ||
|                 # Get the text area region
 | ||
|                 text_region = self.text_entry.region
 | ||
|                 if text_region:
 | ||
|                     # Calculate position relative to text area
 | ||
|                     # Position tooltip below the current line, not over it
 | ||
|                     x = text_region.x + min(cursor_location[0], text_region.width - 40)  # Keep within bounds
 | ||
|                     y = text_region.y + cursor_location[1] + 2  # 2 lines below cursor
 | ||
|                     return (x, y)
 | ||
|         except:
 | ||
|             pass
 | ||
|         return None
 | ||
| 
 | ||
| 
 | ||
|     def compose(self) -> ComposeResult:
 | ||
|         yield self.header
 | ||
|         yield Horizontal(
 | ||
|             self.main,
 | ||
|             self.sidebar,
 | ||
|             id="content_container",
 | ||
|             classes="EditEntryScreen-content-container"
 | ||
|         )
 | ||
|         yield self.footer
 | ||
| 
 | ||
|     def on_mount(self) -> None:
 | ||
|         """Called when the screen is mounted"""
 | ||
|         self.sidebar.display = False
 | ||
|         self.sidebar_visible = False
 | ||
| 
 | ||
|         # First update diary info, then refresh entries
 | ||
|         self.update_diary_info()
 | ||
|         self.refresh_entries()
 | ||
| 
 | ||
|         # Initialize footer with editor context
 | ||
|         self._update_footer_context()
 | ||
|         # self.app.mount(self._photo_suggestion_widget)  # Temporarily disabled
 | ||
| 
 | ||
|     def update_diary_info(self):
 | ||
|         """Updates diary information"""
 | ||
|         try:
 | ||
|             service_manager = self.app.service_manager
 | ||
|             travel_diary_service = service_manager.get_travel_diary_service()
 | ||
| 
 | ||
|             diary = travel_diary_service.read_by_id(self.diary_id)
 | ||
|             if diary:
 | ||
|                 self.diary_name = diary.name
 | ||
|                 self.diary_info.update(f"Diary: {self.diary_name}")
 | ||
|             else:
 | ||
|                 self.diary_name = f"Diary {self.diary_id}"
 | ||
|                 self.diary_info.update(f"Diary: {self.diary_name}")
 | ||
|                 self.notify(f"Diary {self.diary_id} not found, using default name")
 | ||
|         except Exception as e:
 | ||
|             self.diary_name = f"Diary {self.diary_id}"
 | ||
|             self.diary_info.update(f"Diary: {self.diary_name}")
 | ||
|             self.notify(f"Error loading diary info: {str(e)}")
 | ||
| 
 | ||
|         self._ensure_diary_info_updated()
 | ||
| 
 | ||
|     def _ensure_diary_info_updated(self):
 | ||
|         """Ensures the diary info widget is always updated with current diary name"""
 | ||
|         try:
 | ||
|             self.diary_info.update(f"Diary: {self.diary_name}")
 | ||
|         except Exception as e:
 | ||
|             self.diary_info.update(f"Diary: {self.diary_id}")
 | ||
| 
 | ||
|     def refresh_entries(self):
 | ||
|         """Synchronous version of refresh"""
 | ||
|         try:
 | ||
|             service_manager = self.app.service_manager
 | ||
|             entry_service = service_manager.get_entry_service()
 | ||
| 
 | ||
|             all_entries = entry_service.read_all()
 | ||
|             self.entries = [entry for entry in all_entries if entry.fk_travel_diary_id == self.diary_id]
 | ||
|             self.entries.sort(key=lambda x: x.id)
 | ||
| 
 | ||
|             if self.entries:
 | ||
|                 self.next_entry_id = max(entry.id for entry in self.entries) + 1
 | ||
|             else:
 | ||
|                 self.next_entry_id = 1
 | ||
| 
 | ||
|             self._update_entry_display()
 | ||
|             self._update_sub_header()
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error loading entries: {str(e)}")
 | ||
| 
 | ||
|         self._ensure_diary_info_updated()
 | ||
| 
 | ||
|     def _update_status_indicator(self, text: str, css_class: str):
 | ||
|         """Helper to update status indicator text and class."""
 | ||
|         self.status_indicator.update(text)
 | ||
|         self.status_indicator.remove_class("saved", "not-saved", "new", "read-only")
 | ||
|         self.status_indicator.add_class(css_class)
 | ||
| 
 | ||
|     def _update_sub_header(self):
 | ||
|         """Updates the sub-header with current entry information."""
 | ||
|         if not self.entries and not self.is_new_entry:
 | ||
|             self.entry_info.update("No entries")
 | ||
|             self._update_status_indicator("Saved", "saved")
 | ||
|             return
 | ||
| 
 | ||
|         if self.is_new_entry:
 | ||
|             self.entry_info.update(f"New Entry: {self.new_entry_title}")
 | ||
|             if self.has_unsaved_changes:
 | ||
|                 self._update_status_indicator("Not Saved", "not-saved")
 | ||
|             else:
 | ||
|                 self._update_status_indicator("New", "new")
 | ||
|         else:
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
|             entry_text = f"Entry: \\[{self.current_entry_index + 1}/{len(self.entries)}] {current_entry.title}"
 | ||
|             self.entry_info.update(entry_text)
 | ||
|             if self.has_unsaved_changes:
 | ||
|                 self._update_status_indicator("Not Saved", "not-saved")
 | ||
|             else:
 | ||
|                 self._update_status_indicator("Saved", "saved")
 | ||
| 
 | ||
|     def _save_current_state(self):
 | ||
|         """Saves the current state before navigating"""
 | ||
|         if self.is_new_entry:
 | ||
|             self.new_entry_content = self.text_entry.text
 | ||
|         elif self.entries and self.has_unsaved_changes:
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
|             current_entry.text = self.text_entry.text
 | ||
| 
 | ||
|     def _finish_display_update(self):
 | ||
|         """Finishes the display update by reactivating change detection"""
 | ||
|         self._updating_display = False
 | ||
|         self._update_sub_header()
 | ||
|         if self.sidebar_visible:
 | ||
|             self._update_sidebar_content()
 | ||
| 
 | ||
|     def _update_entry_display(self):
 | ||
|         """Updates the display of the current entry"""
 | ||
|         if not self.entries and not self.is_new_entry:
 | ||
|             self.text_entry.text = f"No entries found for diary '{self.diary_name}'\n\nPress Ctrl+N to create a new entry."
 | ||
|             self.text_entry.read_only = True
 | ||
|             self._original_content = self.text_entry.text
 | ||
|             self._update_sub_header()
 | ||
|             return
 | ||
| 
 | ||
|         self._updating_display = True
 | ||
| 
 | ||
|         if self.is_new_entry:
 | ||
|             self.text_entry.text = self.new_entry_content
 | ||
|             self.text_entry.read_only = False
 | ||
|             self._original_content = self.new_entry_content
 | ||
|             self.has_unsaved_changes = False
 | ||
|         else:
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
|             self.text_entry.text = current_entry.text
 | ||
|             self.text_entry.read_only = False
 | ||
|             self._original_content = current_entry.text
 | ||
|             self.has_unsaved_changes = False
 | ||
| 
 | ||
|         self.call_after_refresh(self._finish_display_update)
 | ||
| 
 | ||
|     def _update_sidebar_content(self):
 | ||
|         """Updates the sidebar content with photos for the current diary"""
 | ||
|         try:
 | ||
|             self._load_photos_for_diary(self.diary_id)
 | ||
| 
 | ||
|             # Clear existing options safely
 | ||
|             self.photo_list.clear_options()
 | ||
| 
 | ||
|             # Add the 'Ingest Photo' option at the top
 | ||
|             self.photo_list.add_option("➕ Ingest Photo")
 | ||
| 
 | ||
|             if not self.cached_photos:
 | ||
|                 self.photo_info.update("No photos found for this diary")
 | ||
|                 self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos")
 | ||
|                 return
 | ||
| 
 | ||
|             # Add photos to the list with hash
 | ||
|             for photo in self.cached_photos:
 | ||
|                 # Show name and hash in the list
 | ||
|                 photo_hash = str(photo.photo_hash)[:8]
 | ||
|                 self.photo_list.add_option(f"📷 {photo.name} \\[{photo_hash}\]")
 | ||
| 
 | ||
|             self.photo_info.update(f"📸 {len(self.cached_photos)} photos in diary")
 | ||
| 
 | ||
|             # Updated help a text with hash information
 | ||
|             help_text = (
 | ||
|                 "[b]⌨️  Sidebar Shortcuts[/b]\n"
 | ||
|                 "[b][green]i[/green][/b]: Insert photo into entry\n"
 | ||
|                 "[b][green]n[/green][/b]: Add new photo\n"
 | ||
|                 "[b][green]d[/green][/b]: Delete selected photo\n"
 | ||
|                 "[b][green]e[/green][/b]: Edit selected photo\n"
 | ||
|                 "[b][yellow]Tab[/yellow][/b]: Back to editor\n"
 | ||
|                 "[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n"
 | ||
|                 "[b][yellow]F9[/yellow][/b]: Switch focus (if needed)\n\n"
 | ||
|                 "[b]📝 Photo References[/b]\n"
 | ||
|                 "Use: \\[\\[photo:name:hash\\]\\]\n"
 | ||
|                 "Or: \\[\\[photo::hash\\]\\]"
 | ||
|             )
 | ||
|             self.help_text.update(help_text)
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error updating sidebar: {str(e)}", severity="error")
 | ||
|             # Set fallback content
 | ||
|             self.photo_info.update("Error loading photos")
 | ||
|             self.help_text.update("Error loading sidebar content")
 | ||
| 
 | ||
|     def _load_photos_for_diary(self, diary_id: int):
 | ||
|         """Loads all photos for the specific diary"""
 | ||
|         try:
 | ||
|             service_manager = self.app.service_manager
 | ||
|             photo_service = service_manager.get_photo_service()
 | ||
| 
 | ||
|             all_photos = photo_service.read_all()
 | ||
|             self.cached_photos = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id]
 | ||
|             self.cached_photos.sort(key=lambda x: x.id)
 | ||
|             return self.cached_photos
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error loading photos: {str(e)}")
 | ||
|             return []
 | ||
| 
 | ||
| 
 | ||
|     def action_toggle_sidebar(self):
 | ||
|         """Toggles the sidebar visibility"""
 | ||
|         try:
 | ||
|             self.sidebar_visible = not self.sidebar_visible
 | ||
| 
 | ||
|             if self.sidebar_visible:
 | ||
|                 self.sidebar.display = True
 | ||
|                 self._update_sidebar_content()
 | ||
|                 # Automatically focus the sidebar when opening
 | ||
|                 self.sidebar_focused = True
 | ||
|                 self.photo_list.focus()
 | ||
|                 self._sidebar_opened_once = True
 | ||
|             else:
 | ||
|                 self.sidebar.display = False
 | ||
|                 self.sidebar_focused = False  # Reset focus when hiding
 | ||
|                 self.text_entry.focus()  # Return focus to editor
 | ||
| 
 | ||
|             # Update footer after context change
 | ||
|             self._update_footer_context()
 | ||
|             self.refresh(layout=True)
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error toggling sidebar: {str(e)}", severity="error")
 | ||
|             # Reset state on error
 | ||
|             self.sidebar_visible = False
 | ||
|             self.sidebar_focused = False
 | ||
|             self.sidebar.display = False
 | ||
| 
 | ||
|     def action_toggle_focus(self):
 | ||
|         """Toggles focus between editor and sidebar"""
 | ||
|         if not self.sidebar_visible:
 | ||
|             # If sidebar is not visible, show it and focus it
 | ||
|             self.action_toggle_sidebar()
 | ||
|             return
 | ||
| 
 | ||
|         self.sidebar_focused = not self.sidebar_focused
 | ||
|         if self.sidebar_focused:
 | ||
|             self.photo_list.focus()
 | ||
|         else:
 | ||
|             self.text_entry.focus()
 | ||
| 
 | ||
|         # Update footer after focus change
 | ||
|         self._update_footer_context()
 | ||
| 
 | ||
|     def action_insert_photo(self):
 | ||
|         """Insert selected photo into text"""
 | ||
| 
 | ||
|         if not self.sidebar_focused or not self.sidebar_visible:
 | ||
|             self.notify("Use F8 to open the sidebar first.", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         # Get a selected photo
 | ||
|         if self.photo_list.highlighted is None:
 | ||
|             self.notify("No photo selected", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         # Adjust index because of 'Ingest Photo' at the top
 | ||
|         photo_index = self.photo_list.highlighted - 1
 | ||
| 
 | ||
|         self._load_photos_for_diary(self.diary_id)
 | ||
|         if photo_index < 0 or photo_index >= len(self.cached_photos):
 | ||
|             self.notify("No photo selected", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         selected_photo = self.cached_photos[photo_index]
 | ||
|         photo_hash = selected_photo.photo_hash[:8]
 | ||
| 
 | ||
|         # Insert photo reference using hash format without escaping
 | ||
|         # Using raw string to avoid markup conflicts with [[
 | ||
|         photo_ref = f"[[photo::{photo_hash}]]"
 | ||
| 
 | ||
|         # Insert at the cursor position
 | ||
|         self.text_entry.insert(photo_ref)
 | ||
| 
 | ||
|         # Switch focus back to editor
 | ||
|         self.sidebar_focused = False
 | ||
|         self.text_entry.focus()
 | ||
| 
 | ||
|         # Update footer context
 | ||
|         self._update_footer_context()
 | ||
| 
 | ||
|         # Show selected photo info
 | ||
|         photo_details = f"📷 {selected_photo.name}\n"
 | ||
|         photo_details += f"🔗 {photo_hash}\n"
 | ||
|         photo_details += f"📅 {selected_photo.addition_date}\n"
 | ||
|         photo_details += f"💬 {selected_photo.caption or 'No caption'}\n"
 | ||
|         photo_details += f"📁 {selected_photo.filepath}\n\n"
 | ||
|         photo_details += f"[b]Reference formats:[/b]\n"
 | ||
|         photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n"
 | ||
|         photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
 | ||
| 
 | ||
|         self.photo_info.update(photo_details)
 | ||
| 
 | ||
|         # Show notification without escaping brackets
 | ||
|         self.notify(f"Inserted photo: {selected_photo.name} \\[{photo_hash}\\]", severity="information")
 | ||
| 
 | ||
|     def action_ingest_new_photo(self):
 | ||
|         """Ingest a new photo using modal"""
 | ||
|         if not self.sidebar_focused or not self.sidebar_visible:
 | ||
|             self.notify("Use F8 to open the sidebar first.", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         # Open add photo modal
 | ||
|         try:
 | ||
|             self.notify("Trying to push the modal screen...")
 | ||
|             self.app.push_screen(
 | ||
|                 AddPhotoModal(diary_id=self.diary_id),
 | ||
|                 self.handle_add_photo_result
 | ||
|             )
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error: {str(e)}", severity="error")
 | ||
|             self.app.notify("Error: {str(e)}", severity="error")
 | ||
| 
 | ||
|     def handle_add_photo_result(self, result: dict | None) -> None:
 | ||
|         """Callback that processes the add photo modal result."""
 | ||
|         if result is None:
 | ||
|             self.notify("Add photo cancelled")
 | ||
|             return
 | ||
| 
 | ||
|         # Photo was already created in the modal, just refresh the sidebar
 | ||
|         if self.sidebar_visible:
 | ||
|             self._update_sidebar_content()
 | ||
|         self.notify(f"Photo '{result['name']}' added successfully!")
 | ||
| 
 | ||
|     async def _async_create_photo(self, photo_data: dict):
 | ||
|         """Creates a new photo asynchronously"""
 | ||
|         try:
 | ||
|             service_manager = self.app.service_manager
 | ||
|             photo_service = service_manager.get_photo_service()
 | ||
| 
 | ||
|             current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 | ||
| 
 | ||
|             new_photo = photo_service.create(
 | ||
|                 filepath=Path(photo_data["filepath"]),
 | ||
|                 name=photo_data["name"],
 | ||
|                 travel_diary_id=self.diary_id,
 | ||
|                 addition_date=current_date,
 | ||
|                 caption=photo_data["caption"]
 | ||
|             )
 | ||
| 
 | ||
|             if new_photo:
 | ||
|                 self.notify(f"Photo '{new_photo.name}' added successfully!")
 | ||
|                 # Refresh sidebar content
 | ||
|                 if self.sidebar_visible:
 | ||
|                     self._update_sidebar_content()
 | ||
|             else:
 | ||
|                 self.notify("Error creating photo")
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error creating photo: {str(e)}")
 | ||
| 
 | ||
|     def action_delete_photo(self):
 | ||
|         """Delete selected photo"""
 | ||
|         if not self.sidebar_focused or not self.sidebar_visible:
 | ||
|             self.notify("Use F8 to open the sidebar first.", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         if self.photo_list.highlighted is None:
 | ||
|             self.notify("No photo selected", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         # Adjust index because of 'Ingest Photo' at the top
 | ||
|         photo_index = self.photo_list.highlighted - 1
 | ||
| 
 | ||
|         photos = self._load_photos_for_diary(self.diary_id)
 | ||
|         if photo_index < 0 or photo_index >= len(photos):
 | ||
|             self.notify("No photo selected", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         selected_photo = photos[photo_index]
 | ||
| 
 | ||
|         # Open confirm delete modal
 | ||
|         self.app.push_screen(
 | ||
|             ConfirmDeleteModal(photo=selected_photo),
 | ||
|             self.handle_delete_photo_result
 | ||
|         )
 | ||
| 
 | ||
|     def handle_delete_photo_result(self, result: bool) -> None:
 | ||
|         """Callback that processes the delete photo modal result."""
 | ||
|         if result:
 | ||
|             # Get the selected photo with an adjusted index
 | ||
|             photos = self._load_photos_for_diary(self.diary_id)
 | ||
|             photo_index = self.photo_list.highlighted - 1  # Adjust for 'Ingest Photo' at top
 | ||
| 
 | ||
|             if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos):
 | ||
|                 self.notify("Photo no longer available", severity="error")
 | ||
|                 return
 | ||
| 
 | ||
|             selected_photo = photos[photo_index]
 | ||
| 
 | ||
|             # Schedule async deletion
 | ||
|             self.call_later(self._async_delete_photo, selected_photo)
 | ||
|         else:
 | ||
|             self.notify("Delete cancelled")
 | ||
| 
 | ||
|     async def _async_delete_photo(self, photo: Photo):
 | ||
|         """Deletes a photo asynchronously"""
 | ||
|         try:
 | ||
|             service_manager = self.app.service_manager
 | ||
|             photo_service = service_manager.get_photo_service()
 | ||
| 
 | ||
|             result = photo_service.delete(photo)
 | ||
| 
 | ||
|             if result:
 | ||
|                 self.notify(f"Photo '{photo.name}' deleted successfully!")
 | ||
|                 # Refresh sidebar content
 | ||
|                 if self.sidebar_visible:
 | ||
|                     self._update_sidebar_content()
 | ||
|             else:
 | ||
|                 self.notify("Error deleting photo")
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error deleting photo: {str(e)}")
 | ||
| 
 | ||
|     def action_edit_photo(self):
 | ||
|         """Edit selected photo using modal"""
 | ||
|         if not self.sidebar_focused or not self.sidebar_visible:
 | ||
|             self.notify("Use F8 to open the sidebar first.", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         if self.photo_list.highlighted is None:
 | ||
|             self.notify("No photo selected", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         # Adjust index because of 'Ingest Photo' at the top
 | ||
|         photo_index = self.photo_list.highlighted - 1
 | ||
| 
 | ||
|         photos = self._load_photos_for_diary(self.diary_id)
 | ||
|         if photo_index < 0 or photo_index >= len(photos):
 | ||
|             self.notify("No photo selected", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         selected_photo = photos[photo_index]
 | ||
| 
 | ||
|         # Open edit photo modal
 | ||
|         self.app.push_screen(
 | ||
|             EditPhotoModal(photo=selected_photo),
 | ||
|             self.handle_edit_photo_result
 | ||
|         )
 | ||
| 
 | ||
|     def handle_edit_photo_result(self, result: dict | None) -> None:
 | ||
|         """Callback that processes the edit photo modal result."""
 | ||
|         if result is None:
 | ||
|             self.notify("Edit photo cancelled")
 | ||
|             return
 | ||
| 
 | ||
|         # Get the selected photo with adjusted index
 | ||
|         photos = self._load_photos_for_diary(self.diary_id)
 | ||
|         photo_index = self.photo_list.highlighted - 1  # Adjust for 'Ingest Photo' at top
 | ||
| 
 | ||
|         if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos):
 | ||
|             self.notify("Photo no longer available", severity="error")
 | ||
|             return
 | ||
| 
 | ||
|         selected_photo = photos[photo_index]
 | ||
| 
 | ||
|         # Schedule async update
 | ||
|         self.call_later(self._async_update_photo, selected_photo, result)
 | ||
| 
 | ||
|     async def _async_update_photo(self, original_photo: Photo, photo_data: dict):
 | ||
|         """Updates a photo asynchronously"""
 | ||
|         try:
 | ||
|             service_manager = self.app.service_manager
 | ||
|             photo_service = service_manager.get_photo_service()
 | ||
| 
 | ||
|             # Create updated photo object
 | ||
|             updated_photo = Photo(
 | ||
|                 filepath=photo_data["filepath"],
 | ||
|                 name=photo_data["name"],
 | ||
|                 addition_date=original_photo.addition_date,
 | ||
|                 caption=photo_data["caption"],
 | ||
|                 entries=original_photo.entries if original_photo.entries is not None else [],
 | ||
|                 id=original_photo.id
 | ||
|             )
 | ||
| 
 | ||
|             result = photo_service.update(original_photo, updated_photo)
 | ||
| 
 | ||
|             if result:
 | ||
|                 self.notify(f"Photo '{updated_photo.name}' updated successfully!")
 | ||
|                 # Refresh sidebar content
 | ||
|                 if self.sidebar_visible:
 | ||
|                     self._update_sidebar_content()
 | ||
|             else:
 | ||
|                 self.notify("Error updating photo")
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error updating photo: {str(e)}")
 | ||
| 
 | ||
|     def _get_linked_photos_from_text(self) -> Optional[List[Photo]]:
 | ||
|         """
 | ||
|         Validates photo references in the text against the memory cache.
 | ||
|         Checks for:
 | ||
|         - Malformed references
 | ||
|         - Incorrect hash length
 | ||
|         - Invalid or ambiguous hashes
 | ||
|         Returns a list of unique photos (no duplicates even if referenced multiple times).
 | ||
|         """
 | ||
|         text = self.text_entry.text
 | ||
|         
 | ||
|         # First check for malformed references
 | ||
|         malformed_pattern = r"\[\[photo::([^\]]*)\](?!\])"  # Missing ] at the end
 | ||
|         malformed_matches = re.findall(malformed_pattern, text)
 | ||
|         if malformed_matches:
 | ||
|             for match in malformed_matches:
 | ||
|                 self.notify(f"❌ Malformed reference: '\\[\\[photo::{match}\\]' - Missing closing '\\]'", severity="error", timeout=10)
 | ||
|             return None
 | ||
| 
 | ||
|         # Look for incorrect format references
 | ||
|         invalid_format = r"\[\[photo:[^:\]]+\]\]"  # [[photo:something]] without ::
 | ||
|         invalid_matches = re.findall(invalid_format, text)
 | ||
|         if invalid_matches:
 | ||
|             for match in invalid_matches:
 | ||
|                 escaped_match = match.replace("[", "\\[").replace("]", "\\]")
 | ||
|                 self.notify(f"❌ Invalid format: '{escaped_match}' - Use '\\[\\[photo::hash\\]\\]'", severity="error", timeout=10)
 | ||
|             return None
 | ||
| 
 | ||
|         # Now look for all references to validate
 | ||
|         pattern = r"\[\[photo::([^\]]+)\]\]"
 | ||
|         # Use set to get unique references only
 | ||
|         all_refs = set(re.findall(pattern, text))
 | ||
|         
 | ||
|         if not all_refs:
 | ||
|             return []  # No references, valid operation
 | ||
| 
 | ||
|         self._load_photos_for_diary(self.diary_id)
 | ||
|         linked_photos: List[Photo] = []
 | ||
|         processed_hashes = set()  # Keep track of processed hashes to avoid duplicates
 | ||
| 
 | ||
|         for ref in all_refs:
 | ||
|             # Skip if we already processed this hash
 | ||
|             if ref in processed_hashes:
 | ||
|                 continue
 | ||
| 
 | ||
|             # Validate hash length
 | ||
|             if len(ref) != 8:
 | ||
|                 self.notify(
 | ||
|                     f"❌ Invalid hash: '{ref}' - Must be exactly 8 characters long",
 | ||
|                     severity="error",
 | ||
|                     timeout=10
 | ||
|                 )
 | ||
|                 return None
 | ||
| 
 | ||
|             # Validate if contains only valid hexadecimal characters
 | ||
|             if not re.match(r"^[0-9A-Fa-f]{8}$", ref):
 | ||
|                 self.notify(
 | ||
|                     f"❌ Invalid hash: '{ref}' - Use only hexadecimal characters (0-9, A-F)",
 | ||
|                     severity="error",
 | ||
|                     timeout=10
 | ||
|                 )
 | ||
|                 return None
 | ||
| 
 | ||
|             # Search for photos matching the hash
 | ||
|             found_photos = [p for p in self.cached_photos if p.photo_hash.startswith(ref)]
 | ||
| 
 | ||
|             if len(found_photos) == 0:
 | ||
|                 self.notify(
 | ||
|                     f"❌ Hash not found: '{ref}' - No photo matches this hash",
 | ||
|                     severity="error",
 | ||
|                     timeout=10
 | ||
|                 )
 | ||
|                 return None
 | ||
|             elif len(found_photos) > 1:
 | ||
|                 self.notify(
 | ||
|                     f"❌ Ambiguous hash: '{ref}' - Matches multiple photos",
 | ||
|                     severity="error",
 | ||
|                     timeout=10
 | ||
|                 )
 | ||
|                 return None
 | ||
|             else:
 | ||
|                 linked_photos.append(found_photos[0])
 | ||
|                 processed_hashes.add(ref)  # Mark this hash as processed
 | ||
| 
 | ||
|         # Convert list to set and back to list to ensure uniqueness of photos
 | ||
|         return list(set(linked_photos))
 | ||
| 
 | ||
|     def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
 | ||
|         """Handles photo selection in the sidebar"""
 | ||
|         if not self.sidebar_visible:
 | ||
|             return
 | ||
| 
 | ||
|         # Handle "Ingest Photo" option
 | ||
|         if event.option_index == 0:  # First option is "Ingest Photo"
 | ||
|             self.action_ingest_new_photo()
 | ||
|             return
 | ||
| 
 | ||
|         photos = self._load_photos_for_diary(self.diary_id)
 | ||
|         if not photos:
 | ||
|             return
 | ||
| 
 | ||
|         # Adjust index because of 'Ingest Photo' at the top
 | ||
|         photo_index = event.option_index - 1
 | ||
|         if photo_index >= len(photos):
 | ||
|             return
 | ||
| 
 | ||
|         selected_photo = photos[photo_index]
 | ||
|         photo_hash = selected_photo.photo_hash[:8]
 | ||
|         self.notify(f"Selected photo: {selected_photo.name} \\[{photo_hash}\\]")
 | ||
| 
 | ||
|         # Update photo info with details including hash
 | ||
|         photo_details = f"📷 {selected_photo.name}\n"
 | ||
|         photo_details += f"🔗 {photo_hash}\n"
 | ||
|         photo_details += f"📅 {selected_photo.addition_date}\n"
 | ||
|         if selected_photo.caption:
 | ||
|             photo_details += f"💬 {selected_photo.caption}\n"
 | ||
|         photo_details += f"📁 {selected_photo.filepath}\n\n"
 | ||
|         photo_details += f"[b]Reference formats:[/b]\n"
 | ||
|         photo_details += f"\\[\\[photo:{selected_photo.name}:{photo_hash}\\]\\]\n"
 | ||
|         photo_details += f"\\[\\[photo::{photo_hash}\\]\\]"
 | ||
| 
 | ||
|         self.photo_info.update(photo_details)
 | ||
| 
 | ||
|     def on_text_area_changed(self, event) -> None:
 | ||
|         """Detects text changes and updates status"""
 | ||
|         # Skip if we're currently updating the display
 | ||
|         if getattr(self, '_updating_display', False):
 | ||
|             return
 | ||
|             
 | ||
|         # Skip if text area is read-only
 | ||
|         if not hasattr(self, 'text_entry') or self.text_entry.read_only:
 | ||
|             return
 | ||
|             
 | ||
|         # Skip if we don't have original content to compare against
 | ||
|         if not hasattr(self, '_original_content'):
 | ||
|             return
 | ||
|             
 | ||
|         current_content = self.text_entry.text
 | ||
|         
 | ||
|         # Check if content has changed
 | ||
|         if current_content != self._original_content:
 | ||
|             if not self.has_unsaved_changes:
 | ||
|                 self.has_unsaved_changes = True
 | ||
|                 self._update_sub_header()
 | ||
|         else:
 | ||
|             if self.has_unsaved_changes:
 | ||
|                 self.has_unsaved_changes = False
 | ||
|                 self._update_sub_header()
 | ||
| 
 | ||
| 
 | ||
| 
 | ||
| 
 | ||
| 
 | ||
|     def on_focus(self, event) -> None:
 | ||
|         """Captures focus changes to update footer"""
 | ||
|         # Check if the focus changed to/from sidebar
 | ||
|         if hasattr(event.widget, 'id'):
 | ||
|             if event.widget.id == "photo_list":
 | ||
|                 self.sidebar_focused = True
 | ||
|                 self._update_footer_context()
 | ||
|             elif event.widget.id == "text_entry":
 | ||
|                 self.sidebar_focused = False
 | ||
|                 self._update_footer_context()
 | ||
| 
 | ||
|     def action_back_to_list(self) -> None:
 | ||
|         """Goes back to the diary list"""
 | ||
|         if self.is_new_entry and not self.text_entry.text.strip() and not self.has_unsaved_changes:
 | ||
|             self.app.pop_screen()
 | ||
|             self.notify("Returned to diary list")
 | ||
|             return
 | ||
| 
 | ||
|         if self.has_unsaved_changes or (self.is_new_entry and self.text_entry.text.strip()):
 | ||
|             self.notify("There are unsaved changes! Use Ctrl+S to save before leaving.")
 | ||
|             return
 | ||
| 
 | ||
|         self.app.pop_screen()
 | ||
|         self.notify("Returned to diary list")
 | ||
| 
 | ||
|     def action_next_entry(self) -> None:
 | ||
|         """Goes to the next entry"""
 | ||
|         self._save_current_state()
 | ||
| 
 | ||
|         if not self.entries:
 | ||
|             if not self.is_new_entry:
 | ||
|                 self.is_new_entry = True
 | ||
|                 self._update_entry_display()
 | ||
|                 self.notify("New entry created")
 | ||
|             else:
 | ||
|                 self.notify("Already in a new entry")
 | ||
|             return
 | ||
| 
 | ||
|         if self.is_new_entry:
 | ||
|             self.notify("Already at the last position (new entry)")
 | ||
|         elif self.current_entry_index < len(self.entries) - 1:
 | ||
|             self.current_entry_index += 1
 | ||
|             self._update_entry_display()
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
|             self.notify(f"Navigating to: {current_entry.title}")
 | ||
|         else:
 | ||
|             self.is_new_entry = True
 | ||
|             self._update_entry_display()
 | ||
|             self.notify("New entry created")
 | ||
| 
 | ||
|     def action_prev_entry(self) -> None:
 | ||
|         """Goes to the previous entry"""
 | ||
|         self._save_current_state()
 | ||
| 
 | ||
|         if not self.entries:
 | ||
|             self.notify("No entries to navigate")
 | ||
|             return
 | ||
| 
 | ||
|         if self.is_new_entry:
 | ||
|             if self.entries:
 | ||
|                 self.is_new_entry = False
 | ||
|                 self.current_entry_index = len(self.entries) - 1
 | ||
|                 self._update_entry_display()
 | ||
|                 current_entry = self.entries[self.current_entry_index]
 | ||
|                 self.notify(f"Navigating to: {current_entry.title}")
 | ||
|             else:
 | ||
|                 self.notify("No previous entries")
 | ||
|         elif self.current_entry_index > 0:
 | ||
|             self.current_entry_index -= 1
 | ||
|             self._update_entry_display()
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
|             self.notify(f"Navigating to: {current_entry.title}")
 | ||
|         else:
 | ||
|             self.notify("Already at the first entry")
 | ||
| 
 | ||
|     def action_rename_entry(self) -> None:
 | ||
|         """Opens a modal to rename the entry."""
 | ||
|         if not self.entries and not self.is_new_entry:
 | ||
|             self.notify("No entry to rename", severity="warning")
 | ||
|             return
 | ||
| 
 | ||
|         if self.is_new_entry:
 | ||
|             current_name = self.new_entry_title
 | ||
|         else:
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
|             current_name = current_entry.title
 | ||
| 
 | ||
|         self.app.push_screen(
 | ||
|             RenameEntryModal(current_name=current_name),
 | ||
|             self.handle_rename_result
 | ||
|         )
 | ||
| 
 | ||
|     def handle_rename_result(self, new_name: str | None) -> None:
 | ||
|         """Callback that processes the rename modal result."""
 | ||
|         if new_name is None:
 | ||
|             self.notify("Rename cancelled")
 | ||
|             return
 | ||
| 
 | ||
|         if not new_name.strip():
 | ||
|             self.notify("Name cannot be empty", severity="error")
 | ||
|             return
 | ||
| 
 | ||
|         if self.is_new_entry:
 | ||
|             old_name = self.new_entry_title
 | ||
|             self.new_entry_title = new_name
 | ||
|             self.notify(f"New entry title changed to '{new_name}'")
 | ||
|         else:
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
|             old_name = current_entry.title
 | ||
|             current_entry.title = new_name
 | ||
|             self.notify(f"Title changed from '{old_name}' to '{new_name}'")
 | ||
| 
 | ||
|         self.has_unsaved_changes = True
 | ||
|         self._update_sub_header()
 | ||
| 
 | ||
|     def action_save(self) -> None:
 | ||
|         """Salva a entrada após validar e coletar as fotos referenciadas."""
 | ||
|         photos_to_link = self._get_linked_photos_from_text()
 | ||
| 
 | ||
|         if photos_to_link is None:
 | ||
|             self.notify("⚠️ Saving was canceled ", severity="error")
 | ||
|             return
 | ||
| 
 | ||
|         content = self.text_entry.text.strip()
 | ||
|         if self.is_new_entry:
 | ||
|             if not content:
 | ||
|                 self.notify("Empty entry cannot be saved")
 | ||
|                 return
 | ||
|             # Passe a lista de fotos para o método de criação
 | ||
|             self.call_later(self._async_create_entry, content, photos_to_link)
 | ||
|         else:
 | ||
|             # Passe a lista de fotos para o método de atualização
 | ||
|             self.call_later(self._async_update_entry, content, photos_to_link)
 | ||
| 
 | ||
|     async def _async_create_entry(self, content: str, photos_to_link: List[Photo]):
 | ||
|         """Creates a new entry and links the referenced photos."""
 | ||
|         try:
 | ||
|             service_manager = self.app.service_manager
 | ||
|             entry_service = service_manager.get_entry_service()
 | ||
| 
 | ||
|             new_entry = entry_service.create(
 | ||
|                 travel_diary_id=self.diary_id,
 | ||
|                 title=self.new_entry_title,
 | ||
|                 text=content,
 | ||
|                 date=datetime.now(),
 | ||
|                 photos=photos_to_link
 | ||
|             )
 | ||
| 
 | ||
|             if new_entry:
 | ||
|                 self.entries.append(new_entry)
 | ||
|                 self.entries.sort(key=lambda x: x.id)
 | ||
| 
 | ||
|                 for i, entry in enumerate(self.entries):
 | ||
|                     if entry.id == new_entry.id:
 | ||
|                         self.current_entry_index = i
 | ||
|                         break
 | ||
| 
 | ||
|                 self.is_new_entry = False
 | ||
|                 self.has_unsaved_changes = False
 | ||
|                 self._original_content = new_entry.text
 | ||
|                 self.new_entry_title = "New Entry"
 | ||
|                 self.next_entry_id = max(entry.id for entry in self.entries) + 1
 | ||
| 
 | ||
|                 self._update_entry_display()
 | ||
|                 self.notify(f"Entry '{new_entry.title}' saved successfully!")
 | ||
|             else:
 | ||
|                 self.notify("Error creating entry")
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error creating entry: {str(e)}")
 | ||
| 
 | ||
|     async def _async_update_entry(self, updated_content: str, photos_to_link: List[Photo]):
 | ||
|         """Updates an existing entry and its photo links."""
 | ||
|         try:
 | ||
|             if not self.entries:
 | ||
|                 self.notify("No entry to update")
 | ||
|                 return
 | ||
| 
 | ||
|             service_manager = self.app.service_manager
 | ||
|             entry_service = service_manager.get_entry_service()
 | ||
|             current_entry = self.entries[self.current_entry_index]
 | ||
| 
 | ||
|             entry_result = Entry(
 | ||
|                 id=current_entry.id,
 | ||
|                 title=current_entry.title,
 | ||
|                 text=updated_content,
 | ||
|                 photos=photos_to_link,
 | ||
|                 date=current_entry.date,
 | ||
|                 travel_diary_id=self.diary_id,
 | ||
|                 fk_travel_diary_id=self.diary_id
 | ||
|             )
 | ||
| 
 | ||
|             result = entry_service.update(current_entry, entry_result)
 | ||
| 
 | ||
|             if result:
 | ||
|                 self.has_unsaved_changes = False
 | ||
|                 self._original_content = updated_content
 | ||
|                 self._update_sub_header()
 | ||
|                 self.notify(f"Entry '{current_entry.title}' saved successfully!")
 | ||
|             else:
 | ||
|                 self.notify("Error updating entry")
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             self.notify(f"Error updating entry: {str(e)}")
 | ||
| 
 | ||
|     def on_key(self, event):
 | ||
|         # Sidebar contextual shortcuts
 | ||
|         if self.sidebar_focused and self.sidebar_visible:
 | ||
| 
 | ||
|             if event.key == "i":
 | ||
| 
 | ||
|                 self.action_insert_photo()
 | ||
|                 event.stop()
 | ||
|             elif event.key == "n":
 | ||
| 
 | ||
|                 self.action_ingest_new_photo()
 | ||
|                 event.stop()
 | ||
|             elif event.key == "d":
 | ||
| 
 | ||
|                 self.action_delete_photo()
 | ||
|                 event.stop()
 | ||
|             elif event.key == "e":
 | ||
| 
 | ||
|                 self.action_edit_photo()
 | ||
|                 event.stop()
 | ||
|         # Shift+Tab: remove indent
 | ||
|         elif self.focused is self.text_entry and event.key == "shift+tab":
 | ||
|             textarea = self.text_entry
 | ||
|             row, col = textarea.cursor_location
 | ||
|             lines = textarea.text.splitlines()
 | ||
|             if row < len(lines):
 | ||
|                 line = lines[row]
 | ||
|                 if line.startswith('\t'):
 | ||
|                     lines[row] = line[1:]
 | ||
|                     textarea.text = '\n'.join(lines)
 | ||
|                     textarea.cursor_location = (row, max(col - 1, 0))
 | ||
|                 elif line.startswith('    '):  # 4 spaces
 | ||
|                     lines[row] = line[4:]
 | ||
|                     textarea.text = '\n'.join(lines)
 | ||
|                     textarea.cursor_location = (row, max(col - 4, 0))
 | ||
|                 elif line.startswith(' '):
 | ||
|                     n = len(line) - len(line.lstrip(' '))
 | ||
|                     to_remove = min(n, 4)
 | ||
|                     lines[row] = line[to_remove:]
 | ||
|                     textarea.text = '\n'.join(lines)
 | ||
|                     textarea.cursor_location = (row, max(col - to_remove, 0))
 | ||
|             event.stop()
 | ||
|         # Tab: insert tab
 | ||
|         elif self.focused is self.text_entry and event.key == "tab":
 | ||
|             self.text_entry.insert('\t')
 | ||
|             event.stop()
 | ||
| 
 | ||
|     def on_footer_action(self, event) -> None:
 | ||
|         """Handle clicks on footer actions (Textual 3.x)."""
 | ||
|         action = event.action
 | ||
|         method = getattr(self, f"action_{action}", None)
 | ||
|         if method:
 | ||
|             method()
 | ||
|         else:
 | ||
|             self.notify(f"No action found for: {action}", severity="warning") |