Screenshot tool #83

Merged
zach merged 11 commits from screenshot-tool into main 2026-05-16 01:24:48 +02:00
20 changed files with 2303 additions and 19 deletions
+1
View File
@@ -12,3 +12,4 @@ pkg/
uv.lock
.qtcreator/
dist/
**/target/
+33
View File
@@ -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
View File
@@ -23,6 +23,7 @@ Singleton {
property alias osd: adapter.osd
property alias overview: adapter.overview
property bool recentlySaved: false
property alias screenshot: adapter.screenshot
property alias services: adapter.services
property alias sidebar: adapter.sidebar
property alias utilities: adapter.utilities
@@ -128,7 +129,8 @@ Singleton {
background: serializeBackground(),
launcher: serializeLauncher(),
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 {
return {
weatherLocation: services.weatherLocation,
@@ -430,6 +446,8 @@ Singleton {
}
property Overview overview: Overview {
}
property Screenshot screenshot: Screenshot {
}
property Services services: Services {
}
property SidebarConfig sidebar: SidebarConfig {
+13
View File
@@ -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
View File
@@ -66,7 +66,8 @@ MouseArea {
function save(): void {
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();
}
+79
View File
@@ -11,10 +11,13 @@ Singleton {
id: root
property int availableUpdates: 0
property string cmd: ""
property bool commandReady
property bool loaded
property double now: Date.now()
property var updates: ({})
property bool updating
property string updatingPackage: ""
function formatUpdateTime(timestamp) {
const diffMs = root.now - timestamp;
@@ -34,6 +37,22 @@ Singleton {
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: {
if (!root.loaded)
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 {
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 {
id: saveTimer
+14 -9
View File
@@ -8,21 +8,25 @@ import qs.Helpers
Scope {
id: root
readonly property bool enabled: !Players.list.some(p => p.isPlaying)
required property Lock lock
readonly property bool enabled: !Players.list.some( p => p.isPlaying )
function handleIdleAction( action: var ): void {
if ( !action )
function handleIdleAction(action: var): void {
if (!action)
return;
if ( action === "lock" )
if (action === "lock")
lock.lock.locked = true;
else if ( action === "unlock" )
else if (action === "unlock")
lock.lock.locked = false;
else if ( typeof action === "string" )
Hypr.dispatch( action );
else if (action === "dpms on")
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
Quickshell.execDetached( action );
Quickshell.execDetached(action);
}
Variants {
@@ -33,7 +37,8 @@ Scope {
enabled: root.enabled && modelData.timeout > 0 ? true : false
timeout: modelData.timeout
onIsIdleChanged: root.handleIdleAction( isIdle ? modelData.idleAction : modelData.activeAction )
onIsIdleChanged: root.handleIdleAction(isIdle ? modelData.idleAction : modelData.activeAction)
}
}
}
+12
View File
@@ -104,6 +104,18 @@ Item {
key: "launcher"
name: "Launcher"
}
ListElement {
icon: "screenshot_region"
key: "screenshot"
name: "Screenshot"
}
ListElement {
icon: "cached"
key: "updates"
name: "Updates"
}
}
CustomClippingRect {
@@ -22,6 +22,13 @@ ColumnLayout {
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) {
const list = [...Config.general.idle.timeouts];
let entry = list[i];
@@ -49,6 +56,9 @@ ColumnLayout {
onAddActiveActionRequested: {
root.updateTimeoutEntry(index, "activeAction", "");
}
onDeleteRequested: function (index) {
root.deleteTimeoutEntry(index);
}
onFieldEdited: function (key, value) {
root.updateTimeoutEntry(index, key, value);
}
+130
View File
@@ -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
}))
}
}
}
}
+18
View File
@@ -74,6 +74,10 @@ Item {
stack.push(osd);
else if (currentCategory === "launcher")
stack.push(launcher);
else if (currentCategory === "screenshot")
stack.push(screenshot);
else if (currentCategory === "updates")
stack.push(updates);
}
target: root
@@ -225,4 +229,18 @@ Item {
Cat.Launcher {
}
}
Component {
id: screenshot
Cat.Screenshot {
}
}
Component {
id: updates
Cat.SystemUpdates {
}
}
}
+31 -8
View File
@@ -10,6 +10,7 @@ Item {
required property var modelData
signal addActiveActionRequested
signal deleteRequested(int index)
signal fieldEdited(string key, var value)
Layout.fillWidth: true
@@ -65,42 +66,64 @@ Item {
HoverHandler {
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 {
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.verticalCenter: parent.verticalCenter
elide: Text.ElideRight // enable if CustomText supports it
font.pointSize: Appearance.font.size.larger
text: root.modelData.name
visible: !nameCell.editing
Behavior on anchors.leftMargin {
Anim {
}
}
}
IconButton {
id: editButton
HoverIconButton {
id: deleteButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
font.pointSize: Appearance.font.size.large
icon: "edit"
visible: nameHover.hovered && !nameCell.editing
icon: "delete"
shouldBeVisible: nameHover.hovered && !nameCell.editing
onClicked: nameCell.beginEdit()
onClicked: root.deleteRequested(root.index)
}
CustomRect {
anchors.fill: parent
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
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
visible: nameCell.editing
CustomTextField {
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
Keys.onEscapePressed: {
+66
View File
@@ -951,6 +951,72 @@ export const settingsIndex = [
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 section
{
+1100
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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
+6
View File
@@ -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
+45
View File
@@ -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()))
}
}
+289
View File
@@ -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
}
+211
View File
@@ -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(())
}