Files

633 lines
24 KiB
QML

import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Modules.Settings.Widgets
import qs.Services
import qs.DCommon.Widgets
FocusScope {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property string currentPage: ""
property var parentModal: null
readonly property bool sessionVisible: parentModal?.shouldBeVisible ?? false
readonly property list<string> pagePath: (parentModal?.pageHistory ?? []).concat([currentPage])
readonly property Item currentPageItem: pageStack.currentItem?.item ?? null
readonly property bool currentPageSettled: pageStack.currentItem?.page === currentPage && pageStack.currentItem.settled
readonly property bool isPluginPage: SettingsTabs.isPluginPage(currentPage)
readonly property bool isCompactMode: parentModal?.isCompactMode ?? false
readonly property bool menuHidden: isCompactMode && !(parentModal?.menuVisible ?? true)
readonly property bool showBack: (parentModal?.canGoBack ?? false) || isPluginPage || menuHidden
readonly property bool animationsEnabled: !SettingsData.reduceMotion && Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None
// Mouse navigation lands parked; only keyboard navigation highlights a control on the page
property bool keyboardNavigation: true
focus: true
function rememberFocus() {
const item = Window.activeFocusItem;
if (pageStack.currentItem && item !== currentPageItem && _contains(currentPageItem, item))
pageStack.currentItem.rememberedFocus = item;
}
function _validRemembered() {
const remembered = pageStack.currentItem?.rememberedFocus;
return remembered?.visible && remembered.enabled && _contains(currentPageItem, remembered) ? remembered : null;
}
function _focusPage() {
const revision = parentModal?.modalFocusScope?.focusRevision;
Qt.callLater(() => {
if (revision !== parentModal?.modalFocusScope?.focusRevision)
return;
if (parentModal?.modalFocusScope?.transientOwnsFocus())
return;
if (!sessionVisible || !currentPageItem || pageStack.currentItem.page !== currentPage || !pageStack.currentItem.presented)
return;
if (parentModal?.focusPane === "sidebar")
return;
if (!keyboardNavigation) {
if (!_contains(currentPageItem, Window.activeFocusItem))
parkFocus();
return;
}
const remembered = _validRemembered();
if (remembered) {
remembered.forceActiveFocus(Qt.TabFocusReason);
_reveal(remembered);
return;
}
let item = currentPageItem;
do {
item = item.nextItemInFocusChain(true);
if (!item || item === currentPageItem || !_contains(currentPageItem, item)) {
currentPageItem.forceActiveFocus(Qt.TabFocusReason);
return;
}
} while (!item.visible || !item.enabled || !_inViewport(item))
item.forceActiveFocus(Qt.TabFocusReason);
});
}
function goBack(keyboard) {
if (!showBack)
return false;
if (menuHidden && !(parentModal?.canGoBack ?? false)) {
parentModal.toggleMenu();
return true;
}
parentModal?.goBack(keyboard);
return true;
}
function _contains(ancestor, item) {
for (let p = item; p; p = p.parent) {
if (p === ancestor)
return true;
}
return false;
}
// Parked focus highlights nothing until a key resumes from rememberedFocus
function parkFocus() {
focusAnchor.forceActiveFocus(Qt.MouseFocusReason);
}
Item {
id: focusAnchor
}
function _moveFocus(forward) {
const page = currentPageItem;
let start = Window.activeFocusItem;
if (!page || (start !== focusAnchor && !_contains(page, start)))
return false;
if (start === focusAnchor) {
const remembered = _validRemembered();
if (remembered) {
remembered.forceActiveFocus(Qt.TabFocusReason);
_reveal(remembered);
return true;
}
start = page;
}
let item = start;
do {
item = item.nextItemInFocusChain(forward);
if (!item || item === start || !_contains(page, item))
return false;
} while (!item.visible || !item.enabled)
item.forceActiveFocus(forward ? Qt.TabFocusReason : Qt.BacktabFocusReason);
_reveal(item);
return true;
}
function _inViewport(item) {
for (let f = item.parent; f; f = f.parent) {
if (typeof f.flick !== "function")
continue;
const y = item.mapToItem(f.contentItem, 0, 0).y;
const top = Math.max(f.originY, y - Theme.spacingL);
const bottom = Math.min(f.originY + f.contentHeight, y + item.height + Theme.spacingL);
if (top < f.contentY || bottom > f.contentY + f.height)
return false;
}
return true;
}
function _reveal(item) {
for (let f = item.parent; f; f = f.parent) {
if (typeof f.flick !== "function" || f.contentHeight <= f.height)
continue;
const top = item.mapToItem(f.contentItem, 0, 0).y - Theme.spacingL;
const bottom = top + item.height + Theme.spacingL * 2;
if (typeof f.revealRange === "function") {
f.revealRange(top, bottom);
return;
}
if (top < f.contentY)
f.contentY = Math.max(f.originY, top);
else if (bottom > f.contentY + f.height)
f.contentY = Math.min(f.originY + f.contentHeight - f.height, bottom - f.height);
return;
}
}
// Keys bubble up from the focused control, so a control that uses an arrow itself (text, lists, open menus) keeps it
Keys.onPressed: event => {
const mods = event.modifiers & ~Qt.KeypadModifier;
const backKey = I18n.isRtl ? Qt.Key_Right : Qt.Key_Left;
if ((event.key === Qt.Key_Up || event.key === Qt.Key_Down) && mods === Qt.NoModifier) {
event.accepted = _moveFocus(event.key === Qt.Key_Down);
return;
}
if ((event.key === backKey && mods === Qt.AltModifier) || (event.key === Qt.Key_Backspace && mods === Qt.NoModifier)) {
event.accepted = goBack(true);
return;
}
if (event.key !== Qt.Key_Escape || mods !== Qt.NoModifier)
return;
const focused = Window.activeFocusItem;
if (focused && focused !== currentPageItem && focused.cursorPosition !== undefined)
currentPageItem?.forceActiveFocus();
else if (!goBack(true))
parentModal?.focusSidebar();
event.accepted = true;
}
function _fileFor(page) {
if (pageFiles[page])
return pageFiles[page];
if (SettingsTabs.isPluginPage(page))
return "PluginSettingsPage.qml";
if (SettingsTabs.page(page)?.kind === "hub")
return "SettingsHubPage.qml";
return "";
}
function _propertiesFor(page) {
if (SettingsTabs.isPluginPage(page))
return {
"parentModal": Qt.binding(() => root.parentModal),
"pluginId": SettingsTabs.pluginIdOf(page)
};
if (!pageFiles[page] && SettingsTabs.page(page)?.kind === "hub")
return {
"parentModal": Qt.binding(() => root.parentModal),
"hubId": page
};
const properties = {};
if (pagesWithParentModal.includes(page))
properties.parentModal = Qt.binding(() => root.parentModal);
if (page === "keybinds")
properties.requestedSearchQuery = Qt.binding(() => root.parentModal?.keybindSearchQuery ?? "");
return properties;
}
function _createPage(page, index) {
return pageComponent.createObject(pageStack, {
page,
pathIndex: index
});
}
// A navigation that lands mid-transition cuts in rather than queueing behind it
function _operation(transition) {
const animate = animationsEnabled && pageStack.depth > 0 && !pageStack.busy && (parentModal?.visible ?? false);
return animate ? transition : StackView.Immediate;
}
function _syncPages() {
if (!sessionVisible) {
pageStack.clear(StackView.Immediate);
return;
}
if (!currentPage)
return;
let shared = 0;
while (shared < Math.min(pageStack.depth, pagePath.length) && pageStack.get(shared).page === pagePath[shared])
shared++;
if (shared === pagePath.length) {
if (shared < pageStack.depth)
pageStack.pop(pageStack.get(shared - 1), _operation(StackView.PopTransition));
return;
}
const deferred = parentModal?.visible ?? false;
const pages = [];
for (let index = shared; index < pagePath.length; index++) {
const top = index === pagePath.length - 1;
const page = _createPage(pagePath[index], index);
// StackView only transitions the top of a multi-item push; pages beneath stay painted until hidden here
page.visible = top;
page.load(deferred && top);
pages.push(page);
}
if (shared < pageStack.depth) {
pageStack.replace(pageStack.get(shared), pages, _operation(StackView.ReplaceTransition));
return;
}
pageStack.push(pages, _operation(StackView.PushTransition));
}
onPagePathChanged: Qt.callLater(_syncPages)
onSessionVisibleChanged: Qt.callLater(_syncPages)
// Fade through the pane: the leaving page is gone before the next one starts to show, so nothing blends mid-way
component PageExit: Transition {
id: exit
property real toX: 0
NumberAnimation {
property: "x"
to: exit.toX
duration: SettingsMetrics.exitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standard
}
NumberAnimation {
property: "opacity"
to: 0
duration: SettingsMetrics.exitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
component PageEnter: Transition {
id: enter
property real fromX: 0
SequentialAnimation {
PropertyAction {
property: "opacity"
value: 0
}
PauseAnimation {
duration: SettingsMetrics.exitDuration
}
ParallelAnimation {
NumberAnimation {
property: "x"
from: enter.fromX
to: 0
duration: SettingsMetrics.transitionDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standard
}
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: SettingsMetrics.fadeDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
}
}
function _scrollerOf(item) {
if (typeof item.flick === "function")
return item;
for (const child of item.children) {
const found = _scrollerOf(child);
if (found)
return found;
}
return null;
}
// A page's Flickable accepts every press inside it before the pane handler sees it, so the scroller carries its own.
// Handlers die with their parent, so one per page.
Component {
id: scrollerTap
TapHandler {
onPressedChanged: {
if (pressed)
root.parentModal?.modalFocusScope?.pointerFocus("content");
}
}
}
Component {
id: pageComponent
Item {
id: host
required property string page
required property int pathIndex
readonly property bool pageActive: root.sessionVisible && page === root.currentPage && pageStack.currentItem === host
readonly property alias item: loader.item
readonly property int status: loader.status
readonly property bool settled: !pending && loader.status !== Loader.Loading && (presented || loader.status !== Loader.Ready)
readonly property var pageInfo: SettingsTabs.page(page)
readonly property string pageTitle: (pageInfo?.titleFrom ? SettingsUiState[pageInfo.titleFrom] : "") || (pageInfo?.text ?? "")
readonly property bool showBack: canGoBack || SettingsTabs.isPluginPage(page) || root.menuHidden
readonly property string parentId: SettingsTabs.parentOf(page)
readonly property bool canGoBack: pathIndex > 0 || (parentId !== "" && (SettingsTabs.isPluginPage(page) || SettingsTabs.visibleLeaves(parentId).length > 1 || !!SettingsTabs.page(parentId)?.hubHeader))
property Item rememberedFocus: null
property Item scroller: null
property bool pending: true
property bool presented: false
width: pageStack.width
height: pageStack.height
enabled: pageActive && presented
StackView.onRemoved: destroy()
// An immediate operation skips the nested enter animation, so the final pose is written here
StackView.onActivated: {
opacity = 1;
x = 0;
}
function load(deferred) {
const file = root._fileFor(page);
if (file) {
loader.asynchronous = deferred;
presented = !deferred;
loader.setSource(Qt.resolvedUrl("../../Modules/Settings/" + file), root._propertiesFor(page));
}
pending = false;
}
Item {
id: pageHeader
width: Math.min(host.item?.contentMaxWidth ?? SettingsMetrics.contentMaxWidth, parent.width - SettingsMetrics.panePadding * 2)
anchors.horizontalCenter: parent.horizontalCenter
height: Math.max(SettingsMetrics.pageHeaderHeight, pageHeading.implicitHeight + Theme.spacingM * 2)
Item {
id: backSlot
readonly property real glyphInset: (Theme.iconButtonSize - Theme.iconSize) / 2
anchors.left: parent.left
anchors.leftMargin: host.showBack ? -glyphInset : 0
anchors.verticalCenter: parent.verticalCenter
width: host.showBack ? Theme.iconButtonSize + Theme.spacingM - glyphInset : 0
height: Theme.iconButtonSize
DActionButton {
buttonSize: Theme.iconButtonSize
iconName: I18n.isRtl ? "arrow_forward" : "arrow_back"
Accessible.name: I18n.tr("Back")
iconSize: Theme.iconSize
iconColor: Theme.surfaceText
visible: host.showBack
onClicked: root.goBack()
}
}
StyledText {
id: pageHeading
anchors.left: backSlot.right
anchors.right: parent.right
wrapMode: Text.WordWrap
anchors.verticalCenter: parent.verticalCenter
text: host.pageTitle
font.pixelSize: Theme.fontSizeXXLarge
color: Theme.surfaceText
visible: host.pageTitle !== ""
}
}
Loader {
id: loader
anchors.top: pageHeader.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: SettingsMetrics.panePadding
anchors.rightMargin: SettingsMetrics.panePadding
opacity: host.presented ? 1 : 0
enabled: host.presented
Behavior on opacity {
enabled: root.animationsEnabled
NumberAnimation {
duration: SettingsMetrics.fadeDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
onLoaded: {
if (item.pageActive !== undefined)
item.pageActive = Qt.binding(() => host.pageActive);
const scroller = root._scrollerOf(item);
host.scroller = scroller;
if (scroller)
scrollerTap.createObject(scroller, {
"parent": scroller
});
root._focusPage();
}
}
FrameAnimation {
id: settleWatch
property real lastHeight: -1
property int stableFrames: 0
running: loader.status === Loader.Ready && !host.presented
onTriggered: {
if (loader.item?.settling) {
stableFrames = 0;
return;
}
const h = loader.item?.contentHeight ?? loader.item?.height ?? 0;
if (h === lastHeight)
stableFrames++;
else {
lastHeight = h;
stableFrames = 0;
}
if (stableFrames < SettingsMetrics.pageSettleFrames && elapsedTime * 1000 < SettingsMetrics.pageSettleDeadline)
return;
host.presented = true;
}
}
onPresentedChanged: {
if (presented && pageActive)
root._focusPage();
}
onPageActiveChanged: {
if (pageActive)
root._focusPage();
}
}
}
readonly property var pageFiles: ({
"wallpaper_cycling": "WallpaperCyclingTab.qml",
"theme_schedule": "ThemeScheduleTab.qml",
"surface_shadows": "ShadowsTab.qml",
"time_weather": "TimeWeatherTab.qml",
"weather": "WeatherSettingsTab.qml",
"keybinds": "KeybindsTab.qml",
"dankbar_widgets": "WidgetsTab.qml",
"window_rules": "WindowRulesTab.qml",
"dankbar_settings": "DBarTab.qml",
"dankbar_appearance": "DBarAppearanceTab.qml",
"dankbar_dot": "DDotTab.qml",
"bar_widget": "BarWidgetTab.qml",
"compositor_layout": "CompositorLayoutTab.qml",
"dock_general": "DockGeneralTab.qml",
"dock_widgets": "DockWidgetsTab.qml",
"dock_appearance": "DockAppearanceTab.qml",
"dock_advanced": "DockAdvancedTab.qml",
"display_config": "DisplayConfigTab.qml",
"display_gamma": "GammaControlTab.qml",
"display_widgets": "DisplayWidgetsTab.qml",
"network_status": "NetworkStatusTab.qml",
"network_ethernet": "NetworkEthernetTab.qml",
"network_wifi": "NetworkWifiTab.qml",
"network_vpn": "NetworkVpnTab.qml",
"network_cellular": "NetworkCellularTab.qml",
"printers": "PrinterTab.qml",
"launcher": "LauncherTab.qml",
"theme": "ThemeColorsTab.qml",
"theme_apps": "ThemeAppsTab.qml",
"palette_inject": "PaletteInjectTab.qml",
"lock_screen": "LockScreenTab.qml",
"greeter": "GreeterTab.qml",
"about": "AboutTab.qml",
"typography": "TypographyMotionTab.qml",
"sounds": "SoundsTab.qml",
"media_player": "MediaPlayerTab.qml",
"notification_rules": "NotificationRulesTab.qml",
"osd": "OSDTab.qml",
"default_apps": "DefaultAppsTab.qml",
"running_apps": "RunningAppsTab.qml",
"updater": "SoftwareUpdatesTab.qml",
"updater_changelog": "ChangelogTab.qml",
"power_sleep": "PowerSleepTab.qml",
"clipboard": "ClipboardTab.qml",
"desktop_widgets": "DesktopWidgetsTab.qml",
"desktop_widget": "DesktopWidgetTab.qml",
"audio": "AudioTab.qml",
"locale": "LocaleTab.qml",
"multiplexers": "MuxTab.qml",
"users": "UsersTab.qml",
"user_create": "CreateUserTab.qml",
"greeter_auth": "GreeterAuthTab.qml",
"autostart": "AutoStartTab.qml",
"battery": "BatteryTab.qml",
"dank_dash": "DDashTab.qml",
"wellbeing": "DigitalWellbeingTab.qml",
"mouse_touchpad": "MouseTouchpadTab.qml",
"keyboard": "KeyboardTab.qml",
"plugins_manage": "PluginsManageTab.qml"
})
readonly property var pagesWithParentModal: ["dankbar_widgets", "window_rules", "notification_rules", "display_config", "users", "time_weather", "weather", "lock_screen", "greeter", "dank_dash", "wallpaper_cycling", "theme_schedule", "surface_shadows", "keybinds", "dankbar_settings", "dankbar_appearance", "bar_widget", "dock_general", "dock_widgets", "dock_appearance", "dock_advanced", "launcher", "theme", "theme_apps", "media_player", "desktop_widgets", "desktop_widget", "autostart", "compositor_layout", "updater", "display_gamma", "about"]
// The page scroller stops at the Loader edges; wheel over the header and gutters lands here instead
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: event => {
const scroller = pageStack.currentItem?.scroller;
if (!scroller?.forwardWheel || !scroller.enabled) {
event.accepted = false;
return;
}
scroller.forwardWheel(event);
}
onActiveChanged: {
if (active)
return;
const scroller = pageStack.currentItem?.scroller;
if (scroller?.forwardWheelEnd)
scroller.forwardWheelEnd();
}
}
StackView {
id: pageStack
anchors.fill: parent
clip: true
readonly property real pageOffset: I18n.isRtl ? -Theme.spacingXL : Theme.spacingXL
pushEnter: PageEnter {
fromX: pageStack.pageOffset
}
pushExit: PageExit {
toX: -pageStack.pageOffset
}
popEnter: PageEnter {
fromX: -pageStack.pageOffset
}
popExit: PageExit {
toX: pageStack.pageOffset
}
replaceEnter: PageEnter {}
replaceExit: PageExit {}
DSpinner {
id: pageSpinner
readonly property bool loading: pageStack.currentItem?.presented === false
anchors.centerIn: parent
z: pageStack.depth
visible: false
onLoadingChanged: {
if (!loading) {
spinnerDelay.stop();
visible = false;
return;
}
spinnerDelay.restart();
}
Timer {
id: spinnerDelay
interval: SettingsMetrics.pageSpinnerDelay
onTriggered: pageSpinner.visible = pageSpinner.loading
}
}
}
}