Merge branch 'main' into forgejo-workflows
This commit is contained in:
@@ -12,3 +12,4 @@ pkg/
|
|||||||
uv.lock
|
uv.lock
|
||||||
.qtcreator/
|
.qtcreator/
|
||||||
dist/
|
dist/
|
||||||
|
**/target/
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import qs.Config
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property bool shouldBeVisible
|
||||||
|
|
||||||
|
opacity: 0
|
||||||
|
scale: 0
|
||||||
|
visible: root.scale > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
duration: Appearance.anim.durations.small
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Behavior on scale {
|
||||||
|
Anim {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShouldBeVisibleChanged: {
|
||||||
|
if (root.shouldBeVisible) {
|
||||||
|
root.opacity = 1;
|
||||||
|
root.scale = 1;
|
||||||
|
} else {
|
||||||
|
root.opacity = 0;
|
||||||
|
root.scale = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -23,6 +23,7 @@ Singleton {
|
|||||||
property alias osd: adapter.osd
|
property alias osd: adapter.osd
|
||||||
property alias overview: adapter.overview
|
property alias overview: adapter.overview
|
||||||
property bool recentlySaved: false
|
property bool recentlySaved: false
|
||||||
|
property alias screenshot: adapter.screenshot
|
||||||
property alias services: adapter.services
|
property alias services: adapter.services
|
||||||
property alias sidebar: adapter.sidebar
|
property alias sidebar: adapter.sidebar
|
||||||
property alias utilities: adapter.utilities
|
property alias utilities: adapter.utilities
|
||||||
@@ -128,7 +129,8 @@ Singleton {
|
|||||||
background: serializeBackground(),
|
background: serializeBackground(),
|
||||||
launcher: serializeLauncher(),
|
launcher: serializeLauncher(),
|
||||||
colors: serializeColors(),
|
colors: serializeColors(),
|
||||||
dock: serializeDock()
|
dock: serializeDock(),
|
||||||
|
screenshot: serializeScreenshot()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +277,20 @@ Singleton {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeScreenshot(): var {
|
||||||
|
return {
|
||||||
|
enable_pp: screenshot.enable_pp,
|
||||||
|
mode: screenshot.mode,
|
||||||
|
corner_radius: screenshot.corner_radius,
|
||||||
|
drop_shadow: screenshot.drop_shadow,
|
||||||
|
rounded_corners: screenshot.rounded_corners,
|
||||||
|
shadow_blur_radius: screenshot.shadow_blur_radius,
|
||||||
|
shadow_color: screenshot.shadow_color,
|
||||||
|
shadow_offset_x: screenshot.shadow_offset_x,
|
||||||
|
shadow_offset_y: screenshot.shadow_offset_y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function serializeServices(): var {
|
function serializeServices(): var {
|
||||||
return {
|
return {
|
||||||
weatherLocation: services.weatherLocation,
|
weatherLocation: services.weatherLocation,
|
||||||
@@ -430,6 +446,8 @@ Singleton {
|
|||||||
}
|
}
|
||||||
property Overview overview: Overview {
|
property Overview overview: Overview {
|
||||||
}
|
}
|
||||||
|
property Screenshot screenshot: Screenshot {
|
||||||
|
}
|
||||||
property Services services: Services {
|
property Services services: Services {
|
||||||
}
|
}
|
||||||
property SidebarConfig sidebar: SidebarConfig {
|
property SidebarConfig sidebar: SidebarConfig {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
JsonObject {
|
||||||
|
property real corner_radius: 12.0
|
||||||
|
property bool drop_shadow: true
|
||||||
|
property bool enable_pp: true
|
||||||
|
property string mode: "manual"
|
||||||
|
property bool rounded_corners: false
|
||||||
|
property real shadow_blur_radius: 22.0
|
||||||
|
property list<int> shadow_color: [0, 0, 0, 160]
|
||||||
|
property real shadow_offset_x: 5.0
|
||||||
|
property real shadow_offset_y: 5.0
|
||||||
|
}
|
||||||
+2
-1
@@ -66,7 +66,8 @@ MouseArea {
|
|||||||
|
|
||||||
function save(): void {
|
function save(): void {
|
||||||
const tmpfile = Qt.resolvedUrl(`/tmp/zshell-picker-${Quickshell.processId}-${Date.now()}.png`);
|
const tmpfile = Qt.resolvedUrl(`/tmp/zshell-picker-${Quickshell.processId}-${Date.now()}.png`);
|
||||||
ZShellIo.saveItem(screencopy, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => Quickshell.execDetached(["swappy", "-f", path]));
|
const cmd = Config.screenshot.enable_pp ? ["zshell-img-tools", "--image"] : ["swappy", "-f"];
|
||||||
|
ZShellIo.saveItem(screencopy, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => Quickshell.execDetached([...cmd, path]));
|
||||||
closeAnim.start();
|
closeAnim.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property int availableUpdates: 0
|
property int availableUpdates: 0
|
||||||
|
property string cmd: ""
|
||||||
property bool commandReady
|
property bool commandReady
|
||||||
property bool loaded
|
property bool loaded
|
||||||
property double now: Date.now()
|
property double now: Date.now()
|
||||||
property var updates: ({})
|
property var updates: ({})
|
||||||
|
property bool updating
|
||||||
|
property string updatingPackage: ""
|
||||||
|
|
||||||
function formatUpdateTime(timestamp) {
|
function formatUpdateTime(timestamp) {
|
||||||
const diffMs = root.now - timestamp;
|
const diffMs = root.now - timestamp;
|
||||||
@@ -34,6 +37,22 @@ Singleton {
|
|||||||
return Qt.formatDateTime(new Date(timestamp), "dd hh:mm");
|
return Qt.formatDateTime(new Date(timestamp), "dd hh:mm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function performPackageUpdate(pkg: string): void {
|
||||||
|
if (root.cmd === "pacman")
|
||||||
|
pkgUpdateProc.command = ["pkexec", root.cmd, "--noconfirm", "-Sy", pkg];
|
||||||
|
else
|
||||||
|
pkgUpdateProc.command = [root.cmd, "--noconfirm", "--sudo", "pkexec", "-Sy", pkg];
|
||||||
|
pkgUpdateProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function performSystemUpdate(): void {
|
||||||
|
if (root.cmd === "pacman")
|
||||||
|
sysUpdateProc.command = ["pkexec", root.cmd, "--noconfirm", "-Syu"];
|
||||||
|
else
|
||||||
|
sysUpdateProc.command = [root.cmd, "--noconfirm", "--sudo", "pkexec", "-Syu"];
|
||||||
|
sysUpdateProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
onUpdatesChanged: {
|
onUpdatesChanged: {
|
||||||
if (!root.loaded)
|
if (!root.loaded)
|
||||||
return;
|
return;
|
||||||
@@ -92,6 +111,28 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: updateCmdDetect
|
||||||
|
|
||||||
|
command: ["sh", "-c", "command -v yay || command -v paru"]
|
||||||
|
running: true
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const cmd = this.text.trim();
|
||||||
|
let helper;
|
||||||
|
|
||||||
|
if (cmd.length > 0) {
|
||||||
|
helper = cmd.split("/").pop();
|
||||||
|
} else {
|
||||||
|
helper = "pacman";
|
||||||
|
}
|
||||||
|
|
||||||
|
root.cmd = helper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: updatesProc
|
id: updatesProc
|
||||||
|
|
||||||
@@ -115,6 +156,44 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: sysUpdateProc
|
||||||
|
|
||||||
|
command: []
|
||||||
|
running: false
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
root.updating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRunningChanged: {
|
||||||
|
if (running)
|
||||||
|
root.updating = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: pkgUpdateProc
|
||||||
|
|
||||||
|
command: []
|
||||||
|
running: false
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
root.updating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRunningChanged: {
|
||||||
|
if (running) {
|
||||||
|
root.updatingPackage = command[command.length - 1];
|
||||||
|
root.updating = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: saveTimer
|
id: saveTimer
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,25 @@ import qs.Helpers
|
|||||||
Scope {
|
Scope {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property bool enabled: !Players.list.some(p => p.isPlaying)
|
||||||
required property Lock lock
|
required property Lock lock
|
||||||
readonly property bool enabled: !Players.list.some( p => p.isPlaying )
|
|
||||||
|
|
||||||
function handleIdleAction( action: var ): void {
|
function handleIdleAction(action: var): void {
|
||||||
if ( !action )
|
if (!action)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( action === "lock" )
|
if (action === "lock")
|
||||||
lock.lock.locked = true;
|
lock.lock.locked = true;
|
||||||
else if ( action === "unlock" )
|
else if (action === "unlock")
|
||||||
lock.lock.locked = false;
|
lock.lock.locked = false;
|
||||||
else if ( typeof action === "string" )
|
else if (action === "dpms on")
|
||||||
Hypr.dispatch( action );
|
Hypr.dispatch('hl.dsp.dpms({ action = "enable" })');
|
||||||
|
else if (action === "dpms off")
|
||||||
|
Hypr.dispatch('hl.dsp.dpms({ action = "disable" })');
|
||||||
|
else if (typeof action === "string")
|
||||||
|
Hypr.dispatch(action);
|
||||||
else
|
else
|
||||||
Quickshell.execDetached( action );
|
Quickshell.execDetached(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
@@ -33,7 +37,8 @@ Scope {
|
|||||||
|
|
||||||
enabled: root.enabled && modelData.timeout > 0 ? true : false
|
enabled: root.enabled && modelData.timeout > 0 ? true : false
|
||||||
timeout: modelData.timeout
|
timeout: modelData.timeout
|
||||||
onIsIdleChanged: root.handleIdleAction( isIdle ? modelData.idleAction : modelData.activeAction )
|
|
||||||
|
onIsIdleChanged: root.handleIdleAction(isIdle ? modelData.idleAction : modelData.activeAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ Item {
|
|||||||
key: "launcher"
|
key: "launcher"
|
||||||
name: "Launcher"
|
name: "Launcher"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ListElement {
|
||||||
|
icon: "screenshot_region"
|
||||||
|
key: "screenshot"
|
||||||
|
name: "Screenshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
ListElement {
|
||||||
|
icon: "cached"
|
||||||
|
key: "updates"
|
||||||
|
name: "Updates"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomClippingRect {
|
CustomClippingRect {
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ ColumnLayout {
|
|||||||
Config.save();
|
Config.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteTimeoutEntry(index) {
|
||||||
|
let list = [...Config.general.idle.timeouts];
|
||||||
|
list.splice(index, 1);
|
||||||
|
Config.general.idle.timeouts = list;
|
||||||
|
Config.save();
|
||||||
|
}
|
||||||
|
|
||||||
function updateTimeoutEntry(i, key, value) {
|
function updateTimeoutEntry(i, key, value) {
|
||||||
const list = [...Config.general.idle.timeouts];
|
const list = [...Config.general.idle.timeouts];
|
||||||
let entry = list[i];
|
let entry = list[i];
|
||||||
@@ -49,6 +56,9 @@ ColumnLayout {
|
|||||||
onAddActiveActionRequested: {
|
onAddActiveActionRequested: {
|
||||||
root.updateTimeoutEntry(index, "activeAction", "");
|
root.updateTimeoutEntry(index, "activeAction", "");
|
||||||
}
|
}
|
||||||
|
onDeleteRequested: function (index) {
|
||||||
|
root.deleteTimeoutEntry(index);
|
||||||
|
}
|
||||||
onFieldEdited: function (key, value) {
|
onFieldEdited: function (key, value) {
|
||||||
root.updateTimeoutEntry(index, key, value);
|
root.updateTimeoutEntry(index, key, value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import qs.Modules.Settings.Controls
|
||||||
|
import qs.Config
|
||||||
|
import qs.Components
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
SettingsSection {
|
||||||
|
sectionId: "Screenshot"
|
||||||
|
|
||||||
|
SettingsHeader {
|
||||||
|
name: "Screenshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSwitch {
|
||||||
|
name: "Enable effects"
|
||||||
|
object: Config.screenshot
|
||||||
|
setting: "enable_pp"
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomSplitButtonRow {
|
||||||
|
// active: true
|
||||||
|
label: qsTr("Effects mode")
|
||||||
|
|
||||||
|
menuItems: [
|
||||||
|
MenuItem {
|
||||||
|
icon: "build"
|
||||||
|
text: qsTr("Manual")
|
||||||
|
value: "manual"
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
icon: "rotate_auto"
|
||||||
|
text: qsTr("Auto")
|
||||||
|
value: "auto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
onSelected: item => {
|
||||||
|
Config.screenshot.mode = item.value;
|
||||||
|
Config.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSpinBox {
|
||||||
|
min: 0
|
||||||
|
name: "Corner radius"
|
||||||
|
object: Config.screenshot
|
||||||
|
setting: "corner_radius"
|
||||||
|
step: 1
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSwitch {
|
||||||
|
name: "Enable drop shadow"
|
||||||
|
object: Config.screenshot
|
||||||
|
setting: "drop_shadow"
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSwitch {
|
||||||
|
name: "Enable rounded corners"
|
||||||
|
object: Config.screenshot
|
||||||
|
setting: "rounded_corners"
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSpinBox {
|
||||||
|
min: 0
|
||||||
|
name: "Shadow blur radius"
|
||||||
|
object: Config.screenshot
|
||||||
|
setting: "shadow_blur_radius"
|
||||||
|
step: 1
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSwitch {
|
||||||
|
name: "Shadow color broken atm"
|
||||||
|
object: Config.Screenshot
|
||||||
|
setting: "shadow_color"
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSpinBox {
|
||||||
|
min: 0
|
||||||
|
name: "Shadow offset X"
|
||||||
|
object: Config.screenshot
|
||||||
|
setting: "shadow_offset_x"
|
||||||
|
step: 1
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Separator {
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingSpinBox {
|
||||||
|
min: 0
|
||||||
|
name: "Shadow offset Y"
|
||||||
|
object: Config.screenshot
|
||||||
|
setting: "shadow_offset_y"
|
||||||
|
step: 1
|
||||||
|
visible: Config.screenshot.mode === "manual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick
|
||||||
|
import qs.Config
|
||||||
|
import qs.Helpers
|
||||||
|
import qs.Components
|
||||||
|
import qs.Modules.Settings.Controls
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
radius: Appearance.rounding.normal - Appearance.padding.smaller
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.margins: Appearance.padding.large
|
||||||
|
spacing: Appearance.spacing.large
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
font.pointSize: Appearance.font.size.larger * 4
|
||||||
|
text: "update"
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
CustomText {
|
||||||
|
font.pointSize: Appearance.font.size.large * 2
|
||||||
|
text: "System updates"
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: row
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: text
|
||||||
|
|
||||||
|
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
|
||||||
|
Layout.fillWidth: true
|
||||||
|
font.pointSize: Appearance.font.size.larger
|
||||||
|
text: `${Updates.availableUpdates} available updates`
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
Layout.preferredWidth: 150
|
||||||
|
color: Updates.updating ? DynamicColors.layer(DynamicColors.palette.m3outline, 2) : DynamicColors.palette.m3primary
|
||||||
|
radius: Appearance.rounding.full
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
animate: true
|
||||||
|
color: DynamicColors.palette.m3onPrimary
|
||||||
|
font.pointSize: Appearance.font.size.large
|
||||||
|
text: Updates.updating ? "update" : "download"
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
color: Updates.updating ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onPrimary
|
||||||
|
text: "Update all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
color: DynamicColors.palette.m3onPrimary
|
||||||
|
disabled: Updates.updating
|
||||||
|
|
||||||
|
onClicked: Updates.performSystemUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomListView {
|
||||||
|
id: view
|
||||||
|
|
||||||
|
readonly property int itemHeight: 50 + Appearance.padding.smaller * 2
|
||||||
|
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
clip: true
|
||||||
|
contentHeight: height
|
||||||
|
spacing: Appearance.spacing.normal
|
||||||
|
|
||||||
|
delegate: CustomRect {
|
||||||
|
id: update
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
readonly property list<string> sections: modelData.update.split(" ")
|
||||||
|
|
||||||
|
color: DynamicColors.tPalette.m3surfaceContainer
|
||||||
|
implicitHeight: view.itemHeight
|
||||||
|
implicitWidth: parent.width
|
||||||
|
radius: Appearance.rounding.small - Appearance.padding.small
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Appearance.padding.smaller
|
||||||
|
anchors.rightMargin: Appearance.padding.smaller
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
font.pointSize: Appearance.font.size.large * 2
|
||||||
|
text: "package_2"
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 25
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pointSize: Appearance.font.size.large
|
||||||
|
text: update.sections[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: DynamicColors.palette.m3onSurfaceVariant
|
||||||
|
text: Updates.formatUpdateTime(update.modelData.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredWidth: 500
|
||||||
|
|
||||||
|
MarqueeText {
|
||||||
|
id: versionFrom
|
||||||
|
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredWidth: 225
|
||||||
|
animate: true
|
||||||
|
color: DynamicColors.palette.m3tertiary
|
||||||
|
font.pointSize: Appearance.font.size.large
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
marqueeEnabled: true
|
||||||
|
pauseMs: 4000
|
||||||
|
text: update.sections[1]
|
||||||
|
width: 225
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: DynamicColors.palette.m3secondary
|
||||||
|
font.pointSize: Appearance.font.size.extraLarge
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: "arrow_right_alt"
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
MarqueeText {
|
||||||
|
id: versionTo
|
||||||
|
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredWidth: 225
|
||||||
|
animate: true
|
||||||
|
color: DynamicColors.palette.m3primary
|
||||||
|
font.pointSize: Appearance.font.size.large
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
marqueeEnabled: true
|
||||||
|
pauseMs: 4000
|
||||||
|
text: update.sections[3]
|
||||||
|
width: 225
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
Layout.preferredHeight: width
|
||||||
|
icon: "download"
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
Updates.performPackageUpdate(update.sections[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model: ScriptModel {
|
||||||
|
id: script
|
||||||
|
|
||||||
|
objectProp: "update"
|
||||||
|
values: Object.entries(Updates.updates).sort((a, b) => b[1] - a[1]).map(([update, timestamp]) => ({
|
||||||
|
update,
|
||||||
|
timestamp
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ Item {
|
|||||||
stack.push(osd);
|
stack.push(osd);
|
||||||
else if (currentCategory === "launcher")
|
else if (currentCategory === "launcher")
|
||||||
stack.push(launcher);
|
stack.push(launcher);
|
||||||
|
else if (currentCategory === "screenshot")
|
||||||
|
stack.push(screenshot);
|
||||||
|
else if (currentCategory === "updates")
|
||||||
|
stack.push(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
target: root
|
target: root
|
||||||
@@ -225,4 +229,18 @@ Item {
|
|||||||
Cat.Launcher {
|
Cat.Launcher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: screenshot
|
||||||
|
|
||||||
|
Cat.Screenshot {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: updates
|
||||||
|
|
||||||
|
Cat.SystemUpdates {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Item {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
||||||
signal addActiveActionRequested
|
signal addActiveActionRequested
|
||||||
|
signal deleteRequested(int index)
|
||||||
signal fieldEdited(string key, var value)
|
signal fieldEdited(string key, var value)
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -65,42 +66,64 @@ Item {
|
|||||||
|
|
||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: nameHover
|
id: nameHover
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverIconButton {
|
||||||
|
id: editButton
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
font.pointSize: Appearance.font.size.large
|
||||||
|
icon: "edit"
|
||||||
|
shouldBeVisible: nameHover.hovered && !nameCell.editing
|
||||||
|
|
||||||
|
onClicked: nameCell.beginEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomText {
|
CustomText {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: editButton.left
|
anchors.leftMargin: nameHover.hovered ? editButton.width + Appearance.spacing.smaller * 2 : 0
|
||||||
|
anchors.right: deleteButton.left
|
||||||
anchors.rightMargin: Appearance.spacing.small
|
anchors.rightMargin: Appearance.spacing.small
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
elide: Text.ElideRight // enable if CustomText supports it
|
elide: Text.ElideRight // enable if CustomText supports it
|
||||||
font.pointSize: Appearance.font.size.larger
|
font.pointSize: Appearance.font.size.larger
|
||||||
text: root.modelData.name
|
text: root.modelData.name
|
||||||
visible: !nameCell.editing
|
visible: !nameCell.editing
|
||||||
|
|
||||||
|
Behavior on anchors.leftMargin {
|
||||||
|
Anim {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton {
|
HoverIconButton {
|
||||||
id: editButton
|
id: deleteButton
|
||||||
|
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
font.pointSize: Appearance.font.size.large
|
font.pointSize: Appearance.font.size.large
|
||||||
icon: "edit"
|
icon: "delete"
|
||||||
visible: nameHover.hovered && !nameCell.editing
|
shouldBeVisible: nameHover.hovered && !nameCell.editing
|
||||||
|
|
||||||
onClicked: nameCell.beginEdit()
|
onClicked: root.deleteRequested(root.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomRect {
|
CustomRect {
|
||||||
anchors.fill: parent
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
color: DynamicColors.tPalette.m3surface
|
color: DynamicColors.tPalette.m3surface
|
||||||
|
implicitHeight: nameEditor.implicitHeight + (Appearance.padding.normal * 2)
|
||||||
|
implicitWidth: Math.min(nameEditor.contentWidth + (Appearance.padding.normal * 2), parent.width - Appearance.padding.normal)
|
||||||
radius: Appearance.rounding.small
|
radius: Appearance.rounding.small
|
||||||
visible: nameCell.editing
|
visible: nameCell.editing
|
||||||
|
|
||||||
CustomTextField {
|
CustomTextField {
|
||||||
id: nameEditor
|
id: nameEditor
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.centerIn: parent
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
implicitWidth: Math.min(contentWidth + Appearance.padding.normal * 2, nameCell.width - Appearance.padding.normal)
|
||||||
text: nameCell.draftName
|
text: nameCell.draftName
|
||||||
|
|
||||||
Keys.onEscapePressed: {
|
Keys.onEscapePressed: {
|
||||||
|
|||||||
@@ -951,6 +951,72 @@ export const settingsIndex = [
|
|||||||
keywords: ["size", "osd", "height"],
|
keywords: ["size", "osd", "height"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// SCREENSHOT CATEGORY
|
||||||
|
// Screenshot section
|
||||||
|
{
|
||||||
|
name: "Enable effects",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["effects", "shadow", "screenshot"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Effects mode",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["effects", "mode"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Corner radius",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["corner", "radius"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Enable drop shadow",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["drop", "shadow"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Enable rounded corners",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["rounded", "corners"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shadow blur radius",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["blur", "shadow", "radius"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shadow color",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["color", "shadow"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shadow offset X",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["offset", "shadow"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shadow offset Y",
|
||||||
|
category: "screenshot",
|
||||||
|
categoryName: "Screenshot",
|
||||||
|
section: "Screenshot",
|
||||||
|
keywords: ["offset", "shadow"],
|
||||||
|
},
|
||||||
|
|
||||||
// LAUNCHER CATEGORY
|
// LAUNCHER CATEGORY
|
||||||
// Launcher section
|
// Launcher section
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+1100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "zshell-img-tools"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "zshell-img-tools"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
image = { version = "0.25", features = ["png"] }
|
||||||
|
tiny-skia = "0.11"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
anyhow = "1"
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 0
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 3
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# What_That_Claude_DO?
|
||||||
|
|
||||||
|
What That Claude Do? (WTCD)
|
||||||
|
A repository of random things I ask Claude to do for me.
|
||||||
|
|
||||||
|
In this case it is creating a screenshot tool
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(rename = "screenshot")]
|
||||||
|
pub screenshot: EffectsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EffectsConfig {
|
||||||
|
pub mode: String,
|
||||||
|
pub rounded_corners: bool,
|
||||||
|
pub corner_radius: f32,
|
||||||
|
pub drop_shadow: bool,
|
||||||
|
pub shadow_blur_radius: f32,
|
||||||
|
pub shadow_offset_x: f32,
|
||||||
|
pub shadow_offset_y: f32,
|
||||||
|
pub shadow_color: [u8; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
let home = std::env::var("HOME").ok()?;
|
||||||
|
Some(
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("zshell")
|
||||||
|
.join("config.json"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::config_path().context("Could not determine HOME directory")?;
|
||||||
|
Self::load_from(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from(path: &PathBuf) -> Result<Self> {
|
||||||
|
let raw = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("Failed to read config at {}", path.display()))?;
|
||||||
|
serde_json::from_str(&raw)
|
||||||
|
.with_context(|| format!("Failed to parse JSON config at {}", path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
use crate::config::EffectsConfig;
|
||||||
|
use image::RgbaImage;
|
||||||
|
use tiny_skia::{
|
||||||
|
BlendMode, Color, FillRule, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Transform,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn apply_effects(img: RgbaImage, cfg: &EffectsConfig) -> RgbaImage {
|
||||||
|
let img = if cfg.rounded_corners {
|
||||||
|
apply_rounded_corners(img, cfg.corner_radius)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
if cfg.drop_shadow {
|
||||||
|
apply_drop_shadow(
|
||||||
|
img,
|
||||||
|
cfg.shadow_blur_radius,
|
||||||
|
cfg.shadow_offset_x,
|
||||||
|
cfg.shadow_offset_y,
|
||||||
|
cfg.shadow_color,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let mut mask = Pixmap::new(w, h).expect("mask pixmap");
|
||||||
|
let path = rounded_rect_path(0.0, 0.0, w as f32, h as f32, radius);
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color(Color::WHITE);
|
||||||
|
paint.anti_alias = true;
|
||||||
|
mask.fill_path(
|
||||||
|
&path,
|
||||||
|
&paint,
|
||||||
|
FillRule::Winding,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut pixmap = rgba_image_to_pixmap(&img);
|
||||||
|
let mut dst_paint = PixmapPaint::default();
|
||||||
|
dst_paint.blend_mode = BlendMode::DestinationIn;
|
||||||
|
pixmap.draw_pixmap(0, 0, mask.as_ref(), &dst_paint, Transform::identity(), None);
|
||||||
|
pixmap_to_rgba_image(pixmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_drop_shadow(
|
||||||
|
img: RgbaImage,
|
||||||
|
blur_radius: f32,
|
||||||
|
offset_x: f32,
|
||||||
|
offset_y: f32,
|
||||||
|
shadow_color: [u8; 4],
|
||||||
|
) -> RgbaImage {
|
||||||
|
let (iw, ih) = img.dimensions();
|
||||||
|
let br = blur_radius.ceil() as u32;
|
||||||
|
let spread = br * 2;
|
||||||
|
|
||||||
|
let extra_left = spread + (-offset_x).max(0.0).ceil() as u32;
|
||||||
|
let extra_top = spread + (-offset_y).max(0.0).ceil() as u32;
|
||||||
|
let extra_right = spread + offset_x.max(0.0).ceil() as u32;
|
||||||
|
let extra_bottom = spread + offset_y.max(0.0).ceil() as u32;
|
||||||
|
|
||||||
|
let canvas_w = iw + extra_left + extra_right;
|
||||||
|
let canvas_h = ih + extra_top + extra_bottom;
|
||||||
|
|
||||||
|
let mut shadow_pixmap = Pixmap::new(canvas_w, canvas_h).expect("shadow pixmap");
|
||||||
|
let img_pixmap = rgba_image_to_pixmap(&img);
|
||||||
|
let shadow_x = (extra_left as f32 + offset_x) as i32;
|
||||||
|
let shadow_y = (extra_top as f32 + offset_y) as i32;
|
||||||
|
|
||||||
|
let mut sp = PixmapPaint::default();
|
||||||
|
sp.blend_mode = BlendMode::Source;
|
||||||
|
shadow_pixmap.draw_pixmap(
|
||||||
|
shadow_x,
|
||||||
|
shadow_y,
|
||||||
|
img_pixmap.as_ref(),
|
||||||
|
&sp,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
|
||||||
|
|
||||||
|
let shadow_img = pixmap_to_rgba_image(shadow_pixmap);
|
||||||
|
let blurred = box_blur_rgba(&shadow_img, br);
|
||||||
|
let blurred_pixmap = rgba_image_to_pixmap(&blurred);
|
||||||
|
|
||||||
|
let mut canvas = Pixmap::new(canvas_w, canvas_h).expect("canvas pixmap");
|
||||||
|
let mut p = PixmapPaint::default();
|
||||||
|
p.blend_mode = BlendMode::Source;
|
||||||
|
canvas.draw_pixmap(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
blurred_pixmap.as_ref(),
|
||||||
|
&p,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut p2 = PixmapPaint::default();
|
||||||
|
p2.blend_mode = BlendMode::SourceOver;
|
||||||
|
canvas.draw_pixmap(
|
||||||
|
extra_left as i32,
|
||||||
|
extra_top as i32,
|
||||||
|
img_pixmap.as_ref(),
|
||||||
|
&p2,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
pixmap_to_rgba_image(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Path {
|
||||||
|
let r = r.min(w / 2.0).min(h / 2.0);
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.move_to(x + r, y);
|
||||||
|
pb.line_to(x + w - r, y);
|
||||||
|
pb.quad_to(x + w, y, x + w, y + r);
|
||||||
|
pb.line_to(x + w, y + h - r);
|
||||||
|
pb.quad_to(x + w, y + h, x + w - r, y + h);
|
||||||
|
pb.line_to(x + r, y + h);
|
||||||
|
pb.quad_to(x, y + h, x, y + h - r);
|
||||||
|
pb.line_to(x, y + r);
|
||||||
|
pb.quad_to(x, y, x + r, y);
|
||||||
|
pb.close();
|
||||||
|
pb.finish().expect("rounded rect path")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgba_image_to_pixmap(img: &RgbaImage) -> Pixmap {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let mut pixmap = Pixmap::new(w, h).expect("pixmap alloc");
|
||||||
|
let pixels = pixmap.pixels_mut();
|
||||||
|
for (i, px) in img.pixels().enumerate() {
|
||||||
|
let [r, g, b, a] = px.0;
|
||||||
|
let af = a as f32 / 255.0;
|
||||||
|
pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(
|
||||||
|
(r as f32 * af) as u8,
|
||||||
|
(g as f32 * af) as u8,
|
||||||
|
(b as f32 * af) as u8,
|
||||||
|
a,
|
||||||
|
)
|
||||||
|
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
|
||||||
|
}
|
||||||
|
pixmap
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixmap_to_rgba_image(pixmap: Pixmap) -> RgbaImage {
|
||||||
|
let (w, h) = (pixmap.width(), pixmap.height());
|
||||||
|
let mut out = RgbaImage::new(w, h);
|
||||||
|
for (i, px) in pixmap.pixels().iter().enumerate() {
|
||||||
|
let x = (i as u32) % w;
|
||||||
|
let y = (i as u32) / w;
|
||||||
|
let a = px.alpha();
|
||||||
|
let (r, g, b) = if a == 0 {
|
||||||
|
(0, 0, 0)
|
||||||
|
} else {
|
||||||
|
let af = a as f32 / 255.0;
|
||||||
|
(
|
||||||
|
(px.red() as f32 / af).round().min(255.0) as u8,
|
||||||
|
(px.green() as f32 / af).round().min(255.0) as u8,
|
||||||
|
(px.blue() as f32 / af).round().min(255.0) as u8,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
out.put_pixel(x, y, image::Rgba([r, g, b, a]));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tint_pixmap_as_shadow(pixmap: &mut Pixmap, color: [u8; 4]) {
|
||||||
|
let [sr, sg, sb, _] = color;
|
||||||
|
for px in pixmap.pixels_mut() {
|
||||||
|
let a = px.alpha();
|
||||||
|
if a > 0 {
|
||||||
|
let af = a as f32 / 255.0;
|
||||||
|
*px = tiny_skia::PremultipliedColorU8::from_rgba(
|
||||||
|
(sr as f32 * af) as u8,
|
||||||
|
(sg as f32 * af) as u8,
|
||||||
|
(sb as f32 * af) as u8,
|
||||||
|
a,
|
||||||
|
)
|
||||||
|
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn box_blur_rgba(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||||||
|
if radius == 0 {
|
||||||
|
return img.clone();
|
||||||
|
}
|
||||||
|
let mut buf = sliding_horizontal(img, radius);
|
||||||
|
buf = sliding_vertical(&buf, radius);
|
||||||
|
buf = sliding_horizontal(&buf, radius);
|
||||||
|
buf = sliding_vertical(&buf, radius);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let r = radius as i32;
|
||||||
|
let diam = (2 * r + 1) as u32;
|
||||||
|
let mut out = RgbaImage::new(w, h);
|
||||||
|
|
||||||
|
for y in 0..h {
|
||||||
|
let mut sr = 0u32;
|
||||||
|
let mut sg = 0u32;
|
||||||
|
let mut sb = 0u32;
|
||||||
|
let mut sa = 0u32;
|
||||||
|
|
||||||
|
for dx in -r..=r {
|
||||||
|
let sx = dx.clamp(0, w as i32 - 1) as u32;
|
||||||
|
let p = img.get_pixel(sx, y).0;
|
||||||
|
sr += p[0] as u32;
|
||||||
|
sg += p[1] as u32;
|
||||||
|
sb += p[2] as u32;
|
||||||
|
sa += p[3] as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
for x in 0..w {
|
||||||
|
out.put_pixel(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
image::Rgba([
|
||||||
|
(sr / diam) as u8,
|
||||||
|
(sg / diam) as u8,
|
||||||
|
(sb / diam) as u8,
|
||||||
|
(sa / diam) as u8,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let remove_x = (x as i32 - r).clamp(0, w as i32 - 1) as u32;
|
||||||
|
let add_x = (x as i32 + r + 1).clamp(0, w as i32 - 1) as u32;
|
||||||
|
let rp = img.get_pixel(remove_x, y).0;
|
||||||
|
let ap = img.get_pixel(add_x, y).0;
|
||||||
|
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
||||||
|
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
|
||||||
|
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
||||||
|
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sliding_vertical(img: &RgbaImage, radius: u32) -> RgbaImage {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let r = radius as i32;
|
||||||
|
let diam = (2 * r + 1) as u32;
|
||||||
|
let mut out = RgbaImage::new(w, h);
|
||||||
|
|
||||||
|
for x in 0..w {
|
||||||
|
let mut sr = 0u32;
|
||||||
|
let mut sg = 0u32;
|
||||||
|
let mut sb = 0u32;
|
||||||
|
let mut sa = 0u32;
|
||||||
|
|
||||||
|
for dy in -r..=r {
|
||||||
|
let sy = dy.clamp(0, h as i32 - 1) as u32;
|
||||||
|
let p = img.get_pixel(x, sy).0;
|
||||||
|
sr += p[0] as u32;
|
||||||
|
sg += p[1] as u32;
|
||||||
|
sb += p[2] as u32;
|
||||||
|
sa += p[3] as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
for y in 0..h {
|
||||||
|
out.put_pixel(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
image::Rgba([
|
||||||
|
(sr / diam) as u8,
|
||||||
|
(sg / diam) as u8,
|
||||||
|
(sb / diam) as u8,
|
||||||
|
(sa / diam) as u8,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let remove_y = (y as i32 - r).clamp(0, h as i32 - 1) as u32;
|
||||||
|
let add_y = (y as i32 + r + 1).clamp(0, h as i32 - 1) as u32;
|
||||||
|
let rp = img.get_pixel(x, remove_y).0;
|
||||||
|
let ap = img.get_pixel(x, add_y).0;
|
||||||
|
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
|
||||||
|
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
|
||||||
|
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
|
||||||
|
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
mod config;
|
||||||
|
mod effects;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
/// CLI overrides that map 1:1 to `EffectsConfig` fields.
|
||||||
|
/// All fields are `Option<T>` so we can tell "not supplied" from any concrete value.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CliOverrides {
|
||||||
|
rounded_corners: Option<bool>,
|
||||||
|
corner_radius: Option<f32>,
|
||||||
|
drop_shadow: Option<bool>,
|
||||||
|
shadow_blur_radius: Option<f32>,
|
||||||
|
shadow_offset_x: Option<f32>,
|
||||||
|
shadow_offset_y: Option<f32>,
|
||||||
|
/// Accepted as four comma-separated u8 values, e.g. `255,0,0,200`
|
||||||
|
shadow_color: Option<[u8; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(s: &str) -> Result<bool> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"true" | "1" | "yes" => Ok(true),
|
||||||
|
"false" | "0" | "no" => Ok(false),
|
||||||
|
other => bail!("Expected a boolean (true/false), got '{other}'"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_shadow_color(s: &str) -> Result<[u8; 4]> {
|
||||||
|
let parts: Vec<&str> = s.split(',').collect();
|
||||||
|
if parts.len() != 4 {
|
||||||
|
bail!("--shadow_color expects four comma-separated u8 values, e.g. 255,0,0,200");
|
||||||
|
}
|
||||||
|
let r = parts[0]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color red channel")?;
|
||||||
|
let g = parts[1]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color green channel")?;
|
||||||
|
let b = parts[2]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color blue channel")?;
|
||||||
|
let a = parts[3]
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.context("shadow_color alpha channel")?;
|
||||||
|
Ok([r, g, b, a])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
let mut image_path: Option<String> = None;
|
||||||
|
let mut overrides = CliOverrides::default();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--image" => {
|
||||||
|
i += 1;
|
||||||
|
image_path = Some(
|
||||||
|
args.get(i)
|
||||||
|
.cloned()
|
||||||
|
.context("Expected a path after --image")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--rounded_corners" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected true/false after --rounded_corners")?;
|
||||||
|
overrides.rounded_corners = Some(parse_bool(val)?);
|
||||||
|
}
|
||||||
|
"--corner_radius" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --corner_radius")?;
|
||||||
|
overrides.corner_radius = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--corner_radius must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--drop_shadow" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected true/false after --drop_shadow")?;
|
||||||
|
overrides.drop_shadow = Some(parse_bool(val)?);
|
||||||
|
}
|
||||||
|
"--shadow_blur_radius" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --shadow_blur_radius")?;
|
||||||
|
overrides.shadow_blur_radius = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--shadow_blur_radius must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--shadow_offset_x" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --shadow_offset_x")?;
|
||||||
|
overrides.shadow_offset_x = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--shadow_offset_x must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--shadow_offset_y" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected a number after --shadow_offset_y")?;
|
||||||
|
overrides.shadow_offset_y = Some(
|
||||||
|
val.parse::<f32>()
|
||||||
|
.context("--shadow_offset_y must be a number")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--shadow_color" => {
|
||||||
|
i += 1;
|
||||||
|
let val = args
|
||||||
|
.get(i)
|
||||||
|
.context("Expected r,g,b,a after --shadow_color")?;
|
||||||
|
overrides.shadow_color = Some(parse_shadow_color(val)?);
|
||||||
|
}
|
||||||
|
unknown => bail!("Unknown argument: {unknown}"),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_path = image_path.context("Missing --image <path>")?;
|
||||||
|
|
||||||
|
let config = config::Config::load().context("Failed to load config")?;
|
||||||
|
|
||||||
|
let mut effects = config.screenshot;
|
||||||
|
if effects.mode == "auto" {
|
||||||
|
if let Some(v) = overrides.rounded_corners {
|
||||||
|
effects.rounded_corners = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.corner_radius {
|
||||||
|
effects.corner_radius = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.drop_shadow {
|
||||||
|
effects.drop_shadow = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_blur_radius {
|
||||||
|
effects.shadow_blur_radius = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_offset_x {
|
||||||
|
effects.shadow_offset_x = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_offset_y {
|
||||||
|
effects.shadow_offset_y = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = overrides.shadow_color {
|
||||||
|
effects.shadow_color = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = process_image(&image_path, &effects) {
|
||||||
|
eprintln!("Error processing '{}': {e:#}", image_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_image(path: &str, effects: &config::EffectsConfig) -> Result<()> {
|
||||||
|
let img = image::open(path)
|
||||||
|
.with_context(|| format!("Failed to open image '{path}'"))?
|
||||||
|
.into_rgba8();
|
||||||
|
|
||||||
|
let processed = effects::apply_effects(img, effects);
|
||||||
|
|
||||||
|
let mut png_bytes: Vec<u8> = Vec::new();
|
||||||
|
image::DynamicImage::ImageRgba8(processed)
|
||||||
|
.write_to(
|
||||||
|
&mut std::io::Cursor::new(&mut png_bytes),
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
)
|
||||||
|
.context("Failed to encode processed image as PNG")?;
|
||||||
|
|
||||||
|
let mut child = Command::new("swappy")
|
||||||
|
.args(["-f", "-"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("Failed to spawn swappy. Is it installed and in PATH?")?;
|
||||||
|
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.context("Failed to get swappy stdin")?
|
||||||
|
.write_all(&png_bytes)
|
||||||
|
.context("Failed to write image data to swappy")?;
|
||||||
|
|
||||||
|
let status = child.wait().context("Failed to wait for swappy")?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
eprintln!(
|
||||||
|
"swappy exited with non-zero status for '{}': {}",
|
||||||
|
path, status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user