Merge pull request #13 from gmbrax/feat/photo-sidebar-tui

Feat/photo sidebar tui
This commit is contained in:
Gustavo Henrique Miranda 2025-06-29 23:17:49 -03:00 committed by GitHub
commit 2973620573
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1223 additions and 315 deletions

147
.gitignore vendored
View File

@ -1,3 +1,146 @@
# Database files
database.db
__pycache__
/.idea/
.build-vend/
dist_nuitka/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
build/
temp/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# poetry
poetry.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.env.*
.venv
venv/
ENV/
env/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# IDE settings
.vscode/
.idea/

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.10 (Pilgrim)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Pilgrim.iml" filepath="$PROJECT_DIR$/.idea/Pilgrim.iml" />
</modules>
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -1,200 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="0a7f92e2-b44a-4dfe-8e01-136d1c0c18be" name="Changes" comment="Added the travel_diary id as foreign key to the photos and add a check on the creation to avoid leaving it empty or bad referenced">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pilgrim/service/photo_service.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/pilgrim/service/photo_service.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pilgrim/ui/screens/diary_list_screen.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/pilgrim/ui/screens/diary_list_screen.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="Python Script" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="proposed_changes" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="UPDATE_TYPE" value="REBASE" />
</component>
<component name="GitHubPullRequestSearchHistory">{
&quot;lastFilter&quot;: {
&quot;state&quot;: &quot;OPEN&quot;,
&quot;assignee&quot;: &quot;gmbrax&quot;
}
}</component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/gmbrax/Pilgrim.git&quot;,
&quot;accountId&quot;: &quot;213d8456-c67d-4cfd-99a6-337d47c35b4a&quot;
}
}</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 0
}</component>
<component name="ProjectId" id="2y0y1J9RlHT6m1qL5RuCBf0Ramp" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"Python.Database.executor": "Run",
"Python.command.executor": "Run",
"Python.main.executor": "Run",
"Python.pilgrim.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "feat-TUI",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager">
<configuration name="pilgrim" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="Pilgrim" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.venv/bin" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/.venv/bin/pilgrim" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<recent_temporary>
<list>
<item itemvalue="Python.pilgrim" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-6a121458b545-JavaScript-PY-251.25410.159" />
<option value="bundled-python-sdk-e0ed3721d81e-36ea0e71a18c-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-251.25410.159" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="0a7f92e2-b44a-4dfe-8e01-136d1c0c18be" name="Changes" comment="" />
<created>1748985568579</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1748985568579</updated>
<workItem from="1748985569621" duration="2124000" />
<workItem from="1748992451560" duration="312000" />
<workItem from="1749508687224" duration="371000" />
</task>
<task id="LOCAL-00001" summary="Added a Back Relationship in Entry to list all the photos">
<option name="closed" value="true" />
<created>1749004109515</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1749004109515</updated>
</task>
<task id="LOCAL-00002" summary="Changed the names of the files to conform the python convection and also added photo_in_entry.py to diminish the cyclic import error in pylint">
<option name="closed" value="true" />
<created>1749006784623</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1749006784623</updated>
</task>
<task id="LOCAL-00003" summary="Added the Services layer to the code to do the database operation">
<option name="closed" value="true" />
<created>1749140898576</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1749140898576</updated>
</task>
<task id="LOCAL-00004" summary="Added the photo_service.py and modified photo.py to have all the crud operations">
<option name="closed" value="true" />
<created>1749155713848</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1749155713848</updated>
</task>
<task id="LOCAL-00005" summary="Made some changes to naming in both the classes and the tables of the database to conform the correct naming scheme">
<option name="closed" value="true" />
<created>1749164385581</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1749164385581</updated>
</task>
<task id="LOCAL-00006" summary="Added the travel_diary id as foreign key to the photos and add a check on the creation to avoid leaving it empty or bad referenced">
<option name="closed" value="true" />
<created>1749168650225</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1749168650225</updated>
</task>
<option name="localTasksCounter" value="7" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Added a Back Relationship in Entry to list all the photos" />
<MESSAGE value="Changed the names of the files to conform the python convection and also added photo_in_entry.py to diminish the cyclic import error in pylint" />
<MESSAGE value="Added the Services layer to the code to do the database operation" />
<MESSAGE value="Added the photo_service.py and modified photo.py to have all the crud operations" />
<MESSAGE value="Made some changes to naming in both the classes and the tables of the database to conform the correct naming scheme" />
<MESSAGE value="Added the travel_diary id as foreign key to the photos and add a check on the creation to avoid leaving it empty or bad referenced" />
<option name="LAST_COMMIT_MESSAGE" value="Added the travel_diary id as foreign key to the photos and add a check on the creation to avoid leaving it empty or bad referenced" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/Pilgrim$Database.coverage" NAME="Database Coverage Results" MODIFIED="1748987101492" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/src/Database" />
<SUITE FILE_PATH="coverage/Pilgrim$pilgrim.coverage" NAME="pilgrim Coverage Results" MODIFIED="1749097142827" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/.venv/bin" />
<SUITE FILE_PATH="coverage/Pilgrim$main.coverage" NAME="main Coverage Results" MODIFIED="1748992510527" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/src" />
<SUITE FILE_PATH="coverage/Pilgrim$command.coverage" NAME="command Coverage Results" MODIFIED="1748992876551" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/src/Pilgrim" />
</component>
</project>

