[gnome] Add new extensions
This commit is contained in:
@ -0,0 +1,89 @@
|
||||
import * as Main from "resource:///org/gnome/shell/ui/main.js";
|
||||
import { Extension } from "resource:///org/gnome/shell/extensions/extension.js";
|
||||
import { isMatch } from "./isMatch.js";
|
||||
export var LogLevel;
|
||||
(function (LogLevel) {
|
||||
LogLevel["DEBUG"] = "debug";
|
||||
LogLevel["INFO"] = "info";
|
||||
LogLevel["WARN"] = "warn";
|
||||
LogLevel["ERROR"] = "error";
|
||||
})(LogLevel || (LogLevel = {}));
|
||||
function getObjectLabel(name, values) {
|
||||
const labels = Object.entries(values)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([label, value]) => `${label}: '${value}'`);
|
||||
return `${name}(${labels.join(", ")})`;
|
||||
}
|
||||
function getWindowLabel(window) {
|
||||
return getObjectLabel("Window", {
|
||||
["Title"]: window.title,
|
||||
["WMClass"]: window.wmClass,
|
||||
["GTKAppId"]: window.gtkApplicationId,
|
||||
["SandboxedAppId"]: window.get_sandboxed_app_id(),
|
||||
});
|
||||
}
|
||||
function getSourceLabel(source) {
|
||||
return getObjectLabel("Source", {
|
||||
["Title"]: source.title,
|
||||
["Icon"]: source.icon?.to_string(),
|
||||
});
|
||||
}
|
||||
export default class JunkNotificationCleaner extends Extension {
|
||||
focusListenerId = null;
|
||||
closeListenerId = null;
|
||||
settings = null;
|
||||
log(level, message) {
|
||||
let minLevel = this.settings.get_string("log-level");
|
||||
const levels = Object.values(LogLevel);
|
||||
if (!levels.includes(minLevel))
|
||||
minLevel = LogLevel.INFO;
|
||||
if (levels.indexOf(level) >= levels.indexOf(minLevel)) {
|
||||
log(`[${this.metadata.uuid}][${level}] ${message}`);
|
||||
}
|
||||
}
|
||||
clearNotificationsForApp(window, event) {
|
||||
const windowLabel = getWindowLabel(window);
|
||||
this.log(LogLevel.DEBUG, `${windowLabel}: received ${event}`);
|
||||
const excludedApps = this.settings.get_strv("excluded-apps");
|
||||
for (const wmClassPattern of excludedApps) {
|
||||
if (new RegExp(wmClassPattern).test(window.wmClass)) {
|
||||
this.log(LogLevel.DEBUG, `${windowLabel}: excluded by '${wmClassPattern}'`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const source of Main.messageTray.getSources()) {
|
||||
const sourceLabel = getSourceLabel(source);
|
||||
for (const notification of [...source.notifications]) {
|
||||
this.log(LogLevel.DEBUG, `${windowLabel}: ${sourceLabel}: found ${notification.isTransient ? "transient" : "persistent"} notification${notification.title ? `: ${notification.title}` : ""}`);
|
||||
if (isMatch(window, source) && !notification.isTransient) {
|
||||
notification.destroy();
|
||||
this.log(LogLevel.INFO, `${windowLabel}: ${sourceLabel}: removed notification${notification.title ? `: ${notification.title}` : ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
enable() {
|
||||
this.settings = this.getSettings();
|
||||
this.focusListenerId = global.display.connect("notify::focus-window", ({ focusWindow }) => {
|
||||
if (this.settings.get_boolean("delete-on-focus") && focusWindow) {
|
||||
this.clearNotificationsForApp(focusWindow, "focus");
|
||||
}
|
||||
});
|
||||
this.closeListenerId = global.window_manager.connect("destroy", (_, { metaWindow }) => {
|
||||
if (this.settings.get_boolean("delete-on-close") && metaWindow) {
|
||||
this.clearNotificationsForApp(metaWindow, "close");
|
||||
}
|
||||
});
|
||||
}
|
||||
disable() {
|
||||
if (this.focusListenerId !== null) {
|
||||
global.display.disconnect(this.focusListenerId);
|
||||
}
|
||||
if (this.closeListenerId !== null) {
|
||||
global.window_manager.disconnect(this.closeListenerId);
|
||||
}
|
||||
if (this.settings) {
|
||||
this.settings = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
export function isMatch(window, source) {
|
||||
if (source.icon) {
|
||||
const icon = source.icon.to_string();
|
||||
if (
|
||||
// Ghostty deb: icon matches GTK app id (com.mitchellh.ghostty)
|
||||
icon === window.gtkApplicationId ||
|
||||
// Slack Flatpak: icon matches sandboxed app id (com.slack.Slack)
|
||||
icon === window.get_sandboxed_app_id() ||
|
||||
// Firefox deb: icon matches window manager class (firefox)
|
||||
icon === window.wmClass) {
|
||||
return true;
|
||||
}
|
||||
// Snap apps have icon paths like /snap/firefox/6638/default256.png
|
||||
const snapAppName = icon?.match(/^\/snap\/([^/]+)\//)?.at(1);
|
||||
// Snap sandboxed ids use format appname_appname (firefox_firefox)
|
||||
if (snapAppName) {
|
||||
if (window.get_sandboxed_app_id() === `${snapAppName}_${snapAppName}`) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (source.title) {
|
||||
if (
|
||||
// Proton Mail Bridge: title matches window title
|
||||
source.title === window.title ||
|
||||
// Extract app name from composite title (isMatch.ts - junk-notification-cleaner - Cursor)
|
||||
source.title === window.title?.match(/^.+ (-|\|) (.+)$/)?.[2] ||
|
||||
// Thunderbird: title matches window manager class (thunderbird)
|
||||
source.title === window.wmClass ||
|
||||
// Discord snap: title duplicated matches sandboxed app id (discord_discord)
|
||||
`${source.title}_${source.title}` === window.get_sandboxed_app_id()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
{
|
||||
"_generated": "Generated by SweetTooth, do not edit",
|
||||
"description": "Delete notifications for an application when its window is focused or closed.",
|
||||
"name": "Junk Notification Cleaner",
|
||||
"settings-schema": "org.gnome.shell.extensions.junk-notification-cleaner",
|
||||
"shell-version": [
|
||||
"46",
|
||||
"47",
|
||||
"48",
|
||||
"49"
|
||||
],
|
||||
"url": "https://github.com/murar8/junk-notification-cleaner",
|
||||
"uuid": "junk-notification-cleaner@murar8.github.com",
|
||||
"version": 6
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
import Adw from "gi://Adw";
|
||||
import Gio from "gi://Gio";
|
||||
import Gtk from "gi://Gtk";
|
||||
import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js";
|
||||
const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
||||
export default class JunkNotificationCleanerPreferences extends ExtensionPreferences {
|
||||
async fillPreferencesWindow(window) {
|
||||
const settings = this.getSettings();
|
||||
const page = new Adw.PreferencesPage();
|
||||
page.set_title("Settings");
|
||||
page.set_icon_name("preferences-system-symbolic");
|
||||
window.add(page);
|
||||
const generalGroup = new Adw.PreferencesGroup();
|
||||
generalGroup.set_title("General Settings");
|
||||
page.add(generalGroup);
|
||||
const focusRow = new Adw.ActionRow({
|
||||
title: "Delete on Focus",
|
||||
subtitle: "Delete notifications when an application window is focused.",
|
||||
});
|
||||
const focusSwitch = new Gtk.Switch({
|
||||
active: settings.get_boolean("delete-on-focus"),
|
||||
valign: Gtk.Align.CENTER,
|
||||
});
|
||||
settings.bind("delete-on-focus", focusSwitch, "active", Gio.SettingsBindFlags.DEFAULT);
|
||||
focusRow.add_suffix(focusSwitch);
|
||||
generalGroup.add(focusRow);
|
||||
const closeRow = new Adw.ActionRow({
|
||||
title: "Delete on Close",
|
||||
subtitle: "Delete notifications when an application window is closed.",
|
||||
});
|
||||
const closeSwitch = new Gtk.Switch({
|
||||
active: settings.get_boolean("delete-on-close"),
|
||||
valign: Gtk.Align.CENTER,
|
||||
});
|
||||
settings.bind("delete-on-close", closeSwitch, "active", Gio.SettingsBindFlags.DEFAULT);
|
||||
closeRow.add_suffix(closeSwitch);
|
||||
generalGroup.add(closeRow);
|
||||
const debugGroup = new Adw.PreferencesGroup();
|
||||
debugGroup.set_title("Logging");
|
||||
page.add(debugGroup);
|
||||
const logLevelRow = new Adw.ActionRow({
|
||||
title: "Log Level",
|
||||
subtitle: "Set the logging level for troubleshooting notification matching.",
|
||||
});
|
||||
const logLevelDropdown = new Gtk.DropDown({
|
||||
model: Gtk.StringList.new(LOG_LEVELS),
|
||||
valign: Gtk.Align.CENTER,
|
||||
});
|
||||
let currentLogLevel = settings.get_string("log-level") || "info";
|
||||
const currentIndex = LOG_LEVELS.indexOf(currentLogLevel);
|
||||
if (currentIndex !== -1) {
|
||||
logLevelDropdown.set_selected(currentIndex);
|
||||
}
|
||||
logLevelDropdown.connect("notify::selected", () => {
|
||||
const selectedIndex = logLevelDropdown.get_selected();
|
||||
settings.set_string("log-level", LOG_LEVELS[selectedIndex]);
|
||||
});
|
||||
logLevelRow.add_suffix(logLevelDropdown);
|
||||
debugGroup.add(logLevelRow);
|
||||
const excludedGroup = new Adw.PreferencesGroup();
|
||||
excludedGroup.set_title("Excluded WM Classes");
|
||||
excludedGroup.set_description([
|
||||
"Window Manager Classes whose notifications will not be automatically deleted.",
|
||||
"Will be matched against the wm_class property of the window, supports ECMAScript regular expressions.",
|
||||
].join("\n"));
|
||||
page.add(excludedGroup);
|
||||
const excludedBox = new Gtk.Box({
|
||||
orientation: Gtk.Orientation.VERTICAL,
|
||||
margin_top: 8,
|
||||
margin_bottom: 8,
|
||||
margin_start: 8,
|
||||
margin_end: 8,
|
||||
spacing: 8,
|
||||
});
|
||||
const excludedApps = settings.get_strv("excluded-apps");
|
||||
const listBox = new Gtk.ListBox({
|
||||
selection_mode: Gtk.SelectionMode.NONE,
|
||||
css_classes: ["boxed-list"],
|
||||
});
|
||||
excludedBox.append(listBox);
|
||||
for (const app of excludedApps) {
|
||||
this.addExcludedAppRow(app, listBox, settings);
|
||||
}
|
||||
const addBox = new Gtk.Box({
|
||||
orientation: Gtk.Orientation.HORIZONTAL,
|
||||
spacing: 8,
|
||||
margin_top: 8,
|
||||
});
|
||||
const entry = new Gtk.Entry({
|
||||
placeholder_text: "Enter WM Class regex (e.g. .*firefox.*)",
|
||||
hexpand: true,
|
||||
});
|
||||
const addButton = new Gtk.Button({
|
||||
label: "Add",
|
||||
css_classes: ["suggested-action"],
|
||||
});
|
||||
addButton.connect("clicked", () => {
|
||||
const text = entry.get_text().trim();
|
||||
if (!text)
|
||||
return;
|
||||
const currentApps = settings.get_strv("excluded-apps");
|
||||
if (currentApps.includes(text))
|
||||
return;
|
||||
settings.set_strv("excluded-apps", [...currentApps, text]);
|
||||
this.addExcludedAppRow(text, listBox, settings);
|
||||
entry.set_text("");
|
||||
});
|
||||
addBox.append(entry);
|
||||
addBox.append(addButton);
|
||||
excludedBox.append(addBox);
|
||||
excludedGroup.add(excludedBox);
|
||||
}
|
||||
addExcludedAppRow(app, listBox, settings) {
|
||||
const row = new Gtk.ListBoxRow();
|
||||
const box = new Gtk.Box({
|
||||
orientation: Gtk.Orientation.HORIZONTAL,
|
||||
spacing: 8,
|
||||
margin_top: 8,
|
||||
margin_bottom: 8,
|
||||
margin_start: 8,
|
||||
margin_end: 8,
|
||||
});
|
||||
const label = new Gtk.Label({
|
||||
label: app,
|
||||
hexpand: true,
|
||||
xalign: 0,
|
||||
});
|
||||
const removeButton = new Gtk.Button({
|
||||
icon_name: "user-trash-symbolic",
|
||||
tooltip_text: "Remove",
|
||||
});
|
||||
removeButton.connect("clicked", () => {
|
||||
const currentApps = settings.get_strv("excluded-apps");
|
||||
const newApps = currentApps.filter((a) => a !== app);
|
||||
settings.set_strv("excluded-apps", newApps);
|
||||
listBox.remove(row);
|
||||
});
|
||||
box.append(label);
|
||||
box.append(removeButton);
|
||||
row.set_child(box);
|
||||
listBox.append(row);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist>
|
||||
<schema id="org.gnome.shell.extensions.junk-notification-cleaner" path="/org/gnome/shell/extensions/junk-notification-cleaner/">
|
||||
<key name="delete-on-focus" type="b">
|
||||
<default>true</default>
|
||||
<summary>Delete notifications when focusing app</summary>
|
||||
<description>Delete notifications when an application window is focused.</description>
|
||||
</key>
|
||||
<key name="delete-on-close" type="b">
|
||||
<default>true</default>
|
||||
<summary>Delete notifications when closing app</summary>
|
||||
<description>Delete notifications when an application window is closed.</description>
|
||||
</key>
|
||||
<key name="excluded-apps" type="as">
|
||||
<default>[]</default>
|
||||
<summary>Excluded applications</summary>
|
||||
<description>List of application regex patterns for which notifications should not be automatically deleted.</description>
|
||||
</key>
|
||||
<key name="log-level" type="s">
|
||||
<default>'info'</default>
|
||||
<summary>Log level</summary>
|
||||
<description>Set the logging level for troubleshooting notification matching issues.</description>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
@ -0,0 +1,245 @@
|
||||
/* extension.js
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import Clutter from 'gi://Clutter';
|
||||
|
||||
|
||||
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import { MessageTray } from 'resource:///org/gnome/shell/ui/messageTray.js';
|
||||
import * as Utils from './utils.js';
|
||||
|
||||
const BannerBin = Main.messageTray._bannerBin;
|
||||
|
||||
/* Imports necessary by the code pulled in from messageTray.js */
|
||||
import { State, Urgency } from 'resource:///org/gnome/shell/ui/messageTray.js';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import GObject from 'gi://GObject';
|
||||
import Meta from 'gi://Meta';
|
||||
import Shell from 'gi://Shell';
|
||||
import St from 'gi://St';
|
||||
import * as Calendar from 'resource:///org/gnome/shell/ui/calendar.js'
|
||||
import * as MessageList from 'resource:///org/gnome/shell/ui/messageList.js'
|
||||
|
||||
|
||||
const NOTIFICATION_TIMEOUT = 4000;
|
||||
const HIDE_TIMEOUT = 200;
|
||||
const LONGER_HIDE_TIMEOUT = 600;
|
||||
const IDLE_TIME = 1000;
|
||||
|
||||
let ANIMATION_TIME = 200;
|
||||
let ANIMATION_DIRECTION = 2;
|
||||
let ANCHOR_VERTICAL = 0;
|
||||
let ANCHOR_HORIZONTAL = 2;
|
||||
let PADDING_VERTICAL = 0;
|
||||
let PADDING_HORIZONTAL = 0;
|
||||
let ALWAYS_MINIMIZE = 0;
|
||||
|
||||
function patcher(obj, live, method, original, patch) {
|
||||
const body = eval(`${obj}.prototype.${method}.toString()`);
|
||||
const newBody = body.replace(original, patch).replace(method + "(", "function(")
|
||||
eval(`${obj}.prototype.${method} = ${newBody}`);
|
||||
eval(`${live}.${method} = ${newBody}`);
|
||||
}
|
||||
|
||||
const getMessageTraySize = () => {
|
||||
const { width, height } = Main.layoutManager.getWorkAreaForMonitor(global.display.get_primary_monitor());
|
||||
return {width, height};
|
||||
}
|
||||
|
||||
const originalShow = MessageTray.prototype._showNotification;
|
||||
const originalHide = MessageTray.prototype._hideNotification;
|
||||
const originalUpdateShowing = MessageTray.prototype._updateShowingNotification;
|
||||
|
||||
function calcTarget(self) {
|
||||
let x = 0, y = 0;
|
||||
switch (ANCHOR_HORIZONTAL) {
|
||||
case 0: // left
|
||||
x = 0 + PADDING_HORIZONTAL;
|
||||
break;
|
||||
case 1: // right
|
||||
x = getMessageTraySize().width - self._banner.width - PADDING_HORIZONTAL;
|
||||
break;
|
||||
case 2: // center
|
||||
x = (getMessageTraySize().width - self._banner.width) / 2.0;
|
||||
break;
|
||||
}
|
||||
switch (ANCHOR_VERTICAL) {
|
||||
case 0: // top
|
||||
y = 0 + PADDING_VERTICAL;
|
||||
break;
|
||||
case 1: // bottom
|
||||
y = getMessageTraySize().height - self._banner.height - PADDING_VERTICAL;
|
||||
break;
|
||||
case 2: // center
|
||||
y = (getMessageTraySize().height - self._banner.height) / 2.0;
|
||||
break;
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function calcHide(self) {
|
||||
let { x, y } = calcTarget(self)
|
||||
switch (ANIMATION_DIRECTION) {
|
||||
case 0: // from left
|
||||
x = -self._banner.width;
|
||||
break;
|
||||
case 1: // from right
|
||||
x = getMessageTraySize().width;
|
||||
break;
|
||||
case 2: // from top
|
||||
y = -self._banner.height
|
||||
break;
|
||||
case 3: // from bottom
|
||||
y = getMessageTraySize().height
|
||||
break;
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function calcStart(self) {
|
||||
const { x, y } = calcHide(self);
|
||||
self._bannerBin.x = x;
|
||||
self._bannerBin.y = y;
|
||||
|
||||
|
||||
// if banner is not expanded and anchored to the bottom
|
||||
// it won't have enough vertical space to expand
|
||||
// in such case, move it up enough to fit the expanded banner
|
||||
if (!self._banner.expanded && ANCHOR_VERTICAL == 1) {
|
||||
const unexpandedHeight = self._banner.height
|
||||
// expand without animation to measure height
|
||||
self._banner.expand(false);
|
||||
const expandedDifference = self._banner.height - unexpandedHeight;
|
||||
// go back to unexpanded
|
||||
self._banner.unexpand(false);
|
||||
|
||||
// move up when needed
|
||||
self._banner.connect('expanded', () => {
|
||||
self._bannerBin.ease({
|
||||
y: self._bannerBin.y - expandedDifference,
|
||||
duration: ANIMATION_TIME,
|
||||
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// each of the methods has hardcoded values for showing banners
|
||||
// at the top. instead of rewriting whole functions, just patch the
|
||||
// relevant lines
|
||||
const patches = [
|
||||
{
|
||||
"obj": "MessageTray", "method": "_updateShowingNotification",
|
||||
"live": "Main.messageTray",
|
||||
"original": 'y: 0',
|
||||
"patch": '...calcTarget(this)'
|
||||
},
|
||||
{
|
||||
"obj": "MessageTray", "method": "_showNotification",
|
||||
"live": "Main.messageTray",
|
||||
"original": 'this._bannerBin.y = -this._banner.height',
|
||||
"patch": 'calcStart(this)'
|
||||
},
|
||||
{
|
||||
"obj": "MessageTray", "method": "_hideNotification",
|
||||
"live": "Main.messageTray",
|
||||
"original": 'y: -this._bannerBin.height',
|
||||
"patch": '...calcHide(this)'
|
||||
}
|
||||
];
|
||||
|
||||
const always_minimize_patch = {
|
||||
obj: "MessageTray",
|
||||
live: "Main.messageTray",
|
||||
method: "_updateShowingNotification",
|
||||
original: "this._expandBanner(true)",
|
||||
patch: "// always minimized setting enabled by notification-banner-reloaded ... this._expandBanner(true)",
|
||||
};
|
||||
|
||||
export default class NotificationExtension extends Extension {
|
||||
constructor(metadata) {
|
||||
super(metadata);
|
||||
this._previous_y_align = BannerBin.get_y_align();
|
||||
this._previous_x_align = BannerBin.get_x_align();
|
||||
}
|
||||
|
||||
_loadSettings() {
|
||||
this._settings = this.getSettings();
|
||||
this._settingsChangedId = this._settings.connect('changed', this._onSettingsChange.bind(this));
|
||||
this._fetchSettings();
|
||||
}
|
||||
|
||||
_fetchSettings() {
|
||||
ANCHOR_VERTICAL = this._settings.get_int(Utils.PrefFields.ANCHOR_VERTICAL);
|
||||
ANCHOR_HORIZONTAL = this._settings.get_int(Utils.PrefFields.ANCHOR_HORIZONTAL);
|
||||
PADDING_VERTICAL = this._settings.get_int(Utils.PrefFields.PADDING_VERTICAL);
|
||||
PADDING_HORIZONTAL = this._settings.get_int(Utils.PrefFields.PADDING_HORIZONTAL);
|
||||
ANIMATION_DIRECTION = this._settings.get_int(Utils.PrefFields.ANIMATION_DIRECTION);
|
||||
ANIMATION_TIME = this._settings.get_int(Utils.PrefFields.ANIMATION_TIME);
|
||||
ALWAYS_MINIMIZE = this._settings.get_int(Utils.PrefFields.ALWAYS_MINIMIZE);
|
||||
}
|
||||
|
||||
_onSettingsChange() {
|
||||
this._fetchSettings();
|
||||
this.enable();
|
||||
}
|
||||
|
||||
enable() {
|
||||
this._loadSettings();
|
||||
// generally alignment can be controller with START/CENTER/END
|
||||
// but CENTER and END are problematic to implement animations with
|
||||
// (especially x -> END and animations from left/right)
|
||||
// all positions will then be calculated in relation to the top-left
|
||||
// corner (START/START).
|
||||
let x_align = Clutter.ActorAlign.START;
|
||||
let y_align = Clutter.ActorAlign.START;
|
||||
BannerBin.set_x_align(x_align);
|
||||
BannerBin.set_y_align(y_align);
|
||||
this.restore();
|
||||
for (const { obj, live, method, original, patch } of patches) {
|
||||
patcher(obj, live, method, original, patch)
|
||||
}
|
||||
|
||||
if (ALWAYS_MINIMIZE) {
|
||||
const { obj, live, method, original, patch } = always_minimize_patch;
|
||||
patcher(obj, live, method, original, patch);
|
||||
}
|
||||
}
|
||||
|
||||
disable() {
|
||||
BannerBin.set_x_align(this._previous_x_align);
|
||||
BannerBin.set_y_align(this._previous_y_align);
|
||||
BannerBin.x = 0
|
||||
BannerBin.y = 0
|
||||
this.restore()
|
||||
if (this._settingsChangedId) {
|
||||
this._settings.disconnect(this._settingsChangedId);
|
||||
this._settingsChangedId = null;
|
||||
}
|
||||
this._settings = null;
|
||||
|
||||
}
|
||||
|
||||
restore() {
|
||||
MessageTray.prototype._hideNotification = originalHide;
|
||||
MessageTray.prototype._showNotification = originalShow;
|
||||
MessageTray.prototype._updateShowingNotification = originalUpdateShowing;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"_generated": "Generated by SweetTooth, do not edit",
|
||||
"description": "Configure notification banner position and animation to your liking.\nVersion 9: Gnome 45 changes by mannjani@github\nVersion 10: mannjani@github added a test button inside prefs",
|
||||
"name": "Notification Banner Reloaded",
|
||||
"settings-schema": "org.gnome.shell.extensions.notification-banner-reloaded",
|
||||
"shell-version": [
|
||||
"45",
|
||||
"46",
|
||||
"47",
|
||||
"48",
|
||||
"49"
|
||||
],
|
||||
"url": "https://github.com/marcinjakubowski/notification-position-reloaded",
|
||||
"uuid": "notification-banner-reloaded@marcinjakubowski.github.com",
|
||||
"version": 17
|
||||
}
|
||||
@ -0,0 +1,248 @@
|
||||
/* extension.js
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
/*
|
||||
Swaths of pref related code borrowed from Clipboard Indicator, an amazing extension
|
||||
https://github.com/Tudmotu/gnome-shell-extension-clipboard-indicator
|
||||
https://extensions.gnome.org/extension/779/clipboard-indicator/
|
||||
*/
|
||||
import GObject from 'gi://GObject';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import * as Utils from './utils.js';
|
||||
|
||||
import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
||||
export default class NotificationExtensionPreferences extends ExtensionPreferences {
|
||||
constructor(metadata) {
|
||||
super(metadata);
|
||||
}
|
||||
|
||||
getPreferencesWidget() {
|
||||
let frame = new Gtk.Box();
|
||||
let widget = new Preferences(this.getSettings());
|
||||
frame.append(widget.main);
|
||||
if (frame.show_all)
|
||||
frame.show_all();
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
|
||||
class Preferences{
|
||||
constructor(settings) {
|
||||
this.settings = settings
|
||||
this.main = new Gtk.Grid({
|
||||
margin_top: 10,
|
||||
margin_bottom: 10,
|
||||
margin_start: 10,
|
||||
margin_end: 10,
|
||||
row_spacing: 12,
|
||||
column_spacing: 18,
|
||||
column_homogeneous: false,
|
||||
row_homogeneous: false
|
||||
});
|
||||
|
||||
this.paddingHorizontal = new Gtk.SpinButton({
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 0,
|
||||
upper: 1000,
|
||||
step_increment: 1
|
||||
})
|
||||
});
|
||||
this.paddingVertical = new Gtk.SpinButton({
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 0,
|
||||
upper: 1000,
|
||||
step_increment: 1
|
||||
})
|
||||
});
|
||||
this.animationTime = new Gtk.SpinButton({
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 100,
|
||||
upper: 5000,
|
||||
step_increment: 100
|
||||
})
|
||||
});
|
||||
this.anchorHorizontal = new Gtk.ComboBox({
|
||||
model: this._create_options([ _('Left'), _('Right'), _('Center') ])
|
||||
});
|
||||
this.anchorVertical = new Gtk.ComboBox({
|
||||
model: this._create_options([ _('Top'), _('Bottom'), _('Center') ])
|
||||
});
|
||||
this.animationDirection = new Gtk.ComboBox({
|
||||
model: this._create_options([ _('Slide from Left'), _('Slide from Right'), _('Slide from Top'), _('Slide from Bottom')])
|
||||
});
|
||||
this.alwaysMinimize = new Gtk.ComboBox({
|
||||
model: this._create_options([ _('false'), _('true')])
|
||||
});
|
||||
this.testButton = new Gtk.Button({ label: _("Test") });
|
||||
this.testButton.connect('clicked', () => {
|
||||
const notification = new GLib.Variant('(susssasa{sv}i)', [
|
||||
'Notification Banner Reloaded',
|
||||
0,
|
||||
'dialog-information-symbolic',
|
||||
'Notification Banner Reloaded',
|
||||
'Test',
|
||||
[],
|
||||
{},
|
||||
-1,
|
||||
]);
|
||||
Gio.DBus.session.call(
|
||||
'org.freedesktop.Notifications',
|
||||
'/org/freedesktop/Notifications',
|
||||
'org.freedesktop.Notifications',
|
||||
'Notify',
|
||||
notification,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
null);
|
||||
});
|
||||
|
||||
let rendererText = new Gtk.CellRendererText();
|
||||
for (const widget of [this.anchorHorizontal, this.anchorVertical, this.animationDirection, this.alwaysMinimize]) {
|
||||
widget.pack_start(rendererText, false);
|
||||
widget.add_attribute(rendererText, "text", 0);
|
||||
}
|
||||
|
||||
let anchorHorizontalLabel = new Gtk.Label({
|
||||
label: _("Horizontal Position"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
let anchorVerticalLabel = new Gtk.Label({
|
||||
label: _("Vertical Position"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
let paddingHorizontalLabel = new Gtk.Label({
|
||||
label: _("Horizontal Padding"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
let paddingVerticalLabel = new Gtk.Label({
|
||||
label: _("Vertical Padding"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
let animationDirectionLabel = new Gtk.Label({
|
||||
label: _("Animation Direction"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
let animationTimeLabel = new Gtk.Label({
|
||||
label: _("Animation Time (ms)"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
let alwaysMinimizeLabel = new Gtk.Label({
|
||||
label: _("Always Minimize"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
|
||||
const testButtonLabel = new Gtk.Label({
|
||||
label: _("Send Test Notification"),
|
||||
hexpand: true,
|
||||
halign: Gtk.Align.START
|
||||
});
|
||||
|
||||
const addRow = ((main) => {
|
||||
let row = 0;
|
||||
return (label, input) => {
|
||||
let inputWidget = input;
|
||||
|
||||
if (input instanceof Gtk.Switch) {
|
||||
inputWidget = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL,});
|
||||
addBox(inputWidget, input);
|
||||
}
|
||||
|
||||
if (label) {
|
||||
main.attach(label, 0, row, 1, 1);
|
||||
main.attach(inputWidget, 1, row, 1, 1);
|
||||
}
|
||||
else {
|
||||
main.attach(inputWidget, 0, row, 2, 1);
|
||||
}
|
||||
|
||||
row++;
|
||||
};
|
||||
})(this.main);
|
||||
|
||||
addRow(anchorHorizontalLabel, this.anchorHorizontal);
|
||||
addRow(anchorVerticalLabel, this.anchorVertical);
|
||||
addRow(paddingHorizontalLabel, this.paddingHorizontal);
|
||||
addRow(paddingVerticalLabel, this.paddingVertical);
|
||||
addRow(animationDirectionLabel, this.animationDirection);
|
||||
addRow(animationTimeLabel, this.animationTime);
|
||||
addRow(alwaysMinimizeLabel, this.alwaysMinimize);
|
||||
addRow(testButtonLabel, this.testButton);
|
||||
|
||||
settings.bind(Utils.PrefFields.ANCHOR_HORIZONTAL, this.anchorHorizontal, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
settings.bind(Utils.PrefFields.ANCHOR_VERTICAL, this.anchorVertical, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
settings.bind(Utils.PrefFields.ANIMATION_DIRECTION, this.animationDirection, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
settings.bind(Utils.PrefFields.ALWAYS_MINIMIZE, this.alwaysMinimize, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
|
||||
this._bindSpinWithThrottle(this.paddingHorizontal, Utils.PrefFields.PADDING_HORIZONTAL);
|
||||
this._bindSpinWithThrottle(this.paddingVertical, Utils.PrefFields.PADDING_VERTICAL);
|
||||
this._bindSpinWithThrottle(this.animationTime, Utils.PrefFields.ANIMATION_TIME);
|
||||
}
|
||||
|
||||
_bindSpinWithThrottle(spinButton, key) {
|
||||
/*- Load value from settings -*/
|
||||
spinButton.value = this.settings.get_int(key);
|
||||
let timeoutId = 0;
|
||||
const updateSettings = () => {
|
||||
const newVal = Math.round(spinButton.value);
|
||||
if (this.settings.get_int(key) !== newVal) {
|
||||
this.settings.set_int(key, newVal);
|
||||
}
|
||||
timeoutId = 0;
|
||||
return GLib.SOURCE_REMOVE;
|
||||
};
|
||||
|
||||
spinButton.connect('value-changed', () => {
|
||||
if (timeoutId) {
|
||||
GLib.source_remove(timeoutId);
|
||||
}
|
||||
timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, updateSettings);
|
||||
});
|
||||
|
||||
/*- If the user update elsewhere, we update spin value -*/
|
||||
this.settings.connect(`changed::${key}`, () => {
|
||||
const val = this.settings.get_int(key);
|
||||
if (Math.round(spinButton.value) !== val) {
|
||||
spinButton.value = val;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_create_options(opts) {
|
||||
let options = opts.map(function (v) { return { name: v }});
|
||||
let liststore = new Gtk.ListStore();
|
||||
liststore.set_column_types([GObject.TYPE_STRING])
|
||||
for (let i = 0; i < options.length; i++ ) {
|
||||
let option = options[i];
|
||||
let iter = liststore.append();
|
||||
liststore.set (iter, [0], [option.name]);
|
||||
}
|
||||
return liststore;
|
||||
}
|
||||
};
|
||||
Binary file not shown.
@ -0,0 +1,53 @@
|
||||
<schemalist gettext-domain="gnome-shell-extensions">
|
||||
<schema id="org.gnome.shell.extensions.notification-banner-reloaded"
|
||||
path="/org/gnome/shell/extensions/notification-banner-reloaded/">
|
||||
|
||||
<key type="i" name="animation-time">
|
||||
<default>200</default>
|
||||
<summary>Duration of the show/hide animation</summary>
|
||||
<description>Duration of the show/hide animation, in milliseconds</description>
|
||||
<range min="100" max="5000"/>
|
||||
</key>
|
||||
|
||||
<key type="i" name="anchor-vertical">
|
||||
<default>0</default>
|
||||
<summary>Vertical position of the banner</summary>
|
||||
<description></description>
|
||||
<range min="0" max="2"/>
|
||||
</key>
|
||||
|
||||
<key type="i" name="anchor-horizontal">
|
||||
<default>2</default>
|
||||
<summary>Horizontal position of the banner</summary>
|
||||
<description></description>
|
||||
<range min="0" max="2"/>
|
||||
</key>
|
||||
|
||||
<key type="i" name="padding-vertical">
|
||||
<default>0</default>
|
||||
<summary>Vertical padding of the banner</summary>
|
||||
<description></description>
|
||||
<range min="0" max="1000"/>
|
||||
</key>
|
||||
|
||||
<key type="i" name="padding-horizontal">
|
||||
<default>0</default>
|
||||
<summary>Horizontal padding of the banner</summary>
|
||||
<description></description>
|
||||
<range min="0" max="1000"/>
|
||||
</key>
|
||||
|
||||
<key type="i" name="animation-direction">
|
||||
<default>2</default>
|
||||
<summary>Animation direction</summary>
|
||||
<description></description>
|
||||
<range min="0" max="3"/>
|
||||
</key>
|
||||
<key type="i" name="always-minimized">
|
||||
<default>0</default>
|
||||
<summary>Always minimized notifications by default (ignores urgency of notification)</summary>
|
||||
<description></description>
|
||||
<range min="0" max="1"/>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
@ -0,0 +1,9 @@
|
||||
export const PrefFields = {
|
||||
ANCHOR_VERTICAL : 'anchor-vertical',
|
||||
ANCHOR_HORIZONTAL : 'anchor-horizontal',
|
||||
PADDING_VERTICAL : 'padding-vertical',
|
||||
PADDING_HORIZONTAL : 'padding-horizontal',
|
||||
ANIMATION_TIME : 'animation-time',
|
||||
ANIMATION_DIRECTION : 'animation-direction',
|
||||
ALWAYS_MINIMIZE : 'always-minimized',
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@
|
||||
{
|
||||
"_generated": "Generated by SweetTooth, do not edit",
|
||||
"creator": "comitanigiacomo",
|
||||
"description": "Supercharge your GNOME workflow with this sleek top-bar dock:\n\n ~ 1-Click Access - Launch apps/websites instantly\n ~ Full Customization - Resize icons, adjust spacing, toggle labels\n ~ Live Activity Indicators - Color-coded dots for running apps\n ~ Smart Search - Find+pin apps directly from the menu\n ~ 10 Keyboard Shortcuts - Instant launches with custom keybinds\n ~ Web Links Support - Pin frequently visited URLs\n ~ Auto-Start Essentials - Launch critical apps on login\n ~ Flexible Positioning - Left/Center/Right panel placement\n ~ Press-to-Reorder - Intuitive visual organization\n ~ Smart Limiter - Focus on what matters (max 10 items)",
|
||||
"name": "quick-launch",
|
||||
"settings-schema": "org.gnome.shell.extensions.app-pinner",
|
||||
"shell-version": [
|
||||
"45",
|
||||
"46",
|
||||
"47",
|
||||
"48",
|
||||
"49"
|
||||
],
|
||||
"stylesheet": "stylesheet.css",
|
||||
"url": "https://github.com/comitanigiacomo/quicklaunch",
|
||||
"uuid": "quicklaunch@comitanigiacomo.github.com",
|
||||
"version": 15
|
||||
}
|
||||
@ -0,0 +1,672 @@
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Adw from 'gi://Adw';
|
||||
import Gio from 'gi://Gio';
|
||||
import Gdk from 'gi://Gdk';
|
||||
import GLib from 'gi://GLib';
|
||||
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
||||
export default class AppPinnerExtensionPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
const settings = this.getSettings('org.gnome.shell.extensions.app-pinner');
|
||||
|
||||
window.set_search_enabled(true);
|
||||
|
||||
// Pagina Aspetto
|
||||
const appearancePage = new Adw.PreferencesPage({
|
||||
title: 'Appearance',
|
||||
icon_name: 'applications-graphics-symbolic'
|
||||
});
|
||||
this._buildAppearancePage(appearancePage, settings);
|
||||
window.add(appearancePage);
|
||||
|
||||
// Pagina Scorciatoie
|
||||
const shortcutsPage = new Adw.PreferencesPage({
|
||||
title: 'Shortcuts',
|
||||
icon_name: 'preferences-desktop-keyboard-symbolic'
|
||||
});
|
||||
this._buildShortcutsPage(shortcutsPage, settings);
|
||||
window.add(shortcutsPage);
|
||||
|
||||
// Pagina Avvio Automatico
|
||||
const startupPage = new Adw.PreferencesPage({
|
||||
title: 'Startup Apps',
|
||||
icon_name: 'system-run-symbolic'
|
||||
});
|
||||
this._buildStartupPage(startupPage, settings);
|
||||
window.add(startupPage);
|
||||
|
||||
// Pagina About
|
||||
const aboutPage = new Adw.PreferencesPage({
|
||||
title: 'About',
|
||||
icon_name: 'dialog-information-symbolic'
|
||||
});
|
||||
this._buildAboutPage(aboutPage);
|
||||
window.add(aboutPage);
|
||||
}
|
||||
|
||||
_buildAppearancePage(page, settings) {
|
||||
const group = new Adw.PreferencesGroup({
|
||||
title: 'Panel Customization',
|
||||
description: 'Adjust how pinned apps appear in your top panel'
|
||||
});
|
||||
|
||||
const iconSizeRow = new Adw.SpinRow({
|
||||
title: 'Icon Size',
|
||||
subtitle: 'Recommended between 16-24 pixels for optimal panel integration',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
value: settings.get_int('icon-size'),
|
||||
lower: 16,
|
||||
upper: 24,
|
||||
step_increment: 1
|
||||
})
|
||||
});
|
||||
settings.bind('icon-size', iconSizeRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
group.add(iconSizeRow);
|
||||
|
||||
const positionRow = new Adw.ComboRow({
|
||||
title: 'Panel Position',
|
||||
subtitle: 'Choose where to show the pinned apps in your top panel',
|
||||
model: new Gtk.StringList({ strings: ['left', 'center', 'right'] })
|
||||
});
|
||||
|
||||
positionRow.connect('notify::selected', () => {
|
||||
const selectedString = positionRow.model.get_string(positionRow.selected);
|
||||
settings.set_string('position-in-panel', selectedString);
|
||||
});
|
||||
|
||||
const currentPos = settings.get_string('position-in-panel');
|
||||
positionRow.selected = ['left', 'center', 'right'].indexOf(currentPos);
|
||||
|
||||
group.add(positionRow);
|
||||
|
||||
const spacingRow = new Adw.SpinRow({
|
||||
title: 'Icon Spacing',
|
||||
subtitle: 'Space between app icons in pixels',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
value: settings.get_int('spacing'),
|
||||
lower: 0,
|
||||
upper: 20,
|
||||
step_increment: 1
|
||||
})
|
||||
});
|
||||
settings.bind('spacing', spacingRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
group.add(spacingRow);
|
||||
|
||||
const colorRow = new Adw.ActionRow({
|
||||
title: 'Indicator Color',
|
||||
subtitle: 'Choose color for running apps indicator'
|
||||
});
|
||||
|
||||
const colorButton = new Gtk.ColorButton({
|
||||
valign: Gtk.Align.CENTER,
|
||||
use_alpha: true
|
||||
});
|
||||
|
||||
const initialColor = new Gdk.RGBA();
|
||||
initialColor.parse(settings.get_string('indicator-color'));
|
||||
colorButton.set_rgba(initialColor);
|
||||
|
||||
colorButton.connect('color-set', (btn) => {
|
||||
const color = btn.get_rgba().to_string();
|
||||
settings.set_string('indicator-color', color);
|
||||
});
|
||||
|
||||
colorRow.add_suffix(colorButton);
|
||||
colorRow.set_activatable_widget(colorButton);
|
||||
group.add(colorRow);
|
||||
|
||||
const sortToggle = new Adw.SwitchRow({
|
||||
title: 'Sort Alphabetically',
|
||||
subtitle: 'Automatically sort apps by name',
|
||||
active: settings.get_boolean('sort-alphabetically')
|
||||
});
|
||||
settings.bind('sort-alphabetically', sortToggle, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
group.add(sortToggle);
|
||||
|
||||
const visibilityGroup = new Adw.PreferencesGroup({
|
||||
title: 'Visibility Options',
|
||||
description: 'Control what elements are displayed in the panel'
|
||||
});
|
||||
|
||||
const panelToggle = new Adw.SwitchRow({
|
||||
title: 'Show in Top Panel',
|
||||
subtitle: 'Display pinned apps directly in the panel',
|
||||
active: settings.get_boolean('show-in-panel')
|
||||
});
|
||||
settings.bind('show-in-panel', panelToggle, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
visibilityGroup.add(panelToggle);
|
||||
|
||||
const pinIconToggle = new Adw.SwitchRow({
|
||||
title: 'Show Pin Icon',
|
||||
subtitle: 'Display the pin icon when apps are pinned',
|
||||
active: settings.get_boolean('show-pin-icon')
|
||||
});
|
||||
settings.bind('show-pin-icon', pinIconToggle, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
visibilityGroup.add(pinIconToggle);
|
||||
|
||||
page.add(visibilityGroup);
|
||||
|
||||
page.add(group);
|
||||
}
|
||||
|
||||
_buildShortcutsPage(page, settings) {
|
||||
const group = new Adw.PreferencesGroup({
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: 'Assign custom keyboard shortcuts to launch your pinned apps directly.\nShortcuts are position-based and will follow your current app order.'
|
||||
});
|
||||
|
||||
const listBox = new Gtk.ListBox({
|
||||
selection_mode: Gtk.SelectionMode.NONE,
|
||||
css_classes: ['shortcut-list']
|
||||
});
|
||||
|
||||
const updateList = () => {
|
||||
let child = listBox.get_first_child();
|
||||
while (child) {
|
||||
const next = child.get_next_sibling();
|
||||
listBox.remove(child);
|
||||
child = next;
|
||||
}
|
||||
|
||||
const pinnedApps = settings.get_strv('pinned-apps');
|
||||
if (pinnedApps.length === 0) {
|
||||
const emptyRow = new Adw.ActionRow({
|
||||
title: 'No Pinned Apps',
|
||||
subtitle: 'Pin apps from the main menu to enable shortcuts',
|
||||
css_classes: ['dim-label']
|
||||
});
|
||||
listBox.append(emptyRow);
|
||||
return;
|
||||
}
|
||||
|
||||
pinnedApps.forEach((appId, index) => {
|
||||
const position = index + 1;
|
||||
const appName = this._getAppName(appId) || 'Unknown Application';
|
||||
|
||||
const row = new Adw.ActionRow({
|
||||
title: `Position ${position}`,
|
||||
subtitle: appName,
|
||||
css_classes: ['shortcut-row']
|
||||
});
|
||||
|
||||
const entry = new Gtk.Entry({
|
||||
text: this._acceleratorToLabel(settings.get_string(`shortcut-${position}`)),
|
||||
editable: false,
|
||||
width_chars: 20,
|
||||
css_classes: ['shortcut-entry'],
|
||||
placeholder_text: 'Click to set shortcut',
|
||||
tooltip_text: `Press any key combination for ${appName}`
|
||||
});
|
||||
|
||||
entry.connect('notify::has-focus', (widget) => {
|
||||
if (!widget.hasFocus) {
|
||||
widget.set_selection(0, 0);
|
||||
widget.set_position(-1);
|
||||
}
|
||||
});
|
||||
|
||||
const controller = new Gtk.EventControllerKey();
|
||||
controller.connect('key-pressed', (_, keyval, _keycode, state) => {
|
||||
if (keyval === Gdk.KEY_Escape) {
|
||||
entry.grab_focus_away();
|
||||
return Gdk.EVENT_STOP;
|
||||
}
|
||||
|
||||
const isModifier = [
|
||||
Gdk.KEY_Control_L, Gdk.KEY_Control_R,
|
||||
Gdk.KEY_Shift_L, Gdk.KEY_Shift_R,
|
||||
Gdk.KEY_Alt_L, Gdk.KEY_Alt_R,
|
||||
Gdk.KEY_Super_L, Gdk.KEY_Super_R
|
||||
].includes(keyval);
|
||||
|
||||
if (isModifier) return Gdk.EVENT_STOP;
|
||||
|
||||
const modifiers = state & Gtk.accelerator_get_default_mod_mask();
|
||||
const accelerator = Gtk.accelerator_name(keyval, modifiers);
|
||||
|
||||
entry.text = this._acceleratorToLabel(accelerator);
|
||||
settings.set_string(`shortcut-${position}`, accelerator);
|
||||
return Gdk.EVENT_STOP;
|
||||
});
|
||||
|
||||
entry.add_controller(controller);
|
||||
|
||||
const clearButton = new Gtk.Button({
|
||||
icon_name: 'edit-clear-symbolic',
|
||||
tooltip_text: 'Clear shortcut'
|
||||
});
|
||||
|
||||
clearButton.connect('clicked', () => {
|
||||
entry.text = 'Disabled';
|
||||
settings.set_string(`shortcut-${position}`, '');
|
||||
});
|
||||
|
||||
row.add_suffix(entry);
|
||||
row.add_suffix(clearButton);
|
||||
listBox.append(row);
|
||||
});
|
||||
};
|
||||
|
||||
updateList();
|
||||
|
||||
settings.connect('changed::pinned-apps', () => {
|
||||
updateList();
|
||||
});
|
||||
|
||||
const scrolledWindow = new Gtk.ScrolledWindow({
|
||||
height_request: 300,
|
||||
child: listBox
|
||||
});
|
||||
|
||||
group.add(scrolledWindow);
|
||||
page.add(group);
|
||||
}
|
||||
|
||||
_buildStartupPage(page, settings) {
|
||||
const group = new Adw.PreferencesGroup({
|
||||
title: 'Launch at Startup',
|
||||
description: 'Select applications to launch automatically when you log in'
|
||||
});
|
||||
|
||||
const listStore = new Gtk.StringList();
|
||||
const updateList = () => {
|
||||
const pinned = settings.get_strv('pinned-apps');
|
||||
listStore.splice(0, listStore.get_n_items(), pinned);
|
||||
};
|
||||
|
||||
updateList();
|
||||
settings.connect('changed::pinned-apps', updateList);
|
||||
|
||||
const factory = new Gtk.SignalListItemFactory();
|
||||
factory.connect('setup', (_, listItem) => {
|
||||
const row = new Adw.ActionRow();
|
||||
const icon = new Gtk.Image({ icon_size: Gtk.IconSize.LARGE, margin_end: 12 });
|
||||
row.add_prefix(icon);
|
||||
|
||||
const toggle = new Gtk.Switch({
|
||||
valign: Gtk.Align.CENTER,
|
||||
halign: Gtk.Align.END
|
||||
});
|
||||
row.add_suffix(toggle);
|
||||
|
||||
row._icon = icon;
|
||||
row._toggle = toggle;
|
||||
listItem.set_child(row);
|
||||
});
|
||||
|
||||
factory.connect('bind', (_, listItem) => {
|
||||
const appId = listItem.get_item().string;
|
||||
const row = listItem.get_child();
|
||||
const toggle = row._toggle;
|
||||
|
||||
const startupApps = settings.get_strv('startup-apps');
|
||||
toggle.set_active(startupApps.includes(appId));
|
||||
|
||||
toggle.connect('notify::active', (sw) => {
|
||||
const newStartup = sw.active
|
||||
? [...settings.get_strv('startup-apps'), appId]
|
||||
: settings.get_strv('startup-apps').filter(id => id !== appId);
|
||||
settings.set_strv('startup-apps', newStartup);
|
||||
});
|
||||
|
||||
const appInfo = this._getAppInfo(appId);
|
||||
row.set_title(appInfo?.get_name() || appId);
|
||||
row._icon.set_from_gicon(appInfo?.get_icon());
|
||||
});
|
||||
|
||||
const listView = new Gtk.ListView({
|
||||
model: new Gtk.SingleSelection({ model: listStore }),
|
||||
factory: factory
|
||||
});
|
||||
|
||||
const scrolledWindow = new Gtk.ScrolledWindow({
|
||||
height_request: 300,
|
||||
child: listView
|
||||
});
|
||||
group.add(scrolledWindow);
|
||||
page.add(group);
|
||||
}
|
||||
|
||||
_buildAboutPage(page) {
|
||||
const group = new Adw.PreferencesGroup({
|
||||
title: this.metadata.name,
|
||||
description: 'A modern application pinner and quick launcher for GNOME Shell'
|
||||
});
|
||||
|
||||
const versionRow = new Adw.ActionRow({
|
||||
title: 'Version'
|
||||
});
|
||||
const versionLabel = new Gtk.Label({
|
||||
label: `v${this.metadata.version.toString()}`,
|
||||
css_classes: ['dim-label'],
|
||||
xalign: 0
|
||||
});
|
||||
versionRow.add_suffix(versionLabel);
|
||||
group.add(versionRow);
|
||||
|
||||
const authorRow = new Adw.ActionRow({
|
||||
title: 'Developer'
|
||||
});
|
||||
|
||||
const githubButton = new Gtk.Button({
|
||||
label: this.metadata.creator,
|
||||
css_classes: ['flat', 'suggested-action'],
|
||||
tooltip_text: 'Open GitHub profile',
|
||||
margin_top: 6,
|
||||
margin_bottom: 6
|
||||
});
|
||||
|
||||
githubButton.connect('clicked', () => {
|
||||
Gio.AppInfo.launch_default_for_uri('https://github.com/comitanigiacomo', null);
|
||||
});
|
||||
|
||||
authorRow.add_suffix(githubButton);
|
||||
authorRow.activatable_widget = githubButton;
|
||||
group.add(authorRow);
|
||||
|
||||
const repoRow = new Adw.ActionRow({
|
||||
title: 'Repository'
|
||||
});
|
||||
|
||||
const repoButton = new Gtk.Button({
|
||||
label: 'Source Code',
|
||||
css_classes: ['flat', 'suggested-action'],
|
||||
tooltip_text: 'Open GitHub repository',
|
||||
margin_top: 6,
|
||||
margin_bottom: 6
|
||||
});
|
||||
|
||||
repoButton.connect('clicked', () => {
|
||||
if (this.metadata.url) {
|
||||
Gio.AppInfo.launch_default_for_uri(this.metadata.url, null);
|
||||
}
|
||||
});
|
||||
|
||||
repoRow.add_suffix(repoButton);
|
||||
repoRow.activatable_widget = repoButton;
|
||||
group.add(repoRow);
|
||||
|
||||
const licenseRow = new Adw.ActionRow({
|
||||
title: 'License'
|
||||
});
|
||||
|
||||
const licenseButton = new Gtk.Button({
|
||||
label: 'GPLv3',
|
||||
css_classes: ['flat', 'suggested-action'],
|
||||
tooltip_text: 'View license details',
|
||||
margin_top: 6,
|
||||
margin_bottom: 6
|
||||
});
|
||||
|
||||
licenseButton.connect('clicked', () => {
|
||||
Gio.AppInfo.launch_default_for_uri('https://github.com/comitanigiacomo/quicklaunch?tab=GPL-3.0-1-ov-file', null);
|
||||
});
|
||||
|
||||
licenseRow.add_suffix(licenseButton);
|
||||
licenseRow.activatable_widget = licenseButton;
|
||||
group.add(licenseRow);
|
||||
|
||||
const descriptionLabel = new Gtk.Label({
|
||||
label: `
|
||||
|
||||
● 🚀 One-Click Access: Launch apps/links directly from panel\n
|
||||
● ⌨️ Custom Shortcuts: Configurable keyboard combinations\n
|
||||
● 🔴 Live Indicators: Real-time running app status\n
|
||||
● 🌐 Web Links: Open URLs in default browser\n
|
||||
● ⚡ Auto-Start: Launch critical apps at login\n
|
||||
● 🔧 Smart Reordering: long-press to organize\n
|
||||
● 🔍 Instant Search: Find/pin apps from dropdown\n\n
|
||||
Optimized for power users - Seamless GNOME integration
|
||||
`
|
||||
.replace(/ {8}/g, '')
|
||||
.trim(),
|
||||
wrap: true,
|
||||
xalign: 0.5,
|
||||
halign: Gtk.Align.CENTER,
|
||||
hexpand: true
|
||||
});
|
||||
|
||||
const descGroup = new Adw.PreferencesGroup();
|
||||
descGroup.add(descriptionLabel);
|
||||
|
||||
page.add(group);
|
||||
page.add(descGroup);
|
||||
}
|
||||
|
||||
_acceleratorToLabel(accel) {
|
||||
if (!accel) return 'Disabled';
|
||||
|
||||
try {
|
||||
const [success, keyval, mods] = Gtk.accelerator_parse(accel);
|
||||
return success ? Gtk.accelerator_get_label(keyval, mods) : 'Invalid';
|
||||
} catch (e) {
|
||||
return 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
_labelToAccelerator(label) {
|
||||
let [success, keyval, mods] = Gtk.accelerator_parse(label);
|
||||
|
||||
if (!success || keyval === 0) {
|
||||
const custom = this._parseCustomShortcut(label);
|
||||
[success, keyval, mods] = Gtk.accelerator_parse(custom);
|
||||
}
|
||||
|
||||
if (success && keyval !== 0) {
|
||||
return Gtk.accelerator_name(keyval, mods);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
_configureShortcut(window, settings, key, entry) {
|
||||
|
||||
const dialog = new Gtk.Dialog({
|
||||
title: 'Set Shortcut',
|
||||
transient_for: window,
|
||||
modal: true,
|
||||
use_header_bar: true
|
||||
});
|
||||
|
||||
let accelerator = settings.get_string(key);
|
||||
const label = new Gtk.Label({
|
||||
label: 'Press desired key combination...\nCurrent: ' + this._acceleratorToLabel(accelerator),
|
||||
margin: 12
|
||||
});
|
||||
|
||||
const controller = new Gtk.EventControllerKey();
|
||||
controller.connect('key-pressed', (_, keyval, _keycode, state) => {
|
||||
try {
|
||||
const mask = state & Gtk.accelerator_get_default_mod_mask();
|
||||
|
||||
const validMods = mask & (
|
||||
Gdk.ModifierType.SHIFT_MASK |
|
||||
Gdk.ModifierType.CONTROL_MASK |
|
||||
Gdk.ModifierType.ALT_MASK |
|
||||
Gdk.ModifierType.SUPER_MASK
|
||||
);
|
||||
|
||||
if (keyval === Gdk.KEY_Shift_L || keyval === Gdk.KEY_Shift_R ||
|
||||
keyval === Gdk.KEY_Control_L || keyval === Gdk.KEY_Control_R ||
|
||||
keyval === Gdk.KEY_Alt_L || keyval === Gdk.KEY_Alt_R ||
|
||||
keyval === Gdk.KEY_Super_L || keyval === Gdk.KEY_Super_R) {
|
||||
return Gdk.EVENT_STOP;
|
||||
}
|
||||
|
||||
accelerator = Gtk.accelerator_name(keyval, validMods);
|
||||
label.label = 'New shortcut: ' + this._acceleratorToLabel(accelerator);
|
||||
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
|
||||
dialog.queue_draw();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error processing key:', e);
|
||||
}
|
||||
return Gdk.EVENT_STOP;
|
||||
});
|
||||
|
||||
dialog.add_controller(controller);
|
||||
|
||||
const resetButton = new Gtk.Button({
|
||||
label: 'Reset',
|
||||
margin_end: 12
|
||||
});
|
||||
resetButton.connect('clicked', () => {
|
||||
accelerator = '';
|
||||
label.label = 'Shortcut cleared!';
|
||||
});
|
||||
|
||||
dialog.get_content_area().append(label);
|
||||
dialog.get_header_bar().pack_end(resetButton);
|
||||
|
||||
dialog.add_button('Cancel', Gtk.ResponseType.CANCEL);
|
||||
dialog.add_button('Save', Gtk.ResponseType.OK);
|
||||
|
||||
dialog.connect('response', (_, response) => {
|
||||
if (response === Gtk.ResponseType.OK) {
|
||||
try {
|
||||
const [success] = Gtk.accelerator_parse(accelerator);
|
||||
if (success) {
|
||||
settings.set_string(key, accelerator);
|
||||
entry.text = this._acceleratorToLabel(accelerator);
|
||||
} else {
|
||||
this._showErrorDialog('Invalid shortcut combination');
|
||||
}
|
||||
} catch (e) {
|
||||
this._showErrorDialog(`Error saving shortcut: ${e.message}`);
|
||||
}
|
||||
}
|
||||
dialog.destroy();
|
||||
});
|
||||
|
||||
dialog.present();
|
||||
}
|
||||
|
||||
_showErrorDialog(message) {
|
||||
const dialog = new Gtk.MessageDialog({
|
||||
transient_for: this._window,
|
||||
modal: true,
|
||||
message_type: Gtk.MessageType.ERROR,
|
||||
buttons: Gtk.ButtonsType.OK,
|
||||
text: message
|
||||
});
|
||||
dialog.connect('response', () => dialog.destroy());
|
||||
dialog.present();
|
||||
}
|
||||
|
||||
_parseCustomShortcut(text) {
|
||||
try {
|
||||
const normalized = text
|
||||
.replace(/</g, '')
|
||||
.replace(/>/g, '+')
|
||||
.toLowerCase();
|
||||
|
||||
const parts = normalized.split('+');
|
||||
let mods = 0;
|
||||
let keyval = 0;
|
||||
|
||||
parts.forEach(part => {
|
||||
const mod = Gtk.accelerator_parse_modifier(part.trim());
|
||||
if (mod) {
|
||||
mods |= mod;
|
||||
} else {
|
||||
keyval = Gtk.accelerator_parse_key(part.trim(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
return Gtk.accelerator_name(keyval, mods);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
_validateAccelerator(accel) {
|
||||
try {
|
||||
const [success, keyval] = Gtk.accelerator_parse(accel);
|
||||
return success && keyval !== 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_getAppInfo(appId) {
|
||||
const variants = [
|
||||
appId,
|
||||
`${appId}.desktop`,
|
||||
`${appId.replace(/-/g, '')}.desktop`,
|
||||
appId.toLowerCase(),
|
||||
`${appId.toLowerCase()}.desktop`
|
||||
];
|
||||
|
||||
const searchPaths = [
|
||||
GLib.get_user_data_dir() + '/applications',
|
||||
'/usr/share/applications',
|
||||
'/usr/local/share/applications'
|
||||
];
|
||||
|
||||
for (const variant of variants) {
|
||||
const appInfo = Gio.DesktopAppInfo.new(variant);
|
||||
if (appInfo) return appInfo;
|
||||
|
||||
for (const path of searchPaths) {
|
||||
const fullPath = `${path}/${variant}`;
|
||||
if (GLib.file_test(fullPath, GLib.FileTest.EXISTS)) {
|
||||
return Gio.DesktopAppInfo.new_from_filename(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_getAppDisplayInfo(appId) {
|
||||
let appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`);
|
||||
|
||||
if (!appInfo) {
|
||||
const cleanId = appId.replace(/^application:\/\//, '');
|
||||
appInfo = Gio.DesktopAppInfo.new(`${cleanId}.desktop`);
|
||||
}
|
||||
|
||||
if (!appInfo) {
|
||||
const shellApp = Shell.AppSystem.get_default().lookup_app(appId);
|
||||
if (shellApp) {
|
||||
return {
|
||||
name: shellApp.get_name(),
|
||||
icon: shellApp.get_icon()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!appInfo) {
|
||||
const variations = [
|
||||
appId.toLowerCase(),
|
||||
appId.replace(/-/g, ''),
|
||||
`${appId}.desktop`,
|
||||
`${appId.toLowerCase()}.desktop`
|
||||
];
|
||||
|
||||
for (const variant of variations) {
|
||||
appInfo = Gio.DesktopAppInfo.new(variant);
|
||||
if (appInfo) break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: appInfo ? appInfo.get_name() : appId,
|
||||
icon: appInfo ? appInfo.get_icon() : null
|
||||
};
|
||||
}
|
||||
|
||||
_getAppName(appId) {
|
||||
const appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`);
|
||||
return appInfo ? appInfo.get_name() : appId;
|
||||
}
|
||||
|
||||
_sanitizeAppId(appId) {
|
||||
return appId
|
||||
.replace(/^application:\/\//, '')
|
||||
.replace(/\.desktop$/i, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist>
|
||||
<schema id="org.gnome.shell.extensions.app-pinner" path="/org/gnome/shell/extensions/app-pinner/">
|
||||
|
||||
<key name="custom-keybindings" type="as">
|
||||
<default>[]</default>
|
||||
<summary>Custom keybindings paths</summary>
|
||||
</key>
|
||||
|
||||
<key name="show-pin-icon" type="b">
|
||||
<default>true</default>
|
||||
<summary>Show pin icon in panel</summary>
|
||||
<description>Whether to show the pin icon in the panel when there are pinned items</description>
|
||||
</key>
|
||||
|
||||
<!-- Applicazioni Pinnate -->
|
||||
<key name="pinned-apps" type="as">
|
||||
<default>[]</default>
|
||||
<summary>Pinned Applications</summary>
|
||||
</key>
|
||||
|
||||
<!-- Aspetto -->
|
||||
<key name="icon-size" type="i">
|
||||
<default>24</default>
|
||||
<range min="16" max="48"/>
|
||||
<summary>Icon size in pixels</summary>
|
||||
</key>
|
||||
|
||||
<key name="show-in-panel" type="b">
|
||||
<default>true</default>
|
||||
<summary>Show apps in panel</summary>
|
||||
<description>Whether to display pinned apps in the top panel</description>
|
||||
</key>
|
||||
|
||||
<key name="always-show-menu" type="b">
|
||||
<default>false</default>
|
||||
<summary>Always show menu icon</summary>
|
||||
<description>Keep the menu icon visible even when panel apps are hidden</description>
|
||||
</key>
|
||||
|
||||
<key name="position-in-panel" type="s">
|
||||
<default>'right'</default>
|
||||
<choices>
|
||||
<choice value='left'/>
|
||||
<choice value="center"/>
|
||||
<choice value='right'/>
|
||||
</choices>
|
||||
<summary>Panel position</summary>
|
||||
<description>Posizione nella barra superiore</description>
|
||||
</key>
|
||||
|
||||
<key name="spacing" type="i">
|
||||
<default>6</default>
|
||||
<range min="0" max="24"/>
|
||||
<summary>Spacing between icons</summary>
|
||||
</key>
|
||||
|
||||
<key name="enable-labels" type="b">
|
||||
<default>false</default>
|
||||
<summary>Show application labels</summary>
|
||||
</key>
|
||||
|
||||
<!-- Comportamento -->
|
||||
<key name="launch-animation" type="b">
|
||||
<default>true</default>
|
||||
<summary>Enable launch animation</summary>
|
||||
</key>
|
||||
|
||||
<!-- Avanzate -->
|
||||
<key name="max-apps" type="i">
|
||||
<default>10</default>
|
||||
<range min="1" max="20"/>
|
||||
<summary>Maximum apps to display</summary>
|
||||
</key>
|
||||
|
||||
<key name="default-apps" type="as">
|
||||
<default>['org.gnome.Calculator.desktop']</default>
|
||||
<summary>Default suggested apps</summary>
|
||||
</key>
|
||||
|
||||
<!-- Scorciatoie da tastiera -->
|
||||
<key name="shortcut-1" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 1</summary>
|
||||
</key>
|
||||
<key name="shortcut-2" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 2</summary>
|
||||
</key>
|
||||
<key name="shortcut-3" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 3</summary>
|
||||
</key>
|
||||
<key name="shortcut-4" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 4</summary>
|
||||
</key>
|
||||
<key name="shortcut-5" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 5</summary>
|
||||
</key>
|
||||
<key name="shortcut-6" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 6</summary>
|
||||
</key>
|
||||
<key name="shortcut-7" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 7</summary>
|
||||
</key>
|
||||
<key name="shortcut-8" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 8</summary>
|
||||
</key>
|
||||
<key name="shortcut-9" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 9</summary>
|
||||
</key>
|
||||
<key name="shortcut-10" type="s">
|
||||
<default>''</default>
|
||||
<summary>Shortcut for position 10</summary>
|
||||
</key>
|
||||
<key type="as" name="startup-apps">
|
||||
<default>[]</default>
|
||||
<summary>Startup Applications</summary>
|
||||
<description>List of application IDs to launch at system startup.</description>
|
||||
</key>
|
||||
<key name="indicator-color" type="s">
|
||||
<default>'#00ff00'</default>
|
||||
<summary>Indicator color</summary>
|
||||
<description>Color for running application indicator</description>
|
||||
</key>
|
||||
<key name="custom-links" type="as">
|
||||
<default>[]</default>
|
||||
<summary>Custom pinned links</summary>
|
||||
</key>
|
||||
<key name="sort-alphabetically" type="b">
|
||||
<default>false</default>
|
||||
<summary>Sort apps alphabetically</summary>
|
||||
<description>If enabled, pinned apps will be sorted by name.</description>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
@ -0,0 +1,131 @@
|
||||
.app-pinner-icons {
|
||||
height: 100%;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.app-pinner-icon-box {
|
||||
height: 100%;
|
||||
padding: 2px 0;
|
||||
margin: auto 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-pinner-icon {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 !important;
|
||||
margin: auto;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.app-pinner-link-icon {
|
||||
margin: auto;
|
||||
-st-icon-style: symbolic;
|
||||
}
|
||||
|
||||
.app-pinner-running-indicator {
|
||||
background-color: #ff0000;
|
||||
border-radius: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.app-pinner-label {
|
||||
max-width: 100px;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.8em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.app-pinner-remove-btn {
|
||||
font-size: 18px;
|
||||
background-color: transparent;
|
||||
border-radius: 100px;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: right;
|
||||
justify-content: right;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.app-pinner-remove-btn:hover {
|
||||
background-color: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.app-pinner-remove-label {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 200px;
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-pinner-link-input {
|
||||
min-width: 200px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(0,0,0,0.3);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-pinner-link-input:focus {
|
||||
border-color: #3584e4;
|
||||
box-shadow: inset 0 0 0 1px #3584e4;
|
||||
}
|
||||
|
||||
.app-pinner-add-link-btn {
|
||||
background-color: rgba(78, 166, 78, 0.8);
|
||||
border-radius: 8px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 6px;
|
||||
margin-left: 6px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.app-pinner-add-link-btn:hover {
|
||||
background-color: rgba(98, 196, 98, 0.9);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.app-pinner-add-link-btn:active {
|
||||
background-color: rgba(68, 146, 68, 0.9);
|
||||
}
|
||||
|
||||
.app-pinner-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-pinner-link-box {
|
||||
margin: 8px;
|
||||
padding: 6px;
|
||||
background-color: rgba(255,255,255,0.05);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,262 @@
|
||||
import GObject from "gi://GObject";
|
||||
import GLib from "gi://GLib";
|
||||
import Soup from "gi://Soup";
|
||||
|
||||
import * as Main from "resource:///org/gnome/shell/ui/main.js";
|
||||
import * as QuickSettings from "resource:///org/gnome/shell/ui/quickSettings.js";
|
||||
import { Extension } from "resource:///org/gnome/shell/extensions/extension.js";
|
||||
|
||||
function clamp01(x) {
|
||||
return Math.max(0, Math.min(1, x));
|
||||
}
|
||||
|
||||
function jsonRpc(method, params = {}) {
|
||||
return JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
||||
}
|
||||
|
||||
async function postJson(session, url, bodyStr) {
|
||||
const msg = Soup.Message.new("POST", url);
|
||||
msg.request_headers.append("Content-Type", "application/json");
|
||||
|
||||
// Soup 3: request body as GLib.Bytes
|
||||
msg.set_request_body_from_bytes(
|
||||
"application/json",
|
||||
new GLib.Bytes(bodyStr),
|
||||
);
|
||||
|
||||
const bytes = await session.send_and_read_async(
|
||||
msg,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
);
|
||||
const text = new TextDecoder().decode(bytes.get_data());
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function connectedClients(group) {
|
||||
return (group?.clients ?? []).filter((c) => c?.connected);
|
||||
}
|
||||
|
||||
function connectedClientIds(group) {
|
||||
return connectedClients(group)
|
||||
.map((c) => c.id)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function avgConnectedVolumePercent(group) {
|
||||
const percents = connectedClients(group)
|
||||
.map((c) => c?.config?.volume?.percent)
|
||||
.filter((p) => typeof p === "number");
|
||||
|
||||
if (!percents.length) return null;
|
||||
|
||||
return percents.reduce((a, b) => a + b, 0) / percents.length;
|
||||
}
|
||||
|
||||
function groupLabel(group) {
|
||||
const base =
|
||||
group?.name && group.name.trim()
|
||||
? group.name.trim()
|
||||
: (group?.stream_id ?? "Group");
|
||||
|
||||
const connected = connectedClients(group).length;
|
||||
const total = group?.clients?.length ?? 0;
|
||||
|
||||
return `${base} (${connected}/${total})`;
|
||||
}
|
||||
|
||||
const SnapcastSlider = GObject.registerClass(
|
||||
class SnapcastSlider extends QuickSettings.QuickSlider {
|
||||
constructor(extension) {
|
||||
super({
|
||||
title: "Snapcast",
|
||||
iconName: "audio-volume-high-symbolic",
|
||||
});
|
||||
|
||||
this._ext = extension;
|
||||
this._settings = extension.getSettings();
|
||||
this._session = new Soup.Session();
|
||||
|
||||
this._pollId = 0;
|
||||
this._debounceId = 0;
|
||||
this._sliderChangedId = 0;
|
||||
this._settingsChangedId = 0;
|
||||
|
||||
this._isUpdatingFromServer = false;
|
||||
this._groupClientIds = [];
|
||||
|
||||
// Load settings
|
||||
this._reloadSettings();
|
||||
|
||||
// React to settings changes (server-url, group-id, poll-seconds)
|
||||
this._settingsChangedId = this._settings.connect("changed", () => {
|
||||
this._reloadSettings();
|
||||
this._restartPolling();
|
||||
this._pollOnce().catch(logError);
|
||||
});
|
||||
|
||||
// Slider change -> debounce -> set group volume
|
||||
this._sliderChangedId = this.slider.connect("notify::value", () => {
|
||||
if (this._isUpdatingFromServer) return;
|
||||
this._debouncedSetVolume();
|
||||
});
|
||||
|
||||
// Start polling + initial sync
|
||||
this._restartPolling();
|
||||
this._pollOnce().catch(logError);
|
||||
}
|
||||
|
||||
_reloadSettings() {
|
||||
// You’ll define these keys in your gschema:
|
||||
// - server-url (string)
|
||||
// - group-id (string)
|
||||
// - poll-seconds (uint)
|
||||
this._url = this._settings.get_string("server-url");
|
||||
this._groupId = this._settings.get_string("group-id");
|
||||
this._pollSeconds = this._settings.get_uint("poll-seconds");
|
||||
|
||||
// Sensible guards
|
||||
if (!this._pollSeconds || this._pollSeconds < 2)
|
||||
this._pollSeconds = 5;
|
||||
}
|
||||
|
||||
_restartPolling() {
|
||||
if (this._pollId) {
|
||||
GLib.source_remove(this._pollId);
|
||||
this._pollId = 0;
|
||||
}
|
||||
|
||||
this._pollId = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this._pollSeconds,
|
||||
() => {
|
||||
this._pollOnce().catch(logError);
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_debouncedSetVolume() {
|
||||
if (this._debounceId) {
|
||||
GLib.source_remove(this._debounceId);
|
||||
this._debounceId = 0;
|
||||
}
|
||||
|
||||
// Wait a beat so dragging doesn’t fire 100 requests/sec
|
||||
this._debounceId = GLib.timeout_add(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
150,
|
||||
() => {
|
||||
this._debounceId = 0;
|
||||
this._setGroupVolumeFromSlider().catch(logError);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async _pollOnce() {
|
||||
// If not configured yet
|
||||
if (!this._url) {
|
||||
this.title = "Snapcast — set server URL";
|
||||
this._groupClientIds = [];
|
||||
return;
|
||||
}
|
||||
if (!this._groupId) {
|
||||
this.title = "Snapcast — select a group";
|
||||
this._groupClientIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Pull all status in one request
|
||||
const resp = await postJson(
|
||||
this._session,
|
||||
this._url,
|
||||
jsonRpc("Server.GetStatus", {}),
|
||||
);
|
||||
const groups = resp?.result?.server?.groups ?? [];
|
||||
const group = groups.find((g) => g.id === this._groupId);
|
||||
|
||||
if (!group) {
|
||||
this.title = "Snapcast — group not found";
|
||||
this._groupClientIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Only connected clients
|
||||
this._groupClientIds = connectedClientIds(group);
|
||||
|
||||
// Update title with something human-readable
|
||||
this.title = `Snapcast — ${groupLabel(group)}`;
|
||||
|
||||
// Slider position = average of connected clients’ volumes
|
||||
const avg = avgConnectedVolumePercent(group);
|
||||
if (typeof avg === "number") {
|
||||
const v = clamp01(avg / 100);
|
||||
|
||||
this._isUpdatingFromServer = true;
|
||||
try {
|
||||
if (Math.abs(this.slider.value - v) > 0.01)
|
||||
this.slider.value = v;
|
||||
} finally {
|
||||
this._isUpdatingFromServer = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _setGroupVolumeFromSlider() {
|
||||
if (!this._groupClientIds.length) return;
|
||||
|
||||
const percent = Math.round(clamp01(this.slider.value) * 100);
|
||||
|
||||
// Fan out: set volume for each connected client in the group
|
||||
await Promise.allSettled(
|
||||
this._groupClientIds.map((id) => {
|
||||
const body = jsonRpc("Client.SetVolume", {
|
||||
id,
|
||||
volume: { percent },
|
||||
});
|
||||
return postJson(this._session, this._url, body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._pollId) GLib.source_remove(this._pollId);
|
||||
if (this._debounceId) GLib.source_remove(this._debounceId);
|
||||
if (this._sliderChangedId)
|
||||
this.slider.disconnect(this._sliderChangedId);
|
||||
if (this._settingsChangedId)
|
||||
this._settings.disconnect(this._settingsChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const SnapcastIndicator = GObject.registerClass(
|
||||
class SnapcastIndicator extends QuickSettings.SystemIndicator {
|
||||
constructor(extension) {
|
||||
super();
|
||||
this.quickSettingsItems.push(new SnapcastSlider(extension));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.quickSettingsItems.forEach((item) => item.destroy());
|
||||
super.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default class SnapshellExtension extends Extension {
|
||||
enable() {
|
||||
this._indicator = new SnapcastIndicator(this);
|
||||
Main.panel.statusArea.quickSettings.addExternalIndicator(
|
||||
this._indicator,
|
||||
);
|
||||
}
|
||||
|
||||
disable() {
|
||||
this._indicator?.destroy();
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
{
|
||||
"uuid": "snapshell@unbl.ink",
|
||||
"name": "Snapshell",
|
||||
"description": "Quick Settings volume slider for a Snapcast group (connected clients only).",
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50", "51"],
|
||||
"version": 1
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
import Adw from "gi://Adw";
|
||||
import Gio from "gi://Gio";
|
||||
import GLib from "gi://GLib";
|
||||
import Gtk from "gi://Gtk";
|
||||
import Soup from "gi://Soup";
|
||||
|
||||
import { ExtensionPreferences } from "resource:///org/gnome/shell/extensions/prefs.js";
|
||||
|
||||
function jsonRpc(method, params = {}) {
|
||||
return JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
||||
}
|
||||
|
||||
async function postJson(session, url, bodyStr) {
|
||||
const msg = Soup.Message.new("POST", url);
|
||||
msg.request_headers.append("Content-Type", "application/json");
|
||||
msg.set_request_body_from_bytes(
|
||||
"application/json",
|
||||
new GLib.Bytes(bodyStr),
|
||||
);
|
||||
|
||||
const bytes = await session.send_and_read_async(
|
||||
msg,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
);
|
||||
const text = new TextDecoder().decode(bytes.get_data());
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function connectedCount(group) {
|
||||
return (group?.clients ?? []).filter((c) => c?.connected).length;
|
||||
}
|
||||
|
||||
function groupLabel(group) {
|
||||
const base =
|
||||
group?.name && group.name.trim()
|
||||
? group.name.trim()
|
||||
: (group?.stream_id ?? "Group");
|
||||
|
||||
const connected = connectedCount(group);
|
||||
const total = group?.clients?.length ?? 0;
|
||||
|
||||
return `${base} (${connected}/${total})`;
|
||||
}
|
||||
|
||||
export default class SnapshellPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
const settings = this.getSettings(
|
||||
"org.gnome.shell.extensions.snapshell",
|
||||
);
|
||||
const session = new Soup.Session();
|
||||
|
||||
// --- UI models ---
|
||||
const groupNames = new Gtk.StringList();
|
||||
let groupIds = []; // parallel array: index -> group id
|
||||
|
||||
// --- Page ---
|
||||
const page = new Adw.PreferencesPage({
|
||||
title: "Snapshell",
|
||||
icon_name: "audio-volume-high-symbolic",
|
||||
});
|
||||
window.add(page);
|
||||
|
||||
// --- Group: Connection ---
|
||||
const connectionGroup = new Adw.PreferencesGroup({
|
||||
title: "Connection",
|
||||
description: "Configure Snapserver JSON-RPC endpoint.",
|
||||
});
|
||||
page.add(connectionGroup);
|
||||
|
||||
const urlRow = new Adw.EntryRow({
|
||||
title: "Server URL",
|
||||
text: settings.get_string("server-url"),
|
||||
});
|
||||
urlRow.set_tooltip_text("Example: https://snap.unbl.ink/jsonrpc");
|
||||
connectionGroup.add(urlRow);
|
||||
|
||||
urlRow.connect("notify::text", () => {
|
||||
settings.set_string("server-url", urlRow.text.trim());
|
||||
});
|
||||
|
||||
// --- Group: Target Group ---
|
||||
const targetGroup = new Adw.PreferencesGroup({
|
||||
title: "Target group",
|
||||
description:
|
||||
"Choose which Snapcast group the Quick Settings slider controls.",
|
||||
});
|
||||
page.add(targetGroup);
|
||||
|
||||
const groupRow = new Adw.ComboRow({
|
||||
title: "Snapcast group",
|
||||
model: groupNames,
|
||||
});
|
||||
targetGroup.add(groupRow);
|
||||
|
||||
const refreshRow = new Adw.ActionRow({
|
||||
title: "Refresh groups",
|
||||
subtitle: "Fetch groups from Server.GetStatus",
|
||||
});
|
||||
const refreshBtn = new Gtk.Button({ label: "Refresh" });
|
||||
refreshRow.add_suffix(refreshBtn);
|
||||
refreshRow.activatable_widget = refreshBtn;
|
||||
targetGroup.add(refreshRow);
|
||||
|
||||
// --- Group: Behavior ---
|
||||
const behaviorGroup = new Adw.PreferencesGroup({
|
||||
title: "Behavior",
|
||||
});
|
||||
page.add(behaviorGroup);
|
||||
|
||||
const pollRow = new Adw.SpinRow({
|
||||
title: "Poll interval (seconds)",
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 2,
|
||||
upper: 60,
|
||||
step_increment: 1,
|
||||
page_increment: 5,
|
||||
value: settings.get_uint("poll-seconds") || 5,
|
||||
}),
|
||||
});
|
||||
behaviorGroup.add(pollRow);
|
||||
|
||||
pollRow.connect("notify::value", () => {
|
||||
settings.set_uint("poll-seconds", pollRow.value);
|
||||
});
|
||||
|
||||
// --- Helpers to load + sync groups ---
|
||||
const setStatus = (text) => {
|
||||
refreshRow.subtitle = text;
|
||||
};
|
||||
|
||||
const clearGroups = () => {
|
||||
// Remove all items from StringList
|
||||
while (groupNames.get_n_items() > 0) groupNames.remove(0);
|
||||
groupIds = [];
|
||||
};
|
||||
|
||||
const syncComboSelectionFromSettings = () => {
|
||||
const selectedId = settings.get_string("group-id");
|
||||
const idx = groupIds.indexOf(selectedId);
|
||||
groupRow.selected = idx >= 0 ? idx : Gtk.INVALID_LIST_POSITION;
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
const url = settings.get_string("server-url").trim();
|
||||
if (!url) {
|
||||
clearGroups();
|
||||
setStatus("Set Server URL first.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Fetching…");
|
||||
try {
|
||||
const resp = await postJson(
|
||||
session,
|
||||
url,
|
||||
jsonRpc("Server.GetStatus", {}),
|
||||
);
|
||||
const groups = resp?.result?.server?.groups ?? [];
|
||||
|
||||
clearGroups();
|
||||
|
||||
// Populate list
|
||||
for (const g of groups) {
|
||||
groupNames.append(groupLabel(g));
|
||||
groupIds.push(g.id);
|
||||
}
|
||||
|
||||
setStatus(`Loaded ${groups.length} group(s).`);
|
||||
syncComboSelectionFromSettings();
|
||||
} catch (e) {
|
||||
clearGroups();
|
||||
setStatus(`Failed: ${e?.message ?? e}`);
|
||||
}
|
||||
};
|
||||
|
||||
// When user picks a row, store its group id
|
||||
groupRow.connect("notify::selected", () => {
|
||||
const idx = groupRow.selected;
|
||||
if (idx === Gtk.INVALID_LIST_POSITION) return;
|
||||
const id = groupIds[idx];
|
||||
if (id) settings.set_string("group-id", id);
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
refreshBtn.connect("clicked", () => {
|
||||
fetchGroups();
|
||||
});
|
||||
|
||||
// If URL changes, offer fresh fetch (don’t auto-spam)
|
||||
urlRow.connect("notify::text", () => {
|
||||
setStatus("URL changed. Click Refresh.");
|
||||
clearGroups();
|
||||
syncComboSelectionFromSettings();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetchGroups();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist>
|
||||
<schema id="org.gnome.shell.extensions.snapshell"
|
||||
path="/org/gnome/shell/extensions/snapshell/">
|
||||
|
||||
<key name="server-url" type="s">
|
||||
<default>'https://snap.unbl.ink/jsonrpc'</default>
|
||||
<summary>Snapcast JSON-RPC URL</summary>
|
||||
<description>
|
||||
HTTP(S) endpoint for Snapserver JSON-RPC.
|
||||
</description>
|
||||
</key>
|
||||
|
||||
<key name="group-id" type="s">
|
||||
<default>'a16be8ca-e36f-5498-08cd-3879f2025775'</default>
|
||||
<summary>Snapcast group id</summary>
|
||||
<description>
|
||||
UUID of the Snapcast group to control by default.
|
||||
</description>
|
||||
</key>
|
||||
|
||||
<key name="poll-seconds" type="u">
|
||||
<default>5</default>
|
||||
<summary>Polling interval (seconds)</summary>
|
||||
<description>
|
||||
How often to refresh Server.GetStatus to update slider position.
|
||||
</description>
|
||||
</key>
|
||||
|
||||
</schema>
|
||||
</schemalist>
|
||||
1
gnome/.local/share/gnome-shell/extensions/vpn-toggler@rheddes.nl
Symbolic link
1
gnome/.local/share/gnome-shell/extensions/vpn-toggler@rheddes.nl
Symbolic link
@ -0,0 +1 @@
|
||||
/home/powellc/src/github.com/Rheddes/vpn-toggler
|
||||
Reference in New Issue
Block a user