mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
Add fullscreen in-app image view, no stretch, sharing the editor's in-app/external toggle
This commit is contained in:
1 parent
d588d445b4
commit
117532e017
5 files changed
+281
-2
No files matched your search
+69
-2
@@ -28,11 +28,13 @@ from Core import (
|
||||
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, ImportSettings,
|
||||
NotificationStyle, Palette, ResizeHandle,
|
||||
)
|
||||
from Editor import EditorView
|
||||
from ImageView import ImageView
|
||||
from Preview import HumanSize, Preview, TextExtensions
|
||||
from SaraSortdInfo import GetSortRules
|
||||
from Settings import BuildSettingsDialog
|
||||
from Tags import TagPanel
|
||||
from Thumbnails import GetThumbnail, IsThumbnailable
|
||||
from Thumbnails import GetThumbnail, ImageExtensions, IsThumbnailable
|
||||
|
||||
DoubleClickWindow = 0.4 # seconds
|
||||
|
||||
@@ -46,6 +48,13 @@ def _IsArchive(EntryPath: Path) -> bool:
|
||||
return any(NameLower.endswith(Ext) for Ext in ArchiveExtensions)
|
||||
|
||||
|
||||
def _IsInAppViewable(EntryPath: Path) -> bool:
|
||||
"""Everything Config.OpenFilesInApp can route to (Editor.py's EditorView
|
||||
for text/config/markup, ImageView.py's ImageView for images)."""
|
||||
Suffix = EntryPath.suffix.lower()
|
||||
return Suffix in TextExtensions or Suffix in ImageExtensions
|
||||
|
||||
|
||||
|
||||
|
||||
def _EntryDescription(Entry: Path) -> str:
|
||||
@@ -301,6 +310,9 @@ class Browser(ft.Row):
|
||||
expand=True,
|
||||
)
|
||||
self.EntryList = self._MakeEntryListContainer()
|
||||
# None: the file list shows. A Path: EditorView fills the file area
|
||||
# instead (Features/Major: "Different display in middle").
|
||||
self.OpenFilePath: Optional[Path] = None
|
||||
# SaraSortd integration (Features/Major): "*.png -> ~/Pictures" style
|
||||
# lines for the currently-browsed directory. Mutated in place by
|
||||
# _RefreshSortBanner on every NavigateTo, rather than rebuilt as part
|
||||
@@ -497,6 +509,27 @@ class Browser(ft.Row):
|
||||
# bottom toolbar sat right under the last entry. Inside that fixed
|
||||
# height, the inner Container's bgcolor makes it paint, and so
|
||||
# hit-test, everywhere, including below the last entry.
|
||||
if self.OpenFilePath is not None:
|
||||
# A file is open in-app (Features/Major: "Different display in
|
||||
# middle") — it takes over the whole file area, SortBanner
|
||||
# included, same as it takes over the KeyboardListener/entry list
|
||||
# tree below.
|
||||
if self.OpenFilePath.suffix.lower() in ImageExtensions:
|
||||
Viewer: ft.Control = ImageView(self.OpenFilePath, OnClose=self._CloseEditor)
|
||||
else:
|
||||
Viewer = EditorView(
|
||||
self.OpenFilePath,
|
||||
FontFamily=self.Config.FontFamily,
|
||||
OnClose=self._CloseEditor,
|
||||
OnNotify=self._Notify,
|
||||
)
|
||||
Items.append(Viewer)
|
||||
if ShowBar and self.Config.BreadcrumbsPosition == "bottom":
|
||||
Items += [ft.Divider(height=1, color=Palette.Surface1), self._BuildToolbarBar()]
|
||||
if ShowTagsAt("bottom"):
|
||||
Items.append(TagsBar)
|
||||
return Items
|
||||
|
||||
Items.append(self.SortBanner)
|
||||
Items.append(
|
||||
ft.Container(
|
||||
@@ -650,6 +683,10 @@ class Browser(ft.Row):
|
||||
self.SelectedPaths = set()
|
||||
self.SelectionAnchor = None
|
||||
self.MetadataCache = {} # a new directory's entries make the old cache meaningless
|
||||
if self.OpenFilePath is not None: # navigating away closes whatever's open in-app
|
||||
self.OpenFilePath = None
|
||||
self.FileListColumn.controls = self._BuildChrome()
|
||||
self.FileListColumn.update()
|
||||
self._Refresh()
|
||||
if self.Config.ShowPreview:
|
||||
self.PreviewPane.ShowPath(None)
|
||||
@@ -779,18 +816,36 @@ class Browser(ft.Row):
|
||||
return [P for P in self.CurrentEntries if P in self.SelectedPaths]
|
||||
return [Entry]
|
||||
|
||||
def OpenEntry(self, EntryPath: Path) -> None:
|
||||
def OpenEntry(self, EntryPath: Path, ForceInApp: Optional[bool] = None) -> None:
|
||||
"""ForceInApp overrides Config.OpenFilesInApp for this one call — used
|
||||
by the per-entry "Open" menu item, which always does the opposite of
|
||||
whatever double-click currently does (Features/Major: "Different
|
||||
display in middle")."""
|
||||
if EntryPath.is_dir():
|
||||
self.NavigateTo(EntryPath)
|
||||
return
|
||||
if self.OnActivateFile:
|
||||
self.OnActivateFile(EntryPath)
|
||||
return
|
||||
WantInApp = self.Config.OpenFilesInApp if ForceInApp is None else ForceInApp
|
||||
if WantInApp and _IsInAppViewable(EntryPath):
|
||||
self._OpenInEditor(EntryPath)
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", str(EntryPath)])
|
||||
except OSError as Error:
|
||||
self._Notify(f"Couldn't open {EntryPath.name}: {Error}")
|
||||
|
||||
def _OpenInEditor(self, EntryPath: Path) -> None:
|
||||
self.OpenFilePath = EntryPath
|
||||
self.FileListColumn.controls = self._BuildChrome()
|
||||
self.FileListColumn.update()
|
||||
|
||||
def _CloseEditor(self) -> None:
|
||||
self.OpenFilePath = None
|
||||
self.FileListColumn.controls = self._BuildChrome()
|
||||
self.FileListColumn.update()
|
||||
|
||||
# --- Rendering --------------------------------------------------------
|
||||
|
||||
def Refresh(self) -> None:
|
||||
@@ -1095,6 +1150,18 @@ class Browser(ft.Row):
|
||||
selection changes."""
|
||||
Items = [
|
||||
ft.PopupMenuItem(content="Rename", on_click=lambda e, p=Entry: self._HandleRename(p)),
|
||||
]
|
||||
if not Entry.is_dir():
|
||||
if _IsInAppViewable(Entry):
|
||||
# Always the opposite of what double-click currently does —
|
||||
# see OpenEntry's ForceInApp and Config.OpenFilesInApp.
|
||||
Label = "Open Externally" if self.Config.OpenFilesInApp else "Open in App"
|
||||
OpenHandler = lambda e, p=Entry: self.OpenEntry(p, ForceInApp=not self.Config.OpenFilesInApp)
|
||||
else:
|
||||
Label = "Open"
|
||||
OpenHandler = lambda e, p=Entry: self.OpenEntry(p)
|
||||
Items.insert(0, ft.PopupMenuItem(content=Label, on_click=OpenHandler))
|
||||
Items += [
|
||||
ft.PopupMenuItem(
|
||||
content="Copy", on_click=lambda e, p=Entry: self._SetClipboard(self._TargetSelection(p), "copy")
|
||||
),
|
||||
|
||||
@@ -205,6 +205,11 @@ class AppConfig:
|
||||
ListIconSize: int = 32
|
||||
FontSize: int = 14 # entry name text, list and grid alike; metadata/header text scales with it
|
||||
ToolbarHamburger: bool = False # collapse the toolbar's buttons into one overflow menu
|
||||
# Double-click on a text/config/markup file: True opens it in SaraFem's own
|
||||
# viewer/editor (Editor.py); False (matches pre-existing behaviour) hands it
|
||||
# to xdg-open instead. Either way, the per-entry "Open" menu item does the
|
||||
# opposite of this default, so both are always one click away.
|
||||
OpenFilesInApp: bool = False
|
||||
|
||||
# Batch-rename pattern for multi-select rename, syntax modeled on
|
||||
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""In-app text/config/markup viewer and editor (Features/Major: "Different
|
||||
display in middle" — the code-editor part). Fills Browser's file-area
|
||||
Container in place of the entry list while a file is open.
|
||||
|
||||
Flet 1.0.1 has no CodeMirror-equivalent rich editor widget and no embeddable
|
||||
WebView, so this mirrors SaraView's View/Edit split as closely as Flet
|
||||
actually allows: View mode gets real syntax highlighting by wrapping the
|
||||
content in a fenced code block and rendering it read-only via ft.Markdown
|
||||
(which ships GitHub-style highlight.js theming); Edit mode is a plain
|
||||
monospace TextField — no highlighting while typing — with an explicit Save."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Core import ChipButtonStyle, Palette
|
||||
|
||||
LanguageByExtension = {
|
||||
".py": "python", ".json": "json", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml",
|
||||
".js": "javascript", ".ts": "typescript", ".css": "css", ".html": "xml", ".htm": "xml",
|
||||
".xml": "xml", ".sh": "bash", ".fish": "bash", ".md": "markdown", ".ipynb": "json",
|
||||
".cfg": "ini", ".ini": "ini", ".csv": "", ".log": "", ".scad": "",
|
||||
}
|
||||
|
||||
# Above this, re-parsing the whole file into a highlighted markdown block on
|
||||
# every render gets slow — large logs stay readable (unhighlighted) instead.
|
||||
HighlightCharLimit = 500_000
|
||||
|
||||
|
||||
def LanguageFor(EntryPath: Path) -> str:
|
||||
return LanguageByExtension.get(EntryPath.suffix.lower(), "")
|
||||
|
||||
|
||||
def FencedBlock(Content: str, Language: str) -> str:
|
||||
"""A fence long enough that no run of backticks already in Content can
|
||||
prematurely close it (CommonMark: closing fence >= opening fence length)."""
|
||||
LongestRun = max((len(Run) for Run in re.findall(r"`+", Content)), default=0)
|
||||
Fence = "`" * max(LongestRun + 1, 3)
|
||||
return f"{Fence}{Language}\n{Content}\n{Fence}"
|
||||
|
||||
|
||||
class EditorView(ft.Container):
|
||||
def __init__(
|
||||
self,
|
||||
EntryPath: Path,
|
||||
FontFamily: str,
|
||||
OnClose: Callable[[], None],
|
||||
OnNotify: Callable[[str, str], None],
|
||||
):
|
||||
self.EntryPath = EntryPath
|
||||
self.OnClose = OnClose
|
||||
self.OnNotify = OnNotify
|
||||
self.Language = LanguageFor(EntryPath)
|
||||
|
||||
try:
|
||||
self.OriginalContent = EntryPath.read_text(errors="replace")
|
||||
self.ReadError: Optional[str] = None
|
||||
except OSError as Error:
|
||||
self.OriginalContent = ""
|
||||
self.ReadError = str(Error)
|
||||
|
||||
self.IsEditing = False
|
||||
self.IsDirty = False
|
||||
self.SkipHighlight = len(self.OriginalContent) > HighlightCharLimit
|
||||
CodeFont = ft.TextStyle(font_family=FontFamily, size=13)
|
||||
|
||||
self.TextField = ft.TextField(
|
||||
value=self.OriginalContent,
|
||||
multiline=True,
|
||||
expand=True,
|
||||
border=ft.InputBorder.NONE,
|
||||
text_style=CodeFont,
|
||||
on_change=self._OnTextChanged,
|
||||
)
|
||||
self.MarkdownView = ft.Markdown(
|
||||
value=FencedBlock(self.OriginalContent, self.Language),
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
|
||||
)
|
||||
self.PlainView = ft.Text(self.OriginalContent, selectable=True, style=CodeFont)
|
||||
|
||||
self.SaveButton = ft.OutlinedButton(
|
||||
content="Save", style=ChipButtonStyle(), on_click=self._Save, disabled=True
|
||||
)
|
||||
self.ToggleButton = ft.OutlinedButton(content="Edit", style=ChipButtonStyle(), on_click=self._ToggleEdit)
|
||||
self.StatusText = ft.Text("", color=Palette.Overlay0, size=12)
|
||||
|
||||
Toolbar = ft.Row(
|
||||
[
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.ARROW_BACK, tooltip="Back to file list", on_click=lambda e: self.OnClose()
|
||||
),
|
||||
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD, expand=True),
|
||||
self.StatusText,
|
||||
self.ToggleButton,
|
||||
self.SaveButton,
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
|
||||
if self.ReadError:
|
||||
BodyContent: ft.Control = ft.Text(f"Couldn't read this file: {self.ReadError}", color=Palette.Red)
|
||||
elif self.SkipHighlight:
|
||||
BodyContent = self.PlainView
|
||||
else:
|
||||
BodyContent = self.MarkdownView
|
||||
self.Body = ft.Container(content=BodyContent, expand=True, padding=12)
|
||||
|
||||
super().__init__(
|
||||
content=ft.Column(
|
||||
[Toolbar, ft.Divider(height=1, color=Palette.Surface1), self.Body], expand=True, spacing=8
|
||||
),
|
||||
expand=True,
|
||||
bgcolor=Palette.Base,
|
||||
padding=12,
|
||||
)
|
||||
|
||||
def _OnTextChanged(self, Event: ft.ControlEvent) -> None:
|
||||
if not self.IsDirty:
|
||||
self.IsDirty = True
|
||||
self.SaveButton.disabled = False
|
||||
self.StatusText.value = "Unsaved changes"
|
||||
self.SaveButton.update()
|
||||
self.StatusText.update()
|
||||
|
||||
def _ToggleEdit(self, Event: ft.ControlEvent) -> None:
|
||||
if self.ReadError:
|
||||
return
|
||||
self.IsEditing = not self.IsEditing
|
||||
if self.IsEditing:
|
||||
self.Body.content = self.TextField
|
||||
self.ToggleButton.content = "View"
|
||||
else:
|
||||
# Re-render the highlighted view from whatever's currently typed
|
||||
# (even if unsaved), so Edit -> View -> Edit doesn't discard it.
|
||||
if not self.SkipHighlight:
|
||||
self.MarkdownView.value = FencedBlock(self.TextField.value, self.Language)
|
||||
else:
|
||||
self.PlainView.value = self.TextField.value
|
||||
self.Body.content = self.PlainView if self.SkipHighlight else self.MarkdownView
|
||||
self.ToggleButton.content = "Edit"
|
||||
self.Body.update()
|
||||
self.ToggleButton.update()
|
||||
|
||||
def _Save(self, Event: ft.ControlEvent) -> None:
|
||||
try:
|
||||
self.EntryPath.write_text(self.TextField.value)
|
||||
except OSError as Error:
|
||||
self.OnNotify(f"Couldn't save {self.EntryPath.name}: {Error}", "error")
|
||||
return
|
||||
self.OriginalContent = self.TextField.value
|
||||
self.IsDirty = False
|
||||
self.SaveButton.disabled = True
|
||||
self.StatusText.value = "Saved."
|
||||
self.SaveButton.update()
|
||||
self.StatusText.update()
|
||||
self.OnNotify(f"Saved {self.EntryPath.name}.", "success")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Fullscreen in-app image view (Features/Major: "Different display in
|
||||
middle" — fullscreen image, no stretch). Fills Browser's file-area Container
|
||||
in place of the entry list, same mechanism as Editor.py's EditorView. Just
|
||||
fit-to-window display — no zoom/pan, matching what was actually asked for."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Core import Palette
|
||||
|
||||
|
||||
class ImageView(ft.Container):
|
||||
def __init__(self, EntryPath: Path, OnClose: Callable[[], None]):
|
||||
self.EntryPath = EntryPath
|
||||
self.OnClose = OnClose
|
||||
|
||||
Toolbar = ft.Row(
|
||||
[
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.ARROW_BACK, tooltip="Back to file list", on_click=lambda e: self.OnClose()
|
||||
),
|
||||
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD, expand=True),
|
||||
],
|
||||
spacing=8,
|
||||
)
|
||||
Body = ft.Container(
|
||||
content=ft.Image(src=str(EntryPath), fit=ft.BoxFit.CONTAIN, expand=True),
|
||||
expand=True,
|
||||
padding=12,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
content=ft.Column(
|
||||
[Toolbar, ft.Divider(height=1, color=Palette.Surface1), Body], expand=True, spacing=8
|
||||
),
|
||||
expand=True,
|
||||
bgcolor=Palette.Base,
|
||||
padding=12,
|
||||
)
|
||||
@@ -155,6 +155,10 @@ def BuildSettingsDialog(
|
||||
|
||||
UseSharedSwitch.on_change = OnUseSharedChanged
|
||||
DotfilesSwitch = ft.Switch(label="Show dotfiles", value=Config.ShowDotfiles)
|
||||
OpenFilesInAppSwitch = ft.Switch(
|
||||
label="Open text/config/markup files in SaraFem's own viewer, not your default app",
|
||||
value=Config.OpenFilesInApp,
|
||||
)
|
||||
BatchRenamePatternField = ft.TextField(
|
||||
value=Config.BatchRenamePattern,
|
||||
label="Batch rename pattern (~ = name, ! = counter, ^ = extension)",
|
||||
@@ -288,6 +292,7 @@ def BuildSettingsDialog(
|
||||
"General": ft.Column(
|
||||
[
|
||||
DotfilesSwitch,
|
||||
OpenFilesInAppSwitch,
|
||||
BatchRenamePatternField,
|
||||
DeleteZipSwitch,
|
||||
_SectionLabel("Default compress format"),
|
||||
@@ -450,6 +455,7 @@ def BuildSettingsDialog(
|
||||
HideWindowTitleBar=SharedTitleBarSwitch.value,
|
||||
).Save()
|
||||
Config.ShowDotfiles = DotfilesSwitch.value
|
||||
Config.OpenFilesInApp = OpenFilesInAppSwitch.value
|
||||
Config.BatchRenamePattern = BatchRenamePatternField.value.strip() or "~!^"
|
||||
Config.DeleteZipAfterExtract = DeleteZipSwitch.value
|
||||
Config.DefaultArchiveFormat = GetArchiveFormat()
|
||||
|
||||
Reference in new issue
Block a user