View File

@ -1,3 +1,50 @@
# Python_Pilgrim
Python Based Travel Diary
## Overview
**Python_Pilgrim** is a Python-based travel diary application designed to help users document and manage their travel experiences. The project provides tools for recording trips, organizing travel notes, and storing memories in a structured and accessible format.
## Features
- Create and manage travel diaries
- Add, edit, and delete travel entries
- Organize trips by date, location, or theme
- Store photos, notes, and other media
- Export and share travel logs
## Requirements
- Python 3.8 or higher
- Linux operating system (tested on Ubuntu 20.04+)
- Visual Studio Code (VSCode) for development (optional but strongly recommended)
- pip (Python package installer)
- Optional: virtualenv for isolated environments
## Installation
1. Clone the repository:
```bash
git clone https://github.com/gmbrax/Pilgrim.git
```
2. Navigate to the project directory:
```bash
cd Pilgrim
```
3. Create a virtual environment and, then, activate it:
```bash
python -m venv .venv
source .venv/bin/activate
```
4. Install the required dependencies:
```bash
pip install -r requirements.txt
```
## Usage
To run the main application, execute:
```bash
python <???>.py
```
This will start the Python_Pilgrim application. Follow the on-screen instructions to create and manage your travel diaries.

View File

@ -1,5 +1,4 @@
from pilgrim.database import Database
from pilgrim.service.mocks.service_manager_mock import ServiceManagerMock
from pilgrim.service.servicemanager import ServiceManager
from pilgrim.ui.ui import UIApp

View File

@ -12,10 +12,13 @@ class Database:
echo=False,
connect_args={"check_same_thread": False},
)
self.session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self._session_maker = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
def create(self):
Base.metadata.create_all(self.engine)
def session(self):
return self._session_maker()
def get_db(self):
return self.session()
return self._session_maker()

View File

@ -1,6 +1,8 @@
from typing import Any
from datetime import datetime
from pathlib import Path
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from pilgrim.models.photo_in_entry import photo_entry_association
@ -12,7 +14,7 @@ class Photo(Base):
id = Column(Integer, primary_key=True)
filepath = Column(String)
name = Column(String)
addition_date = Column(String)
addition_date = Column(DateTime, default=datetime.now)
caption = Column(String)
entries = relationship(
"Entry",
@ -22,10 +24,16 @@ class Photo(Base):
fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False)
def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, **kw: Any):
def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any):
super().__init__(**kw)
self.filepath = filepath
# Convert Path to string if needed
if isinstance(filepath, Path):
self.filepath = str(filepath)
else:
self.filepath = filepath
self.name = name
self.addition_date = addition_date
self.addition_date = addition_date if addition_date is not None else datetime.now()
self.caption = caption
self.entries = entries
self.entries = entries if entries is not None else []
if fk_travel_diary_id is not None:
self.fk_travel_diary_id = fk_travel_diary_id

