From 117532e017ec057609b7e08beb5dbfc71ab749f3 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Sat, 26 Sep 2026 18:50:38 +0200 Subject: [PATCH] Add fullscreen in-app image view, no stretch, sharing the editor's in-app/external toggle --- Browser.py | 71 ++++++++++++++++++++++- Core.py | 5 ++ Editor.py | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++ ImageView.py | 41 +++++++++++++ Settings.py | 6 ++ 5 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 Editor.py create mode 100644 ImageView.py diff --git a/Browser.py b/Browser.py index 4856525..40b00b0 100644 --- a/Browser.py +++ b/Browser.py @@ -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") ), diff --git a/Core.py b/Core.py index 5ebbad5..cd14975 100644 --- a/Core.py +++ b/Core.py @@ -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 diff --git a/Editor.py b/Editor.py new file mode 100644 index 0000000..b4a21a8 --- /dev/null +++ b/Editor.py @@ -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") diff --git a/ImageView.py b/ImageView.py new file mode 100644 index 0000000..f216d3a --- /dev/null +++ b/ImageView.py @@ -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, + ) diff --git a/Settings.py b/Settings.py index 3c14ed4..f1e4284 100644 --- a/Settings.py +++ b/Settings.py @@ -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()