Files
SaraView/Client/Core.py
T
2026-10-09 14:25:03 +02:00

549 lines
23 KiB
Python

"""Shared app-wide constants: palette, theme, paths, SaraView client config.
Palette, theming and the shared SaraSuite settings mirror SaraFem's Core.py
and read the same ~/.config/SaraSuite files, so a theme picked for one Sara
program looks the same in all of them. The role names are SaraView's own
(Backend/DefaultThemes/*.toml)."""
import os
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Optional
import flet as ft
import tomlkit
class Palette:
"""Live values for the active theme (see ApplyTheme). Starts as Catppuccin
Mocha so anything constructed before ApplyTheme() runs still looks right.
Same attribute names and role mapping as SaraFem's Palette."""
Mauve = "#cba6f7" # SaraView's "Purple" role: the one accent colour
Red = "#f38ba8"
Peach = "#fab387" # SaraView's "Orange"
Yellow = "#f9e2af"
Green = "#a6e3a1"
Blue = "#89b4fa"
Text = "#cdd6f4"
White = "#cdd6f4"
Overlay0 = "#6c7086" # SaraView's "TextMuted"
Surface1 = "#45475a" # SaraView's "Border"
Surface0 = "#313244" # SaraView's "SurfaceAlt"
Base = "#1e1e2e" # SaraView's "Background"
Mantle = "#181825" # SaraView's "Surface"
Crust = "#11111b" # SaraView's "Black"
AppName = "SaraView"
# Bundled in assets/fonts and always registered, so it works as FontFamily
# without being installed.
BundledFontFamily = "Fixedsys Excelsior"
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
IsWindows = sys.platform == "win32"
# Each system's own per-user folders, like SaraSuite's: ~/.config, ~/.cache
# and ~/.local/share on Linux; %APPDATA% and %LOCALAPPDATA% on Windows.
Home = Path.home()
if IsWindows:
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
CacheHome = DataHome
else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
CacheHome = Path(os.environ.get("XDG_CACHE_HOME") or Home / ".cache")
SharedConfigDir = ConfigHome / "SaraSuite"
ThemesDir = SharedConfigDir / "Themes"
SharedConfigPath = SharedConfigDir / "Config.toml"
ConfigDir = SharedConfigDir / AppName
ConfigPath = ConfigDir / "Config.toml"
# The server's session token, kept out of Config.toml so a settings export
# never carries it.
SessionPath = ConfigDir / "Session.toml"
# Server files opened in another program are downloaded here first.
CacheDir = CacheHome / AppName / "Cache" if IsWindows else CacheHome / AppName
# Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS.
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
AssetsDir = BundleDir / "assets"
# Shared by the file list and the sidebar tree.
FileIcons = {
"markdown": ft.Icons.DESCRIPTION_OUTLINED,
"text": ft.Icons.ARTICLE_OUTLINED,
"config": ft.Icons.SETTINGS_OUTLINED,
"html": ft.Icons.CODE,
"image": ft.Icons.IMAGE_OUTLINED,
"video": ft.Icons.MOVIE_OUTLINED,
}
_ThemeRoleMap = {
"Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1",
"Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White",
"Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green",
"Blue": "Blue", "Purple": "Mauve",
}
# Same as SaraFem's BuiltinThemes, copied from Backend/DefaultThemes/*.toml.
BuiltinThemes: dict[str, dict[str, str]] = {
"CatppuccinMocha": {
"Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a",
"Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4",
"Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1",
"Blue": "#89b4fa", "Purple": "#cba6f7",
},
"Forest": {
"Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539",
"Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea",
"Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069",
"Blue": "#5c9e8f", "Purple": "#9b7fb0",
},
"Gruvbox": {
"Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945",
"Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7",
"Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26",
"Blue": "#83a598", "Purple": "#d3869b",
},
}
def _ReadToml(FilePath: Path) -> dict:
"""The file's data, or {} if it's missing or unreadable."""
try:
return tomlkit.parse(FilePath.read_text()).unwrap()
except (OSError, tomlkit.exceptions.TOMLKitError):
return {}
def EnsureSharedThemes() -> None:
"""Seed the shared Themes directory with the bundled defaults, like SaraFem."""
ThemesDir.mkdir(parents=True, exist_ok=True)
for Name, RoleValues in BuiltinThemes.items():
ThemeFile = ThemesDir / f"{Name}.toml"
if not ThemeFile.exists():
ThemeFile.write_text(tomlkit.dumps({"Palette": RoleValues}))
def AvailableThemes() -> list[str]:
EnsureSharedThemes()
return sorted(ThemeFile.stem for ThemeFile in ThemesDir.glob("*.toml"))
def ApplyTheme(ThemeName: str) -> None:
"""Mutate Palette in place to the named theme. Must run before any control
that reads Palette.X is built."""
EnsureSharedThemes()
RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"]))
RoleValues.update(_ReadToml(ThemesDir / f"{ThemeName}.toml").get("Palette", {}))
for RoleName, AttrName in _ThemeRoleMap.items():
if RoleName in RoleValues:
setattr(Palette, AttrName, RoleValues[RoleName])
@dataclass
class AppConfig:
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
# ~/.config/SaraSuite/Config.toml. False: SaraView's own values below.
UseSharedSettings: bool = True
Theme: str = "CatppuccinMocha"
FontFamily: str = BundledFontFamily
HideWindowTitleBar: bool = False
# "Local" browses LocalRoot on this computer; "Server" talks to a
# SaraView server (the one on SpineS), with its users, locks and blame.
Mode: str = "Local"
LocalRoot: str = "~"
ServerUrl: str = "" # e.g. http://spines:8420
Username: str = "" # last one logged in with, prefilled in the login dialog
RememberLogin: bool = True # keep the session in Session.toml between starts
# Local mode only; on a server these come from your SaraView user settings.
ShowHiddenFolders: bool = False
ShowSvFiles: bool = False
# Same as the server's [SaraTag] HideInternalFiles/InternalPatterns.
HideInternalFiles: bool = True
InternalPatterns: list[str] = field(default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"])
ConfirmDelete: bool = True
ShowSidebar: bool = True
SidebarWidth: int = 240
ShowFilesInTree: bool = True # False: the sidebar tree shows directories only
FontSize: int = 14 # file list and viewer text
# "Separate": Markdown opens rendered, with Edit/View to switch to the raw
# text. "Live": rendered, and clicking a block shows its Markdown to edit
# in place (like Obsidian's live preview). Only affects Markdown.
MarkdownEditing: str = "Live"
# Graph view: directories as dots too (off: only files and the references
# between them), and the mouse button that pans ("Left" or "Right"; the
# other one opens a dot).
GraphShowDirectories: bool = True
GraphPanButton: str = "Right"
# Directory dots: heading colours by depth (off: all Mauve), and smaller
# the deeper they are.
GraphColourByDepth: bool = True
GraphShrinkByDepth: bool = False
# Double-click on a file: True opens it in SaraView's own viewer, False
# hands it to the system's default program. The menu always offers the other.
OpenFilesInApp: bool = True
def Save(self) -> None:
ConfigDir.mkdir(parents=True, exist_ok=True)
ConfigPath.write_text(tomlkit.dumps(asdict(self)))
@property
def LocalRootPath(self) -> Path:
return Path(os.path.expanduser(self.LocalRoot.strip() or "~")).resolve()
def LoadConfig() -> AppConfig:
if not ConfigPath.exists():
Config = AppConfig()
Config.Save()
return Config
Data = _ReadToml(ConfigPath)
return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in AppConfig.__dataclass_fields__})
def LoadSessionToken(ServerUrl: str) -> str:
"""The saved session for ServerUrl, or "" (a session from another server is useless)."""
Data = _ReadToml(SessionPath)
return Data.get("Token", "") if Data.get("ServerUrl") == ServerUrl else ""
def SaveSessionToken(ServerUrl: str, Token: str) -> None:
if not Token:
SessionPath.unlink(missing_ok=True)
return
ConfigDir.mkdir(parents=True, exist_ok=True)
SessionPath.write_text(tomlkit.dumps({"ServerUrl": ServerUrl, "Token": Token}))
SessionPath.chmod(0o600)
@dataclass
class SharedSettings:
"""~/.config/SaraSuite/Config.toml, shared by every SaraSuite program.
Same schema and fallback rules as SaraFem's."""
Theme: str = "CatppuccinMocha"
FontFamily: str = BundledFontFamily
HideWindowTitleBar: bool = False
def Save(self) -> None:
SharedConfigDir.mkdir(parents=True, exist_ok=True)
SharedConfigPath.write_text(tomlkit.dumps(asdict(self)))
def _OwnSettings(Config: AppConfig) -> SharedSettings:
return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar)
def LoadSharedSettings(Config: AppConfig) -> SharedSettings:
"""The shared file, created from SaraView's own values if missing."""
if not SharedConfigPath.exists():
Shared = _OwnSettings(Config)
Shared.Save()
return Shared
Result = _OwnSettings(Config) # fallback for keys the file doesn't have
Data = _ReadToml(SharedConfigPath)
for Key in SharedSettings.__dataclass_fields__:
if Key in Data:
setattr(Result, Key, Data[Key])
return Result
def EffectiveSettings(Config: AppConfig) -> SharedSettings:
return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config)
def ExportSettings(Config: AppConfig, DestPath: Path) -> None:
"""One portable file in SaraFem's format: the shared settings under
[SaraSuite] (with a custom theme's palette as ThemeData) and SaraView's
own under [SaraView]. Other programs ignore [SaraView] and vice versa."""
Shared = LoadSharedSettings(Config)
SharedData = asdict(Shared)
if Shared.Theme not in BuiltinThemes:
ThemeData = _ReadToml(ThemesDir / f"{Shared.Theme}.toml").get("Palette")
if ThemeData:
SharedData["ThemeData"] = ThemeData
DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)}))
def ImportSettings(Config: AppConfig, SourcePath: Path) -> None:
"""Applies [SaraSuite] to the shared file and [SaraView] onto Config, key
by key; unknown sections and keys are skipped. Raises TOMLKitError on an
unreadable file."""
Data = tomlkit.parse(SourcePath.read_text()).unwrap()
SharedData = Data.get("SaraSuite")
if isinstance(SharedData, dict):
ThemeData = SharedData.pop("ThemeData", None)
Merged = asdict(LoadSharedSettings(Config))
Merged.update({Key: Value for Key, Value in SharedData.items() if Key in SharedSettings.__dataclass_fields__})
ImportedTheme = Merged.get("Theme")
if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemes:
ThemesDir.mkdir(parents=True, exist_ok=True)
(ThemesDir / f"{ImportedTheme}.toml").write_text(tomlkit.dumps({"Palette": ThemeData}))
SharedSettings(**Merged).Save()
OwnData = Data.get(AppName)
if isinstance(OwnData, dict):
for Key, Value in OwnData.items():
if Key in AppConfig.__dataclass_fields__:
setattr(Config, Key, Value)
Config.Save()
def ApplyAppearance(Settings: SharedSettings) -> None:
"""Theme colours and font; must run before anything reads Palette.X."""
global ActiveFontFamily
ApplyTheme(Settings.Theme)
ActiveFontFamily = Settings.FontFamily or BundledFontFamily
def ChildEnvironment() -> dict[str, str]:
"""The environment for child processes (xdg-open, fc-list). A PyInstaller
build points LD_LIBRARY_PATH at its own bundled libraries; system tools
need the original back. Same as SaraSort's."""
Environment = dict(os.environ)
if getattr(sys, "frozen", False):
Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None)
if Original is not None:
Environment["LD_LIBRARY_PATH"] = Original
else:
Environment.pop("LD_LIBRARY_PATH", None)
for Key in ("PYTHONHOME", "PYTHONPATH"):
Environment.pop(Key, None)
if IsWindows:
Environment["PYTHONUTF8"] = "1" # every Sara program reads and writes UTF-8
return Environment
def OpenExternally(FilePath: Path) -> None:
"""Hands a file to the system's default program. Raises OSError."""
if IsWindows:
os.startfile(str(FilePath))
return
subprocess.Popen(["xdg-open", str(FilePath)], env=ChildEnvironment(), start_new_session=True)
def _WindowsFonts() -> set[str]:
"""Font families from the system and per-user Fonts registry keys
("Arial Bold (TrueType)" -> "Arial")."""
import re
import winreg
Families: set[str] = set()
Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
try:
with winreg.OpenKey(Root, Key) as Fonts:
Index = 0
while True:
try:
Name = winreg.EnumValue(Fonts, Index)[0]
except OSError:
break
Index += 1
Name = re.sub(r"\s*\(.*\)$", "", Name)
Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name)
if Name and "&" not in Name:
Families.add(Name.strip())
except OSError:
continue
return Families
def InstalledFontFamilies() -> list[str]:
"""Font families fontconfig (or, on Windows, the registry) knows about,
with the bundled font first."""
if IsWindows:
Families = _WindowsFonts()
Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower)
try:
Result = subprocess.run(
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
)
Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()}
except (OSError, subprocess.SubprocessError):
Families = set()
Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower)
def BuildTheme() -> ft.Theme:
ColorScheme = ft.ColorScheme(
primary=Palette.Mauve,
on_primary=Palette.Crust,
primary_container=Palette.Surface0,
on_primary_container=Palette.Mauve,
secondary=Palette.Mauve,
on_secondary=Palette.Crust,
secondary_container=Palette.Surface0,
on_secondary_container=Palette.Mauve,
surface=Palette.Base,
on_surface=Palette.Text,
on_surface_variant=Palette.Overlay0,
surface_container=Palette.Mantle,
surface_container_low=Palette.Mantle,
surface_container_high=Palette.Surface0,
surface_container_highest=Palette.Surface1,
outline=Palette.Surface1,
outline_variant=Palette.Surface1,
error=Palette.Red,
on_error=Palette.Crust,
)
return ft.Theme(
font_family=ActiveFontFamily,
color_scheme=ColorScheme,
use_material3=True,
scaffold_bgcolor=Palette.Base,
canvas_color=Palette.Base,
dialog_theme=ft.DialogTheme(
bgcolor=Palette.Mantle,
shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)),
title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily),
),
)
def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle:
"""SaraView's button look: SurfaceAlt fill, 1px accent border, small radius.
Disabled, it fades to the muted colours (plain colours would look enabled)."""
return ft.ButtonStyle(
color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text},
icon_color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text},
bgcolor={ft.ControlState.DISABLED: Palette.Mantle, ft.ControlState.DEFAULT: Palette.Surface0},
side={
ft.ControlState.DISABLED: ft.BorderSide(1, Palette.Surface1),
ft.ControlState.DEFAULT: ft.BorderSide(1, Accent or Palette.Mauve),
},
shape=ft.RoundedRectangleBorder(radius=4),
)
def CodeStyleSheet() -> ft.MarkdownStyleSheet:
"""Code in the app's own font rather than Flutter's default monospace."""
return ft.MarkdownStyleSheet(code_text_style=ft.TextStyle(font_family=ActiveFontFamily))
def MarkdownSheet(FontSize: int) -> ft.MarkdownStyleSheet:
"""Rendered Markdown's colours, the same roles as the web view's
.MarkdownBody (Frontend/src/Styles/Theme.css): rainbow headings, coloured
bold/italic/code, a Purple bar on quotes."""
def Heading(Colour: str, Extra: int) -> ft.TextStyle:
return ft.TextStyle(size=FontSize + Extra, color=Colour, weight=ft.FontWeight.BOLD)
return ft.MarkdownStyleSheet(
p_text_style=ft.TextStyle(size=FontSize, color=Palette.Text),
h1_text_style=Heading(Palette.Mauve, 10),
h2_text_style=Heading(Palette.Blue, 7),
h3_text_style=Heading(Palette.Green, 4),
h4_text_style=Heading(Palette.Yellow, 2),
h5_text_style=Heading(Palette.Peach, 1),
h6_text_style=Heading(Palette.Red, 0),
strong_text_style=ft.TextStyle(color=Palette.Peach, weight=ft.FontWeight.BOLD),
em_text_style=ft.TextStyle(color=Palette.Yellow, italic=True),
del_text_style=ft.TextStyle(color=Palette.Overlay0, decoration=ft.TextDecoration.LINE_THROUGH),
a_text_style=ft.TextStyle(color=Palette.Blue, decoration=ft.TextDecoration.UNDERLINE),
code_text_style=ft.TextStyle(font_family=ActiveFontFamily, size=FontSize, color=Palette.Green, bgcolor=Palette.Surface0),
blockquote_text_style=ft.TextStyle(size=FontSize, color=Palette.Overlay0),
blockquote_decoration=ft.BoxDecoration(
bgcolor=Palette.Mantle, border=ft.Border(left=ft.BorderSide(3, Palette.Mauve)),
),
blockquote_padding=ft.Padding.only(left=12, top=6, right=8, bottom=6),
list_bullet_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve),
checkbox_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve),
table_head_text_style=ft.TextStyle(size=FontSize, color=Palette.Mauve, weight=ft.FontWeight.BOLD),
table_body_text_style=ft.TextStyle(size=FontSize, color=Palette.Text),
table_cells_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle),
codeblock_decoration=ft.BoxDecoration(bgcolor=Palette.Mantle, border_radius=4),
horizontal_rule_decoration=ft.BoxDecoration(border=ft.Border(top=ft.BorderSide(1, Palette.Surface1))),
)
def MarkdownCodeTheme() -> ft.MarkdownCustomCodeTheme:
"""Syntax colours for code blocks from the palette roles (Catppuccin's
own mapping), in place of a fixed highlight.js theme that ignored the
active SaraSuite theme."""
def Style(Colour: str, **Extra) -> ft.TextStyle:
return ft.TextStyle(color=Colour, **Extra)
return ft.MarkdownCustomCodeTheme(
root=ft.TextStyle(color=Palette.Text, bgcolor=Palette.Mantle, font_family=ActiveFontFamily),
keyword=Style(Palette.Mauve), meta_keyword=Style(Palette.Mauve), doctag=Style(Palette.Mauve),
section=Style(Palette.Mauve, weight=ft.FontWeight.BOLD), bullet=Style(Palette.Mauve), tag=Style(Palette.Mauve),
string=Style(Palette.Green), meta_string=Style(Palette.Green), addition=Style(Palette.Green),
number=Style(Palette.Peach), literal=Style(Palette.Peach), regexp=Style(Palette.Peach), meta=Style(Palette.Peach),
built_in=Style(Palette.Yellow), builtin_name=Style(Palette.Yellow), type=Style(Palette.Yellow),
class_name=Style(Palette.Yellow), attr=Style(Palette.Yellow), attribute=Style(Palette.Yellow),
selector_class=Style(Palette.Yellow),
function=Style(Palette.Blue), title=Style(Palette.Blue), name=Style(Palette.Blue), link=Style(Palette.Blue),
selector_tag=Style(Palette.Blue), selector_id=Style(Palette.Blue),
variable=Style(Palette.Red), template_variable=Style(Palette.Red), symbol=Style(Palette.Red),
deletion=Style(Palette.Red),
comment=Style(Palette.Overlay0, italic=True), quote=Style(Palette.Overlay0, italic=True),
emphasis=ft.TextStyle(italic=True), strong=ft.TextStyle(weight=ft.FontWeight.BOLD),
)
def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton:
return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled)
def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str:
"""Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent).
Same as SaraFem's."""
BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB))
return "#" + "".join(f"{C:02x}" for C in Mixed)
def NotificationStyle(Kind: str) -> tuple[str, str]:
"""(Background, AccentColor) for a toast, from the theme's own colours like SaraFem's."""
Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve)
return BlendHex(Palette.Mantle, Accent, 0.12), Accent
def ResizeHandle(OnDrag: Callable[[float], None], OnDragEnd: Callable[[], None]) -> ft.GestureDetector:
"""A thin draggable divider between side-by-side panels (SaraFem's
ResizeHandle, horizontal only). The parent Row must stretch its children
vertically, or the handle has no height to drag."""
return ft.GestureDetector(
content=ft.Container(width=6, bgcolor=Palette.Surface1),
mouse_cursor=ft.MouseCursor.RESIZE_COLUMN,
on_horizontal_drag_update=lambda e: OnDrag(e.primary_delta or 0),
on_horizontal_drag_end=lambda e: OnDragEnd(),
)
def HumanSize(NumBytes: Optional[int]) -> str:
if NumBytes is None:
return ""
Size = float(NumBytes)
for Unit in ("B", "KB", "MB", "GB"):
if Size < 1024 or Unit == "GB":
return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}"
Size /= 1024
return f"{Size:.1f} TB"
def DisplayPath(PathText: str) -> str:
"""/home/sarah/Notes -> ~/Notes, for labels (C:\\Users\\x\\Notes -> ~\\Notes)."""
HomeText = str(Home)
if PathText == HomeText or PathText.startswith(HomeText + os.sep):
return "~" + PathText[len(HomeText):]
return PathText
def ClearCache() -> None:
shutil.rmtree(CacheDir, ignore_errors=True)