View File

@ -12,7 +12,14 @@ class PhotoServiceMock(PhotoService):
self._next_id = 1
def create(self, filepath: Path, name: str, travel_diary_id, addition_date=None, caption=None) -> Photo | None:
new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption)
new_photo = Photo(
filepath=filepath,
name=name,
addition_date=addition_date,
caption=caption,
fk_travel_diary_id=travel_diary_id
)
new_photo.id = self._next_id
self.mock_data[self._next_id] = new_photo
self._next_id += 1
return new_photo
@ -24,19 +31,18 @@ class PhotoServiceMock(PhotoService):
def read_all(self) -> List[Photo]:
return list(self.mock_data.values())
def update(self, photo_id: Photo, photo_dst: Photo) -> Photo | None:
item_to_update:Photo = self.mock_data.get(photo_id)
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
item_to_update: Photo = self.mock_data.get(photo_src.id)
if item_to_update:
item_to_update.filepath = photo_dst.filepath if photo_dst.filepath else item_to_update.filepath
item_to_update.name = photo_dst.name if photo_dst.name else item_to_update.name
item_to_update.caption = photo_dst.caption if photo_dst.caption else item_to_update.caption
item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date\
else item_to_update.addition_date
item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id \
else item_to_update.fk_travel_diary_id
item_to_update.entries.extend(photo_dst.entries)
item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date else item_to_update.addition_date
item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id else item_to_update.fk_travel_diary_id
if photo_dst.entries:
item_to_update.entries = photo_dst.entries
return item_to_update
return None
def delete(self, photo_id: int) -> Photo | None:
return self.mock_data.pop(photo_id, None)
def delete(self, photo_src: Photo) -> Photo | None:
return self.mock_data.pop(photo_src.id, None)

View File

@ -1,5 +1,6 @@
from pathlib import Path
from typing import List
from datetime import datetime
from pilgrim.models.photo import Photo
@ -9,11 +10,25 @@ class PhotoService:
def __init__(self, session):
self.session = session
def create(self, filepath:Path, name:str, travel_diary_id, addition_date=None, caption=None, ) -> Photo | None:
def create(self, filepath: Path, name: str, travel_diary_id: int, caption=None, addition_date=None) -> Photo | None:
travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first()
if not travel_diary:
return None
new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption)
# Convert addition_date string to datetime if needed
if isinstance(addition_date, str):
try:
addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S")
except ValueError:
addition_date = None
new_photo = Photo(
filepath=filepath,
name=name,
caption=caption,
fk_travel_diary_id=travel_diary_id,
addition_date=addition_date
)
self.session.add(new_photo)
self.session.commit()
self.session.refresh(new_photo)
@ -25,24 +40,37 @@ class PhotoService:
def read_all(self) -> List[Photo]:
return self.session.query(Photo).all()
def update(self,photo_src:Photo,photo_dst:Photo) -> Photo | None:
original:Photo = self.read_by_id(photo_src.id)
def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None:
original: Photo = self.read_by_id(photo_src.id)
if original:
original.filepath = photo_dst.filepath
original.name = photo_dst.name
original.addition_date = photo_dst.addition_date
original.caption = photo_dst.caption
original.entries.extend(photo_dst.entries)
if photo_dst.entries and len(photo_dst.entries) > 0:
if original.entries is None:
original.entries = []
original.entries = photo_dst.entries # Replace instead of extend
self.session.commit()
self.session.refresh(original)
return original
return None
def delete(self, photo_src:Photo) -> Photo | None:
def delete(self, photo_src: Photo) -> Photo | None:
excluded = self.read_by_id(photo_src.id)
if excluded:
# Store photo data before deletion
deleted_photo = Photo(
filepath=excluded.filepath,
name=excluded.name,
addition_date=excluded.addition_date,
caption=excluded.caption,
fk_travel_diary_id=excluded.fk_travel_diary_id,
id=excluded.id
)
self.session.delete(excluded)
self.session.commit()
self.session.refresh(excluded)
return excluded
return deleted_photo
return None

