22 Commits

Author SHA1 Message Date
zach ad23da4eda initial commit, primitive loading of QML code outside of shell directory 2026-05-25 13:32:06 +02:00
zach 06ebc4ffbf remove irrelevant settings options plus bugfixes 2026-05-25 11:51:16 +02:00
zach f2f9fa1302 fix opening links on non-uwsm and disable file watcher when shell is installed 2026-05-25 11:01:01 +02:00
zach b4020438f9 Merge pull request 'Tune transparency scale impact on luminance scaling' (#98) from fix/transparency-luminance-scaling into main
Reviewed-on: #98
Reviewed-by: AramJonghu <2+aramjonghu@noreply.git.zach-dev.cc>
2026-05-24 22:37:03 +02:00
zach 184ab20d11 Merge branch 'main' into fix/transparency-luminance-scaling
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 12s
Python / lint-format (pull_request) Successful in 26s
Python / test (pull_request) Successful in 42s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m45s
2026-05-24 22:36:52 +02:00
zach 5097e30a77 fix anchors used in Layouts 2026-05-24 19:33:06 +02:00
zach ef71ae8afd button to install colorscheme and wallpaper to greeter in settings 2026-05-24 19:28:02 +02:00
AramJonghu 6533533936 Merge branch 'main' into fix/transparency-luminance-scaling
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 12s
Python / lint-format (pull_request) Successful in 23s
Python / test (pull_request) Successful in 51s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m47s
2026-05-24 18:53:39 +02:00
zach 9688072e93 record.py 2026-05-24 18:48:03 +02:00
AramJonghu 9c36f0de5b pycache in cache removal 2026-05-24 18:28:32 +02:00
zach 9ca46967d9 Merge pull request 'hotfix(cli): replace raw subprocess tracebacks with styled error messages and fix restart race condition' (#96) from hotfix-restart-race-condition into main
Reviewed-on: #96
Reviewed-by: zach <zach@brohn.se>
2026-05-24 18:23:43 +02:00
zach 16e84ca998 fixed region selection for recording, plus cache file cleanup
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 12s
Lint & Format (Python) / lint-format (pull_request) Successful in 18s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m34s
2026-05-24 18:21:37 +02:00
AramJonghu c30128cf95 check every 50ms -> 250ms for restart
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 11s
Python / lint-format (pull_request) Successful in 30s
Python / test (pull_request) Successful in 46s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m44s
2026-05-24 18:03:32 +02:00
zach ba9926af18 Increased floor and decreased ceiling of offset for brightening darker colors in dark mode/darkening brighter colors in light mode
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 13s
Python / lint-format (pull_request) Successful in 28s
Python / test (pull_request) Successful in 43s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m45s
2026-05-24 11:40:44 +02:00
AramJonghu 78fcf33b3a refactor(cli): clean shell start/restart, drop redundant ipc check
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 11s
Python / lint-format (pull_request) Successful in 24s
Python / test (pull_request) Successful in 48s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m44s
2026-05-24 03:09:19 +02:00
AramJonghu 5e9b373405 tests did not match changed code logic
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 11s
Python / lint-format (pull_request) Successful in 25s
Python / test (pull_request) Successful in 50s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m46s
2026-05-23 20:54:35 +02:00
AramJonghu b49165e7ea minor typer adjustments to use typer in error/exception throws
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 14s
Python / lint-format (pull_request) Successful in 34s
Python / test (pull_request) Failing after 53s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m48s
2026-05-23 20:48:51 +02:00
AramJonghu d0cda51639 wait for instance to fully terminate before restart 2026-05-23 20:31:48 +02:00
zach fd620e7487 prep for replay 2026-05-22 20:42:51 +02:00
zach 0ec426e0f0 Record module added to sidebar, file list and buttons. Region recording is broken 2026-05-22 12:51:06 +02:00
zach ec5e6d3995 add typer command 2026-05-22 11:06:17 +02:00
zach 41a129bb90 init commit 2026-05-22 11:04:54 +02:00
42 changed files with 1915 additions and 808 deletions
+8 -1
View File
@@ -31,6 +31,13 @@ if("shell" IN_LIST ENABLE_MODULES)
foreach(dir assets scripts Components Config Modules Daemons Drawers Effects Helpers Paths)
install(DIRECTORY ${dir} DESTINATION "${INSTALL_QSCONFDIR}")
endforeach()
install(FILES shell.qml DESTINATION "${INSTALL_QSCONFDIR}")
# Disable watching for changes
file(READ shell.qml SHELL_QML)
string(REPLACE "settings.watchFiles: true" "settings.watchFiles: false" SHELL_QML "${SHELL_QML}")
file(WRITE "${CMAKE_BINARY_DIR}/qml/shell.qml" "${SHELL_QML}")
install(FILES "${CMAKE_BINARY_DIR}/qml/shell.qml" DESTINATION "${INSTALL_QSCONFDIR}")
# Greeter
install(DIRECTORY Greeter/ DESTINATION "${INSTALL_GREETERCONFDIR}")
endif()
+1
View File
@@ -15,6 +15,7 @@ Text {
color: DynamicColors.palette.m3onSurface
font.family: Appearance.font.family.sans
font.pointSize: Appearance.font.size.normal
linkColor: DynamicColors.palette.m3onPrimaryFixedVariant
renderType: Text.NativeRendering
textFormat: Text.PlainText
+12 -1
View File
@@ -22,6 +22,7 @@ Singleton {
property alias notifs: adapter.notifs
property alias osd: adapter.osd
property alias overview: adapter.overview
property alias plugins: adapter.plugins
property bool recentlySaved: false
property alias screenshot: adapter.screenshot
property alias services: adapter.services
@@ -140,7 +141,8 @@ Singleton {
launcher: serializeLauncher(),
colors: serializeColors(),
dock: serializeDock(),
screenshot: serializeScreenshot()
screenshot: serializeScreenshot(),
plugins: serializePlugins()
};
}
@@ -289,6 +291,13 @@ Singleton {
};
}
function serializePlugins(): var {
return {
enabled: plugins.enabled,
entries: plugins.entries
};
}
function serializeScreenshot(): var {
return {
enable_pp: screenshot.enable_pp,
@@ -458,6 +467,8 @@ Singleton {
}
property Overview overview: Overview {
}
property PluginConfig plugins: PluginConfig {
}
property Screenshot screenshot: Screenshot {
}
property Services services: Services {
+3 -2
View File
@@ -30,9 +30,10 @@ Singleton {
readonly property alias wallLuminance: analyser.luminance
function alterColor(c: color, a: real, layer: int): color {
const luminance = getLuminance(c);
const initLuminance = getLuminance(c);
const luminance = Math.max(initLuminance, 0.001);
const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (1 - transparency.base) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5));
const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (0.2 + 0.3 * (1 - transparency.base)) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5));
const scale = (luminance + offset) / luminance;
const r = Math.max(0, Math.min(1, c.r * scale));
const g = Math.max(0, Math.min(1, c.g * scale));
+11
View File
@@ -0,0 +1,11 @@
import Quickshell.Io
JsonObject {
property bool enabled: false
property list<var> entries: [
{
id: "Plugin",
enabled: false
},
]
}
+18
View File
@@ -0,0 +1,18 @@
pragma Singleton
import Quickshell
import ZShell.Models
Singleton {
id: root
property alias plugins: plugins.entries
FileSystemModel {
id: plugins
nameFilters: ["*.qml"]
path: Quickshell.env("HOME") + "/.config/zshell"
recursive: false
}
}
+17
View File
@@ -0,0 +1,17 @@
import Quickshell
import QtQuick
import ZShell.Models
import qs.Config
Repeater {
model: FetchPlugins.plugins
LazyLoader {
required property FileSystemEntry modelData
activeAsync: Config.plugins.entries.some(p => {
return p.id === modelData.baseName && p.enabled;
})
source: modelData.path
}
}
+82 -41
View File
@@ -1,41 +1,82 @@
// pragma Singleton
//
// import Quickshell
// import QtQuick
//
// Singleton {
// id: root
//
// function start(extraArgs = []): void {
// needsStart = true;
// startArgs = extraArgs;
// checkProc.running = true;
// }
//
// PersistentProperties {
// id: props
//
// property real elapsed: 0
// property bool paused: false
// property bool running: false
//
// reloadableId: "recorder"
// }
//
// Process {
// id: checkProc
//
// command: ["pidof", "gpu-screen-recorder"]
// running: true
//
// onExited: code => {
// props.running = code === 0;
//
// if (code === 0) {
// if (root.needsStop) {
// Quickshell.execDetached(["zshell-cli"]);
// }
// }
// }
// }
// }
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property alias elapsed: props.elapsed
property bool needsPause
property bool needsStart
property bool needsStop
readonly property alias paused: props.paused
readonly property alias running: props.running
property list<string> startArgs
function start(extraArgs = []): void {
needsStart = true;
startArgs = extraArgs;
checkProc.running = true;
}
function stop(): void {
needsStop = true;
checkProc.running = true;
}
function togglePause(): void {
needsPause = true;
checkProc.running = true;
}
PersistentProperties {
id: props
property real elapsed: 0
property bool paused: false
property bool running: false
reloadableId: "recorder"
}
Process {
id: checkProc
command: ["pidof", "gpu-screen-recorder"]
running: true
onExited: code => {
props.running = code === 0;
if (code === 0) {
if (root.needsStop) {
Quickshell.execDetached(["zshell-cli", "record", "record"]);
props.running = false;
props.paused = false;
} else if (root.needsPause) {
Quickshell.execDetached(["zshell-cli", "record", "record", "-p"]);
props.paused = !props.paused;
}
} else if (root.needsStart) {
Quickshell.execDetached(["zshell-cli", "record", "record", ...root.startArgs]);
props.running = true;
props.paused = false;
props.elapsed = 0;
}
root.needsStart = false;
root.needsStop = false;
root.needsPause = false;
}
}
Connections {
function onSecondsChanged(): void {
props.elapsed++;
}
target: Time // qmllint disable incompatible-type
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import Quickshell
import "../scripts/fzf.js" as Fzf
import "../scripts/fuzzysort.js" as Fuzzy
import QtQuick
import Quickshell
Singleton {
property var extraOpts: ({})
+4 -1
View File
@@ -136,7 +136,10 @@ CustomRect {
wrapMode: Text.WordWrap
onLinkActivated: link => {
Quickshell.execDetached(["app2unit", "-O", "--", link]);
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "-O", "--", link]);
else
Quickshell.execDetached(["xdg-open", link]);
root.visibilities.sidebar = false;
}
}
@@ -0,0 +1,290 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Components
import qs.Config
import qs.Helpers
CustomRect {
id: root
required property var props
required property PersistentProperties visibilities
Layout.fillWidth: true
color: DynamicColors.tPalette.m3surfaceContainer
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
radius: Appearance.rounding.smallest
ColumnLayout {
id: layout
anchors.fill: parent
anchors.margins: Appearance.padding.large
spacing: Appearance.spacing.normal
RowLayout {
spacing: Appearance.spacing.normal
z: 1
CustomRect {
color: Recorder.running ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3secondaryContainer
implicitHeight: {
const h = icon.implicitHeight + Appearance.padding.smaller * 2;
return h - (h % 2);
}
implicitWidth: implicitHeight
radius: Appearance.rounding.full
MaterialIcon {
id: icon
anchors.centerIn: parent
anchors.horizontalCenterOffset: -0.5
anchors.verticalCenterOffset: 1.5
color: Recorder.running ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSecondaryContainer
font.pointSize: Appearance.font.size.large
text: "screen_record"
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
CustomText {
Layout.fillWidth: true
elide: Text.ElideRight
font.pointSize: Appearance.font.size.normal
text: qsTr("Screen Recorder")
}
CustomText {
Layout.fillWidth: true
color: DynamicColors.palette.m3onSurfaceVariant
elide: Text.ElideRight
font.pointSize: Appearance.font.size.small
text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off")
}
}
CustomSplitButton {
active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0]
disabled: Recorder.running
menuItems: [
MenuItem {
activeText: qsTr("Fullscreen")
icon: "fullscreen"
text: qsTr("Record fullscreen")
onClicked: Recorder.start()
},
MenuItem {
activeText: qsTr("Region")
icon: "screenshot_region"
text: qsTr("Record region")
onClicked: Recorder.start(["-r"])
},
MenuItem {
activeText: qsTr("Fullscreen")
icon: "select_to_speak"
text: qsTr("Record fullscreen with sound")
onClicked: Recorder.start(["-s"])
},
MenuItem {
activeText: qsTr("Region")
icon: "volume_up"
text: qsTr("Record region with sound")
onClicked: Recorder.start(["-s", "-r"])
}
]
menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text
}
}
Loader {
id: listOrControls
property bool running: Recorder.running
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
asynchronous: true
sourceComponent: running ? recordingControls : recordingList
Behavior on Layout.preferredHeight {
id: locHeightAnim
enabled: false
Anim {
}
}
Behavior on running {
SequentialAnimation {
ParallelAnimation {
Anim {
duration: Appearance.anim.durations.small
easing: Appearance.anim.curves.standardAccel
property: "scale"
target: listOrControls
to: 0.7
}
Anim {
duration: Appearance.anim.durations.small
easing: Appearance.anim.curves.standardAccel
property: "opacity"
target: listOrControls
to: 0
}
}
PropertyAction {
property: "enabled"
target: locHeightAnim
value: true
}
PropertyAction {
}
PropertyAction {
property: "enabled"
target: locHeightAnim
value: false
}
ParallelAnimation {
Anim {
duration: Appearance.anim.durations.small
easing: Appearance.anim.curves.standardDecel
property: "scale"
target: listOrControls
to: 1
}
Anim {
duration: Appearance.anim.durations.small
easing: Appearance.anim.curves.standardDecel
property: "opacity"
target: listOrControls
to: 1
}
}
}
}
}
}
Component {
id: recordingList
RecordingList {
props: root.props
visibilities: root.visibilities
}
}
Component {
id: recordingControls
RowLayout {
spacing: Appearance.spacing.normal
CustomRect {
color: Recorder.paused ? DynamicColors.palette.m3tertiary : DynamicColors.palette.m3error
implicitHeight: recText.implicitHeight + Appearance.padding.smaller * 2
implicitWidth: recText.implicitWidth + Appearance.padding.normal * 2
radius: Appearance.rounding.full
Behavior on implicitWidth {
Anim {
}
}
SequentialAnimation on opacity {
alwaysRunToEnd: true
loops: Animation.Infinite
running: !Recorder.paused
Anim {
duration: Appearance.anim.durations.large
easing: Appearance.anim.curves.emphasizedAccel
from: 1
to: 0
}
Anim {
duration: Appearance.anim.durations.extraLarge
easing: Appearance.anim.curves.emphasizedDecel
from: 0
to: 1
}
}
CustomText {
id: recText
anchors.centerIn: parent
animate: true
color: Recorder.paused ? DynamicColors.palette.m3onTertiary : DynamicColors.palette.m3onError
font.family: Appearance.font.family.mono
text: Recorder.paused ? "PAUSED" : "REC"
}
}
CustomText {
font.pointSize: Appearance.font.size.normal
text: {
const elapsed = Recorder.elapsed;
const hours = Math.floor(elapsed / 3600);
const mins = Math.floor((elapsed % 3600) / 60);
const secs = Math.floor(elapsed % 60).toString().padStart(2, "0");
let time;
if (hours > 0)
time = `${hours}:${mins.toString().padStart(2, "0")}:${secs}`;
else
time = `${mins}:${secs}`;
return qsTr("Recording for %1").arg(time);
}
}
Item {
Layout.fillWidth: true
}
IconButton {
checked: Recorder.paused
font.pointSize: Appearance.font.size.large
icon: Recorder.paused ? "play_arrow" : "pause"
label.animate: true
toggle: true
type: IconButton.Tonal
onClicked: {
Recorder.togglePause();
internalChecked = Recorder.paused;
}
}
IconButton {
font.pointSize: Appearance.font.size.large
icon: "stop"
inactiveColour: DynamicColors.palette.m3error
inactiveOnColour: DynamicColors.palette.m3onError
onClicked: Recorder.stop()
}
}
}
}
@@ -0,0 +1,226 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import ZShell.Models
import qs.Components
import qs.Helpers
import qs.Paths
import qs.Config
ColumnLayout {
id: root
required property var props
required property PersistentProperties visibilities
spacing: 0
WrapperMouseArea {
Layout.fillWidth: true
cursorShape: Qt.PointingHandCursor
onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded
RowLayout {
spacing: Appearance.spacing.smaller
MaterialIcon {
Layout.alignment: Qt.AlignVCenter
font.pointSize: Appearance.font.size.large
text: "list"
}
CustomText {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
font.pointSize: Appearance.font.size.normal
text: qsTr("Recordings")
}
IconButton {
icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more"
label.animate: true
type: IconButton.Text
onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded
}
}
}
CustomListView {
id: list
Layout.fillWidth: true
Layout.rightMargin: -Appearance.spacing.small
clip: true
implicitHeight: (Appearance.font.size.larger + Appearance.padding.small) * (root.props.recordingListExpanded ? 10 : 3)
CustomScrollBar.vertical: CustomScrollBar {
flickable: list
}
add: Transition {
Anim {
from: 0
property: "opacity"
to: 1
}
Anim {
from: 0.5
property: "scale"
to: 1
}
}
delegate: RowLayout {
id: recording
property string baseName
required property FileSystemEntry modelData
anchors.left: list.contentItem.left
anchors.right: list.contentItem.right
anchors.rightMargin: Appearance.spacing.small
spacing: Appearance.spacing.small / 2
Component.onCompleted: baseName = modelData.baseName
CustomText {
Layout.fillWidth: true
Layout.rightMargin: Appearance.spacing.small / 2
color: DynamicColors.palette.m3onSurfaceVariant
elide: Text.ElideRight
text: {
const time = recording.baseName;
const matches = time.match(/^recording_(\d{4})(\d{2})(\d{2})_(\d{2})-(\d{2})-(\d{2})/);
if (!matches)
return time;
const date = new Date(...matches.slice(1));
date.setMonth(date.getMonth() - 1);
return qsTr("Recording at %1").arg(Qt.formatDateTime(date, Qt.locale()));
}
}
IconButton {
icon: "play_arrow"
type: IconButton.Text
onClicked: {
root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
}
}
IconButton {
icon: "folder"
type: IconButton.Text
onClicked: {
root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
}
}
}
displaced: Transition {
Anim {
properties: "opacity,scale"
to: 1
}
Anim {
property: "y"
}
}
Behavior on implicitHeight {
Anim {
}
}
model: FileSystemModel {
nameFilters: ["recording_*.mp4"]
path: Paths.recsdir
sortReverse: true
}
remove: Transition {
Anim {
property: "opacity"
to: 0
}
Anim {
property: "scale"
to: 0.5
}
}
Loader {
active: opacity > 0
anchors.centerIn: parent
asynchronous: true
opacity: list.count === 0 ? 1 : 0
Behavior on opacity {
Anim {
}
}
sourceComponent: ColumnLayout {
spacing: Appearance.spacing.small
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0
color: DynamicColors.palette.m3outline
font.pointSize: Appearance.font.size.extraLarge
opacity: root.props.recordingListExpanded ? 1 : 0
scale: root.props.recordingListExpanded ? 1 : 0
text: "scan_delete"
Behavior on Layout.preferredHeight {
Anim {
}
}
Behavior on opacity {
Anim {
}
}
Behavior on scale {
Anim {
}
}
}
RowLayout {
spacing: Appearance.spacing.smaller
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0
color: DynamicColors.palette.m3outline
opacity: !root.props.recordingListExpanded ? 1 : 0
scale: !root.props.recordingListExpanded ? 1 : 0
text: "scan_delete"
Behavior on Layout.preferredWidth {
Anim {
}
}
Behavior on opacity {
Anim {
}
}
Behavior on scale {
Anim {
}
}
}
CustomText {
color: DynamicColors.palette.m3outline
text: qsTr("No recordings found")
}
}
}
}
}
}
@@ -1,13 +1,14 @@
import qs.Modules.Notifications.Sidebar.Utils.Cards
import qs.Config
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Modules.Notifications.Sidebar.Utils.Cards
import qs.Config
Item {
id: root
required property Item popouts
required property var props
required property PersistentProperties props
required property var visibilities
implicitHeight: layout.implicitHeight
@@ -22,6 +23,12 @@ Item {
IdleInhibit {
}
Record {
props: root.props
visibilities: root.visibilities
z: 1
}
Toggles {
popouts: root.popouts
visibilities: root.visibilities
+8 -6
View File
@@ -100,12 +100,14 @@ Item {
icon: `brightness_${(Math.round(value * 6) + 1)}`
value: root.brightness
onMoved: {
if (Config.osd.allMonBrightness) {
root.monitor?.setBrightness(value);
} else {
for (const mon of Brightness.monitors) {
mon.setBrightness(value);
onPressedChanged: {
if (!pressed) {
if (Config.osd.allMonBrightness) {
for (const mon of Brightness.monitors) {
mon.setBrightness(value);
}
} else {
root.monitor?.setBrightness(value);
}
}
}
+6
View File
@@ -116,6 +116,12 @@ Item {
key: "updates"
name: "Updates"
}
ListElement {
icon: "extension"
key: "plugins"
name: "Extensions"
}
}
CustomClippingRect {
+111 -111
View File
@@ -19,8 +19,8 @@ SettingsPage {
}
SettingSpinBox {
name: "Media update interval"
min: 0
name: "Media update interval"
object: Config.dashboard
setting: "mediaUpdateInterval"
step: 50
@@ -30,8 +30,8 @@ SettingsPage {
}
SettingSpinBox {
name: "Resource update interval"
min: 0
name: "Resource update interval"
object: Config.dashboard
setting: "resourceUpdateInterval"
step: 50
@@ -41,8 +41,8 @@ SettingsPage {
}
SettingSpinBox {
name: "Drag threshold"
min: 0
name: "Drag threshold"
object: Config.dashboard
setting: "dragThreshold"
}
@@ -107,112 +107,112 @@ SettingsPage {
}
}
SettingsSection {
sectionId: "Layout Sizes"
SettingsHeader {
name: "Layout Sizes"
}
SettingReadOnly {
name: "Tab indicator height"
value: String(Config.dashboard.sizes.tabIndicatorHeight)
}
Separator {
}
SettingReadOnly {
name: "Tab indicator spacing"
value: String(Config.dashboard.sizes.tabIndicatorSpacing)
}
Separator {
}
SettingReadOnly {
name: "Info width"
value: String(Config.dashboard.sizes.infoWidth)
}
Separator {
}
SettingReadOnly {
name: "Info icon size"
value: String(Config.dashboard.sizes.infoIconSize)
}
Separator {
}
SettingReadOnly {
name: "Date time width"
value: String(Config.dashboard.sizes.dateTimeWidth)
}
Separator {
}
SettingReadOnly {
name: "Media width"
value: String(Config.dashboard.sizes.mediaWidth)
}
Separator {
}
SettingReadOnly {
name: "Media progress sweep"
value: String(Config.dashboard.sizes.mediaProgressSweep)
}
Separator {
}
SettingReadOnly {
name: "Media progress thickness"
value: String(Config.dashboard.sizes.mediaProgressThickness)
}
Separator {
}
SettingReadOnly {
name: "Resource progress thickness"
value: String(Config.dashboard.sizes.resourceProgessThickness)
}
Separator {
}
SettingReadOnly {
name: "Weather width"
value: String(Config.dashboard.sizes.weatherWidth)
}
Separator {
}
SettingReadOnly {
name: "Media cover art size"
value: String(Config.dashboard.sizes.mediaCoverArtSize)
}
Separator {
}
SettingReadOnly {
name: "Media visualiser size"
value: String(Config.dashboard.sizes.mediaVisualiserSize)
}
Separator {
}
SettingReadOnly {
name: "Resource size"
value: String(Config.dashboard.sizes.resourceSize)
}
}
// SettingsSection {
// sectionId: "Layout Sizes"
//
// SettingsHeader {
// name: "Layout Sizes"
// }
//
// SettingReadOnly {
// name: "Tab indicator height"
// value: String(Config.dashboard.sizes.tabIndicatorHeight)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Tab indicator spacing"
// value: String(Config.dashboard.sizes.tabIndicatorSpacing)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Info width"
// value: String(Config.dashboard.sizes.infoWidth)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Info icon size"
// value: String(Config.dashboard.sizes.infoIconSize)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Date time width"
// value: String(Config.dashboard.sizes.dateTimeWidth)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Media width"
// value: String(Config.dashboard.sizes.mediaWidth)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Media progress sweep"
// value: String(Config.dashboard.sizes.mediaProgressSweep)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Media progress thickness"
// value: String(Config.dashboard.sizes.mediaProgressThickness)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Resource progress thickness"
// value: String(Config.dashboard.sizes.resourceProgessThickness)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Weather width"
// value: String(Config.dashboard.sizes.weatherWidth)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Media cover art size"
// value: String(Config.dashboard.sizes.mediaCoverArtSize)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Media visualiser size"
// value: String(Config.dashboard.sizes.mediaVisualiserSize)
// }
//
// Separator {
// }
//
// SettingReadOnly {
// name: "Resource size"
// value: String(Config.dashboard.sizes.resourceSize)
// }
// }
}
@@ -103,6 +103,18 @@ SettingsPage {
}
}
SettingsSection {
sectionId: "Greeter"
SettingsHeader {
name: "Greeter"
}
SettingsIconButton {
name: "Install wallpaper and color scheme to greeter"
}
}
SettingsSection {
sectionId: "Idle"
@@ -9,6 +9,8 @@ import qs.Modules.Settings.Controls
ColumnLayout {
id: root
property bool shouldBeActive: true
function addTimeoutEntry() {
let list = [...Config.general.idle.timeouts];
@@ -40,8 +42,26 @@ ColumnLayout {
Config.save();
}
Layout.fillWidth: true
anchors.left: parent.left
anchors.right: parent.right
height: shouldBeActive ? implicitHeight : 0
opacity: shouldBeActive ? 1 : 0
scale: shouldBeActive ? 1 : 0.8
spacing: Appearance.spacing.smaller
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
Behavior on scale {
Anim {
}
}
Behavior on y {
Anim {
}
}
Settings {
name: "Idle Monitors"
@@ -52,6 +72,8 @@ ColumnLayout {
SettingList {
Layout.fillWidth: true
anchors.left: undefined
anchors.right: undefined
onAddActiveActionRequested: {
root.updateTimeoutEntry(index, "activeAction", "");
+18
View File
@@ -0,0 +1,18 @@
import qs.Modules.Settings.Controls
import qs.Config
SettingsPage {
SettingsSection {
sectionId: "Plugins"
SettingsHeader {
name: "Plugins"
}
SettingBarEntryList {
name: "Enable or disable plugins"
object: Config.plugins
setting: "entries"
}
}
}
+97 -97
View File
@@ -19,8 +19,8 @@ SettingsPage {
}
SettingSpinBox {
name: "Max toasts"
min: 1
name: "Max toasts"
object: Config.utilities
setting: "maxToasts"
}
@@ -29,8 +29,8 @@ SettingsPage {
}
SettingSpinBox {
name: "Panel width"
min: 1
name: "Panel width"
object: Config.utilities.sizes
setting: "width"
}
@@ -39,8 +39,8 @@ SettingsPage {
}
SettingSpinBox {
name: "Toast width"
min: 1
name: "Toast width"
object: Config.utilities.sizes
setting: "toastWidth"
}
@@ -77,100 +77,100 @@ SettingsPage {
setting: "gameModeChanged"
}
Separator {
}
SettingSwitch {
name: "Do not disturb changed"
object: Config.utilities.toasts
setting: "dndChanged"
}
Separator {
}
SettingSwitch {
name: "Audio output changed"
object: Config.utilities.toasts
setting: "audioOutputChanged"
}
Separator {
}
SettingSwitch {
name: "Audio input changed"
object: Config.utilities.toasts
setting: "audioInputChanged"
}
Separator {
}
SettingSwitch {
name: "Caps lock changed"
object: Config.utilities.toasts
setting: "capsLockChanged"
}
Separator {
}
SettingSwitch {
name: "Num lock changed"
object: Config.utilities.toasts
setting: "numLockChanged"
}
Separator {
}
SettingSwitch {
name: "Keyboard layout changed"
object: Config.utilities.toasts
setting: "kbLayoutChanged"
}
Separator {
}
SettingSwitch {
name: "VPN changed"
object: Config.utilities.toasts
setting: "vpnChanged"
}
Separator {
}
SettingSwitch {
name: "Now playing"
object: Config.utilities.toasts
setting: "nowPlaying"
}
// Separator {
// }
//
// SettingSwitch {
// name: "Do not disturb changed"
// object: Config.utilities.toasts
// setting: "dndChanged"
// }
//
// Separator {
// }
//
// SettingSwitch {
// name: "Audio output changed"
// object: Config.utilities.toasts
// setting: "audioOutputChanged"
// }
//
// Separator {
// }
//
// SettingSwitch {
// name: "Audio input changed"
// object: Config.utilities.toasts
// setting: "audioInputChanged"
// }
//
// Separator {
// }
//
// SettingSwitch {
// name: "Caps lock changed"
// object: Config.utilities.toasts
// setting: "capsLockChanged"
// }
//
// Separator {
// }
//
// SettingSwitch {
// name: "Num lock changed"
// object: Config.utilities.toasts
// setting: "numLockChanged"
// }
//
// Separator {
// }
//
// SettingSwitch {
// name: "Keyboard layout changed"
// object: Config.utilities.toasts
// setting: "kbLayoutChanged"
// }
//
// Separator {
// }
//
// SettingSwitch {
// name: "VPN changed"
// object: Config.utilities.toasts
// setting: "vpnChanged"
// }
//
// Separator {
// }
//
// SettingSwitch {
// name: "Now playing"
// object: Config.utilities.toasts
// setting: "nowPlaying"
// }
}
SettingsSection {
sectionId: "VPN"
SettingsHeader {
name: "VPN"
}
SettingSwitch {
name: "Enable VPN integration"
object: Config.utilities.vpn
setting: "enabled"
}
Separator {
}
SettingStringList {
name: "Provider"
addLabel: qsTr("Add VPN provider")
object: Config.utilities.vpn
setting: "provider"
}
}
// SettingsSection {
// sectionId: "VPN"
//
// SettingsHeader {
// name: "VPN"
// }
//
// SettingSwitch {
// name: "Enable VPN integration"
// object: Config.utilities.vpn
// setting: "enabled"
// }
//
// Separator {
// }
//
// SettingStringList {
// name: "Provider"
// addLabel: qsTr("Add VPN provider")
// object: Config.utilities.vpn
// setting: "provider"
// }
// }
}
+10 -1
View File
@@ -79,6 +79,8 @@ Item {
stack.push(screenshot);
else if (currentCategory === "updates")
stack.push(updates);
else if (currentCategory === "plugins")
stack.push(plugins);
}
target: root
@@ -134,7 +136,7 @@ Item {
anchors.right: parent.right
anchors.top: searchBar.bottom
anchors.topMargin: Appearance.spacing.smaller
color: DynamicColors.tPalette.m3surfaceContainer
color: DynamicColors.tPalette.m3surfaceContainerLowest
radius: Appearance.rounding.normal
StackView {
@@ -245,4 +247,11 @@ Item {
Cat.SystemUpdates {
}
}
Component {
id: plugins
Cat.Plugins {
}
}
}
@@ -127,6 +127,9 @@ ColumnLayout {
}
Separator {
Layout.fillWidth: true
anchors.left: undefined
anchors.right: undefined
}
RowLayout {
@@ -207,6 +210,8 @@ ColumnLayout {
StringListEditor {
Layout.fillWidth: true
addLabel: qsTr("Add command argument")
anchors.left: undefined
anchors.right: undefined
values: [...(modelData.command ?? [])]
onListEdited: function (values) {
@@ -215,6 +220,9 @@ ColumnLayout {
}
Separator {
Layout.fillWidth: true
anchors.left: undefined
anchors.right: undefined
}
RowLayout {
@@ -233,6 +241,9 @@ ColumnLayout {
}
Separator {
Layout.fillWidth: true
anchors.left: undefined
anchors.right: undefined
}
RowLayout {
+120 -108
View File
@@ -6,7 +6,7 @@ import qs.Components
import qs.Config
import qs.Helpers
ColumnLayout {
CustomRect {
id: root
readonly property bool highlighted: SettingsHighlight.highlightedSetting === name
@@ -43,10 +43,9 @@ ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
height: shouldBeActive ? implicitHeight : 0
height: shouldBeActive ? layout.implicitHeight : 0
opacity: shouldBeActive ? 1 : 0
scale: shouldBeActive ? 1 : 0.8
spacing: Appearance.spacing.smaller
visible: opacity > 0
Behavior on opacity {
@@ -77,115 +76,128 @@ ColumnLayout {
}
}
CustomText {
Layout.fillWidth: true
font.pointSize: Appearance.font.size.larger
text: root.name
}
ColumnLayout {
id: layout
Repeater {
model: [...root.object[root.setting]]
Item {
required property int index
required property var modelData
Layout.fillWidth: true
Layout.preferredHeight: layout.implicitHeight + Appearance.padding.smaller * 2
CustomRect {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: -(Appearance.spacing.smaller / 2)
color: DynamicColors.tPalette.m3outlineVariant
implicitHeight: 1
visible: index !== 0
}
ColumnLayout {
id: layout
anchors.left: parent.left
anchors.margins: Appearance.padding.small
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Appearance.spacing.small
RowLayout {
Layout.fillWidth: true
CustomText {
Layout.fillWidth: true
text: qsTr("From")
}
CustomRect {
Layout.fillWidth: true
Layout.preferredHeight: 33
color: DynamicColors.tPalette.m3surfaceContainerHigh
radius: Appearance.rounding.full
CustomTextField {
anchors.fill: parent
anchors.leftMargin: Appearance.padding.normal
anchors.rightMargin: Appearance.padding.normal
text: modelData.from ?? ""
onEditingFinished: root.updateAlias(index, "from", text)
}
}
IconButton {
font.pointSize: Appearance.font.size.large
icon: "delete"
type: IconButton.Tonal
onClicked: root.removeAlias(index)
}
}
RowLayout {
Layout.fillWidth: true
CustomText {
Layout.fillWidth: true
text: qsTr("To")
}
CustomRect {
Layout.fillWidth: true
Layout.preferredHeight: 33
color: DynamicColors.tPalette.m3surface
radius: Appearance.rounding.small
CustomTextField {
anchors.fill: parent
anchors.leftMargin: Appearance.padding.normal
anchors.rightMargin: Appearance.padding.normal
text: modelData.to ?? ""
onEditingFinished: root.updateAlias(index, "to", text)
}
}
}
}
}
}
RowLayout {
Layout.fillWidth: true
IconButton {
font.pointSize: Appearance.font.size.large
icon: "add"
onClicked: root.addAlias()
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Appearance.spacing.smaller
CustomText {
Layout.fillWidth: true
text: qsTr("Add alias")
font.pointSize: Appearance.font.size.larger
text: root.name
}
Repeater {
model: [...root.object[root.setting]]
Item {
required property int index
required property var modelData
Layout.fillWidth: true
Layout.preferredHeight: layout.implicitHeight + Appearance.padding.smaller * 2
CustomRect {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: -(Appearance.spacing.smaller / 2)
color: DynamicColors.tPalette.m3outlineVariant
implicitHeight: 1
visible: index !== 0
}
ColumnLayout {
id: layout
anchors.left: parent.left
anchors.margins: Appearance.padding.small
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Appearance.spacing.small
RowLayout {
Layout.fillWidth: true
CustomText {
Layout.fillWidth: true
text: qsTr("From")
}
CustomRect {
Layout.preferredHeight: 33
Layout.preferredWidth: Math.max(Math.min(fromTextField.contentWidth + Appearance.padding.large * 2, 550), 50)
color: DynamicColors.tPalette.m3surfaceContainerHigh
radius: Appearance.rounding.full
CustomTextField {
id: fromTextField
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
implicitWidth: Math.min(contentWidth + Appearance.padding.normal * 2, 550)
text: modelData.from ?? ""
onEditingFinished: root.updateAlias(index, "from", text)
}
}
IconButton {
font.pointSize: Appearance.font.size.large
icon: "delete"
type: IconButton.Tonal
onClicked: root.removeAlias(index)
}
}
RowLayout {
Layout.fillWidth: true
CustomText {
Layout.fillWidth: true
text: qsTr("To")
}
CustomRect {
Layout.preferredHeight: 33
Layout.preferredWidth: Math.max(Math.min(toTextField.contentWidth + Appearance.padding.large * 2, 550), 50)
color: DynamicColors.tPalette.m3surfaceContainerHigh
radius: Appearance.rounding.full
CustomTextField {
id: toTextField
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
implicitWidth: Math.min(contentWidth + Appearance.padding.normal * 2, 550)
text: modelData.to ?? ""
onEditingFinished: root.updateAlias(index, "to", text)
}
}
}
}
}
}
RowLayout {
Layout.fillWidth: true
IconButton {
font.pointSize: Appearance.font.size.large
icon: "add"
onClicked: root.addAlias()
}
CustomText {
Layout.fillWidth: true
text: qsTr("Add alias")
}
}
}
}
@@ -194,6 +194,9 @@ Item {
}
Separator {
Layout.fillWidth: true
anchors.left: undefined
anchors.right: undefined
}
RowLayout {
@@ -225,6 +228,9 @@ Item {
}
Separator {
Layout.fillWidth: true
anchors.left: undefined
anchors.right: undefined
}
Item {
@@ -0,0 +1,83 @@
import Quickshell
import QtQuick
import QtQuick.Layouts
import qs.Paths
import qs.Components
import qs.Config
import qs.Helpers
Item {
id: root
property alias button: iButton
readonly property bool highlighted: SettingsHighlight.highlightedSetting === name
required property string name
property bool shouldBeActive: true
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: shouldBeActive ? row.implicitHeight + Appearance.padding.smaller * 2 : 0
opacity: shouldBeActive ? 1 : 0
scale: shouldBeActive ? 1 : 0.8
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
Behavior on scale {
Anim {
}
}
Behavior on y {
Anim {
}
}
Rectangle {
anchors.fill: parent
anchors.margins: -Appearance.padding.smaller
color: DynamicColors.palette.m3primaryContainer
opacity: root.highlighted ? 0.5 : 0
radius: Appearance.rounding.small
Behavior on opacity {
Anim {
duration: Appearance.anim.durations.normal
}
}
}
RowLayout {
id: row
anchors.left: parent.left
anchors.margins: Appearance.padding.small
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
CustomText {
id: text
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
Layout.fillWidth: true
font.pointSize: Appearance.font.size.larger
text: root.name
}
IconButton {
id: iButton
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
icon: "download"
onClicked: {
const lockBg = `${Paths.state}/lockscreen_bg.png`;
const scheme = `${Paths.state}/scheme.json`;
const face = `${Paths.home}/.face`;
const destination = "/etc/zshell-greeter/images";
Quickshell.execDetached(["pkexec", "sh", "-c", `mkdir -p ${destination}; cp ${lockBg} ${destination}; cp ${scheme} /etc/zshell-greeter; cp ${face} ${destination}`]);
}
}
}
}
+302 -311
View File
@@ -7,473 +7,464 @@
namespace ZShell::models {
FileSystemEntry::FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent)
: QObject(parent)
, m_fileInfo(path)
, m_path(path)
, m_relativePath(relativePath)
, m_isImageInitialised(false)
, m_mimeTypeInitialised(false) {}
: QObject(parent)
, m_fileInfo(path)
, m_path(path)
, m_relativePath(relativePath)
, m_isImageInitialised(false)
, m_mimeTypeInitialised(false) {
}
QString FileSystemEntry::path() const {
return m_path;
return m_path;
};
QString FileSystemEntry::relativePath() const {
return m_relativePath;
return m_relativePath;
};
QString FileSystemEntry::name() const {
return m_fileInfo.fileName();
return m_fileInfo.fileName();
};
QString FileSystemEntry::baseName() const {
return m_fileInfo.baseName();
return m_fileInfo.baseName();
};
QString FileSystemEntry::parentDir() const {
return m_fileInfo.absolutePath();
return m_fileInfo.absolutePath();
};
QString FileSystemEntry::suffix() const {
return m_fileInfo.completeSuffix();
return m_fileInfo.completeSuffix();
};
qint64 FileSystemEntry::size() const {
return m_fileInfo.size();
return m_fileInfo.size();
};
bool FileSystemEntry::isDir() const {
return m_fileInfo.isDir();
return m_fileInfo.isDir();
};
bool FileSystemEntry::isImage() const {
if (!m_isImageInitialised) {
QImageReader reader(m_path);
m_isImage = reader.canRead();
m_isImageInitialised = true;
}
return m_isImage;
if (!m_isImageInitialised) {
QImageReader reader(m_path);
m_isImage = reader.canRead();
m_isImageInitialised = true;
}
return m_isImage;
}
QString FileSystemEntry::mimeType() const {
if (!m_mimeTypeInitialised) {
const QMimeDatabase db;
m_mimeType = db.mimeTypeForFile(m_path).name();
m_mimeTypeInitialised = true;
}
return m_mimeType;
if (!m_mimeTypeInitialised) {
static const QMimeDatabase s_db;
m_mimeType = s_db.mimeTypeForFile(m_path).name();
m_mimeTypeInitialised = true;
}
return m_mimeType;
}
void FileSystemEntry::updateRelativePath(const QDir& dir) {
const auto relPath = dir.relativeFilePath(m_path);
if (m_relativePath != relPath) {
m_relativePath = relPath;
emit relativePathChanged();
}
const auto relPath = dir.relativeFilePath(m_path);
if (m_relativePath != relPath) {
m_relativePath = relPath;
emit relativePathChanged();
}
}
FileSystemModel::FileSystemModel(QObject* parent)
: QAbstractListModel(parent)
, m_recursive(false)
, m_watchChanges(true)
, m_showHidden(false)
, m_filter(NoFilter) {
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::watchDirIfRecursive);
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::updateEntriesForDir);
: QAbstractListModel(parent)
, m_recursive(false)
, m_watchChanges(true)
, m_showHidden(false)
, m_filter(NoFilter) {
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::watchDirIfRecursive);
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &FileSystemModel::updateEntriesForDir);
}
int FileSystemModel::rowCount(const QModelIndex& parent) const {
if (parent != QModelIndex()) {
return 0;
}
return static_cast<int>(m_entries.size());
if (parent != QModelIndex()) {
return 0;
}
return static_cast<int>(m_entries.size());
}
QVariant FileSystemModel::data(const QModelIndex& index, int role) const {
if (role != Qt::UserRole || !index.isValid() || index.row() >= m_entries.size()) {
return QVariant();
}
return QVariant::fromValue(m_entries.at(index.row()));
if (role != Qt::UserRole || !index.isValid() || index.row() >= m_entries.size()) {
return QVariant();
}
return QVariant::fromValue(m_entries.at(index.row()));
}
QHash<int, QByteArray> FileSystemModel::roleNames() const {
return { { Qt::UserRole, "modelData" } };
return { { Qt::UserRole, "modelData" } };
}
QString FileSystemModel::path() const {
return m_path;
return m_path;
}
void FileSystemModel::setPath(const QString& path) {
if (m_path == path) {
return;
}
if (m_path == path) {
return;
}
m_path = path;
emit pathChanged();
m_path = path;
emit pathChanged();
m_dir.setPath(m_path);
m_dir.setPath(m_path);
for (const auto& entry : std::as_const(m_entries)) {
entry->updateRelativePath(m_dir);
}
for (const auto& entry : std::as_const(m_entries)) {
entry->updateRelativePath(m_dir);
}
update();
update();
}
bool FileSystemModel::recursive() const {
return m_recursive;
return m_recursive;
}
void FileSystemModel::setRecursive(bool recursive) {
if (m_recursive == recursive) {
return;
}
if (m_recursive == recursive) {
return;
}
m_recursive = recursive;
emit recursiveChanged();
m_recursive = recursive;
emit recursiveChanged();
update();
update();
}
bool FileSystemModel::watchChanges() const {
return m_watchChanges;
return m_watchChanges;
}
void FileSystemModel::setWatchChanges(bool watchChanges) {
if (m_watchChanges == watchChanges) {
return;
}
if (m_watchChanges == watchChanges) {
return;
}
m_watchChanges = watchChanges;
emit watchChangesChanged();
m_watchChanges = watchChanges;
emit watchChangesChanged();
update();
update();
}
bool FileSystemModel::showHidden() const {
return m_showHidden;
return m_showHidden;
}
void FileSystemModel::setShowHidden(bool showHidden) {
if (m_showHidden == showHidden) {
return;
}
if (m_showHidden == showHidden) {
return;
}
m_showHidden = showHidden;
emit showHiddenChanged();
m_showHidden = showHidden;
emit showHiddenChanged();
update();
update();
}
bool FileSystemModel::sortReverse() const {
return m_sortReverse;
return m_sortReverse;
}
void FileSystemModel::setSortReverse(bool sortReverse) {
if (m_sortReverse == sortReverse) {
return;
}
if (m_sortReverse == sortReverse) {
return;
}
m_sortReverse = sortReverse;
emit sortReverseChanged();
m_sortReverse = sortReverse;
emit sortReverseChanged();
update();
update();
}
FileSystemModel::Filter FileSystemModel::filter() const {
return m_filter;
return m_filter;
}
void FileSystemModel::setFilter(Filter filter) {
if (m_filter == filter) {
return;
}
if (m_filter == filter) {
return;
}
m_filter = filter;
emit filterChanged();
m_filter = filter;
emit filterChanged();
update();
update();
}
QStringList FileSystemModel::nameFilters() const {
return m_nameFilters;
return m_nameFilters;
}
void FileSystemModel::setNameFilters(const QStringList& nameFilters) {
if (m_nameFilters == nameFilters) {
return;
}
if (m_nameFilters == nameFilters) {
return;
}
m_nameFilters = nameFilters;
emit nameFiltersChanged();
m_nameFilters = nameFilters;
emit nameFiltersChanged();
update();
update();
}
QQmlListProperty<FileSystemEntry> FileSystemModel::entries() {
return QQmlListProperty<FileSystemEntry>(this, &m_entries);
return QQmlListProperty<FileSystemEntry>(this, &m_entries);
}
void FileSystemModel::watchDirIfRecursive(const QString& path) {
if (m_recursive && m_watchChanges) {
const auto currentDir = m_dir;
const bool showHidden = m_showHidden;
const auto future = QtConcurrent::run([showHidden, path]() {
QDir::Filters filters = QDir::Dirs | QDir::NoDotAndDotDot;
if (showHidden) {
filters |= QDir::Hidden;
}
if (m_recursive && m_watchChanges) {
const auto currentDir = m_dir;
const bool showHidden = m_showHidden;
auto future = QtConcurrent::run([showHidden, path]() {
QDir::Filters filters = QDir::Dirs | QDir::NoDotAndDotDot;
if (showHidden) {
filters |= QDir::Hidden;
}
QDirIterator iter(path, filters, QDirIterator::Subdirectories);
QStringList dirs;
while (iter.hasNext()) {
dirs << iter.next();
}
return dirs;
});
const auto watcher = new QFutureWatcher<QStringList>(this);
connect(watcher, &QFutureWatcher<QStringList>::finished, this, [currentDir, showHidden, watcher, this]() {
const auto paths = watcher->result();
if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) {
// Ignore if dir or showHidden has changed
m_watcher.addPaths(paths);
}
watcher->deleteLater();
});
watcher->setFuture(future);
}
QDirIterator iter(path, filters, QDirIterator::Subdirectories);
QStringList dirs;
while (iter.hasNext()) {
dirs << iter.next();
}
return dirs;
});
future.then(this, [currentDir, showHidden, this](const QStringList& paths) {
if (currentDir == m_dir && showHidden == m_showHidden && !paths.isEmpty()) {
// Ignore if dir or showHidden has changed
m_watcher.addPaths(paths);
}
});
}
}
void FileSystemModel::update() {
updateWatcher();
updateEntries();
updateWatcher();
updateEntries();
}
void FileSystemModel::updateWatcher() {
if (!m_watcher.directories().isEmpty()) {
m_watcher.removePaths(m_watcher.directories());
}
if (!m_watcher.directories().isEmpty()) {
m_watcher.removePaths(m_watcher.directories());
}
if (!m_watchChanges || m_path.isEmpty()) {
return;
}
if (!m_watchChanges || m_path.isEmpty()) {
return;
}
m_watcher.addPath(m_path);
watchDirIfRecursive(m_path);
m_watcher.addPath(m_path);
watchDirIfRecursive(m_path);
}
void FileSystemModel::updateEntries() {
if (m_path.isEmpty()) {
if (!m_entries.isEmpty()) {
beginResetModel();
qDeleteAll(m_entries);
m_entries.clear();
endResetModel();
emit entriesChanged();
}
if (m_path.isEmpty()) {
if (!m_entries.isEmpty()) {
beginResetModel();
qDeleteAll(m_entries);
m_entries.clear();
endResetModel();
emit entriesChanged();
}
return;
}
return;
}
for (auto& future : m_futures) {
future.cancel();
}
m_futures.clear();
for (auto& future : m_futures) {
future.cancel();
}
m_futures.clear();
updateEntriesForDir(m_path);
updateEntriesForDir(m_path);
}
void FileSystemModel::updateEntriesForDir(const QString& dir) {
const auto recursive = m_recursive;
const auto showHidden = m_showHidden;
const auto filter = m_filter;
const auto nameFilters = m_nameFilters;
const auto recursive = m_recursive;
const auto showHidden = m_showHidden;
const auto filter = m_filter;
const auto nameFilters = m_nameFilters;
QSet<QString> oldPaths;
for (const auto& entry : std::as_const(m_entries)) {
oldPaths << entry->path();
}
QSet<QString> oldPaths;
for (const auto& entry : std::as_const(m_entries)) {
oldPaths << entry->path();
}
const auto future = QtConcurrent::run([=](QPromise<QPair<QSet<QString>, QSet<QString>>>& promise) {
const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags;
auto future = QtConcurrent::run([=](QPromise<QPair<QSet<QString>, QSet<QString> > >& promise) {
const auto flags = recursive ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags;
std::optional<QDirIterator> iter;
std::optional<QDirIterator> iter;
if (filter == Images) {
QStringList extraNameFilters = nameFilters;
const auto formats = QImageReader::supportedImageFormats();
for (const auto& format : formats) {
extraNameFilters << "*." + format;
}
if (filter == Images) {
QStringList extraNameFilters = nameFilters;
const auto formats = QImageReader::supportedImageFormats();
for (const auto& format : formats) {
extraNameFilters << "*." + format;
}
QDir::Filters filters = QDir::Files;
if (showHidden) {
filters |= QDir::Hidden;
}
QDir::Filters filters = QDir::Files;
if (showHidden) {
filters |= QDir::Hidden;
}
iter.emplace(dir, extraNameFilters, filters, flags);
} else {
QDir::Filters filters;
iter.emplace(dir, extraNameFilters, filters, flags);
} else {
QDir::Filters filters;
if (filter == Files) {
filters = QDir::Files;
} else if (filter == Dirs) {
filters = QDir::Dirs | QDir::NoDotAndDotDot;
} else {
filters = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot;
}
if (filter == Files) {
filters = QDir::Files;
} else if (filter == Dirs) {
filters = QDir::Dirs | QDir::NoDotAndDotDot;
} else {
filters = QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot;
}
if (showHidden) {
filters |= QDir::Hidden;
}
if (showHidden) {
filters |= QDir::Hidden;
}
if (nameFilters.isEmpty()) {
iter.emplace(dir, filters, flags);
} else {
iter.emplace(dir, nameFilters, filters, flags);
}
}
if (nameFilters.isEmpty()) {
iter.emplace(dir, filters, flags);
} else {
iter.emplace(dir, nameFilters, filters, flags);
}
}
QSet<QString> newPaths;
while (iter->hasNext()) {
if (promise.isCanceled()) {
return;
}
QSet<QString> newPaths;
while (iter->hasNext()) {
if (promise.isCanceled()) {
return;
}
QString path = iter->next();
QString path = iter->next();
if (filter == Images) {
QImageReader reader(path);
if (!reader.canRead()) {
continue;
}
}
if (filter == Images) {
QImageReader reader(path);
if (!reader.canRead()) {
continue;
}
}
newPaths.insert(path);
}
newPaths.insert(path);
}
if (promise.isCanceled() || newPaths == oldPaths) {
return;
}
if (promise.isCanceled()) {
return;
}
promise.addResult(qMakePair(oldPaths - newPaths, newPaths - oldPaths));
});
promise.addResult(qMakePair(oldPaths - newPaths, newPaths - oldPaths));
});
if (m_futures.contains(dir)) {
m_futures[dir].cancel();
}
m_futures.insert(dir, future);
if (m_futures.contains(dir)) {
m_futures[dir].cancel();
}
m_futures.insert(dir, future);
const auto watcher = new QFutureWatcher<QPair<QSet<QString>, QSet<QString>>>(this);
connect(watcher, &QFutureWatcher<QPair<QSet<QString>, QSet<QString>>>::finished, this, [dir, watcher, this]() {
m_futures.remove(dir);
if (!watcher->future().isResultReadyAt(0)) {
watcher->deleteLater();
return;
}
const auto result = watcher->result();
applyChanges(result.first, result.second);
watcher->deleteLater();
});
watcher->setFuture(future);
future
.then(this,
[dir, this](QPair<QSet<QString>, QSet<QString> > result) {
m_futures.remove(dir);
if (!result.first.isEmpty() || !result.second.isEmpty()) {
applyChanges(result.first, result.second);
}
})
.onCanceled(this, [dir, this]() {
m_futures.remove(dir);
});
}
void FileSystemModel::applyChanges(const QSet<QString>& removedPaths, const QSet<QString>& addedPaths) {
QList<int> removedIndices;
for (int i = 0; i < m_entries.size(); ++i) {
if (removedPaths.contains(m_entries[i]->path())) {
removedIndices << i;
}
}
std::sort(removedIndices.begin(), removedIndices.end(), std::greater<int>());
QList<int> removedIndices;
for (int i = 0; i < m_entries.size(); ++i) {
if (removedPaths.contains(m_entries[i]->path())) {
removedIndices << i;
}
}
std::sort(removedIndices.begin(), removedIndices.end(), std::greater<int>());
// Batch remove old entries
int start = -1;
int end = -1;
for (int idx : std::as_const(removedIndices)) {
if (start == -1) {
start = idx;
end = idx;
} else if (idx == end - 1) {
end = idx;
} else {
beginRemoveRows(QModelIndex(), end, start);
for (int i = start; i >= end; --i) {
m_entries.takeAt(i)->deleteLater();
}
endRemoveRows();
// Batch remove old entries
int start = -1;
int end = -1;
for (int idx : std::as_const(removedIndices)) {
if (start == -1) {
start = idx;
end = idx;
} else if (idx == end - 1) {
end = idx;
} else {
beginRemoveRows(QModelIndex(), end, start);
for (int i = start; i >= end; --i) {
m_entries.takeAt(i)->deleteLater();
}
endRemoveRows();
start = idx;
end = idx;
}
}
if (start != -1) {
beginRemoveRows(QModelIndex(), end, start);
for (int i = start; i >= end; --i) {
m_entries.takeAt(i)->deleteLater();
}
endRemoveRows();
}
start = idx;
end = idx;
}
}
if (start != -1) {
beginRemoveRows(QModelIndex(), end, start);
for (int i = start; i >= end; --i) {
m_entries.takeAt(i)->deleteLater();
}
endRemoveRows();
}
// Create new entries
QList<FileSystemEntry*> newEntries;
for (const auto& path : addedPaths) {
newEntries << new FileSystemEntry(path, m_dir.relativeFilePath(path), this);
}
std::sort(newEntries.begin(), newEntries.end(), [this](const FileSystemEntry* a, const FileSystemEntry* b) {
return compareEntries(a, b);
});
// Create new entries
QList<FileSystemEntry*> newEntries;
for (const auto& path : addedPaths) {
newEntries << new FileSystemEntry(path, m_dir.relativeFilePath(path), this);
}
std::sort(newEntries.begin(), newEntries.end(), [this](const FileSystemEntry* a, const FileSystemEntry* b) {
return compareEntries(a, b);
});
// Batch insert new entries
int insertStart = -1;
QList<FileSystemEntry*> batchItems;
for (const auto& entry : std::as_const(newEntries)) {
const auto it = std::lower_bound(
m_entries.begin(), m_entries.end(), entry, [this](const FileSystemEntry* a, const FileSystemEntry* b) {
return compareEntries(a, b);
});
const auto row = static_cast<int>(it - m_entries.begin());
// Batch insert new entries
int insertStart = -1;
QList<FileSystemEntry*> batchItems;
for (const auto& entry : std::as_const(newEntries)) {
const auto it = std::lower_bound(
m_entries.begin(), m_entries.end(), entry, [this](const FileSystemEntry* a, const FileSystemEntry* b) {
return compareEntries(a, b);
});
const auto row = static_cast<int>(it - m_entries.begin());
if (insertStart == -1) {
insertStart = row;
batchItems << entry;
} else if (row == insertStart + batchItems.size()) {
batchItems << entry;
} else {
beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast<int>(batchItems.size()) - 1);
for (int i = 0; i < batchItems.size(); ++i) {
m_entries.insert(insertStart + i, batchItems[i]);
}
endInsertRows();
if (insertStart == -1) {
insertStart = row;
batchItems << entry;
} else if (row == insertStart + batchItems.size()) {
batchItems << entry;
} else {
beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast<int>(batchItems.size()) - 1);
for (int i = 0; i < batchItems.size(); ++i) {
m_entries.insert(insertStart + i, batchItems[i]);
}
endInsertRows();
insertStart = row;
batchItems.clear();
batchItems << entry;
}
}
if (!batchItems.isEmpty()) {
beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast<int>(batchItems.size()) - 1);
for (int i = 0; i < batchItems.size(); ++i) {
m_entries.insert(insertStart + i, batchItems[i]);
}
endInsertRows();
}
insertStart = row;
batchItems.clear();
batchItems << entry;
}
}
if (!batchItems.isEmpty()) {
beginInsertRows(QModelIndex(), insertStart, insertStart + static_cast<int>(batchItems.size()) - 1);
for (int i = 0; i < batchItems.size(); ++i) {
m_entries.insert(insertStart + i, batchItems[i]);
}
endInsertRows();
}
emit entriesChanged();
emit entriesChanged();
}
bool FileSystemModel::compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const {
if (a->isDir() != b->isDir()) {
return m_sortReverse ^ a->isDir();
}
const auto cmp = a->relativePath().localeAwareCompare(b->relativePath());
return m_sortReverse ? cmp > 0 : cmp < 0;
if (a->isDir() != b->isDir()) {
return m_sortReverse ^ a->isDir();
}
const auto cmp = a->relativePath().localeAwareCompare(b->relativePath());
return m_sortReverse ? cmp > 0 : cmp < 0;
}
} // namespace ZShell::models
+95 -95
View File
@@ -13,136 +13,136 @@
namespace ZShell::models {
class FileSystemEntry : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("FileSystemEntry instances can only be retrieved from a FileSystemModel")
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("FileSystemEntry instances can only be retrieved from a FileSystemModel")
Q_PROPERTY(QString path READ path CONSTANT)
Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged)
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(QString baseName READ baseName CONSTANT)
Q_PROPERTY(QString parentDir READ parentDir CONSTANT)
Q_PROPERTY(QString suffix READ suffix CONSTANT)
Q_PROPERTY(qint64 size READ size CONSTANT)
Q_PROPERTY(bool isDir READ isDir CONSTANT)
Q_PROPERTY(bool isImage READ isImage CONSTANT)
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
Q_PROPERTY(QString path READ path CONSTANT)
Q_PROPERTY(QString relativePath READ relativePath NOTIFY relativePathChanged)
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(QString baseName READ baseName CONSTANT)
Q_PROPERTY(QString parentDir READ parentDir CONSTANT)
Q_PROPERTY(QString suffix READ suffix CONSTANT)
Q_PROPERTY(qint64 size READ size CONSTANT)
Q_PROPERTY(bool isDir READ isDir CONSTANT)
Q_PROPERTY(bool isImage READ isImage CONSTANT)
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
public:
explicit FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent = nullptr);
explicit FileSystemEntry(const QString& path, const QString& relativePath, QObject* parent = nullptr);
[[nodiscard]] QString path() const;
[[nodiscard]] QString relativePath() const;
[[nodiscard]] QString name() const;
[[nodiscard]] QString baseName() const;
[[nodiscard]] QString parentDir() const;
[[nodiscard]] QString suffix() const;
[[nodiscard]] qint64 size() const;
[[nodiscard]] bool isDir() const;
[[nodiscard]] bool isImage() const;
[[nodiscard]] QString mimeType() const;
[[nodiscard]] QString path() const;
[[nodiscard]] QString relativePath() const;
[[nodiscard]] QString name() const;
[[nodiscard]] QString baseName() const;
[[nodiscard]] QString parentDir() const;
[[nodiscard]] QString suffix() const;
[[nodiscard]] qint64 size() const;
[[nodiscard]] bool isDir() const;
[[nodiscard]] bool isImage() const;
[[nodiscard]] QString mimeType() const;
void updateRelativePath(const QDir& dir);
void updateRelativePath(const QDir& dir);
signals:
void relativePathChanged();
void relativePathChanged();
private:
const QFileInfo m_fileInfo;
const QFileInfo m_fileInfo;
const QString m_path;
QString m_relativePath;
const QString m_path;
QString m_relativePath;
mutable bool m_isImage;
mutable bool m_isImageInitialised;
mutable bool m_isImage;
mutable bool m_isImageInitialised;
mutable QString m_mimeType;
mutable bool m_mimeTypeInitialised;
mutable QString m_mimeType;
mutable bool m_mimeTypeInitialised;
};
class FileSystemModel : public QAbstractListModel {
Q_OBJECT
QML_ELEMENT
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged)
Q_PROPERTY(bool recursive READ recursive WRITE setRecursive NOTIFY recursiveChanged)
Q_PROPERTY(bool watchChanges READ watchChanges WRITE setWatchChanges NOTIFY watchChangesChanged)
Q_PROPERTY(bool showHidden READ showHidden WRITE setShowHidden NOTIFY showHiddenChanged)
Q_PROPERTY(bool sortReverse READ sortReverse WRITE setSortReverse NOTIFY sortReverseChanged)
Q_PROPERTY(Filter filter READ filter WRITE setFilter NOTIFY filterChanged)
Q_PROPERTY(QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged)
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged)
Q_PROPERTY(bool recursive READ recursive WRITE setRecursive NOTIFY recursiveChanged)
Q_PROPERTY(bool watchChanges READ watchChanges WRITE setWatchChanges NOTIFY watchChangesChanged)
Q_PROPERTY(bool showHidden READ showHidden WRITE setShowHidden NOTIFY showHiddenChanged)
Q_PROPERTY(bool sortReverse READ sortReverse WRITE setSortReverse NOTIFY sortReverseChanged)
Q_PROPERTY(Filter filter READ filter WRITE setFilter NOTIFY filterChanged)
Q_PROPERTY(QStringList nameFilters READ nameFilters WRITE setNameFilters NOTIFY nameFiltersChanged)
Q_PROPERTY(QQmlListProperty<ZShell::models::FileSystemEntry> entries READ entries NOTIFY entriesChanged)
Q_PROPERTY(QQmlListProperty<ZShell::models::FileSystemEntry> entries READ entries NOTIFY entriesChanged)
public:
enum Filter {
NoFilter,
Images,
Files,
Dirs
};
Q_ENUM(Filter)
enum Filter {
NoFilter,
Images,
Files,
Dirs
};
Q_ENUM(Filter)
explicit FileSystemModel(QObject* parent = nullptr);
explicit FileSystemModel(QObject* parent = nullptr);
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] QString path() const;
void setPath(const QString& path);
[[nodiscard]] QString path() const;
void setPath(const QString& path);
[[nodiscard]] bool recursive() const;
void setRecursive(bool recursive);
[[nodiscard]] bool recursive() const;
void setRecursive(bool recursive);
[[nodiscard]] bool watchChanges() const;
void setWatchChanges(bool watchChanges);
[[nodiscard]] bool watchChanges() const;
void setWatchChanges(bool watchChanges);
[[nodiscard]] bool showHidden() const;
void setShowHidden(bool showHidden);
[[nodiscard]] bool showHidden() const;
void setShowHidden(bool showHidden);
[[nodiscard]] bool sortReverse() const;
void setSortReverse(bool sortReverse);
[[nodiscard]] bool sortReverse() const;
void setSortReverse(bool sortReverse);
[[nodiscard]] Filter filter() const;
void setFilter(Filter filter);
[[nodiscard]] Filter filter() const;
void setFilter(Filter filter);
[[nodiscard]] QStringList nameFilters() const;
void setNameFilters(const QStringList& nameFilters);
[[nodiscard]] QStringList nameFilters() const;
void setNameFilters(const QStringList& nameFilters);
[[nodiscard]] QQmlListProperty<FileSystemEntry> entries();
[[nodiscard]] QQmlListProperty<FileSystemEntry> entries();
signals:
void pathChanged();
void recursiveChanged();
void watchChangesChanged();
void showHiddenChanged();
void sortReverseChanged();
void filterChanged();
void nameFiltersChanged();
void entriesChanged();
void pathChanged();
void recursiveChanged();
void watchChangesChanged();
void showHiddenChanged();
void sortReverseChanged();
void filterChanged();
void nameFiltersChanged();
void entriesChanged();
private:
QDir m_dir;
QFileSystemWatcher m_watcher;
QList<FileSystemEntry*> m_entries;
QHash<QString, QFuture<QPair<QSet<QString>, QSet<QString>>>> m_futures;
QDir m_dir;
QFileSystemWatcher m_watcher;
QList<FileSystemEntry*> m_entries;
QHash<QString, QFuture<QPair<QSet<QString>, QSet<QString> > > > m_futures;
QString m_path;
bool m_recursive;
bool m_watchChanges;
bool m_showHidden;
bool m_sortReverse;
Filter m_filter;
QStringList m_nameFilters;
QString m_path;
bool m_recursive;
bool m_watchChanges;
bool m_showHidden;
bool m_sortReverse = false;
Filter m_filter;
QStringList m_nameFilters;
void watchDirIfRecursive(const QString& path);
void update();
void updateWatcher();
void updateEntries();
void updateEntriesForDir(const QString& dir);
void applyChanges(const QSet<QString>& removedPaths, const QSet<QString>& addedPaths);
[[nodiscard]] bool compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const;
void watchDirIfRecursive(const QString& path);
void update();
void updateWatcher();
void updateEntries();
void updateEntriesForDir(const QString& dir);
void applyChanges(const QSet<QString>& removedPaths, const QSet<QString>& addedPaths);
[[nodiscard]] bool compareEntries(const FileSystemEntry* a, const FileSystemEntry* b) const;
};
} // namespace ZShell::models
+1
View File
@@ -9,6 +9,7 @@ version = "0.1.0"
dependencies = [
"typer",
"pillow",
"jinja2",
"materialyoucolor"
]
+2 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import typer
from zshell.subcommands import shell, scheme, screenshot, wallpaper
from zshell.subcommands import shell, scheme, screenshot, wallpaper, record
app = typer.Typer()
@@ -8,6 +8,7 @@ app.add_typer(shell.app, name="shell")
app.add_typer(scheme.app, name="scheme")
app.add_typer(screenshot.app, name="screenshot")
app.add_typer(wallpaper.app, name="wallpaper")
app.add_typer(record.app, name="record")
# app.add_typer(preset.app, name="preset")
+214
View File
@@ -0,0 +1,214 @@
import os
import json
import subprocess
import time
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer()
RECORDER = "gpu-screen-recorder"
HOME = str(os.getenv("HOME", str(Path.home())))
CONFIG = Path(HOME) / ".config/zshell/config.json"
STATE_DIR = Path(HOME) / ".local/state/zshell/record"
TEMP_RECORDING = STATE_DIR / "recording.mp4"
REPLAY_RECORDING = STATE_DIR / "replay.mp4"
NOTIF_ID_FILE = STATE_DIR / "notifid.txt"
RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR",
str(Path(HOME) / "Videos/Recordings"))
def _read_extra_args() -> list[str]:
try:
if CONFIG.is_file():
data = json.loads(CONFIG.read_text())
return data.get("record", {}).get("extraArgs", [])
except Exception:
pass
return []
def _is_recording() -> bool:
return subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
def _notify(summary: str, body: str = "", actions: list = None, timeout: int = 5000) -> Optional[int]:
args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
if actions:
for action in actions:
args.extend(["-A", action])
try:
proc = subprocess.run(args, capture_output=True, text=True)
return int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
except Exception:
return None
def _close_notification(notif_id: int):
subprocess.run(["notify-send", "--close", str(notif_id)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _get_monitors() -> list[dict]:
try:
res = subprocess.run(["hyprctl", "monitors", "-j"],
capture_output=True, text=True)
return json.loads(res.stdout)
except Exception:
return []
def _focused_monitor_name() -> Optional[str]:
for m in _get_monitors():
if m.get("focused"):
return m["name"]
return None
def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]:
region = (x, y, x + w, y + h)
intersecting = []
for m in _get_monitors():
mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"]
if not (region[2] <= mx or region[0] >= mx + mw or region[3] <= my or region[1] >= my + mh):
intersecting.append(m)
return intersecting
def _highest_refresh(monitors: list[dict]) -> float:
return max((m["refreshRate"] for m in monitors), default=60.0)
def _slurp_region() -> Optional[str]:
try:
return subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True).strip()
except subprocess.CalledProcessError:
return None
def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]:
import re
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
if match:
return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2))
return None
def start_recording(region: Optional[str], sound: bool):
STATE_DIR.mkdir(parents=True, exist_ok=True)
cmd = [RECORDER]
extra_args = _read_extra_args()
if region:
if region.lower() == "slurp" or not region:
geometry = _slurp_region()
if not geometry:
typer.echo("Region selection cancelled.")
raise typer.Abort()
else:
geometry = region
parsed = _parse_geometry(geometry)
if not parsed:
typer.echo("Invalid geometry format.")
raise typer.Abort()
x, y, w, h = parsed
monitors = _monitors_intersecting_region(x, y, w, h)
framerate = _highest_refresh(monitors)
cmd.extend(["-w", "region", "-region", geometry, "-f", str(int(framerate))])
else:
monitor_name = _focused_monitor_name()
if not monitor_name:
typer.echo("No focused monitor found.")
raise typer.Abort()
monitors = _get_monitors()
mon = next((m for m in monitors if m["name"] == monitor_name), None)
rate = int(mon["refreshRate"]) if mon else 60
cmd.extend(["-w", monitor_name, "-f", str(rate)])
if sound:
cmd.extend(["-a", "default_output"])
cmd.extend(extra_args)
cmd.extend(["-o", str(TEMP_RECORDING)])
subprocess.Popen(cmd, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
if notif_id is not None:
NOTIF_ID_FILE.write_text(str(notif_id))
time.sleep(1)
if not _is_recording():
_notify("Recording failed",
"Check gpu-screen-recorder output.", timeout=5000)
raise typer.Exit(code=1)
def stop_recording(clipboard: bool):
subprocess.run(["pkill", "-f", RECORDER],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
if not _is_recording():
break
time.sleep(0.1)
dest_dir = Path(RECORDINGS_DIR)
dest_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S")
final_path = dest_dir / f"recording_{timestamp}.mp4"
if TEMP_RECORDING.exists():
TEMP_RECORDING.rename(final_path)
if NOTIF_ID_FILE.is_file():
try:
_close_notification(int(NOTIF_ID_FILE.read_text().strip()))
except Exception:
pass
NOTIF_ID_FILE.unlink()
if clipboard:
subprocess.run(["wl-copy", "--type", "text/uri-list", f"file://{final_path}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
_notify("Recording stopped", f"Saved to {final_path}", timeout=5000)
def toggle_pause():
subprocess.run(["pkill", "-USR2", "-f", RECORDER],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
typer.echo("Toggled pause.")
@app.command()
def record(
region: Optional[str] = typer.Option(
None, "--region", "-r",
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
),
sound: bool = typer.Option(
False, "--sound", "-s", help="Record audio from default output."),
pause: bool = typer.Option(
False, "--pause", "-p", help="Toggle pause/resume."),
clipboard: bool = typer.Option(
False, "--clipboard", "-c", help="Copy the final recording path to clipboard."),
):
"""Start or stop a screen recording with gpu-screen-recorder."""
if pause:
toggle_pause()
raise typer.Exit()
if _is_recording():
stop_recording(clipboard)
else:
start_recording(region, sound)
+45 -8
View File
@@ -1,4 +1,8 @@
import subprocess
import sys
import time
import click
import typer
args = ["qs", "-c", "zshell"]
@@ -8,35 +12,68 @@ app = typer.Typer()
@app.command()
def kill():
subprocess.run(args + ["kill"], check=True)
result = subprocess.run(args + ["kill"], capture_output=True)
if result.returncode != 0:
raise click.ClickException("No running instance to kill.")
sys.stderr.write(result.stderr.decode())
def start_instance(no_daemon: bool = False) -> None:
result = subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True)
stdout = result.stdout.decode().strip()
if stdout:
if "already running" in stdout.lower():
raise click.ClickException(stdout)
if result.returncode != 0:
stderr = result.stderr.decode().strip()
raise click.ClickException(stderr)
@app.command()
def start(no_daemon: bool = False):
subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), check=True)
start_instance(no_daemon)
@app.command()
def restart(no_daemon: bool = False):
subprocess.run(args + ["kill"], check=False)
subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), check=True)
subprocess.run(args + ["kill"], capture_output=True)
deadline = time.monotonic() + 2.5
while time.monotonic() < deadline:
result = subprocess.run(args + ["kill"], capture_output=True)
if result.returncode == 255:
break
time.sleep(0.25)
start_instance(no_daemon=no_daemon)
@app.command()
def show():
subprocess.run(args + ["ipc"] + ["show"], check=True)
result = subprocess.run(args + ["ipc"] + ["show"], capture_output=True)
if result.returncode != 0:
raise click.ClickException(result.stderr.decode().strip())
sys.stderr.write(result.stderr.decode())
@app.command()
def log():
subprocess.run(args + ["log"], check=True)
result = subprocess.run(args + ["log"], capture_output=True)
if result.returncode != 0:
raise click.ClickException(result.stderr.decode().strip())
sys.stdout.write(result.stdout.decode())
sys.stderr.write(result.stderr.decode())
@app.command()
def lock():
subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], check=True)
result = subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], capture_output=True)
if result.returncode != 0:
raise click.ClickException(result.stderr.decode().strip())
sys.stderr.write(result.stderr.decode())
@app.command()
def call(target: str, method: str, method_args: list[str] = typer.Argument(None)):
subprocess.run(args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), check=True)
result = subprocess.run(args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), capture_output=True)
if result.returncode != 0:
raise click.ClickException(result.stderr.decode().strip())
sys.stderr.write(result.stderr.decode())
+61 -18
View File
@@ -1,13 +1,15 @@
from __future__ import annotations
from subprocess import CompletedProcess
from unittest.mock import patch, call
from typer.testing import CliRunner
from zshell.subcommands.shell import app
runner = CliRunner()
def invoke(*args: str) -> None:
from typer.testing import CliRunner
runner = CliRunner()
def invoke(*args: str):
result = runner.invoke(app, args)
if result.exit_code != 0:
raise RuntimeError(result.output)
@@ -16,72 +18,113 @@ def invoke(*args: str) -> None:
class TestKill:
@patch("zshell.subcommands.shell.subprocess.run")
def test_kill_runs_qs_kill(self, mock_run):
def test_kill_runs_qs_kill_success(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"Killed abc\n")
invoke("kill")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "kill"], check=True)
mock_run.assert_called_once_with(["qs", "-c", "zshell", "kill"], capture_output=True)
@patch("zshell.subcommands.shell.subprocess.run")
def test_kill_no_instance_errors(self, mock_run):
mock_run.return_value = CompletedProcess([], 255, b"", b"No running instances\n")
result = runner.invoke(app, ["kill"])
assert result.exit_code != 0
assert "No running instance to kill" in result.output
class TestStart:
@patch("zshell.subcommands.shell.subprocess.run")
def test_start_default_daemon(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n")
invoke("start")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n", "-d"], check=True)
mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n", "-d"], capture_output=True)
@patch("zshell.subcommands.shell.subprocess.run")
def test_start_no_daemon(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n")
invoke("start", "--no-daemon")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n"], check=True)
mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n"], capture_output=True)
@patch("zshell.subcommands.shell.subprocess.run")
def test_start_already_running_errors(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"An instance of this configuration is already running.\n", b"")
result = runner.invoke(app, ["start"])
assert result.exit_code != 0
assert "already running" in result.output
@patch("zshell.subcommands.shell.subprocess.run")
def test_start_other_failure_errors(self, mock_run):
mock_run.return_value = CompletedProcess([], 1, b"", b"Config error\n")
result = runner.invoke(app, ["start"])
assert result.exit_code != 0
assert "Config error" in result.output
class TestShow:
@patch("zshell.subcommands.shell.subprocess.run")
def test_show_runs_ipc_show(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"target visibilities\n")
invoke("show")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "show"], check=True)
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "show"], capture_output=True)
class TestLog:
@patch("zshell.subcommands.shell.subprocess.run")
def test_log_runs_qs_log(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"log output\n", b"")
invoke("log")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "log"], check=True)
mock_run.assert_called_once_with(["qs", "-c", "zshell", "log"], capture_output=True)
class TestLock:
@patch("zshell.subcommands.shell.subprocess.run")
def test_lock_runs_ipc_call_lock(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("lock")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], check=True)
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], capture_output=True)
class TestCall:
@patch("zshell.subcommands.shell.subprocess.run")
def test_call_no_args(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("call", "target", "method")
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "target", "method"], check=True)
mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "target", "method"], capture_output=True)
@patch("zshell.subcommands.shell.subprocess.run")
def test_call_with_args(self, mock_run):
mock_run.return_value = CompletedProcess([], 0, b"", b"")
invoke("call", "target", "method", "arg1", "arg2")
mock_run.assert_called_once_with(
["qs", "-c", "zshell", "ipc", "call", "target", "method", "arg1", "arg2"],
check=True,
capture_output=True,
)
class TestRestart:
@patch("zshell.subcommands.shell.start_instance")
@patch("zshell.subcommands.shell.subprocess.run")
def test_restart_kills_then_starts_daemon(self, mock_run):
def test_restart_kills_then_starts(self, mock_run, mock_start):
mock_run.side_effect = [
CompletedProcess([], 0, b"", b"Killed abc\n"), # first kill (captured)
CompletedProcess([], 255, b"", b""), # poll → no instance
]
invoke("restart")
assert mock_run.call_args_list == [
call(["qs", "-c", "zshell", "kill"], check=False),
call(["qs", "-c", "zshell", "-n", "-d"], check=True),
call(["qs", "-c", "zshell", "kill"], capture_output=True),
call(["qs", "-c", "zshell", "kill"], capture_output=True),
]
mock_start.assert_called_once_with(no_daemon=False)
@patch("zshell.subcommands.shell.start_instance")
@patch("zshell.subcommands.shell.subprocess.run")
def test_restart_no_daemon(self, mock_run):
def test_restart_no_daemon(self, mock_run, mock_start):
mock_run.side_effect = [
CompletedProcess([], 0, b"", b"Killed abc\n"),
CompletedProcess([], 255, b"", b""),
]
invoke("restart", "--no-daemon")
assert mock_run.call_args_list == [
call(["qs", "-c", "zshell", "kill"], check=False),
call(["qs", "-c", "zshell", "-n"], check=True),
call(["qs", "-c", "zshell", "kill"], capture_output=True),
call(["qs", "-c", "zshell", "kill"], capture_output=True),
]
mock_start.assert_called_once_with(no_daemon=True)
+7 -1
View File
@@ -1,11 +1,12 @@
//@ pragma UseQApplication
//@ pragma Env QSG_RENDER_LOOP=threaded
// @ pragma Env QSG_RHI_BACKEND=vulkan
//@ pragma Env QSG_RHI_BACKEND=vulkan
//@ pragma Env QSG_NO_VSYNC=1
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_SCALE_FACTOR_ROUNDING_POLICY=Round
//@ pragma DropExpensiveFonts
import Quickshell
import qs.Extensions
import qs.Modules
import qs.Modules.Wallpaper
import qs.Modules.Lock
@@ -14,6 +15,8 @@ import qs.Helpers
import qs.Modules.Polkit
ShellRoot {
settings.watchFiles: true
Windows {
}
@@ -36,4 +39,7 @@ ShellRoot {
Polkit {
}
LoadExtensions {
}
}