Files
2026-10-14 13:18:20 +02:00

367 lines
16 KiB
Python

"""Settings dialog: the shared SaraSuite settings (with export/import),
SaraTune's own, and Music mode's library. Laid out like SaraFem's and
SaraSuite's Settings (a category list on the left, the chosen category on the
right)."""
import os
import subprocess
from pathlib import Path
from typing import Callable
import flet as ft
import tomlkit
from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, DisplayPath, ExportSettings,
Home, ImportSettings, IsMac, IsWindows, LoadSharedSettings, Palette, PlaylistsDir, SharedConfigPath,
SharedSettings,
)
from Widgets import App
DialogWidth = 700
DialogHeight = 520
CategoryListWidth = 150
RestartNote = "Theme, font and title bar changes apply the next time SaraTune starts."
Modes = ["Music", "Audio"]
def _Note(Text: str) -> ft.Text:
return ft.Text(Text, color=Palette.Overlay0, size=12)
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 _MacFonts() -> set[str]:
"""Font families on macOS (no fontconfig there), read from the font files'
name tables in the system, shared and personal Fonts folders. The
typographic family (name ID 16) where there is one, else ID 1."""
import struct
Families: set[str] = set()
for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Path.home() / "Library" / "Fonts"):
if not Folder.is_dir():
continue
for FilePath in Folder.rglob("*"):
if FilePath.suffix.lower() not in (".ttf", ".otf", ".ttc"):
continue
try:
Data = FilePath.read_bytes()
except OSError:
continue
Offsets = [0]
if Data[:4] == b"ttcf": # a collection: every font in it
Count = struct.unpack_from(">I", Data, 8)[0]
Offsets = list(struct.unpack_from(f">{Count}I", Data, 12))
for Offset in Offsets:
try:
for Index in range(struct.unpack_from(">H", Data, Offset + 4)[0]):
Tag, _Sum, Start, _Length = struct.unpack_from(">4sIII", Data, Offset + 12 + Index * 16)
if Tag != b"name":
continue
_Format, Count, Strings = struct.unpack_from(">HHH", Data, Start)
Found: dict[int, str] = {}
for Record in range(Count):
PlatformId, _Encoding, Language, NameId, Length, At = struct.unpack_from(
">6H", Data, Start + 6 + Record * 12)
if NameId not in (1, 16) or NameId in Found:
continue
Raw = Data[Start + Strings + At:Start + Strings + At + Length]
if PlatformId in (0, 3):
Found[NameId] = Raw.decode("utf-16-be", "replace")
elif PlatformId == 1 and Language == 0:
Found[NameId] = Raw.decode("mac-roman", "replace")
Name = (Found.get(16) or Found.get(1) or "").strip()
if Name and not Name.startswith("."): # ".SF NS" and co. are system-internal
Families.add(Name)
except struct.error:
continue
return Families
def _Fonts() -> list[str]:
"""Installed font families (fontconfig, the registry on Windows, the font
files themselves on macOS), with the bundled font first."""
if IsWindows or IsMac:
Families = _WindowsFonts() if IsWindows else _MacFonts()
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 _NumberField(Label: str, Value: int) -> ft.TextField:
return ft.TextField(label=Label, value=str(Value), keyboard_type=ft.KeyboardType.NUMBER)
def _ReadInt(Field: ft.TextField, Fallback: int, Minimum: int) -> int:
try:
return max(Minimum, int(Field.value))
except (TypeError, ValueError):
return Fallback # keep the previous value rather than crash on bad input
def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], OnClearCache: Callable[[], None]) -> None:
"""OnSaved(FoldersChanged) runs after saving; OnClearCache empties the
library cache (Music mode rescans)."""
Page = TheApp.Page
Config: AppConfig = TheApp.Config
Themes = AvailableThemes()
Fonts = _Fonts()
def ThemePicker(Value: str) -> ft.Dropdown:
Names = Themes if Value in Themes else [Value] + Themes
return ft.Dropdown(value=Value, label="Theme", options=[ft.DropdownOption(key=Name, text=Name) for Name in Names])
def FontPicker(Value: str) -> ft.Dropdown:
# Type to filter the long list.
Names = Fonts if Value in Fonts else [Value] + Fonts
return ft.Dropdown(
value=Value, label="Font", editable=True, enable_filter=True, menu_height=320,
options=[ft.DropdownOption(key=Name, text=Name) for Name in Names],
)
# SaraSuite: ~/.config/SaraSuite/Config.toml, read by every SaraSuite program.
Shared = LoadSharedSettings(Config)
UseSharedSwitch = ft.Switch(label="SaraTune uses these shared settings", value=Config.UseSharedSettings)
SharedTheme = ThemePicker(Shared.Theme)
SharedFont = FontPicker(Shared.FontFamily)
SharedTitleBar = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
SettingsFileField = ft.TextField(value=str(Home / "SaraTune-Settings.toml"), label="Settings file path")
# SaraTune's own appearance, only used with shared settings off.
OwnTheme = ThemePicker(Config.Theme)
OwnFont = FontPicker(Config.FontFamily)
OwnTitleBar = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar)
OwnAppearance = ft.Column(
[_Note("SaraTune's own appearance, used when shared settings are off"), OwnTheme, OwnFont, OwnTitleBar],
spacing=12,
disabled=Config.UseSharedSettings,
opacity=0.5 if Config.UseSharedSettings else 1,
)
def OnUseSharedChanged(e) -> None:
OwnAppearance.disabled = UseSharedSwitch.value
OwnAppearance.opacity = 0.5 if UseSharedSwitch.value else 1
OwnAppearance.update()
UseSharedSwitch.on_change = OnUseSharedChanged
StartMode = ft.Dropdown(
label="Start in", value=Config.StartMode if Config.StartMode in Modes else "Music",
options=[ft.DropdownOption(key=Name, text=f"{Name} mode") for Name in Modes],
)
SeekField = _NumberField("Arrow keys seek by (seconds, Audio mode)", Config.SeekSeconds)
AdvanceSwitch = ft.Switch(label="Audio mode plays the next file in the folder when one ends",
value=Config.AudioAutoAdvance)
MediaSwitch = ft.Switch(label="Show in the system's media controls and answer media keys",
value=Config.MediaControls)
# Music: one folder per line, plus a Browse button that adds a line.
FoldersField = ft.TextField(label="Music folders (one per line)", value="\n".join(Config.MusicFolders),
multiline=True, min_lines=3, max_lines=6, expand=True)
async def AddFolder(e) -> None:
Chosen = await TheApp.Picker.get_directory_path(dialog_title="Add a music folder")
if Chosen:
Lines = [Line for Line in (FoldersField.value or "").splitlines() if Line.strip()]
if Chosen not in Lines:
FoldersField.value = "\n".join(Lines + [Chosen])
FoldersField.update()
ScanSwitch = ft.Switch(label="Look for new and changed files every start", value=Config.ScanOnStart)
SessionSwitch = ft.Switch(label="Remember the queue and position between runs", value=Config.RememberSession)
def ClearCache(e) -> None:
Page.pop_dialog()
OnClearCache()
def OnExport(e) -> None:
PathText = (SettingsFileField.value or "").strip()
if not PathText:
return
try:
ExportSettings(Config, Path(PathText).expanduser())
except OSError as Error:
TheApp.Notify(f"Couldn't export settings: {Error}", "error")
return
TheApp.Notify("Settings exported.", "success")
def OnImport(e) -> None:
PathText = (SettingsFileField.value or "").strip()
if not PathText:
return
OldFolders = list(Config.MusicFolders)
try:
ImportSettings(Config, Path(PathText).expanduser())
except (OSError, tomlkit.exceptions.TOMLKitError) as Error:
TheApp.Notify(f"Couldn't import settings: {Error}", "error")
return
# The open dialog still shows the pre-import values; closing it
# rather than leaving a stale Save that would overwrite the import.
Page.pop_dialog()
OnSaved(Config.MusicFolders != OldFolders)
TheApp.Notify("Settings imported. Reopen Settings to see them; restart for Theme/Font/Title bar.", "success")
Categories: dict[str, ft.Control] = {
"SaraSuite": ft.Column(
[
_Note(f"Shared by every SaraSuite program ({DisplayPath(str(SharedConfigPath))}). "
"Other programs pick changes up the next time they start."),
UseSharedSwitch,
ft.Divider(height=1, color=Palette.Surface1),
SharedTheme,
SharedFont,
SharedTitleBar,
ft.Divider(height=1, color=Palette.Surface1),
_Note("Export/import settings as one file: this tab's shared settings plus everything else "
"in Settings. Same format as SaraFem's."),
SettingsFileField,
ft.Row([
ft.OutlinedButton(content="Export", style=ChipButtonStyle(), on_click=OnExport),
ft.OutlinedButton(content="Import", style=ChipButtonStyle(), on_click=OnImport),
]),
],
spacing=12,
scroll=ft.ScrollMode.AUTO,
),
"General": ft.Column(
[
StartMode,
_Note("Opening files with SaraTune (e.g. from a file manager) always starts in Audio mode."),
SeekField,
AdvanceSwitch,
MediaSwitch,
_Note("Media controls: the desktop's media widget and lock screen (MPRIS on Linux), the media "
"overlay on Windows. Changing it applies the next time SaraTune starts."),
ft.Divider(height=1, color=Palette.Surface1),
OwnAppearance,
],
spacing=12,
scroll=ft.ScrollMode.AUTO,
),
"Music": ft.Column(
[
_Note("Music mode's library is every audio file below these folders."),
ft.Row([FoldersField, ft.IconButton(ft.Icons.CREATE_NEW_FOLDER_OUTLINED, tooltip="Add a folder",
on_click=AddFolder)],
vertical_alignment=ft.CrossAxisAlignment.START),
ScanSwitch,
SessionSwitch,
ft.Divider(height=1, color=Palette.Surface1),
_Note("Playlists are kept in ~/.config/SaraSuite/SaraTune/Playlists/, one TOML file each. "
"The library's tags and cover thumbnails are only a cache."),
ft.Row([
ft.OutlinedButton(content="Open playlists folder", style=ChipButtonStyle(),
on_click=lambda e: (PlaylistsDir.mkdir(parents=True, exist_ok=True),
TheApp.OpenFolder(str(PlaylistsDir)))),
ft.OutlinedButton(content="Clear cache and rescan", style=ChipButtonStyle(Palette.Yellow),
on_click=ClearCache),
], wrap=True),
],
spacing=12,
scroll=ft.ScrollMode.AUTO,
),
}
DetailArea = ft.Container(Categories["General"], expand=True, padding=ft.Padding.only(left=12))
CategoryTiles: dict[str, ft.ListTile] = {}
def SelectCategory(Name: str) -> None:
for CategoryName, Tile in CategoryTiles.items():
Tile.selected = CategoryName == Name
DetailArea.content = Categories[Name]
DetailArea.update()
CategoryList.update()
for Name in Categories:
CategoryTiles[Name] = ft.ListTile(
title=Name,
selected=Name == "General",
selected_tile_color=Palette.Surface1,
selected_color=Palette.Mauve,
on_click=lambda e, n=Name: SelectCategory(n),
)
CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO)
def Save(e) -> None:
OldFolders = list(Config.MusicFolders)
Config.UseSharedSettings = UseSharedSwitch.value
Config.Theme = OwnTheme.value or Config.Theme
Config.FontFamily = OwnFont.value or Config.FontFamily
Config.HideWindowTitleBar = OwnTitleBar.value
Config.StartMode = StartMode.value or Config.StartMode
Config.SeekSeconds = _ReadInt(SeekField, Config.SeekSeconds, 1)
Config.AudioAutoAdvance = AdvanceSwitch.value
Config.MediaControls = MediaSwitch.value
Config.MusicFolders = [os.path.expanduser(Line.strip()) for Line in (FoldersField.value or "").splitlines()
if Line.strip()]
Config.ScanOnStart = ScanSwitch.value
Config.RememberSession = SessionSwitch.value
Config.Save()
SharedSettings(
Theme=SharedTheme.value,
FontFamily=SharedFont.value or Shared.FontFamily,
HideWindowTitleBar=SharedTitleBar.value,
).Save()
Page.pop_dialog()
OnSaved(Config.MusicFolders != OldFolders)
TheApp.Notify("Settings saved.", "success")
Page.show_dialog(ft.AlertDialog(
title="Settings",
content=ft.Column(
[
ft.Row(
[
ft.Container(
CategoryList, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4,
),
DetailArea,
],
expand=True,
vertical_alignment=ft.CrossAxisAlignment.STRETCH,
),
ft.Divider(height=1, color=Palette.Surface1),
_Note(RestartNote),
],
width=DialogWidth,
height=DialogHeight,
),
actions=[
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()),
ft.OutlinedButton(content="Save", style=ChipButtonStyle(), on_click=Save),
],
))