View File

@ -1,4 +1,5 @@
from pilgrim.service.entry_service import EntryService
from pilgrim.service.photo_service import PhotoService
from pilgrim.service.travel_diary_service import TravelDiaryService
@ -17,3 +18,7 @@ class ServiceManager:
if self.session is not None:
return TravelDiaryService(self.session)
return None
def get_photo_service(self):
if self.session is not None:
return PhotoService(self.session)
return None

View File

@ -1,15 +1,21 @@
from typing import Optional, List
import asyncio
from datetime import datetime
from pathlib import Path
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, TextArea
from textual.widgets import Header, Footer, Static, TextArea, OptionList, Input, Button
from textual.binding import Binding
from textual.containers import Container, Horizontal
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from pilgrim.models.entry import Entry
from pilgrim.models.travel_diary import TravelDiary
from pilgrim.models.photo import Photo
from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal
from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal
from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal
from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal
from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal
@ -21,13 +27,16 @@ class EditEntryScreen(Screen):
Binding("ctrl+n", "next_entry", "Next/New Entry"),
Binding("ctrl+b", "prev_entry", "Previous Entry"),
Binding("ctrl+r", "rename_entry", "Rename Entry"),
Binding("escape", "back_to_list", "Back to List")
Binding("escape", "back_to_list", "Back to List"),
Binding("f8", "toggle_sidebar", "Toggle Sidebar"),
Binding("f9", "toggle_focus", "Focus Sidebar/Editor"),
]
def __init__(self, diary_id: int = 1):
print("DEBUG: EditEntryScreen INIT")
super().__init__()
self.diary_id = diary_id
self.diary_name = f"Diary {diary_id}" # Use a better default name
self.diary_name = f"Diary {diary_id}"
self.current_entry_index = 0
self.entries: List[Entry] = []
self.is_new_entry = False
@ -38,6 +47,9 @@ class EditEntryScreen(Screen):
self._updating_display = False
self._original_content = ""
self.is_refreshing = False
self.sidebar_visible = False
self.sidebar_focused = False
self._sidebar_opened_once = False
# Main header
self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header")
@ -60,6 +72,27 @@ class EditEntryScreen(Screen):
# 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,
@ -71,17 +104,33 @@ class EditEntryScreen(Screen):
# Footer
self.footer = Footer(classes="EditEntryScreen-footer")
def _update_footer_context(self):
"""Forces footer refresh to show updated bindings"""
self.refresh()
def compose(self) -> ComposeResult:
print("DEBUG: EditEntryScreen COMPOSE", getattr(self, 'sidebar_visible', None))
yield self.header
yield self.main
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()
def update_diary_info(self):
"""Updates diary information"""
try:
@ -93,17 +142,14 @@ class EditEntryScreen(Screen):
self.diary_name = diary.name
self.diary_info.update(f"Diary: {self.diary_name}")
else:
# If diary not found, try to get a default name
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:
# If there's an error, use a default name but don't break the app
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)}")
# Always ensure the diary info is updated
self._ensure_diary_info_updated()
def _ensure_diary_info_updated(self):
@ -111,7 +157,6 @@ class EditEntryScreen(Screen):
try:
self.diary_info.update(f"Diary: {self.diary_name}")
except Exception as e:
# If even this fails, at least try to show something
self.diary_info.update(f"Diary: {self.diary_id}")
def refresh_entries(self):
@ -120,14 +165,10 @@ class EditEntryScreen(Screen):
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
# Get all entries for this diary
all_entries = entry_service.read_all()
self.entries = [entry for entry in all_entries if entry.fk_travel_diary_id == self.diary_id]
# Sort by ID
self.entries.sort(key=lambda x: x.id)
# Update next entry ID
if self.entries:
self.next_entry_id = max(entry.id for entry in self.entries) + 1
else:
@ -139,41 +180,8 @@ class EditEntryScreen(Screen):
except Exception as e:
self.notify(f"Error loading entries: {str(e)}")
# Ensure diary info is updated even if entries fail to load
self._ensure_diary_info_updated()
async def async_refresh_entries(self):
"""Asynchronous version of refresh"""
if self.is_refreshing:
return
self.is_refreshing = True
try:
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
# For now, use synchronous method since mock doesn't have async
all_entries = entry_service.read_all()
self.entries = [entry for entry in all_entries if entry.fk_travel_diary_id == self.diary_id]
# Sort by ID
self.entries.sort(key=lambda x: x.id)
# Update next entry 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)}")
finally:
self.is_refreshing = False
def _update_status_indicator(self, text: str, css_class: str):
"""Helper to update status indicator text and class."""
self.status_indicator.update(text)
@ -211,6 +219,8 @@ class EditEntryScreen(Screen):
"""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"""
@ -237,6 +247,342 @@ class EditEntryScreen(Screen):
self.call_after_refresh(self._finish_display_update)
def _update_sidebar_content(self):
"""Updates the sidebar content with photos for the current diary"""
photos = self._load_photos_for_diary(self.diary_id)
# Clear existing options safely
self.photo_list.clear_options()
# Add 'Ingest Photo' option at the top
self.photo_list.add_option(" Ingest Photo")
if not 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
for photo in photos:
self.photo_list.add_option(f"📷 {photo.name}")
self.photo_info.update(f"📸 {len(photos)} photos in diary")
# English, visually distinct help text
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)"
)
self.help_text.update(help_text)
def _load_photos_for_diary(self, diary_id: int) -> List[Photo]:
"""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()
photos = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id]
photos.sort(key=lambda x: x.id)
return photos
except Exception as e:
self.notify(f"Error loading photos: {str(e)}")
return []
def action_toggle_sidebar(self):
"""Toggles the sidebar visibility"""
print("DEBUG: TOGGLE SIDEBAR", self.sidebar_visible)
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()
# Notification when opening the sidebar for the first time
if not self._sidebar_opened_once:
self.notify(
"Sidebar opened and focused! Use the shortcuts shown in the help panel.",
severity="info"
)
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)
def action_toggle_focus(self):
"""Toggles focus between editor and sidebar"""
print("DEBUG: TOGGLE FOCUS", self.sidebar_visible, self.sidebar_focused)
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 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
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]
# Insert photo reference into text
photo_ref = f"\n[📷 {selected_photo.name}]({selected_photo.filepath})\n"
if selected_photo.caption:
photo_ref += f"*{selected_photo.caption}*\n"
# Insert at cursor position or at end
current_text = self.text_entry.text
cursor_position = len(current_text) # Insert at end for now
new_text = current_text + photo_ref
self.text_entry.text = new_text
self.notify(f"Inserted photo: {selected_photo.name}")
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
self.app.push_screen(
AddPhotoModal(diary_id=self.diary_id),
self.handle_add_photo_result
)
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 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 on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Handles photo selection in the sidebar"""
if not self.sidebar_visible:
return
# If 'Ingest Photo' is selected (always index 0)
if event.option_index == 0:
self.action_ingest_new_photo()
return
photos = self._load_photos_for_diary(self.diary_id)
# Adjust index because of 'Ingest Photo' at the top
photo_index = event.option_index - 1
if not photos or photo_index >= len(photos):
return
selected_photo = photos[photo_index]
self.notify(f"Selected photo: {selected_photo.name}")
# Update photo info with details
photo_details = f"📷 {selected_photo.name}\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}"
self.photo_info.update(photo_details)
def on_text_area_changed(self, event) -> None:
"""Detects text changes to mark as unsaved"""
if (hasattr(self, 'text_entry') and not self.text_entry.read_only and
@ -251,6 +597,17 @@ class EditEntryScreen(Screen):
self.has_unsaved_changes = False
self._update_sub_header()
def on_focus(self, event) -> None:
"""Captures focus changes to update footer"""
# Check if 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:
@ -375,7 +732,6 @@ class EditEntryScreen(Screen):
service_manager = self.app.service_manager
entry_service = service_manager.get_entry_service()
# Get current date as datetime object
current_date = datetime.now()
new_entry = entry_service.create(
@ -389,7 +745,6 @@ class EditEntryScreen(Screen):
self.entries.append(new_entry)
self.entries.sort(key=lambda x: x.id)
# Find the new entry index
for i, entry in enumerate(self.entries):
if entry.id == new_entry.id:
self.current_entry_index = i
@ -419,7 +774,6 @@ class EditEntryScreen(Screen):
current_entry = self.entries[self.current_entry_index]
updated_content = self.text_entry.text
# Create updated entry object
updated_entry = Entry(
title=current_entry.title,
text=updated_content,
@ -445,8 +799,44 @@ class EditEntryScreen(Screen):
except Exception as e:
self.notify(f"Error updating entry: {str(e)}")
def action_force_refresh(self):
"""Forces manual refresh"""
self.notify("Forcing refresh...")
self.refresh_entries()
self.call_later(self.async_refresh_entries)
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()

View File

@ -0,0 +1,105 @@
import os
from pathlib import Path
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Input, Button
from textual.containers import Horizontal, Container
from .file_picker_modal import FilePickerModal
class AddPhotoModal(Screen):
"""Modal for adding a new photo"""
def __init__(self, diary_id: int):
super().__init__()
self.diary_id = diary_id
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("📷 Add New Photo", classes="AddPhotoModal-Title"),
Static("File path:", classes="AddPhotoModal-Label"),
Horizontal(
Input(placeholder="Enter file path...", id="filepath-input", classes="AddPhotoModal-Input"),
Button("Escolher arquivo...", id="choose-file-button", classes="AddPhotoModal-Button"),
classes="AddPhotoModal-FileRow"
),
Static("Photo name:", classes="AddPhotoModal-Label"),
Input(placeholder="Enter photo name...", id="name-input", classes="AddPhotoModal-Input"),
Static("Caption (optional):", classes="AddPhotoModal-Label"),
Input(placeholder="Enter caption...", id="caption-input", classes="AddPhotoModal-Input"),
Horizontal(
Button("Add Photo", id="add-button", classes="AddPhotoModal-Button"),
Button("Cancel", id="cancel-button", classes="AddPhotoModal-Button"),
classes="AddPhotoModal-Buttons"
),
classes="AddPhotoModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "choose-file-button":
self.app.push_screen(
FilePickerModal(),
self.handle_file_picker_result
)
return
if event.button.id == "add-button":
filepath = self.query_one("#filepath-input", Input).value
name = self.query_one("#name-input", Input).value
caption = self.query_one("#caption-input", Input).value
if not filepath.strip() or not name.strip():
self.notify("File path and name are required", severity="error")
return
# Try to create the photo in the database
self.call_later(self._async_create_photo, {
"filepath": filepath.strip(),
"name": name.strip(),
"caption": caption.strip() if caption.strip() else None
})
elif event.button.id == "cancel-button":
self.dismiss()
async def _async_create_photo(self, photo_data: dict):
"""Creates a new photo asynchronously using PhotoService"""
try:
service_manager = self.app.service_manager
photo_service = service_manager.get_photo_service()
new_photo = photo_service.create(
filepath=Path(photo_data["filepath"]),
name=photo_data["name"],
travel_diary_id=self.diary_id,
caption=photo_data["caption"]
)
if new_photo:
self.notify(f"Photo '{new_photo.name}' added successfully!")
# Return the created photo data to the calling screen
self.result = {
"filepath": photo_data["filepath"],
"name": photo_data["name"],
"caption": photo_data["caption"],
"photo_id": new_photo.id
}
self.dismiss(self.result)
else:
self.notify("Error creating photo in database", severity="error")
except Exception as e:
self.notify(f"Error creating photo: {str(e)}", severity="error")
def handle_file_picker_result(self, result: str | None) -> None:
if result:
# Set the filepath input value
filepath_input = self.query_one("#filepath-input", Input)
filepath_input.value = result
# Trigger the input change event to update the UI
filepath_input.refresh()
# Auto-fill the name field with the filename (without extension)
filename = Path(result).stem
name_input = self.query_one("#name-input", Input)
if not name_input.value.strip():
name_input.value = filename
name_input.refresh()
else:
# User cancelled the file picker
self.notify("File selection cancelled", severity="information")

View File

@ -0,0 +1,32 @@
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Button
from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo
class ConfirmDeleteModal(Screen):
"""Modal for confirming photo deletion"""
def __init__(self, photo: Photo):
super().__init__()
self.photo = photo
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("🗑️ Confirm Deletion", classes="ConfirmDeleteModal-Title"),
Static(f"Are you sure you want to delete the photo '{self.photo.name}'?", classes="ConfirmDeleteModal-Message"),
Static("This action cannot be undone.", classes="ConfirmDeleteModal-Warning"),
Horizontal(
Button("Delete", variant="error", id="delete-button", classes="ConfirmDeleteModal-Button"),
Button("Cancel", variant="default", id="cancel-button", classes="ConfirmDeleteModal-Button"),
classes="ConfirmDeleteModal-Buttons"
),
classes="ConfirmDeleteModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "delete-button":
self.result = True
self.dismiss(True)
elif event.button.id == "cancel-button":
self.dismiss(False)

View File

@ -0,0 +1,68 @@
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Input, Button
from textual.containers import Container, Horizontal
from pilgrim.models.photo import Photo
class EditPhotoModal(Screen):
"""Modal for editing an existing photo (name and caption only)"""
def __init__(self, photo: Photo):
super().__init__()
self.photo = photo
self.result = None
def compose(self) -> ComposeResult:
yield Container(
Static("✏️ Edit Photo", classes="EditPhotoModal-Title"),
Static("File path (read-only):", classes="EditPhotoModal-Label"),
Input(
value=self.photo.filepath,
id="filepath-input",
classes="EditPhotoModal-Input",
disabled=True
),
Static("Photo name:", classes="EditPhotoModal-Label"),
Input(
value=self.photo.name,
placeholder="Enter photo name...",
id="name-input",
classes="EditPhotoModal-Input"
),
Static("Caption (optional):", classes="EditPhotoModal-Label"),
Input(
value=self.photo.caption or "",
placeholder="Enter caption...",
id="caption-input",
classes="EditPhotoModal-Input"
),
Horizontal(
Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"),
Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"),
classes="EditPhotoModal-Buttons"
),
classes="EditPhotoModal-Dialog"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save-button":
name = self.query_one("#name-input", Input).value
caption = self.query_one("#caption-input", Input).value
if not name.strip():
self.notify("Photo name is required", severity="error")
return
# Return the updated photo data
self.result = {
"filepath": self.photo.filepath, # Keep original filepath
"name": name.strip(),
"caption": caption.strip() if caption.strip() else None
}
self.dismiss(self.result)
elif event.button.id == "cancel-button":
self.dismiss()
def on_mount(self) -> None:
"""Focus on the name input when modal opens"""
self.query_one("#name-input", Input).focus()

View File

@ -0,0 +1,66 @@
import os
from pathlib import Path
from typing import Iterable
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Static, DirectoryTree, Button
from textual.containers import Horizontal, Container
class ImageDirectoryTree(DirectoryTree):
"""DirectoryTree that only shows image files"""
def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
"""Filter to show only directories and image files"""
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
return [
path for path in paths
if path.is_dir() or path.suffix.lower() in image_extensions
]
class FilePickerModal(Screen):
"""Modal for picking an image file using DirectoryTree"""
def __init__(self, start_path=None):
super().__init__()
self.start_path = Path(start_path or os.getcwd())
# Start one level up to make navigation easier
self.current_path = self.start_path.parent
def compose(self) -> ComposeResult:
yield Container(
Static(f"Current: {self.current_path}", id="title", classes="FilePickerModal-Title"),
ImageDirectoryTree(str(self.current_path), id="directory-tree"),
Horizontal(
Button("Up", id="up-button", classes="FilePickerModal-Button"),
Button("Cancel", id="cancel-button", classes="FilePickerModal-Button"),
classes="FilePickerModal-Buttons"
),
classes="FilePickerModal-Dialog"
)
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
"""Handle file selection"""
file_path = event.path
# Check if it's an image file
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
if file_path.suffix.lower() in image_extensions:
# Return the file path as result
self.dismiss(str(file_path))
else:
self.notify("Please select an image file", severity="warning")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses"""
if event.button.id == "up-button":
# Navigate to parent directory
parent = self.current_path.parent
if parent != self.current_path:
self.current_path = parent
self.query_one("#title", Static).update(f"Current: {self.current_path}")
# Reload the directory tree
tree = self.query_one("#directory-tree", ImageDirectoryTree)
tree.path = str(self.current_path)
tree.reload()
elif event.button.id == "cancel-button":
# Return None to indicate cancellation
self.dismiss(None)

View File

@ -388,3 +388,242 @@ Screen.-modal {
margin: 0 1;
width: 1fr;
}
.EditEntryScreen-sidebar {
width: 40;
min-height: 10;
border-left: solid green;
padding: 1;
background: $surface-darken-2;
color: $primary;
text-style: bold;
content-align: left top;
}
.EditEntryScreen-content-container {
layout: horizontal;
height: 1fr;
}
.EditEntryScreen-sidebar-title {
text-align: center;
text-style: bold;
color: $accent;
padding: 1;
border-bottom: solid $accent;
margin-bottom: 1;
}
.EditEntryScreen-sidebar-content {
height: 1fr;
layout: vertical;
}
.EditEntryScreen-sidebar-photo-list {
height: 1fr;
border: solid $accent;
margin-bottom: 1;
}
.EditEntryScreen-sidebar-photo-info {
height: auto;
min-height: 3;
border: solid $warning;
padding: 1;
margin-bottom: 1;
background: $surface-darken-1;
}
.EditEntryScreen-sidebar-help {
height: auto;
min-height: 8;
border: solid $success;
padding: 1;
background: $surface-darken-1;
text-style: italic;
}
/* Photo Modal Styles */
.modal-dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.modal-title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.modal-label {
margin-bottom: 1;
color: $text;
}
.modal-input {
width: 1fr;
margin-bottom: 2;
}
.modal-buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.modal-button {
margin: 0 1;
width: 1fr;
}
/* AddPhotoModal styles */
.AddPhotoModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.AddPhotoModal-Title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.AddPhotoModal-Label {
margin-bottom: 1;
color: $text;
}
.AddPhotoModal-Input {
width: 1fr;
margin-bottom: 2;
}
.AddPhotoModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.AddPhotoModal-Button {
margin: 0 1;
width: 1fr;
}
/* EditPhotoModal styles */
.EditPhotoModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.EditPhotoModal-Title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.EditPhotoModal-Label {
margin-bottom: 1;
color: $text;
}
.EditPhotoModal-Input {
width: 1fr;
margin-bottom: 2;
}
.EditPhotoModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.EditPhotoModal-Button {
margin: 0 1;
width: 1fr;
}
/* FilePickerModal styles */
.FilePickerModal-Dialog {
layout: vertical;
width: 80%;
height: 80%;
background: $surface;
border: thick $accent;
padding: 2 4;
align: center middle;
}
.FilePickerModal-Title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.FilePickerModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.FilePickerModal-Button {
margin: 0 1;
width: 1fr;
}
/* DirectoryTree specific styles */
#directory-tree {
height: 1fr;
border: solid $accent;
margin: 1;
}
/* ConfirmDeleteModal styles */
.ConfirmDeleteModal-Dialog {
layout: vertical;
width: 60%;
height: auto;
background: $surface;
border: thick $error;
padding: 2 4;
align: center middle;
}
.ConfirmDeleteModal-Title {
text-align: center;
text-style: bold;
color: $error;
margin-bottom: 1;
}
.ConfirmDeleteModal-Message {
text-align: center;
color: $text;
margin-bottom: 1;
}
.ConfirmDeleteModal-Warning {
text-align: center;
color: $warning;
text-style: italic;
margin-bottom: 2;
}
.ConfirmDeleteModal-Buttons {
width: 1fr;
height: auto;
align: center middle;
padding-top: 1;
}
.ConfirmDeleteModal-Button {
margin: 0 1;
width: 1fr;
}