Watch
1
0
Fork
You've already forked souveraine
0

publish: the public projection begins here

This is a projection, not a development branch. The tree above was constructed
from the internal source named below under a manifest that decides which paths
may leave, then scanned as a whole tree rather than as a series of patches, and
only then published.

Public history starts here because the history before it was not admissible,
and neither was the tree. What used to stand in this repository included a
rescue copy of another machine, a directory of phone handoffs, deployment
wired to one house, and a submodule pointing at a forge no stranger can reach.
None of that was ever the product. It stays in the private forge, which is
allowed to hold the whole working organism, and this is what was deliberately
sent out instead.

Three mechanisms produced this tree, in decreasing order of trust. A top-level
path the manifest does not name never arrives at all, which is the one that
catches directories nobody has thought of yet. Named internal files inside
admitted roots are dropped. A short, reviewed table replaces deployment
defaults that a public build must not carry -- an endpoint aimed at one LAN, a
VPN profile belonging to one phone, packaging built from one checkout path.

Everything after this commit is an ordinary publication with the same three
trailers, so a force push stops being routine and starts meaning that
something deliberate happened. The trailers bind the projection to its source
without pretending the public SHA is the private one: same lineage, different
tree, and the record says so.

Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047
Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27
Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
This commit is contained in:
Fimeg 2026-09-04 15:55:48 -04:00
commit 8f42fc953d
1476 changed files with 238455 additions and 0 deletions

View file

@ -0,0 +1,937 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions as CF
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.services.ai
/**
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
* Supports Gemini and OpenAI models.
* Limitations:
* - For now functions only work with Gemini API format
*/
Singleton {
id: root
property Component aiMessageComponent: AiMessageData {}
property Component aiModelComponent: AiModel {}
property Component geminiApiStrategy: GeminiApiStrategy {}
property Component openaiApiStrategy: OpenAiApiStrategy {}
property Component openaiApiResponsesStrategy: OpenAiResponsesApiStrategy {}
property Component mistralApiStrategy: MistralApiStrategy {}
readonly property string interfaceRole: "interface"
readonly property string apiKeyEnvVarName: "API_KEY"
signal responseFinished()
property string systemPrompt: {
let prompt = Config.options?.ai?.systemPrompt ?? "";
for (let key in root.promptSubstitutions) {
// prompt = prompt.replaceAll(key, root.promptSubstitutions[key]);
// QML/JS doesn't support replaceAll, so use split/join
prompt = prompt.split(key).join(root.promptSubstitutions[key]);
}
return prompt;
}
// property var messages: []
property var messageIDs: []
property var messageByID: ({})
readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {}
readonly property var apiKeysLoaded: KeyringStorage.loaded
readonly property bool currentModelHasApiKey: {
const model = models[currentModelId];
if (!model || !model.requires_key) return true;
if (!apiKeysLoaded) return false;
const key = apiKeys[model.key_id];
return (key?.length > 0);
}
property var postResponseHook
property real temperature: Persistent.states?.ai?.temperature ?? 0.5
property QtObject tokenCount: QtObject {
property int input: -1
property int output: -1
property int total: -1
}
function idForMessage(message) {
// Generate a unique ID using timestamp and random value
return Date.now().toString(36) + Math.random().toString(36).substr(2, 8);
}
function safeModelName(modelName) {
return modelName.replace(/:/g, "_").replace(/ /g, "-").replace(/\//g, "-")
}
property list<var> defaultPrompts: []
property list<var> userPrompts: []
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
property list<var> savedChats: []
property var promptSubstitutions: {
"{DISTRO}": SystemInfo.distroName,
"{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`,
"{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown",
"{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})`
}
// Gemini: https://ai.google.dev/gemini-api/docs/function-calling
// OpenAI: https://platform.openai.com/docs/guides/function-calling
property string currentTool: Config?.options.ai.tool ?? "search"
property var tools: {
"gemini": {
"functions": [{"functionDeclarations": [
{
"name": "switch_to_search_mode",
"description": "Search the web",
},
{
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
},
{
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
}
},
"required": ["key", "value"]
}
},
{
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
]}],
"search": [{
"google_search": {}
}],
"none": []
},
"openai": {
"functions": [
{
"type": "function",
"function": {
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
"parameters": {"type": "object", "properties": {}}
},
},
{
"type": "function",
"function": {
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
}
},
"required": ["key", "value"]
}
}
},
{
"type": "function",
"function": {
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
},
],
"search": [],
"none": [],
},
"responses": {
"functions": [
]
},
"mistral": {
"functions": [
{
"type": "function",
"function": {
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
"parameters": {"type": "object", "properties": {}}
},
},
{
"type": "function",
"function": {
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
}
},
"required": ["key", "value"]
}
}
},
{
"type": "function",
"function": {
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
},
],
"search": [],
"none": [],
}
}
property list<var> availableTools: Object.keys(root.tools[models[currentModelId]?.api_format])
property var toolDescriptions: {
"functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"),
"search": Translation.tr("Gives the model search capabilities (immediately)"),
"none": Translation.tr("Disable tools")
}
// Model properties:
// - name: Name of the model
// - icon: Icon name of the model
// - description: Description of the model
// - endpoint: Endpoint of the model
// - model: Model name of the model
// - requires_key: Whether the model requires an API key
// - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key.
// - key_get_link: Link to get an API key
// - key_get_description: Description of pricing and how to get an API key
// - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai".
// - extraParams: Extra parameters to be passed to the model. This is a JSON object.
property var models: Config.options.policies.ai === 2 ? {} : {
"gemini-2.5-flash": aiModelComponent.createObject(this, {
"name": "Gemini 2.5 Flash",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent",
"model": "gemini-2.5-flash",
"requires_key": true,
"key_id": "gemini",
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
}),
"gemini-3-flash": aiModelComponent.createObject(this, {
"name": "Gemini 3 Flash",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nPro-level intelligence at the speed and pricing of Flash."),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent",
"model": "gemini-3-flash-preview",
"requires_key": true,
"key_id": "gemini",
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
}),
"mistral-medium-3": aiModelComponent.createObject(this, {
"name": "Mistral Medium 3",
"icon": "mistral-symbolic",
"description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"),
"homepage": "https://mistral.ai/news/mistral-medium-3",
"endpoint": "https://api.mistral.ai/v1/chat/completions",
"model": "mistral-medium-2505",
"requires_key": true,
"key_id": "mistral",
"key_get_link": "https://console.mistral.ai/api-keys",
"key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"),
"api_format": "mistral",
}),
}
property var modelList: Object.keys(root.models)
property var currentModelId: Persistent.states?.ai?.model || modelList[0]
property var currentModel: models[currentModelId] || models[modelList[0]]
property var apiStrategies: {
"openai": openaiApiStrategy.createObject(this),
"responses": openaiApiResponsesStrategy.createObject(this),
"gemini": geminiApiStrategy.createObject(this),
"mistral": mistralApiStrategy.createObject(this),
}
property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"]
function addUserModels() {
(Config?.options.ai?.extraModels ?? []).forEach(model => {
const safeModelName = root.safeModelName(model["model"]);
root.addModel(safeModelName, model)
});
}
Connections {
target: Config
function onReadyChanged() {
if (!Config.ready) return;
root.addUserModels()
}
}
property string requestScriptFilePath: "/tmp/quickshell/ai/request.sh"
property string pendingFilePath: ""
Component.onCompleted: {
setModel(currentModelId, false, false); // Do necessary setup for model
root.addUserModels() // Config onReadyChanged above might not fire if config is loaded before this service
}
function guessModelLogo(model) {
if (model.includes("llama")) return "ollama-symbolic";
if (model.includes("gemma")) return "google-gemini-symbolic";
if (model.includes("deepseek")) return "deepseek-symbolic";
if (/^phi\d*:/i.test(model)) return "microsoft-symbolic";
return "ollama-symbolic";
}
function guessModelName(model) {
const replaced = model.replace(/-/g, ' ').replace(/:/g, ' ');
let words = replaced.split(' ');
words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`)
words = words.map((word) => {
return (word.charAt(0).toUpperCase() + word.slice(1))
});
if (words[words.length - 1] === "Latest") words.pop();
else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets
const result = words.join(' ');
return result;
}
function addModel(modelName, data) {
root.models = Object.assign({}, root.models, {
[modelName]: aiModelComponent.createObject(this, data)
});
}
Process {
id: getOllamaModels
running: true
command: ["bash", "-c", `${Directories.scriptPath}/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")]
stdout: SplitParser {
onRead: data => {
try {
if (data.length === 0) return;
const dataJson = JSON.parse(data);
root.modelList = [...root.modelList, ...dataJson];
dataJson.forEach(model => {
const safeModelName = root.safeModelName(model);
root.addModel(safeModelName, {
"name": guessModelName(model),
"icon": guessModelLogo(model),
"description": Translation.tr("Local Ollama model | %1").arg(model),
"homepage": `https://ollama.com/library/${model}`,
"endpoint": "http://localhost:11434/v1/chat/completions",
"model": model,
"requires_key": false,
})
});
root.modelList = Object.keys(root.models);
if (root.modelList.includes(root.currentModelId)) {
root.setModel(root.currentModelId, false, false);
}
} catch (e) {
console.log("Could not fetch Ollama models:", e);
}
}
}
}
Process {
id: getDefaultPrompts
running: true
command: ["ls", "-1", Directories.defaultAiPrompts]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
root.defaultPrompts = text.split("\n")
.filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt"))
.map(fileName => `${Directories.defaultAiPrompts}/${fileName}`)
}
}
}
Process {
id: getUserPrompts
running: true
command: ["ls", "-1", Directories.userAiPrompts]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
root.userPrompts = text.split("\n")
.filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt"))
.map(fileName => `${Directories.userAiPrompts}/${fileName}`)
}
}
}
Process {
id: getSavedChats
running: true
command: ["ls", "-1", Directories.aiChats]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
root.savedChats = text.split("\n")
.filter(fileName => fileName.endsWith(".json"))
.map(fileName => `${Directories.aiChats}/${fileName}`)
}
}
}
FileView {
id: promptLoader
watchChanges: false;
onLoadedChanged: {
if (!promptLoader.loaded) return;
Config.options.ai.systemPrompt = promptLoader.text();
root.addMessage(Translation.tr("Loaded the following system prompt\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole);
}
}
function printPrompt() {
root.addMessage(Translation.tr("The current system prompt is\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole);
}
function loadPrompt(filePath) {
promptLoader.path = "" // Unload
promptLoader.path = filePath; // Load
promptLoader.reload();
}
function addMessage(message, role) {
if (message.length === 0) return;
const aiMessage = aiMessageComponent.createObject(root, {
"role": role,
"content": message,
"rawContent": message,
"thinking": false,
"done": true,
});
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function removeMessage(index) {
if (index < 0 || index >= messageIDs.length) return;
const id = root.messageIDs[index];
root.messageIDs.splice(index, 1);
root.messageIDs = [...root.messageIDs];
delete root.messageByID[id];
}
function addApiKeyAdvice(model) {
root.addMessage(
Translation.tr('To set an API key, pass it with the %4 command\n\nTo view the key, pass "get" with the command<br/>\n\n### For %1:\n\n**Link**: %2\n\n%3')
.arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("<i>No further instruction provided</i>")).arg("/key"),
Ai.interfaceRole
);
}
function getModel() {
return models[currentModelId];
}
function setModel(modelId, feedback = true, setPersistentState = true) {
if (!modelId) modelId = ""
modelId = modelId.toLowerCase()
if (modelList.indexOf(modelId) !== -1) {
const model = models[modelId]
// See if policy prevents online models
if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) {
root.addMessage(
Translation.tr("Online models disallowed\n\nControlled by `policies.ai` config option"),
root.interfaceRole
);
return;
}
if (setPersistentState) Persistent.states.ai.model = modelId;
if (feedback) root.addMessage(Translation.tr("Model set to %1").arg(model.name), root.interfaceRole);
root.currentModel = model
if (model.requires_key) {
// If key not there show advice
if (root.apiKeysLoaded && (!root.apiKeys[model.key_id] || root.apiKeys[model.key_id].length === 0)) {
root.addApiKeyAdvice(model)
}
}
} else {
if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```"
}
}
function setTool(tool) {
if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) {
root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole);
return false;
}
Config.options.ai.tool = tool;
return true;
}
function getTemperature() {
return root.temperature;
}
function setTemperature(value) {
if (value == NaN || value < 0 || value > 2) {
root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole);
return;
}
Persistent.states.ai.temperature = value;
root.temperature = value;
root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole);
}
function setApiKey(key) {
const model = models[currentModelId];
if (!model.requires_key) {
root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole);
return;
}
if (!key || key.length === 0) {
const model = models[currentModelId];
root.addApiKeyAdvice(model)
return;
}
KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim());
root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole);
}
function printApiKey() {
const model = models[currentModelId];
if (model.requires_key) {
const key = root.apiKeys[model.key_id];
if (key) {
root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole);
} else {
root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole);
}
} else {
root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole);
}
}
function printTemperature() {
root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole);
}
function clearMessages() {
root.messageIDs = [];
root.messageByID = ({});
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
}
FileView {
id: requesterScriptFile
}
Process {
id: requester
property list<string> baseCommand: ["bash"]
property AiMessageData message
property ApiStrategy currentStrategy
function markDone() {
requester.message.done = true;
if (root.postResponseHook) {
root.postResponseHook();
root.postResponseHook = null; // Reset hook after use
}
root.saveChat("lastSession")
root.responseFinished()
}
function makeRequest() {
const model = models[currentModelId];
// Fetch API keys if needed
if (model?.requires_key && !KeyringStorage.loaded) KeyringStorage.fetchKeyringData();
requester.currentStrategy = root.currentApiStrategy;
requester.currentStrategy.reset(); // Reset strategy state
/* Put API key in environment variable */
if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : ""
/* Build endpoint, request data */
const endpoint = root.currentApiStrategy.buildEndpoint(model);
const messageArray = root.messageIDs.map(id => root.messageByID[id]);
const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole);
const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature, root.tools[model.api_format][root.currentTool], root.pendingFilePath);
// console.log("[Ai] Request data: ", JSON.stringify(data, null, 2));
let requestHeaders = {
"Content-Type": "application/json",
}
/* Create local message object */
requester.message = root.aiMessageComponent.createObject(root, {
"role": "assistant",
"model": currentModelId,
"content": "",
"rawContent": "",
"thinking": true,
"done": false,
});
const id = idForMessage(requester.message);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = requester.message;
/* Build header string for curl */
let headerString = Object.entries(requestHeaders)
.filter(([k, v]) => v && v.length > 0)
.map(([k, v]) => `-H '${k}: ${v}'`)
.join(' ');
// console.log("Request headers: ", JSON.stringify(requestHeaders));
// console.log("Header string: ", headerString);
/* Get authorization header from strategy */
const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName);
/* Script shebang */
const scriptShebang = "#!/usr/bin/env bash\n";
/* Create extra setup when there's an attached file */
let scriptFileSetupContent = ""
if (root.pendingFilePath && root.pendingFilePath.length > 0) {
requester.message.localFilePath = root.pendingFilePath;
scriptFileSetupContent = requester.currentStrategy.buildScriptFileSetup(root.pendingFilePath);
root.pendingFilePath = ""
}
/* Create command string */
let scriptRequestContent = ""
scriptRequestContent += `curl --no-buffer "${endpoint}"`
+ ` ${headerString}`
+ (authHeader ? ` ${authHeader}` : "")
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
+ "\n"
/* Send the request */
const scriptContent = requester.currentStrategy.finalizeScriptContent(scriptShebang + scriptFileSetupContent + scriptRequestContent)
const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath)
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath)
requesterScriptFile.setText(scriptContent)
requester.command = baseCommand.concat([shellScriptPath]);
requester.running = true
}
stdout: SplitParser {
onRead: data => {
if (data.length === 0) return;
if (requester.message.thinking) requester.message.thinking = false;
// console.log("[Ai] Raw response line: ", data);
// Handle response line
try {
const result = requester.currentStrategy.parseResponseLine(data, requester.message);
// console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2));
if (result.functionCall) {
requester.message.functionCall = result.functionCall;
root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message);
}
if (result.tokenUsage) {
root.tokenCount.input = result.tokenUsage.input;
root.tokenCount.output = result.tokenUsage.output;
root.tokenCount.total = result.tokenUsage.total;
}
if (result.finished) {
requester.markDone();
}
} catch (e) {
console.log("[AI] Could not parse response: ", e);
requester.message.rawContent += data;
requester.message.content += data;
}
}
}
onExited: (exitCode, exitStatus) => {
const result = requester.currentStrategy.onRequestFinished(requester.message);
if (result.finished) {
requester.markDone();
} else if (!requester.message.done) {
requester.markDone();
}
// Handle error responses
if (requester.message.content.includes("API key not valid")) {
root.addApiKeyAdvice(models[requester.message.model]);
}
}
}
function sendUserMessage(message) {
if (message.length === 0) return;
root.addMessage(message, "user");
requester.makeRequest();
}
function attachFile(filePath: string) {
root.pendingFilePath = CF.FileUtils.trimFileProtocol(filePath);
}
function regenerate(messageIndex) {
if (messageIndex < 0 || messageIndex >= messageIDs.length) return;
const id = root.messageIDs[messageIndex];
const message = root.messageByID[id];
if (message.role !== "assistant") return;
// Remove all messages after this one
for (let i = root.messageIDs.length - 1; i >= messageIndex; i--) {
root.removeMessage(i);
}
requester.makeRequest();
}
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"done": true,
// "visibleToUser": false,
});
}
function addFunctionOutputMessage(name, output) {
const aiMessage = createFunctionOutputMessage(name, output);
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function rejectCommand(message: AiMessageData) {
if (!message.functionPending) return;
message.functionPending = false; // User decided, no more "thinking"
addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user"))
}
function approveCommand(message: AiMessageData) {
if (!message.functionPending) return;
message.functionPending = false; // User decided, no more "thinking"
const responseMessage = createFunctionOutputMessage(message.functionName, "", false);
const id = idForMessage(responseMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = responseMessage;
commandExecutionProc.message = responseMessage;
commandExecutionProc.baseMessageContent = responseMessage.content;
commandExecutionProc.shellCommand = message.functionCall.args.command;
commandExecutionProc.running = true; // Start the command execution
}
Process {
id: commandExecutionProc
property string shellCommand: ""
property AiMessageData message
property string baseMessageContent: ""
command: ["bash", "-c", shellCommand]
stdout: SplitParser {
onRead: (output) => {
commandExecutionProc.message.functionResponse += output + "\n\n";
const updatedContent = commandExecutionProc.baseMessageContent + `\n\n<think>\n<tt>${commandExecutionProc.message.functionResponse}</tt>\n</think>`;
commandExecutionProc.message.rawContent = updatedContent;
commandExecutionProc.message.content = updatedContent;
}
}
onExited: (exitCode, exitStatus) => {
commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`;
requester.makeRequest(); // Continue
}
}
function handleFunctionCall(name, args: var, message: AiMessageData) {
if (name === "switch_to_search_mode") {
const modelId = root.currentModelId;
root.currentTool = "search"
root.postResponseHook = () => { root.currentTool = "functions" }
addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request."))
requester.makeRequest();
} else if (name === "get_shell_config") {
const configJson = CF.ObjectUtils.toPlainObject(Config.options)
addFunctionOutputMessage(name, JSON.stringify(configJson));
requester.makeRequest();
} else if (name === "set_shell_config") {
if (!args.key || !args.value) {
addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `key` and `value`."));
return;
}
const key = args.key;
const value = args.value;
Config.setNestedValue(key, value);
} else if (name === "run_shell_command") {
if (!args.command || args.command.length === 0) {
addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`."));
return;
}
const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``;
message.rawContent += contentToAppend;
message.content += contentToAppend;
message.functionPending = true; // Use thinking to indicate the command is waiting for approval
}
else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant");
}
function chatToJson() {
return root.messageIDs.map(id => {
const message = root.messageByID[id]
return ({
"role": message.role,
"rawContent": message.rawContent,
"fileMimeType": message.fileMimeType,
"fileUri": message.fileUri,
"localFilePath": message.localFilePath,
"model": message.model,
"thinking": false,
"done": true,
"annotations": message.annotations,
"annotationSources": message.annotationSources,
"functionName": message.functionName,
"functionCall": message.functionCall,
"thoughtSignature": message.thoughtSignature,
"functionResponse": message.functionResponse,
"visibleToUser": message.visibleToUser,
})
})
}
FileView {
id: chatSaveFile
property string chatName: ""
path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : ""
blockLoading: true // Prevent race conditions
}
FileView {
id: chatWriter
}
/**
* Saves chat to a JSON list of message objects.
* @param chatName name of the chat
*/
function saveChat(chatName) {
const filePath = `${Directories.aiChats}/${chatName.trim()}.json`
chatWriter.path = filePath
const saveContent = JSON.stringify(root.chatToJson())
chatWriter.setText(saveContent)
getSavedChats.running = true;
}
/**
* Loads chat from a JSON list of message objects.
* @param chatName name of the chat
*/
function loadChat(chatName) {
try {
chatSaveFile.chatName = chatName.trim()
chatSaveFile.reload()
const saveContent = chatSaveFile.text()
// console.log(saveContent)
const saveData = JSON.parse(saveContent)
root.clearMessages()
root.messageIDs = saveData.map((_, i) => {
return i
})
// console.log(JSON.stringify(messageIDs))
for (let i = 0; i < saveData.length; i++) {
const message = saveData[i];
root.messageByID[i] = root.aiMessageComponent.createObject(root, {
"role": message.role,
"rawContent": message.rawContent,
"content": message.rawContent,
"fileMimeType": message.fileMimeType,
"fileUri": message.fileUri,
"localFilePath": message.localFilePath,
"model": message.model,
"thinking": message.thinking,
"done": message.done,
"annotations": message.annotations,
"annotationSources": message.annotationSources,
"functionName": message.functionName,
"functionCall": message.functionCall,
"thoughtSignature": message.thoughtSignature,
"functionResponse": message.functionResponse,
"visibleToUser": message.visibleToUser,
});
}
} catch (e) {
console.log("[AI] Could not load chat: ", e);
} finally {
getSavedChats.running = true;
}
}
}

View file

@ -0,0 +1,205 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import Quickshell
/**
* - Eases fuzzy searching for applications by name
* - Guesses icon name for window class name
*/
Singleton {
id: root
property bool sloppySearch: Config.options?.search.sloppy ?? false
property real scoreThreshold: 0.2
property var substitutions: ({
"code-url-handler": "visual-studio-code",
"Code": "visual-studio-code",
"gnome-tweaks": "org.gnome.tweaks",
"pavucontrol-qt": "pavucontrol",
"wps": "wps-office2019-kprometheus",
"wpsoffice": "wps-office2019-kprometheus",
"footclient": "foot",
})
property var regexSubstitutions: [
{
"regex": /^steam_app_(\d+)$/,
"replace": "steam_icon_$1"
},
{
"regex": /Minecraft.*/,
"replace": "minecraft"
},
{
"regex": /.*polkit.*/,
"replace": "system-lock-screen"
},
{
"regex": /gcr.prompter/,
"replace": "system-lock-screen"
}
]
// Deduped list to fix double icons
readonly property list<DesktopEntry> list: Array.from(DesktopEntries.applications.values)
.filter((app, index, self) =>
index === self.findIndex((t) => (
t.id === app.id
))
)
readonly property var preppedNames: list.map(a => ({
name: Fuzzy.prepare(`${a.name} `),
entry: a
}))
readonly property var preppedIcons: list.map(a => ({
name: Fuzzy.prepare(`${a.icon} `),
entry: a
}))
function fuzzyQuery(search: string): var { // Idk why list<DesktopEntry> doesn't work
if (root.sloppySearch) {
return root.levenshteinQuery(search);
}
return Fuzzy.go(search, preppedNames, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
}
function levenshteinQuery(search: string): var {
const prepared = BitwiseFuzzy.prepare(search);
return BitwiseFuzzy.search(prepared, list, { key: "name", threshold: root.scoreThreshold });
}
function iconExists(iconName) {
if (!iconName || iconName.length == 0) return false;
return (Quickshell.iconPath(iconName, true).length > 0)
&& !iconName.includes("image-missing");
}
function getReverseDomainNameAppName(str) {
return str.split('.').slice(-1)[0]
}
function getKebabNormalizedAppName(str) {
return str.toLowerCase().replace(/\s+/g, "-");
}
function getUndescoreToKebabAppName(str) {
return str.toLowerCase().replace(/_/g, "-");
}
/**
* Resolve a window app_id / pinned appId to its DesktopEntry.
*
* DesktopEntries.heuristicLookup() matches the entry id and
* StartupWMClass, which covers most apps but NOT the ones that append an
* instance suffix to their Wayland app_id. Firefox is the reference case:
* it derives its remoting name from the profile, so the window reports
* "firefox-default" while the entry is "firefox" with
* StartupWMClass=firefox. heuristicLookup returns null, and a dock tap on
* a pinned-but-not-running app then calls execute() on null — a silent
* dead tap that reports no error anywhere.
*
* So: fall back to trimming trailing "-segment" pieces, longest match
* first, then to a StartupWMClass prefix scan. Returns null only when
* nothing in the entry set plausibly owns the id.
*/
function resolveEntry(appId) {
if (!appId || appId.length == 0) return null;
const direct = DesktopEntries.heuristicLookup(appId);
if (direct) return direct;
// "firefox-default" -> "firefox"; "signal-desktop-beta" -> "signal-desktop"
let candidate = appId;
while (candidate.includes("-")) {
candidate = candidate.slice(0, candidate.lastIndexOf("-"));
const trimmed = DesktopEntries.heuristicLookup(candidate);
if (trimmed) return trimmed;
}
// Last resort: an entry whose StartupWMClass prefixes the app_id.
const lowered = appId.toLowerCase();
for (const entry of root.list) {
const wmClass = entry.startupClass;
if (!wmClass || wmClass.length == 0) continue;
if (lowered.startsWith(wmClass.toLowerCase())) return entry;
}
return null;
}
function guessIcon(str) {
if (!str || str.length == 0) return "image-missing";
// Quickshell's desktop entry lookup
const entry = DesktopEntries.byId(str);
if (entry) return entry.icon;
// Normal substitutions
if (substitutions[str]) return substitutions[str];
if (substitutions[str.toLowerCase()]) return substitutions[str.toLowerCase()];
// Regex substitutions
for (let i = 0; i < regexSubstitutions.length; i++) {
const substitution = regexSubstitutions[i];
const replacedName = str.replace(
substitution.regex,
substitution.replace,
);
if (replacedName != str) return replacedName;
}
// Icon exists -> return as is
if (iconExists(str)) return str;
// Simple guesses
const lowercased = str.toLowerCase();
if (iconExists(lowercased)) return lowercased;
const reverseDomainNameAppName = getReverseDomainNameAppName(str);
if (iconExists(reverseDomainNameAppName)) return reverseDomainNameAppName;
const lowercasedDomainNameAppName = reverseDomainNameAppName.toLowerCase();
if (iconExists(lowercasedDomainNameAppName)) return lowercasedDomainNameAppName;
const kebabNormalizedGuess = getKebabNormalizedAppName(str);
if (iconExists(kebabNormalizedGuess)) return kebabNormalizedGuess;
const undescoreToKebabGuess = getUndescoreToKebabAppName(str);
if (iconExists(undescoreToKebabGuess)) return undescoreToKebabGuess;
// Search in desktop entries
const iconSearchResults = Fuzzy.go(str, preppedIcons, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
if (iconSearchResults.length > 0) {
const guess = iconSearchResults[0].icon
if (iconExists(guess)) return guess;
}
const nameSearchResults = root.fuzzyQuery(str);
if (nameSearchResults.length > 0) {
const guess = nameSearchResults[0].icon
if (iconExists(guess)) return guess;
}
// Desktop entry lookup, suffix-tolerant so "firefox-default" and
// friends land on their real entry's icon instead of falling through.
const heuristicEntry = root.resolveEntry(str);
if (heuristicEntry) return heuristicEntry.icon;
// Give up
return "application-x-executable";
}
}

View file

@ -0,0 +1,149 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
/**
* A nice wrapper for default Pipewire audio sink and source.
*/
Singleton {
id: root
// Misc props
property bool ready: Pipewire.defaultAudioSink?.ready ?? false
property PwNode sink: Pipewire.defaultAudioSink
property PwNode source: Pipewire.defaultAudioSource
readonly property real hardMaxValue: 2.00 // People keep joking about setting volume to 5172% so...
property string audioTheme: Config.options.sounds.theme
property real value: sink?.audio.volume ?? 0
property bool autoMuted: false
function friendlyDeviceName(node) {
return (node.nickname || node.description || Translation.tr("Unknown"));
}
function appNodeDisplayName(node) {
return (node.properties["application.name"] || node.description || node.name)
}
// Lists
function correctType(node, isSink) {
return (node.isSink === isSink) && node.audio
}
function appNodes(isSink) {
return Pipewire.nodes.values.filter((node) => { // Should be list<PwNode> but it breaks ScriptModel
return root.correctType(node, isSink) && node.isStream
})
}
function devices(isSink) {
return Pipewire.nodes.values.filter(node => {
return root.correctType(node, isSink) && !node.isStream
})
}
readonly property list<var> outputAppNodes: root.appNodes(true)
readonly property list<var> inputAppNodes: root.appNodes(false)
readonly property list<var> outputDevices: root.devices(true)
readonly property list<var> inputDevices: root.devices(false)
// Signals
signal sinkProtectionTriggered(string reason);
// Controls
function toggleMute() {
root.autoMuted = false
Audio.sink.audio.muted = !Audio.sink.audio.muted
}
function toggleMicMute() {
Audio.source.audio.muted = !Audio.source.audio.muted
}
function incrementVolume() {
const currentVolume = Audio.value;
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step);
}
function decrementVolume() {
const currentVolume = Audio.value;
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
Audio.sink.audio.volume -= step;
}
function setDefaultSink(node) {
Pipewire.preferredDefaultAudioSink = node;
}
function setDefaultSource(node) {
Pipewire.preferredDefaultAudioSource = node;
}
// Internals
PwObjectTracker {
objects: [sink, source]
}
Connections { // Protection against sudden volume changes
target: sink?.audio ?? null
property bool lastReady: false
property real lastVolume: 0
function onVolumeChanged() {
if (!Config.options.audio.protection.enable) return;
const newVolume = sink.audio.volume;
// when resuming from suspend, we should not write volume to avoid pipewire volume reset issues
if (isNaN(newVolume) || newVolume === undefined || newVolume === null) {
lastReady = false;
lastVolume = 0;
return;
}
if (!lastReady) {
lastVolume = newVolume;
lastReady = true;
return;
}
const maxAllowedIncrease = Config.options.audio.protection.maxAllowedIncrease / 100;
const maxAllowed = Config.options.audio.protection.maxAllowed / 100;
if (newVolume - lastVolume > maxAllowedIncrease) {
sink.audio.volume = lastVolume;
root.sinkProtectionTriggered(Translation.tr("Illegal increment"));
} else if (newVolume > maxAllowed || newVolume > root.hardMaxValue) {
root.sinkProtectionTriggered(Translation.tr("Exceeded max allowed"));
sink.audio.volume = Math.min(lastVolume, maxAllowed);
}
lastVolume = sink.audio.volume;
if (lastVolume === 0 && !sink.audio.muted) {
sink.audio.muted = true;
root.autoMuted = true;
} else if (lastVolume > 0 && root.autoMuted) {
sink.audio.muted = false;
root.autoMuted = false;
}
}
}
function playSystemSound(soundName) {
const ogaPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.oga`;
const oggPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.ogg`;
// Try playing .oga first
let command = [
"ffplay",
"-nodisp",
"-autoexit",
ogaPath
];
Quickshell.execDetached(command);
// Also try playing .ogg (ffplay will just fail silently if file doesn't exist)
command = [
"ffplay",
"-nodisp",
"-autoexit",
oggPath
];
Quickshell.execDetached(command);
}
}

View file

@ -0,0 +1,112 @@
pragma Singleton
import qs.services
import qs.modules.common
import Quickshell
import Quickshell.Services.UPower
import Quickshell.Services.Mpris
import QtQuick
import Quickshell.Io
Singleton {
id: root
property bool available: UPower.displayDevice.isLaptopBattery
property var chargeState: UPower.displayDevice.state
property bool isCharging: chargeState == UPowerDeviceState.Charging
property bool isPluggedIn: isCharging || chargeState == UPowerDeviceState.PendingCharge
property real percentage: UPower.displayDevice?.percentage ?? 1
readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend
readonly property bool soundEnabled: Config.options.sounds.battery
property bool isLow: available && (percentage <= Config.options.battery.low / 100)
property bool isCritical: available && (percentage <= Config.options.battery.critical / 100)
property bool isSuspending: available && (percentage <= Config.options.battery.suspend / 100)
property bool isFull: available && (percentage >= Config.options.battery.full / 100)
property bool isLowAndNotCharging: isLow && !isCharging
property bool isCriticalAndNotCharging: isCritical && !isCharging
property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging
property bool isFullAndCharging: isFull && isCharging
property real energyRate: UPower.displayDevice.changeRate
property real timeToEmpty: UPower.displayDevice.timeToEmpty
property real timeToFull: UPower.displayDevice.timeToFull
property real health: (function() {
const devList = UPower.devices.values;
for (let i = 0; i < devList.length; ++i) {
const dev = devList[i];
if (dev.isLaptopBattery && dev.healthSupported) {
const health = dev.healthPercentage;
if (health === 0) {
return 0.01;
} else if (health < 1) {
return health * 100;
} else {
return health;
}
}
}
return 0;
})()
onIsLowAndNotChargingChanged: {
if (!root.available || !isLowAndNotCharging) return;
Quickshell.execDetached([
"notify-send",
Translation.tr("Low battery"),
Translation.tr("Consider plugging in your device"),
"-u", "critical",
"-a", "Shell",
"--hint=int:transient:1",
])
if (root.soundEnabled) Audio.playSystemSound("dialog-warning");
}
onIsCriticalAndNotChargingChanged: {
if (!root.available || !isCriticalAndNotCharging) return;
Quickshell.execDetached([
"notify-send",
Translation.tr("Critically low battery"),
Translation.tr("Please charge!\nAutomatic suspend triggers at %1%").arg(Config.options.battery.suspend),
"-u", "critical",
"-a", "Shell",
"--hint=int:transient:1",
]);
if (root.soundEnabled) Audio.playSystemSound("suspend-error");
}
onIsSuspendingAndNotChargingChanged: {
if (root.available && isSuspendingAndNotCharging) {
for (const player of Mpris.players.values) {
if (player.canPause) player.pause();
}
Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]);
}
}
onIsFullAndChargingChanged: {
if (!root.available || !isFullAndCharging) return;
Quickshell.execDetached([
"notify-send",
Translation.tr("Battery full"),
Translation.tr("Please unplug the charger"),
"-a", "Shell",
"--hint=int:transient:1",
]);
if (root.soundEnabled) Audio.playSystemSound("complete");
}
onIsPluggedInChanged: {
if (!root.available || !root.soundEnabled) return;
if (isPluggedIn) {
Audio.playSystemSound("power-plug")
} else {
Audio.playSystemSound("power-unplug")
}
}
}

View file

@ -0,0 +1,37 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property bool available: Bluetooth.adapters.values.length > 0
readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false
readonly property BluetoothDevice firstActiveDevice: Bluetooth.defaultAdapter?.devices.values.find(device => device.connected) ?? null
readonly property int activeDeviceCount: Bluetooth.defaultAdapter?.devices.values.filter(device => device.connected).length ?? 0
readonly property bool connected: Bluetooth.devices.values.some(d => d.connected)
function sortFunction(a, b) {
// Ones with meaningful names before MAC addresses
const macRegex = /^([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}$/;
const aIsMac = macRegex.test(a.name);
const bIsMac = macRegex.test(b.name);
if (aIsMac !== bIsMac)
return aIsMac ? 1 : -1;
// Alphabetical by name
return a.name.localeCompare(b.name);
}
property list<var> connectedDevices: Bluetooth.devices.values.filter(d => d.connected).sort(sortFunction)
property list<var> pairedButNotConnectedDevices: Bluetooth.devices.values.filter(d => d.paired && !d.connected).sort(sortFunction)
property list<var> unpairedDevices: Bluetooth.devices.values.filter(d => !d.paired && !d.connected).sort(sortFunction)
property list<var> friendlyDeviceList: [
...connectedDevices,
...pairedButNotConnectedDevices,
...unpairedDevices
]
}

View file

@ -0,0 +1,470 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.services
import Quickshell;
import QtQuick;
/**
* A service for interacting with various booru APIs.
*/
Singleton {
id: root
property Component booruResponseDataComponent: BooruResponseData {}
signal tagSuggestion(string query, var suggestions)
signal responseFinished()
property string failMessage: Translation.tr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number")
property var responses: []
property int runningRequests: 0
property var defaultUserAgent: Config.options?.networking?.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
property var providerList: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api)
property var providers: {
"system": { "name": Translation.tr("System") },
"yandere": {
"name": "yande.re",
"url": "https://yande.re",
"api": "https://yande.re/post.json",
"description": Translation.tr("All-rounder | Good quality, decent quantity"),
"mapFunc": (response) => {
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags,
"rating": item.rating,
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_url,
"sample_url": item.sample_url ?? item.file_url,
"file_url": item.file_url,
"file_ext": item.file_ext,
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://yande.re/tag.json?order=count&limit=10&name={{query}}*",
"tagMapFunc": (response) => {
return response.map(item => {
return {
"name": item.name,
"count": item.count
}
})
}
},
"konachan": {
"name": "Konachan",
"url": "https://konachan.net",
"api": "https://konachan.net/post.json",
"description": Translation.tr("For desktop wallpapers | Good quality"),
"mapFunc": (response) => {
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags,
"rating": item.rating,
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_url,
"sample_url": item.sample_url ?? item.file_url,
"file_url": item.file_url,
"file_ext": item.file_ext,
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://konachan.net/tag.json?order=count&limit=10&name={{query}}*",
"tagMapFunc": (response) => {
return response.map(item => {
return {
"name": item.name,
"count": item.count
}
})
}
},
"zerochan": {
"name": "Zerochan",
"url": "https://www.zerochan.net",
"api": "https://www.zerochan.net/?json",
"description": Translation.tr("Clean stuff | Excellent quality, no NSFW"),
"mapFunc": (response) => {
response = response.items
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags.join(" "),
"rating": "safe", // Zerochan doesn't have nsfw
"is_nsfw": false,
"md5": item.md5,
"preview_url": item.thumbnail,
"sample_url": item.thumbnail,
"file_url": item.thumbnail,
"file_ext": "avif",
"source": getWorkingImageSource(item.source) ?? item.thumbnail,
"character": item.tag
}
})
}
},
"danbooru": {
"name": "Danbooru",
"url": "https://danbooru.donmai.us",
"api": "https://danbooru.donmai.us/posts.json",
"description": Translation.tr("The popular one | Best quantity, but quality can vary wildly"),
"mapFunc": (response) => {
return response.map(item => {
return {
"id": item.id,
"width": item.image_width,
"height": item.image_height,
"aspect_ratio": item.image_width / item.image_height,
"tags": item.tag_string,
"rating": item.rating,
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_file_url,
"sample_url": item.file_url ?? item.large_file_url,
"file_url": item.large_file_url,
"file_ext": item.file_ext,
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://danbooru.donmai.us/tags.json?limit=10&search[name_matches]={{query}}*",
"tagMapFunc": (response) => {
return response.map(item => {
return {
"name": item.name,
"count": item.post_count
}
})
}
},
"gelbooru": {
"name": "Gelbooru",
"url": "https://gelbooru.com",
"api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1",
"description": Translation.tr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"),
"mapFunc": (response) => {
response = response.post
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags,
"rating": item.rating.replace('general', 's').charAt(0),
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_url,
"sample_url": item.sample_url ?? item.file_url,
"file_url": item.file_url,
"file_ext": item.file_url.split('.').pop(),
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&limit=10&name_pattern={{query}}%",
"tagMapFunc": (response) => {
return response.tag.map(item => {
return {
"name": item.name,
"count": item.count
}
})
}
},
"waifu.im": {
"name": "waifu.im",
"url": "https://waifu.im",
"api": "https://api.waifu.im/images",
"description": Translation.tr("Waifus only | Excellent quality, limited quantity"),
"mapFunc": (response) => {
response = response.items
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags.map(tag => {return tag.name}).join(" "),
"rating": item.isNsfw ? "e" : "s",
"is_nsfw": item.isNsfw,
"md5": item.md5,
"preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk)
"sample_url": item.url,
"file_url": item.url,
"file_ext": item.extension,
"source": getWorkingImageSource(item.source) ?? item.url,
}
})
},
"tagSearchTemplate": "https://api.waifu.im/tags?Name={{query}}",
"tagMapFunc": (response) => {
return response.items.map(item => {return {"name": item.name}})
}
},
"t.alcy.cc": {
"name": "Alcy",
"url": "https://t.alcy.cc",
"api": "https://t.alcy.cc/",
"description": Translation.tr("Large images | God tier quality, no NSFW."),
"fixedTags": [
{
"name": "ycy",
"count": "General"
},
{
"name": "moez",
"count": "Moe"
},
{
"name": "ysz",
"count": "Genshin Impact"
},
{
"name": "fj",
"count": "Landscape"
},
{
"name": "bd",
"count": "Girl on white background"
},
{
"name": "xhl",
"count": "Shiggy"
},
],
"manualParseFunc": (responseText) => {
// Alcy just returns image links, each on a new line
const lines = responseText.trim().split('\n');
return lines.map(line => {
return {
"id": Qt.md5(line),
// Alcy doesn't provide dimensions and images are often of god resolution
"width": 1000,
"height": 1000,
"aspect_ratio": 1,
"tags": "[no tags]",
"rating": "s",
"is_nsfw": false,
"md5": Qt.md5(line),
"preview_url": line,
"sample_url": line,
"file_url": line,
"file_ext": line.split('.').pop(),
"source": "",
}
});
},
}
}
property var currentProvider: Persistent.states.booru.provider
function getWorkingImageSource(url) {
if (url?.includes('pximg.net')) {
return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`;
}
return url;
}
function setProvider(provider) {
provider = provider.toLowerCase()
if (providerList.indexOf(provider) !== -1) {
Persistent.states.booru.provider = provider
root.addSystemMessage(Translation.tr("Provider set to ") + providers[provider].name
+ (provider == "zerochan" ? Translation.tr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : ""))
} else {
root.addSystemMessage(Translation.tr("Invalid API provider. Supported: \n- ") + providerList.join("\n- "))
}
}
function clearResponses() {
responses = []
}
function addSystemMessage(message) {
responses = [...responses, root.booruResponseDataComponent.createObject(null, {
"provider": "system",
"tags": [],
"page": -1,
"images": [],
"message": `${message}`
})]
}
function constructRequestUrl(tags, nsfw=true, limit=20, page=1) {
var provider = providers[currentProvider]
var baseUrl = provider.api
var url = baseUrl
var tagString = tags.join(" ")
if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) {
if (currentProvider == "gelbooru")
tagString += " rating:general";
else
tagString += " rating:safe";
}
var params = []
// Tags & limit
if (currentProvider === "zerochan") {
params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color
params.push("l=" + limit)
params.push("s=" + "fav")
params.push("t=" + 1)
params.push("p=" + page)
}
else if (currentProvider === "waifu.im") {
var tagsArray = tagString.split(" ");
tagsArray.forEach(tag => {
params.push("IncludedTags=" + encodeURIComponent(tag.toLowerCase()));
});
params.push("PageSize=" + Math.min(limit, 30)) // Only admin can do > 30
params.push("IsNsfw=" + (nsfw ? "All" : "False")) // null is random
}
else if (currentProvider === "t.alcy.cc") {
url += tagString
params.push("json")
params.push("quantity=" + limit)
}
else {
params.push("tags=" + encodeURIComponent(tagString))
params.push("limit=" + limit)
if (currentProvider == "gelbooru") {
params.push("pid=" + page)
}
else {
params.push("page=" + page)
}
}
if (baseUrl.indexOf("?") === -1) {
url += "?" + params.join("&")
} else {
url += "&" + params.join("&")
}
return url
}
function makeRequest(tags, nsfw=false, limit=20, page=1) {
var url = constructRequestUrl(tags, nsfw, limit, page)
console.log("[Booru] Making request to " + url)
const newResponse = root.booruResponseDataComponent.createObject(null, {
"provider": currentProvider,
"tags": tags,
"page": page,
"images": [],
"message": ""
})
var xhr = new XMLHttpRequest()
xhr.open("GET", url)
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
try {
// console.log("[Booru] Raw response: " + xhr.responseText)
const provider = providers[currentProvider]
let response;
if (provider.manualParseFunc) {
response = provider.manualParseFunc(xhr.responseText)
} else {
response = JSON.parse(xhr.responseText)
response = provider.mapFunc(response)
}
// console.log("[Booru] Mapped response: " + JSON.stringify(response))
newResponse.images = response
newResponse.message = response.length > 0 ? "" : root.failMessage
} catch (e) {
console.log("[Booru] Failed to parse response: " + e)
newResponse.message = root.failMessage
} finally {
root.runningRequests--;
root.responses = [...root.responses, newResponse]
}
}
else if (xhr.readyState === XMLHttpRequest.DONE) {
console.log("[Booru] Request failed with status: " + xhr.status)
newResponse.message = root.failMessage
root.runningRequests--;
root.responses = [...root.responses, newResponse]
}
root.responseFinished()
}
try {
// Required for danbooru and konachan
if (["danbooru", "konachan"].includes(currentProvider)) {
xhr.setRequestHeader("User-Agent", defaultUserAgent)
}
else if (currentProvider == "zerochan") {
const userAgent = Config.options?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${Config.options.sidebar.booru.zerochan.username}` : defaultUserAgent
xhr.setRequestHeader("User-Agent", userAgent)
}
root.runningRequests++;
xhr.send()
} catch (error) {
console.log("Could not set User-Agent:", error)
}
}
property var currentTagRequest: null
function triggerTagSearch(query) {
if (currentTagRequest) {
currentTagRequest.abort();
}
var provider = providers[currentProvider]
if (provider.fixedTags) {
root.tagSuggestion(query, provider.fixedTags)
return provider.fixedTags;
} else if (!provider.tagSearchTemplate) {
return
}
var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query))
var xhr = new XMLHttpRequest()
currentTagRequest = xhr
xhr.open("GET", url)
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
currentTagRequest = null
try {
// console.log("[Booru] Raw response: " + xhr.responseText)
var response = JSON.parse(xhr.responseText)
response = provider.tagMapFunc(response)
// console.log("[Booru] Mapped response: " + JSON.stringify(response))
root.tagSuggestion(query, response)
} catch (e) {
console.log("[Booru] Failed to parse response: " + e)
}
}
else if (xhr.readyState === XMLHttpRequest.DONE) {
console.log("[Booru] Request failed with status: " + xhr.status)
}
}
try {
// Required for danbooru and konachan
if (["danbooru", "konachan"].includes(currentProvider)) {
xhr.setRequestHeader("User-Agent", defaultUserAgent)
}
xhr.send()
} catch (error) {
console.log("Could not set User-Agent:", error)
}
}
}

View file

@ -0,0 +1,13 @@
import qs.modules.common
import QtQuick;
/**
* A booru response.
*/
QtObject {
property string provider
property var tags
property var page
property var images
property string message
}

View file

@ -0,0 +1,271 @@
pragma Singleton
pragma ComponentBehavior: Bound
// From https://github.com/caelestia-dots/shell with modifications.
// License: GPLv3
import qs.modules.common
import qs.modules.common.functions
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
/**
* For managing brightness of monitors. Supports both brightnessctl and ddcutil.
*/
Singleton {
id: root
signal brightnessChanged()
property var ddcMonitors: []
readonly property list<BrightnessMonitor> monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, {
screen
}))
function getMonitorForScreen(screen: ShellScreen): var {
return monitors.find(m => m.screen === screen);
}
function increaseBrightness(): void {
// if gamma is not yet 100, first increase gamma
if (Hyprsunset.gamma !== 100) {
Hyprsunset.setGamma(Hyprsunset.gamma + 5);
return;
}
const focusedName = Hyprland.focusedMonitor.name;
const monitor = monitors.find(m => focusedName === m.screen.name);
if (monitor)
monitor.setBrightness(monitor.brightness + 0.05);
}
function decreaseBrightness(): void {
const focusedName = Hyprland.focusedMonitor.name;
const monitor = monitors.find(m => focusedName === m.screen.name);
if (monitor && monitor.brightness > 0)
monitor.setBrightness(monitor.brightness - 0.05);
// if brightness is 0, then decrease gamma
else {
Hyprsunset.setGamma(Hyprsunset.gamma - 5);
}
}
reloadableId: "brightness"
onMonitorsChanged: {
ddcMonitors = [];
ddcProc.running = true;
}
function initializeMonitor(i: int): void {
if (i >= monitors.length)
return;
monitors[i].initialize();
}
function ddcDetectFinished(): void {
initializeMonitor(0);
}
Process {
id: ddcProc
command: ["ddcutil", "detect", "--brief"]
stdout: SplitParser {
splitMarker: "\n\n"
onRead: data => {
if (data.startsWith("Display ")) {
const lines = data.split("\n").map(l => l.trim());
root.ddcMonitors.push({
name: lines.find(l => l.startsWith("DRM connector:")).split("-").slice(1).join('-'),
busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1]
});
}
}
}
onExited: root.ddcDetectFinished()
}
Process {
id: setProc
}
component BrightnessMonitor: QtObject {
id: monitor
required property ShellScreen screen
property bool isDdc
property string busNum
property int rawMaxBrightness: 100
property real brightness
property real brightnessMultiplier: 1.0
property real multipliedBrightness: Math.max(0, Math.min(1, brightness * (Config.options.light.antiFlashbang.enable ? brightnessMultiplier : 1)))
property bool ready: false
property bool animateChanges: !monitor.isDdc
onBrightnessChanged: {
if (!monitor.ready) return;
root.brightnessChanged();
}
Behavior on multipliedBrightness {
enabled: monitor.animateChanges
NumberAnimation {
duration: 200
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.animationCurves.expressiveEffects
}
}
onMultipliedBrightnessChanged: {
if (monitor.animationEnabled) syncBrightness();
else setTimer.restart();
}
function initialize() {
monitor.ready = false;
const match = root.ddcMonitors.find(m => m.name === screen.name && !root.monitors.slice(0, root.monitors.indexOf(this)).some(mon => mon.busNum === m.busNum));
isDdc = !!match;
busNum = match?.busNum ?? "";
initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`];
initProc.running = true;
}
readonly property Process initProc: Process {
stdout: SplitParser {
onRead: data => {
const [, , , current, max] = data.split(" ");
monitor.rawMaxBrightness = parseInt(max);
monitor.brightness = parseInt(current) / monitor.rawMaxBrightness;
monitor.ready = true;
}
}
onExited: (exitCode, exitStatus) => {
initializeMonitor(root.monitors.indexOf(monitor) + 1);
}
}
// We need a delay for DDC monitors because they can be quite slow and might act weird with rapid changes
property var setTimer: Timer {
id: setTimer
interval: monitor.isDdc ? 300 : 0
onTriggered: {
syncBrightness();
}
}
function syncBrightness() {
const brightnessValue = Math.max(monitor.multipliedBrightness, 0);
if (isDdc) {
const rawValueRounded = Math.max(Math.floor(brightnessValue * monitor.rawMaxBrightness), 1);
setProc.exec(["ddcutil", "-b", busNum, "setvcp", "10", rawValueRounded]);
} else {
const valuePercentNumber = Math.floor(brightnessValue * 100);
let valuePercent = `${valuePercentNumber}%`;
if (valuePercentNumber == 0) valuePercent = "1"; // Prevent fully black
setProc.exec(["brightnessctl", "--class", "backlight", "s", valuePercent, "--quiet"])
}
}
function setBrightness(value: real): void {
value = Math.max(0, Math.min(1, value));
monitor.brightness = value;
}
function setBrightnessMultiplier(value: real): void {
monitor.brightnessMultiplier = value;
}
}
Component {
id: monitorComp
BrightnessMonitor {}
}
// Anti-flashbang
property int workspaceAnimationDelay: 500
property int contentSwitchDelay: 30
property string screenshotDir: "/tmp/quickshell/brightness/antiflashbang"
function brightnessMultiplierForLightness(x: real): real {
// I hand picked some values and fitted an exponential curve for this
// 6.600135 + 216.360356 * e^(-0.0811129189x)
// Division by 100 is to normalize to [0, 1]
return (6.600135 + 216.360356 * Math.pow(Math.E, -0.0811129189 * x)) / 100.0;
}
Variants {
model: Quickshell.screens
Scope {
id: screenScope
required property var modelData
property string screenName: modelData.name
property string screenshotPath: `${root.screenshotDir}/screenshot-${screenName}.png`
Connections {
enabled: Config.options.light.antiFlashbang.enable && Appearance.m3colors.darkmode
target: Hyprland
function onRawEvent(event) {
if (["activewindowv2", "windowtitlev2"].includes(event.name)) {
screenshotTimer.interval = root.contentSwitchDelay;
screenshotTimer.restart();
} else if (["workspacev2"].includes(event.name)) {
screenshotTimer.interval = root.workspaceAnimationDelay;
screenshotTimer.restart();
}
}
}
Timer {
id: screenshotTimer
interval: 700 // This is what I have for a Hyprland ws anim
onTriggered: {
screenshotProc.running = false;
screenshotProc.running = true;
}
}
Process {
id: screenshotProc
command: ["bash", "-c",
`mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}'`
+ ` && grim -o '${StringUtils.shellSingleQuoteEscape(screenScope.screenName)}' -`
+ ` | magick png:- -colorspace Gray -format "%[fx:mean*100]" info:`
]
stdout: StdioCollector {
id: lightnessCollector
onStreamFinished: {
Quickshell.execDetached(["rm", screenScope.screenshotPath]); // Cleanup
const lightness = lightnessCollector.text
const newMultiplier = root.brightnessMultiplierForLightness(parseFloat(lightness))
Brightness.getMonitorForScreen(screenScope.modelData).setBrightnessMultiplier(newMultiplier)
}
}
}
}
}
// External trigger points
IpcHandler {
target: "brightness"
function increment() {
onPressed: root.increaseBrightness()
}
function decrement() {
onPressed: root.decreaseBrightness()
}
}
GlobalShortcut {
name: "brightnessIncrease"
description: "Increase brightness"
onPressed: root.increaseBrightness()
}
GlobalShortcut {
name: "brightnessDecrease"
description: "Decrease brightness"
onPressed: root.decreaseBrightness()
}
}

View file

@ -0,0 +1,152 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.services
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Claude subscription usage (Pro / Max).
*
* Reads the OAuth access token from ~/.claude/.credentials.json (kept fresh by
* Claude Code) and polls https://api.anthropic.com/api/oauth/usage — the same
* data Claude Code's /usage command shows. Only active when
* Config.options.bar.claudeUsage.enable is true.
*
* Requires `jq` and `curl` (already used elsewhere in the shell).
*/
Singleton {
id: root
readonly property bool enabled: Config.options.bar.claudeUsage.enable
readonly property int fetchInterval: Config.options.bar.claudeUsage.fetchInterval * 60 * 1000
property bool available: false
property string lastError: ""
property string subscriptionType: "" // "pro" | "max" | ...
// Utilization percentages (0-100); -1 means "not reported by the API"
property real fiveHour: 0
property real sevenDay: 0
property real sevenDayOpus: -1
property real sevenDaySonnet: -1
// Reset timestamps (epoch ms; 0 if unknown)
property double fiveHourReset: 0
property double sevenDayReset: 0
// Pay-as-you-go extra credits
property bool extraEnabled: false
property real extraUsedCredits: 0
property real extraMonthlyLimit: 0
property string extraCurrency: ""
function _pct(v) {
return (v === null || v === undefined) ? -1 : v;
}
function _parseIso(s) {
if (!s)
return 0;
const t = Date.parse(s);
return isNaN(t) ? 0 : t;
}
// Human "2h 5m" until the given epoch-ms. References DateTime.time so it
// recomputes on the clock tick.
function timeUntil(epochMs) {
DateTime.time; // reactivity dependency
if (!epochMs)
return "—";
let diff = Math.floor((epochMs - Date.now()) / 1000);
if (diff <= 0)
return Translation.tr("now");
const d = Math.floor(diff / 86400);
diff %= 86400;
const h = Math.floor(diff / 3600);
diff %= 3600;
const m = Math.floor(diff / 60);
let out = "";
if (d > 0)
out += `${d}d `;
if (h > 0)
out += `${h}h `;
out += `${m}m`;
return out.trim();
}
function refine(data) {
root.subscriptionType = data.subscriptionType ?? "";
root.fiveHour = data.five_hour?.utilization ?? 0;
root.sevenDay = data.seven_day?.utilization ?? 0;
root.sevenDayOpus = root._pct(data.seven_day_opus?.utilization);
root.sevenDaySonnet = root._pct(data.seven_day_sonnet?.utilization);
root.fiveHourReset = root._parseIso(data.five_hour?.resets_at);
root.sevenDayReset = root._parseIso(data.seven_day?.resets_at);
const ex = data.extra_usage;
root.extraEnabled = ex?.is_enabled ?? false;
root.extraUsedCredits = ex?.used_credits ?? 0;
root.extraMonthlyLimit = ex?.monthly_limit ?? 0;
root.extraCurrency = ex?.currency ?? "";
root.available = true;
root.lastError = "";
}
function getData() {
if (!root.enabled)
return;
fetcher.running = false;
fetcher.running = true;
}
Process {
id: fetcher
command: ["bash", "-c", "creds=\"$HOME/.claude/.credentials.json\"; " + "tok=$(jq -r '.claudeAiOauth.accessToken' \"$creds\" 2>/dev/null); " + "sub=$(jq -r '.claudeAiOauth.subscriptionType' \"$creds\" 2>/dev/null); " + "if [ -z \"$tok\" ] || [ \"$tok\" = null ]; then echo '{\"error\":\"no Claude token\"}'; exit 0; fi; " + "curl -s --max-time 10 " + "-H \"Authorization: Bearer $tok\" " + "-H \"anthropic-beta: oauth-2025-04-20\" " + "-H \"anthropic-version: 2023-06-01\" " + "https://api.anthropic.com/api/oauth/usage " + "| jq -c --arg sub \"$sub\" '. + {subscriptionType:$sub}'"]
stdout: StdioCollector {
onStreamFinished: {
if (text.trim().length === 0) {
root.available = false;
root.lastError = "empty response";
retryTimer.restart();
return;
}
try {
const d = JSON.parse(text);
if (d.error) {
root.available = false;
root.lastError = String(d.error);
retryTimer.restart();
return;
}
root.refine(d);
} catch (e) {
root.available = false;
root.lastError = e.message;
retryTimer.restart();
console.error(`[ClaudeUsage] ${e.message}: ${text}`);
}
}
}
}
Timer {
running: root.enabled
repeat: true
interval: root.fetchInterval
triggeredOnStart: true
onTriggered: root.getData()
}
// Quick retry while we don't have data yet (cold start / token mid-refresh /
// transient network), so a failed first fetch doesn't leave "Unavailable"
// showing until the next full interval.
Timer {
id: retryTimer
interval: 15000
repeat: false
onTriggered: if (root.enabled && !root.available) root.getData()
}
}

View file

@ -0,0 +1,157 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
// property string cliphistBinary: FileUtils.trimFileProtocol(`${Directories.home}/.cargo/bin/stash`)
property string cliphistBinary: "cliphist"
property real pasteDelay: 0.05
property string pressPasteCommand: "ydotool key -d 1 29:1 47:1 47:0 29:0"
property bool sloppySearch: Config.options?.search.sloppy ?? false
property real scoreThreshold: 0.2
property list<string> entries: []
readonly property var preparedEntries: entries.map(a => ({
name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`),
entry: a
}))
function fuzzyQuery(search: string): var {
if (search.trim() === "") {
return entries;
}
if (root.sloppySearch) {
const results = entries.slice(0, 100).map(str => ({
entry: str,
score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase())
})).filter(item => item.score > root.scoreThreshold)
.sort((a, b) => b.score - a.score)
return results
.map(item => item.entry)
}
return Fuzzy.go(search, preparedEntries, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
}
function entryIsImage(entry) {
return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry))
}
function refresh() {
readProc.buffer = []
readProc.running = true
}
function copy(entry) {
if (root.cliphistBinary.includes("cliphist")) // Classic cliphist
Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]);
else { // Stash
const entryNumber = entry.split("\t")[0];
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy`]);
}
}
function paste(entry) {
if (root.cliphistBinary.includes("cliphist")) // Classic cliphist
Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]);
else { // Stash
const entryNumber = entry.split("\t")[0];
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy; ${root.pressPasteCommand}`]);
}
}
function superpaste(count, isImage = false) {
// Find entries
const targetEntries = entries.filter(entry => {
if (!isImage) return true;
return entryIsImage(entry);
}).slice(0, count)
const pasteCommands = [...targetEntries].reverse().map(entry => `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && sleep ${root.pasteDelay} && ${root.pressPasteCommand}`)
// Act
Quickshell.execDetached(["bash", "-c", pasteCommands.join(` && sleep ${root.pasteDelay} && `)]);
}
Process {
id: deleteProc
property string entry: ""
command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | ${root.cliphistBinary} delete`]
function deleteEntry(entry) {
deleteProc.entry = entry;
deleteProc.running = true;
deleteProc.entry = "";
}
onExited: (exitCode, exitStatus) => {
root.refresh();
}
}
function deleteEntry(entry) {
deleteProc.deleteEntry(entry);
}
Process {
id: wipeProc
command: [root.cliphistBinary, "wipe"]
onExited: (exitCode, exitStatus) => {
root.refresh();
}
}
function wipe() {
wipeProc.running = true;
}
Connections {
target: Quickshell
function onClipboardTextChanged() {
delayedUpdateTimer.restart()
}
}
Timer {
id: delayedUpdateTimer
interval: Config.options.hacks.arbitraryRaceConditionDelay
repeat: false
onTriggered: {
root.refresh()
}
}
Process {
id: readProc
property list<string> buffer: []
command: [root.cliphistBinary, "list"]
stdout: SplitParser {
onRead: (line) => {
readProc.buffer.push(line)
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
root.entries = readProc.buffer
} else {
console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus)
}
}
}
IpcHandler {
target: "cliphistService"
function update(): void {
root.refresh()
}
}
}

View file

@ -0,0 +1,48 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string killDialogQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("killDialog.qml"))
function load() {
// dummy to force init
}
Connections {
target: Config
function onReadyChanged() {
if (Config.ready) checkConflictsProc.running = true
}
}
Process {
id: checkConflictsProc
command: ["bash", "-c", `echo "$(pidof kded6);$(pidof mako dunst)"`]
stdout: StdioCollector {
onStreamFinished: {
const output = this.text;
const conflictingTrays = output.split(";")[0].trim().length > 0;
const conflictingNotifications = output.split(";")[1].trim().length > 0;
var openDialog = false;
if (conflictingTrays) {
if (!Config.options.conflictKiller.autoKillTrays) openDialog = true;
else Quickshell.execDetached(["killall", "kded6"])
}
if (conflictingNotifications) {
if (!Config.options.conflictKiller.autoKillNotificationDaemons) openDialog = true;
else Quickshell.execDetached(["killall", "mako", "dunst"])
}
if (openDialog) {
Quickshell.execDetached(["qs", "-p", root.killDialogQmlPath])
}
}
}
}
}

View file

@ -0,0 +1,64 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/**
* A nice wrapper for date and time strings.
*/
Singleton {
property var clock: SystemClock {
id: clock
precision: {
if (Config.options.time.secondPrecision || GlobalStates.screenLocked)
return SystemClock.Seconds;
return SystemClock.Minutes;
}
}
property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm")
property string shortDate: Qt.locale().toString(clock.date, Config.options?.time.shortDateFormat ?? "dd/MM")
property string date: Qt.locale().toString(clock.date, Config.options?.time.dateWithYearFormat ?? "dd/MM/yyyy")
property string longDate: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM")
property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dddd, MMMM dd")
property string uptime: "0h, 0m"
Timer {
// Uptime advances one minute per minute; reload /proc/uptime once a
// minute, not 100x/second. (Was interval: 10 — a 10ms busy-reload of a
// disk file, an always-on drain independent of the SystemClock above.)
interval: 60000
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
fileUptime.reload();
const textUptime = fileUptime.text();
const uptimeSeconds = Number(textUptime.split(" ")[0] ?? 0);
// Convert seconds to days, hours, and minutes
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
// Build the formatted uptime string
let formatted = "";
if (days > 0)
formatted += `${days}d`;
if (hours > 0)
formatted += `${formatted ? ", " : ""}${hours}h`;
if (minutes > 0 || !formatted)
formatted += `${formatted ? ", " : ""}${minutes}m`;
uptime = formatted;
interval = Config.options?.resources?.updateInterval ?? 3000;
}
}
FileView {
id: fileUptime
path: "/proc/uptime"
}
}

View file

@ -0,0 +1,61 @@
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
pragma Singleton
pragma ComponentBehavior: Bound
/**
* Handles EasyEffects active state and presets.
*/
Singleton {
id: root
property bool available: false
property bool active: false
function fetchAvailability() {
fetchAvailabilityProc.running = true
}
function fetchActiveState() {
fetchActiveStateProc.running = true
}
function disable() {
root.active = false
Quickshell.execDetached(["bash", "-c", "pkill easyeffects || flatpak pkill com.github.wwmm.easyeffects"])
}
function enable() {
root.active = true
Quickshell.execDetached(["bash", "-c", "easyeffects --hide-window --service-mode || flatpak run com.github.wwmm.easyeffects --hide-window --service-mode"])
}
function toggle() {
if (root.active) {
root.disable()
} else {
root.enable()
}
}
Process {
id: fetchAvailabilityProc
running: true
command: ["bash", "-c", "command -v easyeffects || flatpak info com.github.wwmm.easyeffects > /dev/null 2>&1"]
onExited: (exitCode, exitStatus) => {
root.available = exitCode === 0
}
}
Process {
id: fetchActiveStateProc
running: true
command: ["bash", "-c", "pidof easyeffects || flatpak ps | grep com.github.wwmm.easyeffects > /dev/null 2>&1"]
onExited: (exitCode, exitStatus) => {
root.active = exitCode === 0
}
}
}

View file

@ -0,0 +1,64 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Emojis.
*/
Singleton {
id: root
property string emojiScriptPath: `${Directories.config}/hypr/hyprland/scripts/fuzzel-emoji.sh`
property string lineBeforeData: "### DATA ###"
property list<var> list
readonly property var preparedEntries: list.map(a => ({
name: Fuzzy.prepare(`${a}`),
entry: a
}))
function fuzzyQuery(search: string): var {
if (root.sloppySearch) {
const results = entries.slice(0, 100).map(str => ({
entry: str,
score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase())
})).filter(item => item.score > root.scoreThreshold)
.sort((a, b) => b.score - a.score)
return results
.map(item => item.entry)
}
return Fuzzy.go(search, preparedEntries, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
}
function load() {
emojiFileView.reload()
}
function updateEmojis(fileContent) {
const lines = fileContent.split("\n")
const dataIndex = lines.indexOf(root.lineBeforeData)
if (dataIndex === -1) {
console.warn("No data section found in emoji script file.")
return
}
const emojis = lines.slice(dataIndex + 1).filter(line => line.trim() !== "")
root.list = emojis.map(line => line.trim())
}
FileView {
id: emojiFileView
path: Qt.resolvedUrl(root.emojiScriptPath)
onLoadedChanged: {
const fileContent = emojiFileView.text()
root.updateEmojis(fileContent)
}
}
}

View file

@ -0,0 +1,158 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
property var results: []
property string pendingQuery: ""
property string activeQuery: ""
property int searchId: 0
property bool running: false
function normalizePath(path) {
if (!path) return "";
let normalized = FileUtils.trimFileProtocol(String(path)).trim();
if (normalized.startsWith("~/")) {
normalized = FileUtils.trimFileProtocol(Directories.home) + normalized.slice(1);
}
if (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
function isExcluded(path) {
if (Config.options.search.fileSearch.excludeHiddenDirs) {
// Match any hidden dir segment: "/.name/" or ending "/.name"
if (path.match(/\/\.[^/]+(\/|$)/)) {
return true;
}
}
const exclude = Config.options.search.fileSearch.exclude;
if (!exclude || exclude.length === 0) return false;
for (let i = 0; i < exclude.length; i++) {
const needle = String(exclude[i]).trim();
if (needle.length === 0) continue;
if (path.includes(`/${needle}/`) || path.endsWith(`/${needle}`)) {
return true;
}
}
return false;
}
function isAllowedByPath(path) {
const paths = Config.options.search.fileSearch.paths;
if (!paths || paths.length === 0) return true;
for (let i = 0; i < paths.length; i++) {
const base = normalizePath(paths[i]);
if (!base) continue;
if (path === base || path.startsWith(base + "/")) {
return true;
}
}
return false;
}
function shouldSearch(query) {
if (!Config.options.search.fileSearch.enable) return false;
if (!query || query.trim().length < 2) return false;
return true;
}
function reset() {
results = [];
activeQuery = "";
if (plocateProc.running) {
plocateProc.running = false;
}
}
function search(query) {
pendingQuery = String(query || "").trim();
if (!shouldSearch(pendingQuery)) {
reset();
return;
}
debounceTimer.restart();
}
Timer {
id: debounceTimer
interval: 200
repeat: false
onTriggered: {
root.runSearch(pendingQuery);
}
}
function runSearch(query) {
const trimmed = String(query || "").trim();
if (!shouldSearch(trimmed)) { reset(); return; }
if (plocateProc.running) plocateProc.running = false;
searchId += 1;
activeQuery = trimmed;
results = [];
const maxResults = Config.options.search.fileSearch.maxResults;
// Tag each line with d: or f: prefix
const command = [
"bash", "-c",
`plocate -i --basename --limit ${maxResults} '${trimmed}' | while IFS= read -r p; do [ -d "$p" ] && printf 'd:%s\n' "$p" || printf 'f:%s\n' "$p"; done`
];
plocateProc.runId = searchId;
plocateProc.accepted = 0;
plocateProc.command = command;
plocateProc.running = true;
root.running = true;
}
Process {
id: plocateProc
property int runId: 0
property int accepted: 0
stdout: SplitParser {
onRead: line => {
if (plocateProc.runId !== root.searchId) return;
const raw = String(line || "").trim();
if (!raw || raw.length < 3) return;
const isDir = raw.startsWith("d:");
const rawPath = raw.slice(2);
const normalized = root.normalizePath(rawPath);
if (!normalized) return;
if (!root.isAllowedByPath(normalized)) return;
if (root.isExcluded(normalized)) return;
if (plocateProc.accepted >= Config.options.search.fileSearch.maxResults) {
plocateProc.running = false;
return;
}
plocateProc.accepted += 1;
root.results = root.results.concat([{ path: normalized, isDir: isDir }]);
}
}
stderr: SplitParser {
onRead: line => {
if (plocateProc.runId !== root.searchId) return;
}
}
onExited: (exitCode, exitStatus) => {
if (plocateProc.runId !== root.searchId) return;
root.running = false;
}
}
}

View file

@ -0,0 +1,43 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string firstRunFilePath: `${Directories.state}/user/first_run.txt`
property string firstRunFileContent: "This file is just here to confirm you've been greeted :>"
property string firstRunNotifSummary: "Welcome!"
property string firstRunNotifBody: "Hit Super+/ for a list of keybinds"
property string defaultWallpaperPath: FileUtils.trimFileProtocol(`${Directories.assetsPath}/images/default_wallpaper.png`)
property string welcomeQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("welcome.qml"))
function load() {
firstRunFileView.reload()
}
function enableNextTime() {
Quickshell.execDetached(["rm", "-f", root.firstRunFilePath])
}
function disableNextTime() {
Quickshell.execDetached(["bash", "-c", `echo '${root.firstRunFileContent}' > '${root.firstRunFilePath}'`])
}
function handleFirstRun() {
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, root.defaultWallpaperPath])
Quickshell.execDetached(["bash", "-c", `qs -p '${root.welcomeQmlPath}'`])
}
FileView {
id: firstRunFileView
path: Qt.resolvedUrl(firstRunFilePath)
onLoadFailed: (error) => {
if (error == FileViewError.FileNotFound) {
firstRunFileView.setText(root.firstRunFileContent)
root.handleFirstRun()
}
}
}
}

View file

@ -0,0 +1,72 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Hyprland
/**
* Manages a HyprlandFocusGrab that's to be shared by all windows.
* "Persistent" is for windows that should always be included but not closed on dismiss, like bar and onscreen keyboard.
* "Dismissable" is for stuff like sidebars
*/
Singleton {
id: root
signal dismissed()
property list<var> persistent: []
property list<var> dismissable: []
function dismiss() {
root.dismissable = [];
root.dismissed();
}
Component.onCompleted: {
console.log("[GlobalFocusGrab] Initialized");
}
function addPersistent(window) {
if (root.persistent.indexOf(window) === -1) {
root.persistent.push(window);
}
}
function removePersistent(window) {
var index = root.persistent.indexOf(window);
if (index !== -1) {
root.persistent.splice(index, 1);
}
}
function addDismissable(window) {
if (root.dismissable.indexOf(window) === -1) {
root.dismissable.push(window);
}
}
function removeDismissable(window) {
var index = root.dismissable.indexOf(window);
if (index !== -1) {
root.dismissable.splice(index, 1);
}
}
function hasActive(element) {
return element?.activeFocus || Array.from(
element?.children
).some(
(child) => hasActive(child)
);
}
HyprlandFocusGrab {
id: grab
windows: root.dismissable.every(w => !w?.focusable) || root.dismissable.some(w => hasActive(w?.contentItem)) ? [...root.dismissable, ...root.persistent] : [...root.dismissable]
active: root.dismissable.length > 0
onCleared: () => {
root.dismiss();
}
}
}

View file

@ -0,0 +1,115 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.modules.common.utils
Singleton {
id: root
property var keyContent: ({})
property string keyProjectId: keyContent?.project_id
property bool keyError: false
property bool keyReady: false
property string token: ""
property date tokenExpiry
property bool tokenError: false
property bool tokenReady: false
readonly property string projectId: keyProjectId
readonly property bool loaded: keyReady && tokenReady
readonly property string tokenForKeyScriptPath: Quickshell.shellPath("services/gCloud/token-from-key-venv.sh")
function load() {
// Init load will be handled by Component.onCompleted
if (!tokenReady) return;
// We just reload if key expired
if (new Date() >= root.tokenExpiry) {
root.tokenReady = false;
root.keyReady = false;
loadKeyIfPossible();
}
}
function unready() {
root.keyReady = false;
root.tokenReady = false;
root.keyError = false;
root.tokenError = false;
}
function setKeyJson(str: string): bool {
try {
var keyData = JSON.parse(str)
root.unready();
KeyringStorage.setNestedField(["googleCloud", "serviceAccountKey"], keyData);
return true;
} catch(e) {
return false;
}
}
function getToken() {
if (root.keyError) {
root.tokenError = true;
root.tokenReady = true;
return;
}
tokenProc.runSequence([(() => { // prep token fetcher
tokenProc.environment.SERVICE_KEY_CONTENT = JSON.stringify(root.keyContent);
tokenProc.command = [ //
"bash", "-c" //
, `${tokenForKeyScriptPath} "$SERVICE_KEY_CONTENT"`];
}), //
[], // run token fetcher
((out) => {
try {
const data = JSON.parse(out)
root.token = data.token
// Js wants millis instead of seconds
root.tokenExpiry = new Date(data.expiry * 1000)
root.tokenError = false;
} catch(e) {
root.tokenError = true;
print("[GoogleCloud] Failed to parse token response: " + e + "\n" + out)
}
root.tokenReady = true;
}
)]);
}
function loadKeyIfPossible() {
if (KeyringStorage.loaded) {
root.keyContent = KeyringStorage.keyringData?.googleCloud?.serviceAccountKey;
if (!root.keyContent?.project_id) {
root.keyError = true;
} else {
root.keyError = false;
root.keyProjectId = root.keyContent.project_id;
}
root.keyReady = true;
root.getToken();
} else {
KeyringStorage.fetchKeyringData();
}
}
Component.onCompleted: {
loadKeyIfPossible();
}
Connections {
target: KeyringStorage
function onLoadedChanged() {
root.loadKeyIfPossible();
}
function onDataChanged() {
root.loadKeyIfPossible();
}
}
MultiTurnProcess {
id: tokenProc
}
}

View file

@ -0,0 +1,57 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.modules.common.models.hyprland
Singleton {
id: root
readonly property string shaderPath: Quickshell.shellPath("services/hyprlandAntiFlashbangShader/anti-flashbang.glsl")
readonly property string weakShaderPath: Quickshell.shellPath("services/hyprlandAntiFlashbangShader/anti-flashbang-weak.glsl")
property bool enabled: confOpt.value == shaderPath || weak
property bool weak: confOpt.value == weakShaderPath
function enable() {
HyprlandConfig.setMany({
"decoration:screen_shader": root.shaderPath,
"debug:damage_tracking": 1, // Turn off dmg tracking to prevent weird flashes. 1 = monitor only
});
}
function enableWeak() {
HyprlandConfig.setMany({
"decoration:screen_shader": root.weakShaderPath,
"debug:damage_tracking": 1,
});
}
function disable() {
HyprlandConfig.resetMany([
"decoration:screen_shader",
"debug:damage_tracking"
]);
}
function toggle() {
if (root.enabled) disable()
else enable()
}
function cycle() {
if (!enabled) {
enableWeak();
} else if (weak) {
enable();
} else {
disable();
}
}
HyprlandConfigOption {
id: confOpt
key: "decoration:screen_shader"
}
}

View file

@ -0,0 +1,63 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.modules.common
import qs.modules.common.functions
/**
* Configs Hyprland
*/
Singleton {
id: root
signal reloaded()
readonly property string configuratorScriptPath: Quickshell.shellPath("scripts/hyprland/hyprconfigurator.py")
readonly property string shellOverridesPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/shellOverrides/main.lua`)
function set(key: string, value: var) {
Quickshell.execDetached(["bash", "-c", //
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} --set "${key}" "${value}"` //
])
}
function setMany(entries: var) {
let args = ""
for (let key in entries) {
args += `--set "${key}" "${entries[key]}" `
}
Quickshell.execDetached(["bash", "-c", //
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} ${args}` //
])
}
function reset(key: string) {
Quickshell.execDetached(["bash", "-c", //
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} --reset "${key}"` //
])
}
function resetMany(keys: list<string>) {
let args = ""
for (let i = 0; i < keys.length; i++) {
args += `--reset "${keys[i]}" `
}
Quickshell.execDetached(["bash", "-c", //
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} ${args}` //
])
}
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name == "configreloaded") {
root.reloaded()
}
}
}
}

View file

@ -0,0 +1,226 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
/**
* Provides access to some Hyprland data not available in Quickshell.Hyprland.
*/
Singleton {
id: root
property var windowList: []
property var addresses: []
property var windowByAddress: ({})
property var workspaces: []
property var workspaceIds: []
property var workspaceById: ({})
property var activeWorkspace: null
property var activeWindow: null
property var monitors: []
// Parse hyprctl's JSON, or keep what we had.
//
// Every collector below did a bare `JSON.parse()` on the process output.
// That is fine while Hyprland is the compositor and a hard error the
// moment it is not: under viewtop there is no `hyprctl`, the output is
// empty, and each collector threw a SyntaxError on every refresh — six
// exceptions per pass, forever, drowning the log the shell is diagnosed
// from.
//
// Absence is a state, not a failure. `HYPRLAND_INSTANCE_SIGNATURE` unset
// means "another compositor", and the honest answer is to keep the last
// known value and say so once — the same distinction
// DEVICE-STATE-MACHINE.md §10 draws between "no evidence" and "evidence
// says nothing".
property bool available: true
function parseOrKeep(text, fallback, what) {
if (!text || text.trim().length === 0) {
if (root.available) {
root.available = false;
console.log("[HyprlandData] no hyprctl output for", what,
"— assuming another compositor; monitor geometry comes from `screen`");
}
return fallback;
}
try {
const parsed = JSON.parse(text);
if (!root.available) {
root.available = true;
console.log("[HyprlandData] hyprctl is answering again");
}
return parsed;
} catch (e) {
// Latched like the empty case: `hyprctl` missing does not always
// mean *empty* output — a shell that prints an error to stdout
// lands here instead, and it lands here on every refresh. One line
// per edge, not four per pass. Same rule §10 applies to a sensor
// that has gone quiet: say it when it changes, not when it repeats.
if (root.available) {
root.available = false;
console.log("[HyprlandData]", what, "is unparseable —",
"assuming another compositor; monitor geometry comes from `screen`:", e);
}
return fallback;
}
}
property var layers: ({})
// Convenient stuff
function toplevelsForWorkspace(workspace) {
return ToplevelManager.toplevels.values.filter(toplevel => {
const address = `0x${toplevel.HyprlandToplevel?.address}`;
var win = HyprlandData.windowByAddress[address];
return win?.workspace?.id === workspace;
})
}
function hyprlandClientsForWorkspace(workspace) {
return root.windowList.filter(win => win.workspace.id === workspace);
}
function clientForToplevel(toplevel) {
if (!toplevel || !toplevel.HyprlandToplevel) {
return null;
}
const address = `0x${toplevel?.HyprlandToplevel?.address}`;
return root.windowByAddress[address];
}
// Internals
function updateWindows() {
getClients.running = true;
getActiveWindow.running = true;
}
function updateLayers() {
getLayers.running = true;
}
function updateMonitors() {
getMonitors.running = true;
}
function updateWorkspaces() {
getWorkspaces.running = true;
getActiveWorkspace.running = true;
}
function updateAll() {
updateWindows();
updateMonitors();
updateLayers();
updateWorkspaces();
}
function biggestWindowForWorkspace(workspaceId) {
const windowsInThisWorkspace = HyprlandData.windowList.filter(w => w.workspace.id == workspaceId);
return windowsInThisWorkspace.reduce((maxWin, win) => {
const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0);
const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0);
return winArea > maxArea ? win : maxWin;
}, null);
}
Component.onCompleted: {
updateAll();
}
Connections {
target: Hyprland
function onRawEvent(event) {
// console.log("Hyprland raw event:", event.name);
if (["openlayer", "closelayer", "screencast"].includes(event.name)) return;
updateAll()
}
}
Process {
id: getClients
command: ["hyprctl", "clients", "-j"]
stdout: StdioCollector {
id: clientsCollector
onStreamFinished: {
root.windowList = root.parseOrKeep(clientsCollector.text, [], "data")
let tempWinByAddress = {};
for (var i = 0; i < root.windowList.length; ++i) {
var win = root.windowList[i];
tempWinByAddress[win.address] = win;
}
root.windowByAddress = tempWinByAddress;
root.addresses = root.windowList.map(win => win.address);
}
}
}
Process {
id: getActiveWindow
command: ["hyprctl", "activewindow", "-j"]
stdout: StdioCollector {
id: activeWindowCollector
onStreamFinished: {
root.activeWindow = root.parseOrKeep(activeWindowCollector.text, root.activeWindow, "activewindow")
}
}
}
Process {
id: getMonitors
command: ["hyprctl", "monitors", "-j"]
stdout: StdioCollector {
id: monitorsCollector
onStreamFinished: {
root.monitors = root.parseOrKeep(monitorsCollector.text, root.monitors, "monitors");
}
}
}
Process {
id: getLayers
command: ["hyprctl", "layers", "-j"]
stdout: StdioCollector {
id: layersCollector
onStreamFinished: {
root.layers = root.parseOrKeep(layersCollector.text, root.layers, "layers");
}
}
}
Process {
id: getWorkspaces
command: ["hyprctl", "workspaces", "-j"]
stdout: StdioCollector {
id: workspacesCollector
onStreamFinished: {
var rawWorkspaces = root.parseOrKeep(workspacesCollector.text, root.workspaces, "workspaces");
// Filter out invalid workspace ids (e.g. lock-screen temp workspace 2147483647 - N)
root.workspaces = rawWorkspaces.filter(ws => ws.id >= 1 && ws.id <= 100);
let tempWorkspaceById = {};
for (var i = 0; i < root.workspaces.length; ++i) {
var ws = root.workspaces[i];
tempWorkspaceById[ws.id] = ws;
}
root.workspaceById = tempWorkspaceById;
root.workspaceIds = root.workspaces.map(ws => ws.id);
}
}
}
Process {
id: getActiveWorkspace
command: ["hyprctl", "activeworkspace", "-j"]
stdout: StdioCollector {
id: activeWorkspaceCollector
onStreamFinished: {
root.activeWorkspace = root.parseOrKeep(activeWorkspaceCollector.text, root.activeWorkspace, "activeworkspace");
}
}
}
}

View file

@ -0,0 +1,55 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
/**
* A service that provides access to Hyprland keybinds.
* Uses the `get_keybinds.py` script to parse comments in config files in a certain format and convert to JSON.
*/
Singleton {
id: root
property var keybinds: []
property var keybindCategories: []
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name == "configreloaded") {
getKeybinds.running = true
}
}
}
Process {
id: getKeybinds
running: true
command: ["hyprctl", "binds", "-j"]
stdout: StdioCollector {
onStreamFinished: {
try {
root.keybinds = JSON.parse(text)
var groups = []
for (var i = 0; i < root.keybinds.length; i++) {
var bind = root.keybinds[i].description
var group = bind.substring(0, bind.indexOf(":"))
if (!groups.includes(group) && group.length > 0) {
groups.push(group)
}
}
root.keybindCategories = groups
} catch (e) {
console.error("[CheatsheetKeybinds] Error parsing keybinds:", e)
}
}
}
}
}

View file

@ -0,0 +1,119 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import qs.modules.common
/**
* Exposes the active Hyprland Xkb keyboard layout name and code for indicators.
*/
Singleton {
id: root
// You can read these
property list<string> layoutCodes: []
property var cachedLayoutCodes: ({})
property string currentLayoutName: ""
property string currentLayoutCode: ""
// For the service
property var baseLayoutFilePath: "/usr/share/X11/xkb/rules/base.lst"
property bool needsLayoutRefresh: false
// Update the layout code according to the layout name (Hyprland gives the name not the code)
onCurrentLayoutNameChanged: root.updateLayoutCode()
function updateLayoutCode() {
if (cachedLayoutCodes.hasOwnProperty(currentLayoutName)) {
root.currentLayoutCode = cachedLayoutCodes[currentLayoutName];
} else {
getLayoutProc.running = true;
}
}
// Get the layout code from the base.lst file by grabbing the line with the current layout name
Process {
id: getLayoutProc
command: ["cat", root.baseLayoutFilePath]
stdout: StdioCollector {
id: layoutCollector
onStreamFinished: {
const lines = layoutCollector.text.split("\n");
const targetDescription = root.currentLayoutName;
const foundLine = lines.find(line => {
// Skip comment lines and empty lines
if (!line.trim() || line.trim().startsWith('!'))
return false;
// Match layout: (whitespace + ) key + whitespace + description
const matchLayout = line.match(/^\s*(\S+)\s+(.+)$/);
if (matchLayout && matchLayout[2] === targetDescription) {
root.cachedLayoutCodes[matchLayout[2]] = matchLayout[1];
root.currentLayoutCode = matchLayout[1];
return true;
}
// Match variant: (whitespace + ) variant + whitespace + key + whitespace + description
const matchVariant = line.match(/^\s*(\S+)\s+(\S+)\s+(.+)$/);
if (matchVariant && matchVariant[3] === targetDescription) {
const complexLayout = matchVariant[2] + matchVariant[1];
root.cachedLayoutCodes[matchVariant[3]] = complexLayout;
root.currentLayoutCode = complexLayout;
return true;
}
return false;
});
// console.log("[HyprlandXkb] Found line:", foundLine);
// console.log("[HyprlandXkb] Layout:", root.currentLayoutName, "| Code:", root.currentLayoutCode);
// console.log("[HyprlandXkb] Cached layout codes:", JSON.stringify(root.cachedLayoutCodes, null, 2));
}
}
}
// Find out available layouts and current active layout. Should only be necessary on init
Process {
id: fetchLayoutsProc
running: true
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
id: devicesCollector
onStreamFinished: {
const parsedOutput = JSON.parse(devicesCollector.text);
const hyprlandKeyboard = parsedOutput["keyboards"].find(kb => kb.main === true);
root.layoutCodes = hyprlandKeyboard["layout"].split(",");
root.currentLayoutName = hyprlandKeyboard["active_keymap"];
// console.log("[HyprlandXkb] Fetched | Layouts (multiple: " + (root.layoutCodes.length > 1) + "): "
// + root.layoutCodes.join(", ") + " | Active: " + root.currentLayoutName);
}
}
}
// Update the layout name when it changes
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name === "activelayout") {
if (root.needsLayoutRefresh) {
root.needsLayoutRefresh = false;
fetchLayoutsProc.running = true;
}
// If there's only one layout, the updated layout is always the same
if (root.layoutCodes.length <= 1) return;
// Update when layout might have changed
const dataString = event.data;
root.currentLayoutName = dataString.substring(dataString.indexOf(",") + 1);
// Update layout for on-screen keyboard (osk)
Config.options.osk.layout = root.currentLayoutName;
} else if (event.name == "configreloaded") {
// Mark layout code list to be updated when config is reloaded
root.needsLayoutRefresh = true;
}
}
}
}

View file

@ -0,0 +1,172 @@
pragma Singleton
import QtQuick
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
/**
* Simple hyprsunset service with automatic mode.
* In theory we don't need this because hyprsunset has a config file, but it somehow doesn't work.
* It should also be possible to control it via hyprctl, but it doesn't work consistently either so we're just killing and launching.
*/
Singleton {
id: root
signal gammaChangeAttempt()
readonly property real gammaLowerLimit: 25
property string from: Config.options?.light?.night?.from ?? "19:00"
property string to: Config.options?.light?.night?.to ?? "06:30"
property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true)
property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000
property int defaultColorTemperature: 6000
property int gamma: 100
property bool shouldBeOn
property bool firstEvaluation: true
property bool temperatureActive: false
property int fromHour: Number(from.split(":")[0])
property int fromMinute: Number(from.split(":")[1])
property int toHour: Number(to.split(":")[0])
property int toMinute: Number(to.split(":")[1])
property int clockHour: DateTime.clock.hours
property int clockMinute: DateTime.clock.minutes
property var manualActive
property int manualActiveHour
property int manualActiveMinute
onClockMinuteChanged: reEvaluate()
onAutomaticChanged: {
root.manualActive = undefined;
root.firstEvaluation = true;
reEvaluate();
}
function inBetween(t, from, to) {
if (from < to) {
return (t >= from && t <= to);
} else {
// Wrapped around midnight
return (t >= from || t <= to);
}
}
function reEvaluate() {
const t = clockHour * 60 + clockMinute;
const from = fromHour * 60 + fromMinute;
const to = toHour * 60 + toMinute;
const manualActive = manualActiveHour * 60 + manualActiveMinute;
if (root.manualActive !== undefined && (inBetween(from, manualActive, t) || inBetween(to, manualActive, t))) {
root.manualActive = undefined;
}
root.shouldBeOn = inBetween(t, from, to);
if (firstEvaluation) {
firstEvaluation = false;
root.ensureState();
}
}
onShouldBeOnChanged: ensureState()
function ensureState() {
// console.log("[Hyprsunset] Ensuring state:", root.shouldBeOn, "Automatic mode:", root.automatic);
if (!root.automatic || root.manualActive !== undefined)
return;
if (root.shouldBeOn) {
root.enableTemperature();
} else {
root.disableTemperature();
}
}
function startHyprsunset() {
Quickshell.execDetached(["bash", "-c", `pidof hyprsunset || hyprsunset`]);
}
function load() {
root.startHyprsunset();
root.ensureState();
}
Timer {
id: updateHyprsunset
interval: 100
repeat: false
onTriggered: {
root.ensureState();
root.setGamma(root.gamma);
}
}
function enableTemperature() {
root.temperatureActive = true;
// console.log("[Hyprsunset] Enabling");
root.startHyprsunset();
Quickshell.execDetached(["bash", "-c", `hyprctl hyprsunset temperature ${root.colorTemperature}`]);
}
function disableTemperature() {
root.temperatureActive = false;
// console.log("[Hyprsunset] Disabling");
Quickshell.execDetached(["bash", "-c", `hyprctl hyprsunset temperature ${root.defaultColorTemperature}`]);
}
function setGamma(gamma) {
root.gamma = Math.max(root.gammaLowerLimit, Math.min(100, gamma));
root.gammaChangeAttempt();
root.startHyprsunset();
Quickshell.execDetached(["bash", "-c", `hyprctl hyprsunset gamma ${root.gamma}`]);
}
function fetchState() {
fetchProc.running = true;
}
Process {
id: fetchProc
running: true
command: ["bash", "-c", "hyprctl hyprsunset temperature"]
stdout: StdioCollector {
id: stateCollector
onStreamFinished: {
const output = stateCollector.text.trim();
if (output.length == 0 || output.startsWith("Couldn't"))
root.temperatureActive = false;
else
root.temperatureActive = (output != root.defaultColorTemperature); // 6000 is the default when off
// console.log("[Hyprsunset] Fetched state:", output, "->", root.temperatureActive);
}
}
}
function toggleTemperature(active = undefined) {
if (root.manualActive === undefined) {
root.manualActive = root.temperatureActive;
root.manualActiveHour = root.clockHour;
root.manualActiveMinute = root.clockMinute;
}
root.manualActive = active !== undefined ? active : !root.manualActive;
if (root.manualActive) {
root.enableTemperature();
} else {
root.disableTemperature();
}
}
// Change temp
Connections {
target: Config.options.light.night
function onColorTemperatureChanged() {
if (!root.temperatureActive) return;
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", `${Config.options.light.night.colorTemperature}`]);
}
}
}

View file

@ -0,0 +1,60 @@
pragma Singleton
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Wayland
/**
* A nice wrapper for date and time strings.
*/
Singleton {
id: root
property alias inhibit: idleInhibitor.enabled
property bool autoIdleInhibit: Config.options.autoIdleInhibit
inhibit: autoIdleInhibit
Connections {
target: Persistent
function onReadyChanged() {
if (Persistent.isNewHyprlandInstance) {
Persistent.states.idle.inhibit = Config.options.autoIdleInhibit;
}
root.inhibit = Persistent.states.idle.inhibit;
}
}
function toggleInhibit(active = null) {
if (active !== null) {
root.inhibit = active;
} else {
root.inhibit = !root.inhibit;
}
Persistent.states.idle.inhibit = root.inhibit;
}
function toggleAutoInhibit(active = null) {
Config.options.autoIdleInhibit = !Config.options.autoIdleInhibit
}
IdleInhibitor {
id: idleInhibitor
window: PanelWindow {
// Inhibitor requires a "visible" surface
// Actually not lol
implicitWidth: 0
implicitHeight: 0
color: "transparent"
// Just in case...
anchors {
right: true
bottom: true
}
// Make it not interactable
mask: Region {
item: null
}
}
}
}

View file

@ -0,0 +1,126 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
import qs.modules.common.functions
import Quickshell;
import Quickshell.Io;
import QtQuick;
/**
* For storing sensitive data in the keyring.
* Use this for small data only, since it stores a JSON of the contents directly and doesn't use a database.
*/
Singleton {
id: root
signal dataChanged()
property bool loaded: false
property var keyringData: ({})
property var properties: {
"application": "illogical-impulse",
"explanation": Translation.tr("For storing API keys and other sensitive information"),
}
property var propertiesAsArgs: Object.keys(root.properties).reduce(
function(arr, key) {
return arr.concat([key, root.properties[key]]);
}, []
)
property string keyringLabel: Translation.tr("%1 Safe Storage").arg("illogical-impulse")
function setNestedField(path, value) {
if (!root.keyringData) root.keyringData = {};
let keys = path;
let obj = root.keyringData;
let parents = [obj];
// Traverse and collect parent objects
for (let i = 0; i < keys.length - 1; ++i) {
if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") {
obj[keys[i]] = {};
}
obj = obj[keys[i]];
parents.push(obj);
}
// Set the value at the innermost key
obj[keys[keys.length - 1]] = value;
// Reassign each parent object from the bottom up to trigger change notifications
for (let i = keys.length - 2; i >= 0; --i) {
let parent = parents[i];
let key = keys[i];
// Shallow clone to change object identity (spread replaced with Object.assign)
parent[key] = Object.assign({}, parent[key]);
}
// Finally, reassign root.keyringData to trigger top-level change
root.keyringData = Object.assign({}, root.keyringData);
saveKeyringData();
}
function fetchKeyringData() {
// console.log("[KeyringStorage] Fetching keyring data...");
// console.log("[KeyringStorage] getData command:'" + getData.command.join("' '") + "'");
getData.running = true;
}
function saveKeyringData() {
saveData.stdinEnabled = true;
saveData.running = true;
}
Process {
id: saveData
command: [
"secret-tool", "store", "--label=" + keyringLabel,
...propertiesAsArgs,
]
onRunningChanged: {
if (saveData.running) {
// console.log("[KeyringStorage] Saving with command: '" + saveData.command.join("' '") + "'");
saveData.write(JSON.stringify(root.keyringData));
root.dataChanged()
stdinEnabled = false // End input stream
}
}
}
Process {
id: getData
command: [ // We need to use echo for a newline so splitparser does parse
"bash", "-c", `${Directories.scriptPath}/keyring/try_lookup.sh 2> /dev/null`,
]
stdout: StdioCollector {
id: keyringDataOutputCollector
onStreamFinished: {
const data = keyringDataOutputCollector.text;
if (data.length === 0 || !data.startsWith("{")) return;
try {
root.keyringData = JSON.parse(data);
// console.log("[KeyringStorage] Keyring data fetched:", JSON.stringify(root.keyringData));
} catch (e) {
console.error("[KeyringStorage] Failed to get keyring data, reinitializing.");
root.keyringData = {};
saveKeyringData()
}
}
}
onExited: (exitCode, exitStatus) => {
// console.log("[KeyringStorage] Keyring data fetch process exited with code:", exitCode);
if (exitCode === 1) {
console.error("[KeyringStorage] Entry not found, initializing.");
root.keyringData = {};
saveKeyringData()
}
if (exitCode !== 2) {
root.loaded = true;
}
}
}
}

View file

@ -0,0 +1,83 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions
import qs.modules.common
import QtQuick
import Quickshell
/**
* Renders LaTeX snippets with MicroTeX.
* For every request:
* 1. Hash it
* 2. Check if the hash is already processed
* 3. If not, render it with MicroTeX and mark as processed
*/
Singleton {
id: root
readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images
property list<string> processedHashes: []
property var processedExpressions: ({})
property var renderedImagePaths: ({})
property string microtexBinaryDir: "/opt/MicroTeX"
property string microtexBinaryName: "LaTeX"
property string latexOutputPath: Directories.latexOutput
signal renderFinished(string hash, string imagePath)
/**
* Requests rendering of a LaTeX expression.
* Returns the [hash, isNew]
*/
function requestRender(expression) {
// 1. Hash it and initialize necessary variables
const hash = Qt.md5(expression)
const imagePath = `${latexOutputPath}/${hash}.svg`
// 2. Check if the hash is already processed
if (processedHashes.includes(hash)) {
// console.log("Already processed: " + hash)
renderFinished(hash, imagePath)
return [hash, false]
} else {
root.processedHashes.push(hash)
root.processedExpressions[hash] = expression
// console.log("Rendering expression: " + expression)
}
// 3. If not, render it with MicroTeX and mark as processed
// console.log(`[LatexRenderer] Rendering expression: ${expression} with hash: ${hash}`)
// console.log(` to file: ${imagePath}`)
// console.log(` with command: cd ${microtexBinaryDir} && ./${microtexBinaryName} -headless -input=${StringUtils.shellSingleQuoteEscape(expression)} -output=${imagePath} -textsize=${Appearance.font.pixelSize.normal} -padding=${renderPadding} -background=${Appearance.m3colors.m3tertiary} -foreground=${Appearance.m3colors.m3onTertiary} -maxwidth=0.85`)
const processQml = `
import Quickshell.Io
Process {
id: microtexProcess${hash}
running: true
command: [ "bash", "-c",
"cd ${root.microtexBinaryDir} && ./${root.microtexBinaryName} -headless '-input=${StringUtils.shellSingleQuoteEscape(StringUtils.escapeBackslashes(expression))}' "
+ "'-output=${imagePath}' "
+ "'-textsize=${Appearance.font.pixelSize.normal}' "
+ "'-padding=${renderPadding}' "
// + "'-background=${Appearance.m3colors.m3tertiary}' "
+ "'-foreground=${Appearance.colors.colOnLayer1}' "
+ "-maxwidth=0.85 "
]
// stdout: SplitParser {
// onRead: data => { console.log("MicroTeX: " + data) }
// }
onExited: (exitCode, exitStatus) => {
// console.log("[LatexRenderer] MicroTeX process exited with code: " + exitCode + ", status: " + exitStatus)
renderedImagePaths["${hash}"] = "${imagePath}"
root.renderFinished("${hash}", "${imagePath}")
microtexProcess${hash}.destroy()
}
}
`
// console.log("MicroTeX: " + processQml)
Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`)
return [hash, true]
}
}

View file

@ -0,0 +1,41 @@
pragma Singleton
import qs.modules.common
import QtQuick
import Quickshell
Singleton {
id: root
function isPinned(appId) {
return Config.options.launcher.pinnedApps.indexOf(appId) !== -1;
}
function togglePin(appId) {
if (root.isPinned(appId)) {
Config.options.launcher.pinnedApps = Config.options.launcher.pinnedApps.filter(id => id !== appId)
} else {
Config.options.launcher.pinnedApps = Config.options.launcher.pinnedApps.concat([appId])
}
}
function moveToFront(appId) {
if (!root.isPinned(appId)) return;
const pinnedApps = Config.options.launcher.pinnedApps;
Config.options.launcher.pinnedApps = [appId].concat(pinnedApps.filter(id => id !== appId));
}
function moveLeft(appId) {
const pinnedApps = Config.options.launcher.pinnedApps;
const index = pinnedApps.indexOf(appId);
if (index === -1 || index === 0) return;
Config.options.launcher.pinnedApps = pinnedApps.slice(0, index - 1).concat([appId]).concat(pinnedApps[index - 1]).concat(pinnedApps.slice(index + 1));
}
function moveRight(appId) {
const pinnedApps = Config.options.launcher.pinnedApps;
const index = pinnedApps.indexOf(appId);
if (index === -1 || index === pinnedApps.length - 1) return;
Config.options.launcher.pinnedApps = pinnedApps.slice(0, index).concat(pinnedApps[index + 1]).concat([appId]).concat(pinnedApps.slice(index + 2));
}
}

View file

@ -0,0 +1,445 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.models
import qs.modules.common.functions
import QtQuick
import Qt.labs.folderlistmodel
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
Singleton {
id: root
property string query: ""
function ensurePrefix(prefix) {
if ([Config.options.search.prefix.action, Config.options.search.prefix.app, Config.options.search.prefix.clipboard, Config.options.search.prefix.emojis, Config.options.search.prefix.math, Config.options.search.prefix.shellCommand, Config.options.search.prefix.webSearch,].some(i => root.query.startsWith(i))) {
root.query = prefix + root.query.slice(1);
} else {
root.query = prefix + root.query;
}
}
onQueryChanged: {
FileSearch.search(StringUtils.cleanPrefix(root.query, Config.options.search.prefix.app));
}
// https://specifications.freedesktop.org/menu/latest/category-registry.html
property list<string> mainRegisteredCategories: ["AudioVideo", "Development", "Education", "Game", "Graphics", "Network", "Office", "Science", "Settings", "System", "Utility"]
property list<string> appCategories: DesktopEntries.applications.values.reduce((acc, entry) => {
for (const category of entry.categories) {
if (!acc.includes(category) && mainRegisteredCategories.includes(category)) {
acc.push(category);
}
}
return acc;
}, []).sort()
// Load user action scripts from ~/.config/illogical-impulse/actions/
// Uses FolderListModel to auto-reload when scripts are added/removed
property var userActionScripts: {
const actions = [];
for (let i = 0; i < userActionsFolder.count; i++) {
const fileName = userActionsFolder.get(i, "fileName");
const filePath = userActionsFolder.get(i, "filePath");
if (fileName && filePath) {
const actionName = fileName.replace(/\.[^/.]+$/, ""); // strip extension
actions.push({
action: actionName,
execute: ((path) => (args) => {
Quickshell.execDetached([path, ...(args ? args.split(" ") : [])]);
})(FileUtils.trimFileProtocol(filePath.toString()))
});
}
}
return actions;
}
FolderListModel {
id: userActionsFolder
folder: Qt.resolvedUrl(Directories.userActions)
showDirs: false
showHidden: false
sortField: FolderListModel.Name
}
property var searchActions: [
{
action: "accentcolor",
execute: args => {
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--noswitch", "--color", ...(args != '' ? [`${args}`] : [])]);
}
},
{
action: "dark",
execute: () => {
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "dark", "--noswitch"]);
}
},
{
action: "konachanwallpaper",
execute: () => {
Quickshell.execDetached([Quickshell.shellPath("scripts/colors/random/random_konachan_wall.sh")]);
}
},
{
action: "light",
execute: () => {
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "light", "--noswitch"]);
}
},
{
action: "superpaste",
execute: args => {
if (!/^(\d+)/.test(args.trim())) {
// Invalid if doesn't start with numbers
Quickshell.execDetached(["notify-send", Translation.tr("Superpaste"), Translation.tr("Usage: <tt>%1superpaste NUM_OF_ENTRIES[i]</tt>\nSupply <tt>i</tt> when you want images\nExamples:\n<tt>%1superpaste 4i</tt> for the last 4 images\n<tt>%1superpaste 7</tt> for the last 7 entries").arg(Config.options.search.prefix.action), "-a", "Shell"]);
return;
}
const syntaxMatch = /^(?:(\d+)(i)?)/.exec(args.trim());
const count = syntaxMatch[1] ? parseInt(syntaxMatch[1]) : 1;
const isImage = !!syntaxMatch[2];
Cliphist.superpaste(count, isImage);
}
},
{
action: "todo",
execute: args => {
Todo.addTask(args);
}
},
{
action: "wallpaper",
execute: () => {
Hyprland.dispatch(`hl.dsp.global("quickshell:wallpaperSelectorToggle")`)
}
},
{
action: "wipeclipboard",
execute: () => {
Cliphist.wipe();
}
},
]
// Combined built-in and user actions
property var allActions: searchActions.concat(userActionScripts)
property string mathResult: ""
property bool clipboardWorkSafetyActive: {
const enabled = Config.options.workSafety.enable.clipboard;
const sensitiveNetwork = (StringUtils.stringListContainsSubstring(Network.networkName.toLowerCase(), Config.options.workSafety.triggerCondition.networkNameKeywords));
return enabled && sensitiveNetwork;
}
function containsUnsafeLink(entry) {
if (entry == undefined)
return false;
const unsafeKeywords = Config.options.workSafety.triggerCondition.linkKeywords;
return StringUtils.stringListContainsSubstring(entry.toLowerCase(), unsafeKeywords);
}
Timer {
id: nonAppResultsTimer
interval: Config.options.search.nonAppResultDelay
onTriggered: {
let expr = root.query;
if (expr.startsWith(Config.options.search.prefix.math)) {
expr = expr.slice(Config.options.search.prefix.math.length);
}
mathProc.calculateExpression(expr);
}
}
Process {
id: mathProc
property list<string> baseCommand: ["qalc", "-t"]
function calculateExpression(expression) {
mathProc.running = false;
mathProc.command = baseCommand.concat(expression);
mathProc.running = true;
}
stdout: SplitParser {
onRead: data => {
root.mathResult = data;
}
}
}
property list<var> fileResults: {
if (!Config.options.search.fileSearch.enable) return [];
if (!FileSearch.results || FileSearch.results.length === 0) return [];
return FileSearch.results.map(path => {
const trimmed = FileUtils.trimFileProtocol(path.path);
const isDir = path.isDir;
const displayName = isDir ? FileUtils.folderNameForPath(trimmed) : FileUtils.fileNameForPath(trimmed);
const parentDir = FileUtils.parentDirectory(trimmed);
return resultComp.createObject(null, {
rawValue: trimmed,
name: displayName || trimmed,
verb: Translation.tr("Open"),
type: isDir ? Translation.tr("Folder") : Translation.tr("File"),
iconName: isDir ? "folder" : "description",
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
Qt.openUrlExternally(`file://${trimmed}`);
},
actions: [resultComp.createObject(null, {
name: Translation.tr("Open Parent folder"),
iconName: "folder_open",
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
if (parentDir) {
Qt.openUrlExternally(`file://${parentDir}`);
}
}
})]
});
});
}
property list<var> results: {
// Search results are handled here
////////////////// Skip? //////////////////
if (root.query == "")
return [];
///////////// Special cases ///////////////
if (root.query.startsWith(Config.options.search.prefix.clipboard)) {
// Clipboard
const searchString = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.clipboard);
const allClipStrings = [searchString, ...KeymapTranslation.translateAll(searchString)];
const seenClipEntries = new Set();
const clipEntries = allClipStrings.reduce((acc, q) => {
return acc.concat(Cliphist.fuzzyQuery(q).filter(e => {
if (seenClipEntries.has(e)) return false;
seenClipEntries.add(e);
return true;
}));
}, []);
return clipEntries.map((entry, index, array) => {
const mightBlurImage = Cliphist.entryIsImage(entry) && root.clipboardWorkSafetyActive;
let shouldBlurImage = mightBlurImage;
if (mightBlurImage) {
shouldBlurImage = shouldBlurImage && (root.containsUnsafeLink(array[index - 1]) || root.containsUnsafeLink(array[index + 1]));
}
const type = `#${entry.match(/^\s*(\S+)/)?.[1] || ""}`;
return resultComp.createObject(null, {
rawValue: entry,
name: StringUtils.cleanCliphistEntry(entry),
verb: "",
type: type,
execute: () => {
Cliphist.copy(entry);
},
actions: [resultComp.createObject(null, {
name: Translation.tr("Copy"),
iconName: "content_copy",
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
Cliphist.copy(entry);
}
}), resultComp.createObject(null, {
name: Translation.tr("Delete"),
iconName: "delete",
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
Cliphist.deleteEntry(entry);
}
})],
blurImage: shouldBlurImage
});
}).filter(Boolean);
} else if (root.query.startsWith(Config.options.search.prefix.emojis)) {
const searchString = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.emojis);
const allEmojiStrings = [searchString, ...KeymapTranslation.translateAll(searchString)];
const seenEmojis = new Set();
const emojiEntries = allEmojiStrings.reduce((acc, q) => {
return acc.concat(Emojis.fuzzyQuery(q).filter(entry => {
const key = entry.match(/^\s*(\S+)/)?.[1] || entry;
if (seenEmojis.has(key)) return false;
seenEmojis.add(key);
return true;
}));
}, []);
return emojiEntries.map(entry => {
const emoji = entry.match(/^\s*(\S+)/)?.[1] || "";
return resultComp.createObject(null, {
rawValue: entry,
name: entry.replace(/^\s*\S+\s+/, ""),
iconName: emoji,
iconType: LauncherSearchResult.IconType.Text,
verb: Translation.tr("Copy"),
type: Translation.tr("Emoji"),
execute: () => {
Quickshell.clipboardText = entry.match(/^\s*(\S+)/)?.[1];
}
});
}).filter(Boolean);
}
////////////////// Init ///////////////////
nonAppResultsTimer.restart();
const mathResultObject = resultComp.createObject(null, {
name: root.mathResult,
verb: Translation.tr("Copy"),
type: Translation.tr("Math result"),
fontType: LauncherSearchResult.FontType.Monospace,
iconName: 'calculate',
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
Quickshell.clipboardText = root.mathResult;
}
});
const _appQuery = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.app);
const _primaryEntries = AppSearch.fuzzyQuery(_appQuery);
const _seenIds = new Set(_primaryEntries.map(e => e.id));
const _layoutEntries = KeymapTranslation.translateAll(_appQuery).reduce((acc, tq) => {
return acc.concat(AppSearch.fuzzyQuery(tq).filter(e => {
if (_seenIds.has(e.id)) return false;
_seenIds.add(e.id);
return true;
}));
}, []);
const _translitQuery = KeymapTranslation.transliterate(_appQuery);
const _translitEntries = (_translitQuery && _translitQuery !== _appQuery)
? AppSearch.levenshteinQuery(_translitQuery).filter(e => {
if (_seenIds.has(e.id)) return false;
_seenIds.add(e.id);
return true;
})
: [];
const _typoEntries = (_primaryEntries.length === 0 && _layoutEntries.length === 0 && _translitEntries.length === 0)
? AppSearch.levenshteinQuery(_appQuery).filter(e => {
if (_seenIds.has(e.id)) return false;
_seenIds.add(e.id);
return true;
})
: [];
const appResultObjects = [..._primaryEntries, ..._layoutEntries, ..._translitEntries, ..._typoEntries].map(entry => {
return resultComp.createObject(null, {
type: Translation.tr("App"),
id: entry.id,
name: entry.name,
iconName: entry.icon,
iconType: LauncherSearchResult.IconType.System,
verb: Translation.tr("Open"),
execute: () => {
if (!entry.runInTerminal)
entry.execute();
else {
// Fixed: Escape each argument individually and pass as separate tokens to -e
Quickshell.execDetached(["bash", '-c', `${Config.options.apps.terminal} -e ${entry.command.map(arg => "'" + StringUtils.shellSingleQuoteEscape(arg) + "'").join(' ')}`]);
}
},
comment: entry.comment,
runInTerminal: entry.runInTerminal,
genericName: entry.genericName,
keywords: entry.keywords,
actions: entry.actions.map(action => {
return resultComp.createObject(null, {
name: action.name,
iconName: action.icon,
iconType: LauncherSearchResult.IconType.System,
execute: () => {
if (!action.runInTerminal)
action.execute();
else {
Quickshell.execDetached(["bash", '-c', `${Config.options.apps.terminal} -e ${action.command.map(arg => "'" + StringUtils.shellSingleQuoteEscape(arg) + "'").join(' ')}`]);
}
}
});
})
});
});
const commandResultObject = resultComp.createObject(null, {
name: StringUtils.cleanPrefix(root.query, Config.options.search.prefix.shellCommand).replace("file://", ""),
verb: Translation.tr("Run"),
type: Translation.tr("Command"),
fontType: LauncherSearchResult.FontType.Monospace,
iconName: 'terminal',
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
let cleanedCommand = root.query.replace("file://", "");
cleanedCommand = StringUtils.cleanPrefix(cleanedCommand, Config.options.search.prefix.shellCommand);
if (cleanedCommand.startsWith(Config.options.search.prefix.shellCommand)) {
cleanedCommand = cleanedCommand.slice(Config.options.search.prefix.shellCommand.length);
}
Quickshell.execDetached(["bash", "-c", root.query.startsWith('sudo') ? `${Config.options.apps.terminal} fish -C '${cleanedCommand}'` : cleanedCommand]);
}
});
const webSearchResultObject = resultComp.createObject(null, {
name: StringUtils.cleanPrefix(root.query, Config.options.search.prefix.webSearch),
verb: Translation.tr("Search"),
type: Translation.tr("Web search"),
iconName: 'travel_explore',
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
let query = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.webSearch);
let url = Config.options.search.engineBaseUrl + query;
for (let site of Config.options.search.excludedSites) {
url += ` -site:${site}`;
}
Qt.openUrlExternally(url);
}
});
const launcherActionObjects = root.allActions.map(action => {
const actionString = `${Config.options.search.prefix.action}${action.action}`;
if (actionString.startsWith(root.query) || root.query.startsWith(actionString)) {
return resultComp.createObject(null, {
name: root.query.startsWith(actionString) ? root.query : actionString,
verb: Translation.tr("Run"),
type: Translation.tr("Action"),
iconName: 'settings_suggest',
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
action.execute(root.query.split(" ").slice(1).join(" "));
}
});
}
return null;
}).filter(Boolean);
//////// Prioritized by prefix /////////
let result = [];
const startsWithNumber = /^\d/.test(root.query);
const startsWithMathPrefix = root.query.startsWith(Config.options.search.prefix.math);
const startsWithShellCommandPrefix = root.query.startsWith(Config.options.search.prefix.shellCommand);
const startsWithWebSearchPrefix = root.query.startsWith(Config.options.search.prefix.webSearch);
if (startsWithNumber || startsWithMathPrefix) {
result.push(mathResultObject);
} else if (startsWithShellCommandPrefix) {
result.push(commandResultObject);
} else if (startsWithWebSearchPrefix) {
result.push(webSearchResultObject);
}
//////////////// Apps //////////////////
result = result.concat(appResultObjects);
result = result.concat(fileResults);
////////// Launcher actions ////////////
result = result.concat(launcherActionObjects);
/// Math result, command, web search ///
if (Config.options.search.prefix.showDefaultActionsWithoutPrefix) {
if (!startsWithShellCommandPrefix)
result.push(commandResultObject);
if (!startsWithNumber && !startsWithMathPrefix)
result.push(mathResultObject);
if (!startsWithWebSearchPrefix)
result.push(webSearchResultObject);
}
return result;
}
Component {
id: resultComp
LauncherSearchResult {}
}
}

View file

@ -0,0 +1,97 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
/**
* Automatically reloads generated material colors.
* It is necessary to run reapplyTheme() on startup because Singletons are lazily loaded.
*/
Singleton {
id: root
property string filePath: Directories.generatedMaterialThemePath
function reapplyTheme() {
themeFileView.reload()
}
function applyColors(fileContent) {
const json = JSON.parse(fileContent)
for (const key in json) {
if (json.hasOwnProperty(key)) {
// Convert snake_case to CamelCase
const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase())
const m3Key = `m3${camelCaseKey}`
Appearance.m3colors[m3Key] = json[key]
}
}
Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5)
}
function resetFilePathNextTime() {
resetFilePathNextWallpaperChange.enabled = true
}
Connections {
id: resetFilePathNextWallpaperChange
enabled: false
target: Config.options.background
function onWallpaperPathChanged() {
root.filePath = ""
root.filePath = Directories.generatedMaterialThemePath
resetFilePathNextWallpaperChange.enabled = false
}
}
Timer {
id: delayedFileRead
interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 100
repeat: false
running: false
onTriggered: {
root.applyColors(themeFileView.text())
}
}
FileView {
id: themeFileView
path: Qt.resolvedUrl(root.filePath)
watchChanges: true
onFileChanged: {
this.reload()
delayedFileRead.start()
}
onLoadedChanged: {
const fileContent = themeFileView.text()
root.applyColors(fileContent)
}
onLoadFailed: root.resetFilePathNextTime();
}
function toggleLightDark() {
const currentlyDark = Appearance.m3colors.darkmode;
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", currentlyDark ? "light" : "dark", "--noswitch"]);
}
GlobalShortcut {
name: "toggleLightDark"
description: "Toggles between dark theme and light theme"
onPressed: {
root.toggleLightDark();
}
}
IpcHandler {
target: "theme"
function toggleLightDark(): void {
root.toggleLightDark();
}
}
}

View file

@ -0,0 +1,282 @@
pragma Singleton
pragma ComponentBehavior: Bound
// From https://git.outfoxxed.me/outfoxxed/nixnew
// It does not have a license, but the author is okay with redistribution.
import QtQml.Models
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import qs.modules.common
/**
* A service that provides easy access to the active Mpris player.
*/
Singleton {
id: root;
property list<MprisPlayer> players: Mpris.players.values.filter(player => isRealPlayer(player));
property MprisPlayer trackedPlayer: null;
property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null;
signal trackChanged(reverse: bool);
property bool __reverse: false;
property var activeTrack;
readonly property bool hasActivePlasmaIntegration: Mpris.players.values.some(
p => p.dbusName?.startsWith('org.mpris.MediaPlayer2.plasma-browser-integration')
)
function isRealPlayer(player) {
if (!Config.options.media.filterDuplicatePlayers) {
return true;
}
return (
// Remove native browser buses only if plasma-browser-integration is actually active on D-Bus
!(hasActivePlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.firefox')) && !(hasActivePlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.chromium')) &&
// playerctld just copies other buses and we don't need duplicates
!player.dbusName?.startsWith('org.mpris.MediaPlayer2.playerctld') &&
// Non-instance mpd bus
!(player.dbusName?.endsWith('.mpd') && !player.dbusName.endsWith('MediaPlayer2.mpd')));
}
// Last non-empty trackArtUrl seen per player (keyed by dbusName). Some
// MPRIS players (Firefox via firefox-mpris, Spotify) emit transient empty
// trackArtUrl between/during tracks. Consumers like PlayerControl that get
// recreated on panel open/close need a stable fallback so the cover
// doesn't vanish whenever they happen to mount during an empty window.
// dbusName is the right key here: MprisPlayer.uniqueId in Quickshell is a
// per-track identifier (it increments when the track changes), not a
// per-player one, so keying on it cross-contaminates between players that
// happen to share a uniqueId value.
property var stableArtUrlByPlayer: ({})
function _captureArtUrl(player) {
if (player?.trackArtUrl?.length > 0 && !!player?.dbusName) {
const map = Object.assign({}, root.stableArtUrlByPlayer);
map[player.dbusName] = player.trackArtUrl;
root.stableArtUrlByPlayer = map;
}
}
// Best (largest) downloaded cover file seen per player for the current
// track, keyed by dbusName. Persists across PlayerControl lifecycles so
// reopening the panel doesn't downgrade to a thumbnail when Firefox
// happens to be sitting on its low-res variant. Entry shape:
// { trackKey: string, artFilePath: string, artBytes: number }
property var bestArtByPlayer: ({})
function rememberBestArt(player, trackKey, artFilePath, artBytes) {
const id = player?.dbusName;
if (!id || artBytes <= 0 || !artFilePath) return;
const existing = root.bestArtByPlayer[id];
// Same track and existing is already >= new size: nothing to do.
if (existing && existing.trackKey === trackKey && existing.artBytes >= artBytes) return;
const map = Object.assign({}, root.bestArtByPlayer);
map[id] = { trackKey: trackKey, artFilePath: artFilePath, artBytes: artBytes };
root.bestArtByPlayer = map;
}
function getBestArt(player, trackKey) {
const id = player?.dbusName;
if (!id) return null;
const entry = root.bestArtByPlayer[id];
if (!entry || entry.trackKey !== trackKey) return null;
return entry;
}
function _trackKeyOf(player) {
// title|artist (album omitted on purpose). Some players (Firefox via
// firefox-mpris) emit metadata progressively: first trackArtUrl +
// title + artist with album="", then a moment later update album.
// If album were part of the key, the high-res variant emitted with
// the partial metadata and the low-res variant emitted with the
// completed metadata would look like different tracks to the
// "never-downgrade" guard.
return `${player?.trackTitle ?? ""}|${player?.trackArtist ?? ""}`;
}
// Per-player worker. Holds a Process that downloads each new trackArtUrl
// the player emits, regardless of whether the media controls panel is
// open. This is what makes the cover stay sharp across panel
// close/reopen and across auto-advance while the panel is closed —
// PlayerControl is no longer the only thing watching for art emissions.
component PlayerWorker: QtObject {
id: worker
required property MprisPlayer player
function _fetchArt() {
const url = worker.player?.trackArtUrl;
if (!url || url.length === 0) return;
artDownloader.trackKey = root._trackKeyOf(worker.player);
artDownloader.targetFile = url;
artDownloader.artFilePath = `${Directories.coverArt}/${Qt.md5(url)}`;
artDownloader.running = true;
}
property Process artDownloader: Process {
property string trackKey
property string targetFile
property string artFilePath
property int sizeBytes: 0
stdout: SplitParser {
onRead: data => {
const n = parseInt(data.trim());
if (!isNaN(n)) artDownloader.sizeBytes = n;
}
}
command: ["bash", "-c", `[ -f ${artFilePath} ] || curl -4 -sSL '${targetFile}' -o '${artFilePath}'; stat -c %s '${artFilePath}' 2>/dev/null`]
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0 || sizeBytes <= 0 || artFilePath.length === 0) return;
root.rememberBestArt(worker.player, trackKey, artFilePath, sizeBytes);
}
}
property Connections _conn: Connections {
target: worker.player
function onPlaybackStateChanged() {
if (root.trackedPlayer !== worker.player) root.trackedPlayer = worker.player;
}
function onTrackArtUrlChanged() {
root._captureArtUrl(worker.player);
worker._fetchArt();
}
}
Component.onCompleted: {
if (root.trackedPlayer == null || worker.player.isPlaying) {
root.trackedPlayer = worker.player;
}
root._captureArtUrl(worker.player);
worker._fetchArt();
}
Component.onDestruction: {
if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) {
for (const p of Mpris.players.values) {
if (p.playbackState.isPlaying) {
root.trackedPlayer = p;
break;
}
}
if (root.trackedPlayer == null && Mpris.players.values.length != 0) {
root.trackedPlayer = Mpris.players.values[0];
}
}
}
}
Instantiator {
model: Mpris.players
delegate: PlayerWorker {
required property MprisPlayer modelData
player: modelData
}
}
Connections {
target: activePlayer
function onPostTrackChanged() {
root.updateTrack();
}
function onTrackArtUrlChanged() {
// console.log("arturl:", activePlayer.trackArtUrl)
// root.updateTrack();
if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) {
// cantata likes to send cover updates *BEFORE* updating the track info.
// as such, art url changes shouldn't be able to break the reverse animation
const r = root.__reverse;
root.updateTrack();
root.__reverse = r;
}
}
}
onActivePlayerChanged: this.updateTrack();
function updateTrack() {
//console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`)
this.activeTrack = {
uniqueId: this.activePlayer?.uniqueId ?? 0,
artUrl: this.activePlayer?.trackArtUrl ?? "",
title: this.activePlayer?.trackTitle || Translation.tr("Unknown Title"),
artist: this.activePlayer?.trackArtist || Translation.tr("Unknown Artist"),
album: this.activePlayer?.trackAlbum || Translation.tr("Unknown Album"),
};
this.trackChanged(__reverse);
this.__reverse = false;
}
property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying;
property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false;
function togglePlaying() {
if (this.canTogglePlaying) this.activePlayer.togglePlaying();
}
property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false;
function previous() {
if (this.canGoPrevious) {
this.__reverse = true;
this.activePlayer.previous();
}
}
property bool canGoNext: this.activePlayer?.canGoNext ?? false;
function next() {
if (this.canGoNext) {
this.__reverse = false;
this.activePlayer.next();
}
}
property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl;
property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl;
property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None;
function setLoopState(loopState: var) {
if (this.loopSupported) {
this.activePlayer.loopState = loopState;
}
}
property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl;
property bool hasShuffle: this.activePlayer?.shuffle ?? false;
function setShuffle(shuffle: bool) {
if (this.shuffleSupported) {
this.activePlayer.shuffle = shuffle;
}
}
function setActivePlayer(player: MprisPlayer) {
const targetPlayer = player ?? Mpris.players[0];
console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`)
if (targetPlayer && this.activePlayer) {
this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer);
} else {
// always animate forward if going to null
this.__reverse = false;
}
this.trackedPlayer = targetPlayer;
}
IpcHandler {
target: "mpris"
function pauseAll(): void {
for (const player of Mpris.players.values) {
if (player.canPause) player.pause();
}
}
function playPause(): void { root.togglePlaying(); }
function previous(): void { root.previous(); }
function next(): void { root.next(); }
}
}

View file

@ -0,0 +1,332 @@
pragma Singleton
pragma ComponentBehavior: Bound
// Took many bits from https://github.com/caelestia-dots/shell (GPLv3)
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services.network
/**
* Network service with nmcli.
*/
Singleton {
id: root
property bool wifi: true
property bool ethernet: false
property bool wifiEnabled: false
property bool wifiScanning: false
property bool wifiConnecting: connectProc.running
property WifiAccessPoint wifiConnectTarget
readonly property list<WifiAccessPoint> wifiNetworks: []
readonly property WifiAccessPoint active: wifiNetworks.find(n => n.active) ?? null
readonly property list<var> friendlyWifiNetworks: [...wifiNetworks].sort((a, b) => {
if (a.active && !b.active)
return -1;
if (!a.active && b.active)
return 1;
return b.strength - a.strength;
})
property string wifiStatus: "disconnected"
property string networkName: ""
property int networkStrength
property string materialSymbol: root.ethernet
? "lan"
: (root.wifiEnabled && root.wifiStatus === "connected")
? (
(root.active?.strength ?? 0) > 83 ? "signal_wifi_4_bar" :
(root.active?.strength ?? 0) > 67 ? "network_wifi" :
(root.active?.strength ?? 0) > 50 ? "network_wifi_3_bar" :
(root.active?.strength ?? 0) > 33 ? "network_wifi_2_bar" :
(root.active?.strength ?? 0) > 17 ? "network_wifi_1_bar" :
"signal_wifi_0_bar"
)
: (root.wifiStatus === "connecting")
? "signal_wifi_statusbar_not_connected"
: (root.wifiStatus === "disconnected")
? "wifi_find"
: (root.wifiStatus === "disabled")
? "signal_wifi_off"
: "signal_wifi_bad"
// Control
function enableWifi(enabled = true): void {
const cmd = enabled ? "on" : "off";
enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]);
}
function toggleWifi(): void {
enableWifi(!wifiEnabled);
}
function rescanWifi(): void {
wifiScanning = true;
rescanProcess.running = true;
}
function connectToWifiNetwork(accessPoint: WifiAccessPoint): void {
accessPoint.askingPassword = false;
root.wifiConnectTarget = accessPoint;
// We use this instead of `nmcli connection up SSID` because this also creates a connection profile
connectProc.exec(["nmcli", "dev", "wifi", "connect", accessPoint.ssid])
}
function disconnectWifiNetwork(): void {
if (active) disconnectProc.exec(["nmcli", "connection", "down", active.ssid]);
}
function openPublicWifiPortal() {
Quickshell.execDetached(["xdg-open", "https://nmcheck.gnome.org/"]) // From some StackExchange thread, seems to work
}
function changePassword(network: WifiAccessPoint, password: string, username = ""): void {
// TODO: enterprise wifi with username
network.askingPassword = false;
changePasswordProc.exec({
"environment": {
"PASSWORD": password,
"SSID": network.ssid
},
"command": ["bash", "-c", 'nmcli connection modify "$SSID" wifi-sec.psk "$PASSWORD"']
})
}
Process {
id: enableWifiProc
}
Process {
id: connectProc
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: SplitParser {
onRead: line => {
// print(line)
getNetworks.running = true
}
}
stderr: SplitParser {
onRead: line => {
// print("err:", line)
if (line.includes("Secrets were required")) {
root.wifiConnectTarget.askingPassword = true
}
}
}
onExited: (exitCode, exitStatus) => {
root.wifiConnectTarget.askingPassword = (exitCode !== 0)
root.wifiConnectTarget = null
}
}
Process {
id: disconnectProc
stdout: SplitParser {
onRead: getNetworks.running = true
}
}
Process {
id: changePasswordProc
onExited: { // Re-attempt connection after changing password
connectProc.running = false
connectProc.running = true
}
}
Process {
id: rescanProcess
command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"]
stdout: SplitParser {
onRead: {
wifiScanning = false;
getNetworks.running = true;
}
}
}
// Status update
function update() {
updateConnectionType.startCheck();
wifiStatusProcess.running = true
updateNetworkName.running = true;
updateNetworkStrength.running = true;
}
Process {
id: subscriber
running: true
command: ["nmcli", "monitor"]
stdout: SplitParser {
onRead: root.update()
}
}
Process {
id: updateConnectionType
property string buffer
command: ["sh", "-c", "nmcli -t -f TYPE,STATE d status && nmcli -t -f CONNECTIVITY g"]
running: true
function startCheck() {
buffer = "";
updateConnectionType.running = true;
}
stdout: SplitParser {
onRead: data => {
updateConnectionType.buffer += data + "\n";
}
}
onExited: (exitCode, exitStatus) => {
const lines = updateConnectionType.buffer.trim().split('\n');
const connectivity = lines.pop() // none, limited, full
let hasEthernet = false;
let hasWifi = false;
let wifiStatus = "disconnected";
lines.forEach(line => {
if (line.includes("ethernet") && line.includes("connected"))
hasEthernet = true;
else if (line.includes("wifi:")) {
if (line.includes("disconnected")) {
wifiStatus = "disconnected"
}
else if (line.includes("connected")) {
hasWifi = true;
wifiStatus = "connected"
if (connectivity === "limited") {
hasWifi = false;
wifiStatus = "limited"
}
}
else if (line.includes("connecting")) {
wifiStatus = "connecting"
}
else if (line.includes("unavailable")) {
wifiStatus = "disabled"
}
}
});
root.wifiStatus = wifiStatus;
root.ethernet = hasEthernet;
root.wifi = hasWifi;
}
}
Process {
id: updateNetworkName
command: ["sh", "-c", "nmcli -t -f NAME c show --active | head -1"]
running: true
stdout: SplitParser {
onRead: data => {
root.networkName = data;
}
}
}
Process {
id: updateNetworkStrength
running: true
command: ["sh", "-c", "nmcli -f IN-USE,SIGNAL,SSID device wifi | awk '/^\\*/{if (NR!=1) {print $2}}'"]
stdout: SplitParser {
onRead: data => {
root.networkStrength = parseInt(data);
}
}
}
Process {
id: wifiStatusProcess
command: ["nmcli", "radio", "wifi"]
Component.onCompleted: running = true
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: StdioCollector {
onStreamFinished: {
root.wifiEnabled = text.trim() === "enabled";
}
}
}
Process {
id: getNetworks
running: true
command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"]
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: StdioCollector {
onStreamFinished: {
const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED";
const rep = new RegExp("\\\\:", "g");
const rep2 = new RegExp(PLACEHOLDER, "g");
const allNetworks = text.trim().split("\n").map(n => {
const net = n.replace(rep, PLACEHOLDER).split(":");
return {
active: net[0] === "yes",
strength: parseInt(net[1]),
frequency: parseInt(net[2]),
ssid: net[3],
bssid: net[4]?.replace(rep2, ":") ?? "",
security: net[5] || ""
};
}).filter(n => n.ssid && n.ssid.length > 0);
// Group networks by SSID and prioritize connected ones
const networkMap = new Map();
for (const network of allNetworks) {
const existing = networkMap.get(network.ssid);
if (!existing) {
networkMap.set(network.ssid, network);
} else {
// Prioritize active/connected networks
if (network.active && !existing.active) {
networkMap.set(network.ssid, network);
} else if (!network.active && !existing.active) {
// If both are inactive, keep the one with better signal
if (network.strength > existing.strength) {
networkMap.set(network.ssid, network);
}
}
// If existing is active and new is not, keep existing
}
}
const wifiNetworks = Array.from(networkMap.values());
const rNetworks = root.wifiNetworks;
const destroyed = rNetworks.filter(rn => !wifiNetworks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid));
for (const network of destroyed)
rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy());
for (const network of wifiNetworks) {
const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid);
if (match) {
match.lastIpcObject = network;
} else {
rNetworks.push(apComp.createObject(root, {
lastIpcObject: network
}));
}
}
}
}
}
Component {
id: apComp
WifiAccessPoint {}
}
}

View file

@ -0,0 +1,163 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string interfaceName: ""
property real rxBytesPerSecond: 0
property real txBytesPerSecond: 0
property string downloadSpeedText: formatSpeed(rxBytesPerSecond)
property string uploadSpeedText: formatSpeed(txBytesPerSecond)
property string downloadSpeedCompactText: formatCompactSpeed(rxBytesPerSecond)
property string uploadSpeedCompactText: formatCompactSpeed(txBytesPerSecond)
readonly property bool available: interfaceName.length > 0
property real previousRxBytes: -1
property real previousTxBytes: -1
property double previousTimestampMs: 0
function formatSpeed(bytesPerSecond) {
const units = ["B/s", "K/s", "M/s", "G/s"];
let value = Math.max(0, bytesPerSecond);
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)}${units[unitIndex]}`;
}
function formatCompactSpeed(bytesPerSecond) {
const units = ["B", "K", "M", "G"];
let value = Math.max(0, bytesPerSecond);
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
let textValue = "0";
if (value >= 100)
textValue = `${Math.round(value)}`;
else if (value >= 10)
textValue = `${Math.round(value)}`;
else
textValue = `${value.toFixed(1)}`.replace(/\.0$/, "");
return `${textValue}${units[unitIndex]}/s`;
}
function pickInterfaceName(routeText, devText) {
const routeLines = routeText.trim().split("\n").slice(1);
for (const line of routeLines) {
const columns = line.trim().split(/\s+/);
if (columns.length < 2)
continue;
const iface = columns[0];
const destination = columns[1];
if (destination === "00000000" && iface !== "lo")
return iface;
}
const devLines = devText.trim().split("\n").slice(2);
for (const line of devLines) {
const iface = line.split(":")[0]?.trim() ?? "";
if (iface.length > 0 && iface !== "lo")
return iface;
}
return "";
}
function parseCounters(devText, ifaceName) {
if (!ifaceName)
return null;
const escapedName = ifaceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const lineMatch = devText.match(new RegExp(`^\\s*${escapedName}\\s*:\\s*(.+)$`, "m"));
if (!lineMatch)
return null;
const fields = lineMatch[1].trim().split(/\s+/);
if (fields.length < 9)
return null;
return {
rx: Number(fields[0]),
tx: Number(fields[8])
};
}
function update() {
routeFile.reload();
devFile.reload();
const routeText = routeFile.text();
const devText = devFile.text();
const detectedInterface = pickInterfaceName(routeText, devText);
if (detectedInterface !== interfaceName) {
interfaceName = detectedInterface;
previousRxBytes = -1;
previousTxBytes = -1;
previousTimestampMs = 0;
}
if (!interfaceName) {
rxBytesPerSecond = 0;
txBytesPerSecond = 0;
return;
}
const counters = parseCounters(devText, interfaceName);
if (!counters) {
rxBytesPerSecond = 0;
txBytesPerSecond = 0;
return;
}
const now = Date.now();
if (previousRxBytes < 0 || previousTxBytes < 0 || previousTimestampMs <= 0) {
previousRxBytes = counters.rx;
previousTxBytes = counters.tx;
previousTimestampMs = now;
rxBytesPerSecond = 0;
txBytesPerSecond = 0;
return;
}
const elapsedMs = Math.max(1, now - previousTimestampMs);
rxBytesPerSecond = Math.max(0, (counters.rx - previousRxBytes) * 1000 / elapsedMs);
txBytesPerSecond = Math.max(0, (counters.tx - previousTxBytes) * 1000 / elapsedMs);
previousRxBytes = counters.rx;
previousTxBytes = counters.tx;
previousTimestampMs = now;
}
Timer {
interval: 1000
// Souveraine: pause while the display is inactive (idle-power task 4).
// The rate math is elapsed-time based, so the first tick after resume
// yields one correctly averaged sample rather than a spike.
running: GlobalStates.displayActive
repeat: true
triggeredOnStart: true
onTriggered: root.update()
}
FileView { id: routeFile; path: "/proc/net/route" }
FileView { id: devFile; path: "/proc/net/dev" }
}

View file

@ -0,0 +1,305 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
/**
* Provides extra features not in Quickshell.Services.Notifications:
* - Persistent storage
* - Popup notifications, with timeout
* - Notification groups by app
*/
Singleton {
id: root
component Notif: QtObject {
id: wrapper
required property int notificationId // Could just be `id` but it conflicts with the default prop in QtObject
property Notification notification
property list<var> actions: notification?.actions.map((action) => ({
"identifier": action.identifier,
"text": action.text,
})) ?? []
property bool popup: false
property bool isTransient: notification?.hints.transient ?? false
property string appIcon: notification?.appIcon ?? ""
property string appName: notification?.appName ?? ""
property string body: notification?.body ?? ""
property string image: notification?.image ?? ""
property string summary: notification?.summary ?? ""
property double time
property string urgency: notification?.urgency.toString() ?? "normal"
property Timer timer
onNotificationChanged: {
if (notification === null) {
root.discardNotification(notificationId);
}
}
}
function notifToJSON(notif) {
return {
"notificationId": notif.notificationId,
"actions": notif.actions,
"appIcon": notif.appIcon,
"appName": notif.appName,
"body": notif.body,
"image": notif.image,
"summary": notif.summary,
"time": notif.time,
"urgency": notif.urgency,
}
}
function notifToString(notif) {
return JSON.stringify(notifToJSON(notif), null, 2);
}
component NotifTimer: Timer {
required property int notificationId
interval: 7000
running: true
onTriggered: () => {
const index = root.list.findIndex((notif) => notif.notificationId === notificationId);
const notifObject = root.list[index];
print("[Notifications] Notification timer triggered for ID: " + notificationId + ", transient: " + notifObject?.isTransient);
if (notifObject.isTransient) root.discardNotification(notificationId);
else root.timeoutNotification(notificationId);
destroy()
}
}
property bool silent: false
property int unread: 0
property var filePath: Directories.notificationsPath
property list<Notif> list: []
property var popupList: list.filter((notif) => notif.popup);
property bool popupInhibited: (GlobalStates?.sidebarRightOpen ?? false) || silent
property var latestTimeForApp: ({})
Component {
id: notifComponent
Notif {}
}
Component {
id: notifTimerComponent
NotifTimer {}
}
function stringifyList(list) {
return JSON.stringify(list.map((notif) => notifToJSON(notif)), null, 2);
}
onListChanged: {
// Update latest time for each app
root.list.forEach((notif) => {
if (!root.latestTimeForApp[notif.appName] || notif.time > root.latestTimeForApp[notif.appName]) {
root.latestTimeForApp[notif.appName] = Math.max(root.latestTimeForApp[notif.appName] || 0, notif.time);
}
});
// Remove apps that no longer have notifications
Object.keys(root.latestTimeForApp).forEach((appName) => {
if (!root.list.some((notif) => notif.appName === appName)) {
delete root.latestTimeForApp[appName];
}
});
}
function appNameListForGroups(groups) {
return Object.keys(groups).sort((a, b) => {
// Sort by time, descending
return groups[b].time - groups[a].time;
});
}
function groupsForList(list) {
const groups = {};
list.forEach((notif) => {
if (!groups[notif.appName]) {
groups[notif.appName] = {
appName: notif.appName,
appIcon: notif.appIcon,
notifications: [],
time: 0
};
}
groups[notif.appName].notifications.push(notif);
// Always set to the latest time in the group
groups[notif.appName].time = latestTimeForApp[notif.appName] || notif.time;
});
return groups;
}
property var groupsByAppName: groupsForList(root.list)
property var popupGroupsByAppName: groupsForList(root.popupList)
property list<string> appNameList: appNameListForGroups(root.groupsByAppName)
property list<string> popupAppNameList: appNameListForGroups(root.popupGroupsByAppName)
// Quickshell's notification IDs starts at 1 on each run, while saved notifications
// can already contain higher IDs. This is for avoiding id collisions
property int idOffset
signal initDone();
signal notify(notification: var);
signal discard(id: int);
signal discardAll();
signal timeout(id: var);
NotificationServer {
id: notifServer
// actionIconsSupported: true
actionsSupported: true
bodyHyperlinksSupported: true
bodyImagesSupported: true
bodyMarkupSupported: true
bodySupported: true
imageSupported: true
keepOnReload: false
persistenceSupported: true
onNotification: (notification) => {
notification.tracked = true
const newNotifObject = notifComponent.createObject(root, {
"notificationId": notification.id + root.idOffset,
"notification": notification,
"time": Date.now(),
});
root.list = [...root.list, newNotifObject];
// Popup
if (!root.popupInhibited) {
newNotifObject.popup = true;
if (notification.expireTimeout != 0) {
newNotifObject.timer = notifTimerComponent.createObject(root, {
"notificationId": newNotifObject.notificationId,
"interval": Config?.options.notifications.timeout ?? 7000,
});
}
root.unread++;
}
root.notify(newNotifObject);
// console.log(notifToString(newNotifObject));
notifFileView.setText(stringifyList(root.list));
}
}
function markAllRead() {
root.unread = 0;
}
function discardNotification(id) {
console.log("[Notifications] Discarding notification with ID: " + id);
const index = root.list.findIndex((notif) => notif.notificationId === id);
const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id);
if (index !== -1) {
root.list.splice(index, 1);
notifFileView.setText(stringifyList(root.list));
triggerListChange()
}
if (notifServerIndex !== -1) {
notifServer.trackedNotifications.values[notifServerIndex].dismiss()
}
root.discard(id); // Emit signal
}
function discardAllNotifications() {
root.list = []
triggerListChange()
notifFileView.setText(stringifyList(root.list));
notifServer.trackedNotifications.values.forEach((notif) => {
notif.dismiss()
})
root.discardAll();
}
function cancelTimeout(id) {
const index = root.list.findIndex((notif) => notif.notificationId === id);
if (root.list[index] != null)
root.list[index].timer.stop();
}
function timeoutNotification(id) {
const index = root.list.findIndex((notif) => notif.notificationId === id);
if (root.list[index] != null)
root.list[index].popup = false;
root.timeout(id);
}
function timeoutAll() {
root.popupList.forEach((notif) => {
root.timeout(notif.notificationId);
})
root.popupList.forEach((notif) => {
notif.popup = false;
});
}
function attemptInvokeAction(id, notifIdentifier) {
console.log("[Notifications] Attempting to invoke action with identifier: " + notifIdentifier + " for notification ID: " + id);
const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id);
console.log("Notification server index: " + notifServerIndex);
if (notifServerIndex !== -1) {
const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex];
const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier);
// console.log("Action found: " + JSON.stringify(action));
action.invoke()
}
else {
console.log("Notification not found in server: " + id)
}
root.discardNotification(id);
}
function triggerListChange() {
root.list = root.list.slice(0)
}
function refresh() {
notifFileView.reload()
}
Component.onCompleted: {
refresh()
}
FileView {
id: notifFileView
path: Qt.resolvedUrl(filePath)
onLoaded: {
const fileContents = notifFileView.text()
root.list = JSON.parse(fileContents).map((notif) => {
return notifComponent.createObject(root, {
"notificationId": notif.notificationId,
"actions": [], // Notification actions are meaningless if they're not tracked by the server or the sender is dead
"appIcon": notif.appIcon,
"appName": notif.appName,
"body": notif.body,
"image": notif.image,
"summary": notif.summary,
"time": notif.time,
"urgency": notif.urgency,
});
});
// Find largest notificationId
let maxId = 0
root.list.forEach((notif) => {
maxId = Math.max(maxId, notif.notificationId)
})
console.log("[Notifications] File loaded")
root.idOffset = maxId
root.initDone()
}
onLoadFailed: (error) => {
if(error == FileViewError.FileNotFound) {
console.log("[Notifications] File not found, creating new file.")
root.list = []
notifFileView.setText(stringifyList(root.list));
} else {
console.log("[Notifications] Error loading file: " + error)
}
}
}
}

View file

@ -0,0 +1,57 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import Quickshell
import Quickshell.Io
Singleton {
id: root
signal keyPressed(int keycode)
signal keyReleased(int keycode)
property bool active: false
onActiveChanged: {
if (active) {
monitor.running = true;
} else {
monitor.running = false;
}
}
Process {
id: monitor
command: ["python3", `${Directories.scriptPath}/keyboard_monitor.py`]
stdout: SplitParser {
onRead: data => {
try {
const event = JSON.parse(data);
if (event.error) {
console.warn("[PhysicalKeyboard]", event.error);
return;
}
if (event.status === "ready") {
return;
}
if (event.keycode !== undefined) {
if (event.pressed) {
root.keyPressed(event.keycode);
} else {
root.keyReleased(event.keycode);
}
}
} catch (e) {
console.warn("[PhysicalKeyboard] Parse error:", e);
}
}
}
onExited: (exitCode, exitStatus) => {
if (root.active) {
console.warn("[PhysicalKeyboard] Monitor exited unexpectedly with code", exitCode, "- restarting");
monitor.running = true;
}
}
}
}

View file

@ -0,0 +1,49 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Services.Polkit
Singleton {
id: root
property alias agent: polkitAgent
property alias active: polkitAgent.isActive
property alias flow: polkitAgent.flow
property bool interactionAvailable: false
property string cleanMessage: {
if (!root.flow) return "";
return root.flow.message.endsWith(".")
? root.flow.message.slice(0, -1)
: root.flow.message
}
property string cleanPrompt: {
const inputPrompt = PolkitService.flow?.inputPrompt.trim() ?? "";
const cleanedInputPrompt = inputPrompt.endsWith(":") ? inputPrompt.slice(0, -1) : inputPrompt;
const usePasswordChars = !PolkitService.flow?.responseVisible ?? true
return cleanedInputPrompt || (usePasswordChars ? Translation.tr("Password") : Translation.tr("Input"))
}
function cancel() {
root.flow.cancelAuthenticationRequest()
}
function submit(string) {
root.flow.submit(string)
root.interactionAvailable = false
}
Connections {
target: root.flow
function onAuthenticationFailed() {
root.interactionAvailable = true;
}
}
PolkitAgent {
id: polkitAgent
onAuthenticationRequestStarted: {
root.interactionAvailable = true;
}
}
}

View file

@ -0,0 +1,16 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
/**
* Screensharing and mic activity.
*/
Singleton {
id: root
property bool screenSharing: Pipewire.linkGroups.values.filter(pwlg => pwlg.source.type === PwNodeType.VideoSource).map(pwlg => pwlg.target)
property bool micActive: Pipewire.linkGroups.values.filter(pwlg => pwlg.source.type === PwNodeType.AudioSource && pwlg.target.type === PwNodeType.AudioInStream).map(pwlg => pwlg.target)
}

View file

@ -0,0 +1,121 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Simple polled resource usage service with RAM, Swap, and CPU usage.
*/
Singleton {
id: root
property real memoryTotal: 1
property real memoryFree: 0
property real memoryUsed: memoryTotal - memoryFree
property real memoryUsedPercentage: memoryUsed / memoryTotal
property real swapTotal: 1
property real swapFree: 0
property real swapUsed: swapTotal - swapFree
property real swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0
property real cpuUsage: 0
property var previousCpuStats
property string maxAvailableMemoryString: kbToGbString(ResourceUsage.memoryTotal)
property string maxAvailableSwapString: kbToGbString(ResourceUsage.swapTotal)
property string maxAvailableCpuString: "--"
readonly property int historyLength: Config?.options.resources.historyLength ?? 60
property list<real> cpuUsageHistory: []
property list<real> memoryUsageHistory: []
property list<real> swapUsageHistory: []
function kbToGbString(kb) {
return (kb / (1024 * 1024)).toFixed(1) + " GB";
}
function updateMemoryUsageHistory() {
memoryUsageHistory = [...memoryUsageHistory, memoryUsedPercentage]
if (memoryUsageHistory.length > historyLength) {
memoryUsageHistory.shift()
}
}
function updateSwapUsageHistory() {
swapUsageHistory = [...swapUsageHistory, swapUsedPercentage]
if (swapUsageHistory.length > historyLength) {
swapUsageHistory.shift()
}
}
function updateCpuUsageHistory() {
cpuUsageHistory = [...cpuUsageHistory, cpuUsage]
if (cpuUsageHistory.length > historyLength) {
cpuUsageHistory.shift()
}
}
function updateHistories() {
updateMemoryUsageHistory()
updateSwapUsageHistory()
updateCpuUsageHistory()
}
Timer {
interval: 1
// Souveraine: pause while the display is dimmed/locked/asleep —
// stats history nobody can see is pure CPU burn (idle-power task 4).
running: GlobalStates.displayActive
repeat: true
onTriggered: {
// Reload files
fileMeminfo.reload()
fileStat.reload()
// Parse memory and swap usage
const textMeminfo = fileMeminfo.text()
memoryTotal = Number(textMeminfo.match(/MemTotal: *(\d+)/)?.[1] ?? 1)
memoryFree = Number(textMeminfo.match(/MemAvailable: *(\d+)/)?.[1] ?? 0)
swapTotal = Number(textMeminfo.match(/SwapTotal: *(\d+)/)?.[1] ?? 1)
swapFree = Number(textMeminfo.match(/SwapFree: *(\d+)/)?.[1] ?? 0)
// Parse CPU usage
const textStat = fileStat.text()
const cpuLine = textStat.match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
if (cpuLine) {
const stats = cpuLine.slice(1).map(Number)
const total = stats.reduce((a, b) => a + b, 0)
const idle = stats[3]
if (previousCpuStats) {
const totalDiff = total - previousCpuStats.total
const idleDiff = idle - previousCpuStats.idle
cpuUsage = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0
}
previousCpuStats = { total, idle }
}
root.updateHistories()
interval = Config.options?.resources?.updateInterval ?? 3000
}
}
FileView { id: fileMeminfo; path: "/proc/meminfo" }
FileView { id: fileStat; path: "/proc/stat" }
Process {
id: findCpuMaxFreqProc
environment: ({
LANG: "C",
LC_ALL: "C"
})
command: ["bash", "-c", "lscpu | grep 'CPU max MHz' | awk '{print $4}'"]
running: true
stdout: StdioCollector {
id: outputCollector
onStreamFinished: {
root.maxAvailableCpuString = (parseFloat(outputCollector.text) / 1000).toFixed(0) + " GHz"
}
}
}
}

View file

@ -0,0 +1,39 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property bool packageManagerRunning: false
property bool downloadRunning: false
function refresh() {
packageManagerRunning = false;
downloadRunning = false;
detectPackageManagerProc.running = false;
detectPackageManagerProc.running = true;
detectDownloadProc.running = false;
detectDownloadProc.running = true;
}
Process {
id: detectPackageManagerProc
command: ["bash", "-c", "pidof yay paru dnf zypper apt apx xbps snap apk yum epsi pikman || ls /var/lib/pacman/db.lck"]
onExited: (exitCode, exitStatus) => {
root.packageManagerRunning = (exitCode === 0);
}
}
Process {
id: detectDownloadProc
command: ["bash", "-c", "pidof curl wget aria2c yt-dlp || ls ~/Downloads | grep -E '\.crdownload$|\.part$'"]
onExited: (exitCode, exitStatus) => {
root.downloadRunning = (exitCode === 0);
}
}
}

View file

@ -0,0 +1,103 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
enum MonitorSource { Monitor, Input }
property var monitorSource: SongRec.MonitorSource.Monitor
property int timeoutInterval: Config.options.musicRecognition.interval
property int timeoutDuration: Config.options.musicRecognition.timeout
readonly property bool running: recognizeMusicProc.running
function toggleRunning(running) {
if (recognizeMusicProc.running && !running === true) root.manuallyStopped = true;
if (running != undefined) {
recognizeMusicProc.running = running
} else {
recognizeMusicProc.running = !root.running
}
musicReconizedProc.running = false
}
function toggleMonitorSource(source) {
if (source !== undefined) {
root.monitorSource = source
return
}
root.monitorSource = (root.monitorSource === SongRec.MonitorSource.Monitor) ? SongRec.MonitorSource.Input : SongRec.MonitorSource.Monitor
}
function monitorSourceToString(source) {
if (source === SongRec.MonitorSource.Monitor) {
return "monitor"
} else {
return "input"
}
}
readonly property string monitorSourceString: monitorSourceToString(monitorSource)
property var recognizedTrack: ({ title:"", subtitle:"", url:""})
property bool manuallyStopped: false
function handleRecognition(jsonText) {
try {
var obj = JSON.parse(jsonText)
root.recognizedTrack = {
title: obj.track.title,
subtitle: obj.track.subtitle,
url: obj.track.url
}
musicReconizedProc.running = true
} catch(e) {
Quickshell.execDetached(["notify-send", Translation.tr("Couldn't recognize music"), Translation.tr("Perhaps what you're listening to is too niche"), "-a", "Shell"])
}
}
Process {
id: recognizeMusicProc
running: false
command: [`${Directories.scriptPath}/musicRecognition/recognize-music.sh`, "-i", root.timeoutInterval, "-t", root.timeoutDuration, "-s", root.monitorSourceString]
stdout: StdioCollector {
onStreamFinished: {
if (root.manuallyStopped) {
root.manuallyStopped = false
return
}
handleRecognition(this.text)
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 1) {
Quickshell.execDetached(["notify-send", Translation.tr("Couldn't recognize music"), Translation.tr("Make sure you have songrec installed"), "-a", "Shell"])
}
}
}
Process {
id: musicReconizedProc
running: false
command: [
"notify-send",
Translation.tr("Music Recognized"),
root.recognizedTrack.title + " - " + root.recognizedTrack.subtitle,
"-A", "Shazam",
"-A", "YouTube",
"-a", "Shell"
]
stdout: StdioCollector {
onStreamFinished: {
if (this.text === "") return
if (this.text == 0) {
Qt.openUrlExternally(root.recognizedTrack.url);
} else {
Qt.openUrlExternally("https://www.youtube.com/results?search_query=" + root.recognizedTrack.title + " - " + root.recognizedTrack.subtitle);
}
}
}
}
}

View file

@ -0,0 +1,117 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Provides some system info: distro, username.
*/
Singleton {
id: root
property string distroName: "Unknown"
property string distroId: "unknown"
property string distroIcon: "linux-symbolic"
property string username: "user"
property string homeUrl: ""
property string documentationUrl: ""
property string supportUrl: ""
property string bugReportUrl: ""
property string privacyPolicyUrl: ""
property string logo: ""
property string desktopEnvironment: ""
property string windowingSystem: ""
Timer {
triggeredOnStart: true
interval: 1
running: true
repeat: false
onTriggered: {
getUsername.running = true
fileOsRelease.reload()
const textOsRelease = fileOsRelease.text()
// Extract the friendly name (PRETTY_NAME field, fallback to NAME)
const prettyNameMatch = textOsRelease.match(/^PRETTY_NAME="(.+?)"/m)
const nameMatch = textOsRelease.match(/^NAME="(.+?)"/m)
distroName = prettyNameMatch ? prettyNameMatch[1] : (nameMatch ? nameMatch[1].replace(/Linux/i, "").trim() : "Unknown")
// Extract the ID
const idMatch = textOsRelease.match(/^ID="?(.+?)"?$/m)
distroId = idMatch ? idMatch[1] : "unknown"
// Extract additional URLs and logo
const homeUrlMatch = textOsRelease.match(/^HOME_URL="(.+?)"/m)
homeUrl = homeUrlMatch ? homeUrlMatch[1] : ""
const documentationUrlMatch = textOsRelease.match(/^DOCUMENTATION_URL="(.+?)"/m)
documentationUrl = documentationUrlMatch ? documentationUrlMatch[1] : ""
const supportUrlMatch = textOsRelease.match(/^SUPPORT_URL="(.+?)"/m)
supportUrl = supportUrlMatch ? supportUrlMatch[1] : ""
const bugReportUrlMatch = textOsRelease.match(/^BUG_REPORT_URL="(.+?)"/m)
bugReportUrl = bugReportUrlMatch ? bugReportUrlMatch[1] : ""
const privacyPolicyUrlMatch = textOsRelease.match(/^PRIVACY_POLICY_URL="(.+?)"/m)
privacyPolicyUrl = privacyPolicyUrlMatch ? privacyPolicyUrlMatch[1] : ""
const logoFieldMatch = textOsRelease.match(/^LOGO="?(.+?)"?$/m)
logo = logoFieldMatch ? logoFieldMatch[1] : ""
// Update the distroIcon property based on distroId
switch (distroId) {
case "artix":
case "arch": distroIcon = "arch-symbolic"; break;
case "endeavouros": distroIcon = "endeavouros-symbolic"; break;
case "cachyos": distroIcon = "cachyos-symbolic"; break;
case "nixos": distroIcon = "nixos-symbolic"; break;
case "fedora": distroIcon = "fedora-symbolic"; break;
case "linuxmint":
case "ubuntu":
case "zorin":
case "popos": distroIcon = "ubuntu-symbolic"; break;
case "debian":
case "raspbian":
case "kali": distroIcon = "debian-symbolic"; break;
case "funtoo":
case "gentoo": distroIcon = "gentoo-symbolic"; break;
default: distroIcon = "linux-symbolic"; break;
}
if (textOsRelease.toLowerCase().includes("nyarch")) {
distroIcon = "nyarch-symbolic"
}
if (logo.trim().length === 0) {
logo = distroIcon
}
}
}
Process {
id: getUsername
command: ["whoami"]
stdout: SplitParser {
onRead: data => {
root.username = data.trim()
}
}
}
Process {
id: getDesktopEnvironment
running: true
command: ["bash", "-c", "echo $XDG_CURRENT_DESKTOP,$WAYLAND_DISPLAY"]
stdout: StdioCollector {
id: deCollector
onStreamFinished: {
const [desktop, wayland] = deCollector.text.split(",")
root.desktopEnvironment = desktop.trim()
root.windowingSystem = wayland.trim().length > 0 ? "Wayland" : "X11" // Are there others? 🤔
}
}
}
FileView {
id: fileOsRelease
path: "/etc/os-release"
}
}

View file

@ -0,0 +1,72 @@
pragma Singleton
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Wayland
Singleton {
id: root
function isPinned(appId) {
return Config.options.dock.pinnedApps.indexOf(appId) !== -1;
}
function togglePin(appId) {
if (root.isPinned(appId)) {
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appId)
} else {
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appId])
}
}
property list<var> apps: {
var map = new Map();
// Pinned apps
const pinnedApps = Config.options?.dock.pinnedApps ?? [];
for (const appId of pinnedApps) {
if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({
pinned: true,
toplevels: []
}));
}
// Separator
if (pinnedApps.length > 0) {
map.set("SEPARATOR", { pinned: false, toplevels: [] });
}
// Ignored apps
const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? [];
const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i"));
// Open windows
for (const toplevel of ToplevelManager.toplevels.values) {
if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue;
if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({
pinned: false,
toplevels: []
}));
map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel);
}
var values = [];
for (const [key, value] of map) {
values.push(appEntryComp.createObject(null, { appId: key, toplevels: value.toplevels, pinned: value.pinned }));
}
return values;
}
component TaskbarAppEntry: QtObject {
id: wrapper
required property string appId
required property list<var> toplevels
required property bool pinned
}
Component {
id: appEntryComp
TaskbarAppEntry {}
}
}

View file

@ -0,0 +1,144 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import Quickshell
import Quickshell.Io
import QtQuick
/**
* Simple Pomodoro time manager.
*/
Singleton {
id: root
property int focusTime: Config.options.time.pomodoro.focus
property int breakTime: Config.options.time.pomodoro.breakTime
property int longBreakTime: Config.options.time.pomodoro.longBreak
property int cyclesBeforeLongBreak: Config.options.time.pomodoro.cyclesBeforeLongBreak
property bool pomodoroRunning: Persistent.states.timer.pomodoro.running
property bool pomodoroBreak: Persistent.states.timer.pomodoro.isBreak
property bool pomodoroLongBreak: Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak);
property int pomodoroLapDuration: pomodoroLongBreak ? longBreakTime : pomodoroBreak ? breakTime : focusTime // This is a binding that's to be kept
property int pomodoroSecondsLeft: pomodoroLapDuration // Reasonable init value, to be changed
property int pomodoroCycle: Persistent.states.timer.pomodoro.cycle
property bool stopwatchRunning: Persistent.states.timer.stopwatch.running
property int stopwatchTime: 0
property int stopwatchStart: Persistent.states.timer.stopwatch.start
property var stopwatchLaps: Persistent.states.timer.stopwatch.laps
// General
Component.onCompleted: {
if (!stopwatchRunning)
stopwatchReset();
}
function getCurrentTimeInSeconds() { // Pomodoro uses Seconds
return Math.floor(Date.now() / 1000);
}
function getCurrentTimeIn10ms() { // Stopwatch uses 10ms
return Math.floor(Date.now() / 10);
}
// Pomodoro
function refreshPomodoro() {
// Work <-> break ?
if (getCurrentTimeInSeconds() >= Persistent.states.timer.pomodoro.start + pomodoroLapDuration) {
// Reset counts
Persistent.states.timer.pomodoro.isBreak = !Persistent.states.timer.pomodoro.isBreak;
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds();
// Send notification
let notificationMessage;
if (Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak)) {
notificationMessage = Translation.tr(`🌿 Long break: %1 minutes`).arg(Math.floor(longBreakTime / 60));
} else if (Persistent.states.timer.pomodoro.isBreak) {
notificationMessage = Translation.tr(`☕ Break: %1 minutes`).arg(Math.floor(breakTime / 60));
} else {
notificationMessage = Translation.tr(`🔴 Focus: %1 minutes`).arg(Math.floor(focusTime / 60));
}
if (Config.options.time.pomodoro.notifications) {
Quickshell.execDetached(["notify-send", "Pomodoro", notificationMessage, "-a", "Shell"]);
}
if (Config.options.sounds.pomodoro) {
Audio.playSystemSound("alarm-clock-elapsed")
}
if (!pomodoroBreak) {
Persistent.states.timer.pomodoro.cycle = (Persistent.states.timer.pomodoro.cycle + 1) % root.cyclesBeforeLongBreak;
}
}
pomodoroSecondsLeft = pomodoroLapDuration - (getCurrentTimeInSeconds() - Persistent.states.timer.pomodoro.start);
}
Timer {
id: pomodoroTimer
interval: 200
running: root.pomodoroRunning
repeat: true
onTriggered: refreshPomodoro()
}
function togglePomodoro() {
Persistent.states.timer.pomodoro.running = !pomodoroRunning;
if (Persistent.states.timer.pomodoro.running) {
// Start/Resume
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds() + pomodoroSecondsLeft - pomodoroLapDuration;
}
}
function resetPomodoro() {
Persistent.states.timer.pomodoro.running = false;
Persistent.states.timer.pomodoro.isBreak = false;
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds();
Persistent.states.timer.pomodoro.cycle = 0;
refreshPomodoro();
}
// Stopwatch
function refreshStopwatch() { // Stopwatch stores time in 10ms
stopwatchTime = getCurrentTimeIn10ms() - stopwatchStart;
}
Timer {
id: stopwatchTimer
interval: 10
running: root.stopwatchRunning
repeat: true
onTriggered: refreshStopwatch()
}
function toggleStopwatch() {
if (root.stopwatchRunning)
stopwatchPause();
else
stopwatchResume();
}
function stopwatchPause() {
Persistent.states.timer.stopwatch.running = false;
}
function stopwatchResume() {
if (stopwatchTime === 0) Persistent.states.timer.stopwatch.laps = [];
Persistent.states.timer.stopwatch.running = true;
Persistent.states.timer.stopwatch.start = getCurrentTimeIn10ms() - stopwatchTime;
}
function stopwatchReset() {
stopwatchTime = 0;
Persistent.states.timer.stopwatch.laps = [];
Persistent.states.timer.stopwatch.running = false;
}
function stopwatchRecordLap() {
Persistent.states.timer.stopwatch.laps.push(stopwatchTime);
}
}

View file

@ -0,0 +1,87 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import Quickshell;
import Quickshell.Io;
import QtQuick;
/**
* Simple to-do list manager.
* Each item is an object with "content" and "done" properties.
*/
Singleton {
id: root
property var filePath: Directories.todoPath
property var list: []
function addItem(item) {
list.push(item)
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
function addTask(desc) {
const item = {
"content": desc,
"done": false,
}
addItem(item)
}
function markDone(index) {
if (index >= 0 && index < list.length) {
list[index].done = true
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
}
function markUnfinished(index) {
if (index >= 0 && index < list.length) {
list[index].done = false
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
}
function deleteItem(index) {
if (index >= 0 && index < list.length) {
list.splice(index, 1)
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
}
function refresh() {
todoFileView.reload()
}
Component.onCompleted: {
refresh()
}
FileView {
id: todoFileView
path: Qt.resolvedUrl(root.filePath)
onLoaded: {
const fileContents = todoFileView.text()
root.list = JSON.parse(fileContents)
console.log("[To Do] File loaded")
}
onLoadFailed: (error) => {
if(error == FileViewError.FileNotFound) {
console.log("[To Do] File not found, creating new file.")
root.list = []
todoFileView.setText(JSON.stringify(root.list))
} else {
console.log("[To Do] Error loading file: " + error)
}
}
}
}

View file

@ -0,0 +1,146 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
Singleton {
id: root
property var translations: ({})
property var generatedTranslations: ({})
property var availableLanguages: ["en_US"]
property var availableGeneratedLanguages: []
property var allAvailableLanguages: {
const combined = new Set([...root.availableLanguages, ...root.availableGeneratedLanguages]);
return Array.from(combined).sort();
}
property bool isScanning: scanLanguagesProcess.running
property bool isLoading: false
property string translationKeepSuffix: "/*keep*/"
property string translationsDir: Quickshell.shellPath("translations")
property string generatedTranslationsDir: Directories.shellConfig + "/translations"
property string languageCode: {
var configLang = Config?.options.language.ui ?? "auto";
if (configLang !== "auto")
return configLang;
return Qt.locale().name;
}
TranslationScanner {
id: scanLanguagesProcess
translationsDir: root.translationsDir
onLanguagesScanned: (languages) => {
root.availableLanguages = [...languages];
}
}
TranslationScanner {
id: scanGeneratedLanguagesProcess
translationsDir: root.generatedTranslationsDir
onLanguagesScanned: (languages) => {
root.availableGeneratedLanguages = [...languages];
}
}
onLanguageCodeChanged: {
print("[Translation] Language changed to", root.languageCode);
translationFileView.languageCode = root.languageCode;
generatedTranslationFileView.languageCode = root.languageCode;
translationFileView.reread();
generatedTranslationFileView.reread();
}
TranslationReader {
id: translationFileView
translationsDir: root.translationsDir
languageCode: root.languageCode
onContentLoaded: (data) => {
root.translations = data;
root.isLoading = false;
}
}
TranslationReader {
id: generatedTranslationFileView
translationsDir: root.generatedTranslationsDir
languageCode: root.languageCode
onContentLoaded: (data) => {
root.generatedTranslations = data;
root.isLoading = false;
}
}
function tr(text) {
// Special cases
if (!text) return "";
var key = text.toString();
if (root.isLoading || (!root?.translations?.hasOwnProperty(key) && !root?.generatedTranslations?.hasOwnProperty(key)))
return key;
// Normal cases
var translation = root.translations[key] || root.generatedTranslations[key] || key;
// print(key, "-> [", root.translations[key], root.generatedTranslations[key], key, "] ->", translation);
if (translation.endsWith(root.translationKeepSuffix)) {
translation = translation.substring(0, translation.length - root.translationKeepSuffix.length).trim();
}
return translation;
}
component TranslationScanner: Process {
id: translationScanner
required property string translationsDir
signal languagesScanned(var languages)
command: ["find", translationScanner.translationsDir, "-name", "*.json", "-exec", "basename", "{}", ".json", ";"]
running: true
stdout: StdioCollector {
id: languagesCollector
onStreamFinished: {
const output = languagesCollector.text;
const files = output.trim().split('\n').map(f => f.trim());
translationScanner.languagesScanned(files);
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
translationScanner.languagesScanned(["en_US"]);
}
}
}
component TranslationReader: FileView {
id: translationReader
required property string translationsDir
property string languageCode: root.languageCode
signal contentLoaded(var data)
function reread() { // Proper reload in case the file was incorrect before
translationReader.path = "";
translationReader.path = `${translationReader.translationsDir}/${translationReader.languageCode}.json`;
translationReader.reload();
}
path: ""
onLoaded: {
var textContent = "";
try {
textContent = text();
var jsonData = JSON.parse(textContent);
translationReader.contentLoaded(jsonData);
} catch (e) {
console.log("[Translation] Failed to load translations:", e);
translationReader.contentLoaded({});
}
}
onLoadFailed: error => {
translationReader.contentLoaded({});
}
}
}

View file

@ -0,0 +1,53 @@
pragma Singleton
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Services.SystemTray
Singleton {
id: root
property bool smartTray: Config.options.tray.filterPassive
property list<var> itemsInUserList: SystemTray.items.values.filter(i => (Config.options.tray.pinnedItems.includes(i.id) && (!smartTray || i.status !== Status.Passive)))
property list<var> itemsNotInUserList: SystemTray.items.values.filter(i => (!Config.options.tray.pinnedItems.includes(i.id) && (!smartTray || i.status !== Status.Passive)))
property bool invertPins: Config.options.tray.invertPinnedItems
property list<var> pinnedItems: invertPins ? itemsNotInUserList : itemsInUserList
property list<var> unpinnedItems: invertPins ? itemsInUserList : itemsNotInUserList
function getTooltipForItem(item) {
var result = item.tooltipTitle.length > 0 ? item.tooltipTitle
: (item.title.length > 0 ? item.title : item.id);
if (item.tooltipDescription.length > 0) result += " • " + item.tooltipDescription;
if (Config.options.tray.showItemId) result += "\n[" + item.id + "]";
return result;
}
// Pinning
function pin(itemId) {
var pins = Config.options.tray.pinnedItems;
if (pins.includes(itemId)) return;
Config.options.tray.pinnedItems.push(itemId);
}
function unpin(itemId) {
Config.options.tray.pinnedItems = Config.options.tray.pinnedItems.filter(id => id !== itemId);
}
function isPinned(itemId) {
for (var i = 0; i < root.pinnedItems.length; i++) {
if (root.pinnedItems[i].id === itemId)
return true;
}
return false;
}
function togglePin(itemId) {
var pins = Config.options.tray.pinnedItems;
if (pins.includes(itemId)) {
unpin(itemId)
} else {
pin(itemId)
}
}
}

View file

@ -0,0 +1,58 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
/*
* System updates service. Currently only supports Arch.
*/
Singleton {
id: root
property bool available: false
property alias checking: checkUpdatesProc.running
property int count: 0
readonly property bool updateAdvised: available && count > Config.options.updates.adviseUpdateThreshold
readonly property bool updateStronglyAdvised: available && count > Config.options.updates.stronglyAdviseUpdateThreshold
function load() {}
function refresh() {
if (!available) return;
print("[Updates] Checking for system updates")
checkUpdatesProc.running = true;
}
Timer {
interval: Config.options.updates.checkInterval * 60 * 1000
repeat: true
running: Config.ready && Config.options.updates.enableCheck
onTriggered: {
print("[Updates] Periodic update check due")
root.refresh();
}
}
Process {
id: checkAvailabilityProc
running: Config.ready && Config.options.updates.enableCheck
command: ["which", "checkupdates"]
onExited: (exitCode, exitStatus) => {
root.available = (exitCode === 0);
root.refresh();
}
}
Process {
id: checkUpdatesProc
command: ["bash", "-c", "checkupdates | wc -l"]
stdout: StdioCollector {
onStreamFinished: {
root.count = parseInt(text.trim());
}
}
}
}

View file

@ -0,0 +1,182 @@
import qs.modules.common
import qs.modules.common.models
import qs.modules.common.functions
import QtQuick
import Qt.labs.folderlistmodel
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
/**
* Provides a list of wallpapers and an "apply" action that calls the existing
* switchwall.sh script. Pretty much a limited file browsing service.
*/
Singleton {
id: root
property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen-venv.sh`
property string generateThumbnailsMagickScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/generate-thumbnails-magick.sh`
property alias directory: folderModel.folder
readonly property string effectiveDirectory: FileUtils.trimFileProtocol(folderModel.folder.toString())
property url defaultFolder: Qt.resolvedUrl(FileUtils.parentDirectory(Config.options.background.wallpaperPath) || `${Directories.pictures}/Wallpapers`)
property alias folderModel: folderModel // Expose for direct binding when needed
property string searchQuery: ""
readonly property list<string> extensions: [
"jpg", "jpeg", "png", "webp", "avif", "bmp", "svg",
"mp4", "webm", "mkv", "avi", "mov"
]
readonly property list<string> videoExtensions: ["mp4", "webm", "mkv", "avi", "mov"]
property list<string> wallpapers: [] // List of absolute file paths (without file://)
readonly property bool thumbnailGenerationRunning: thumbgenProc.running
property real thumbnailGenerationProgress: 0
signal changed()
signal thumbnailGenerated(directory: string)
signal thumbnailGeneratedFile(filePath: string)
function load () {} // For forcing initialization
function openFallbackPicker(darkMode = Appearance.m3colors.darkmode) {
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", darkMode ? "dark" : "light"]);
}
function apply(path, darkMode = Appearance.m3colors.darkmode) {
if (!path || path.length === 0) return;
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", darkMode ? "dark" : "light", "--image", path]);
root.changed()
}
Process {
id: selectProc
property string filePath: ""
property bool darkMode: Appearance.m3colors.darkmode
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
selectProc.filePath = filePath
selectProc.darkMode = darkMode
selectProc.exec(["test", "-d", FileUtils.trimFileProtocol(filePath)])
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
setDirectory(selectProc.filePath);
return;
}
root.apply(selectProc.filePath, selectProc.darkMode);
}
}
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
selectProc.select(filePath, darkMode);
}
function randomFromCurrentFolder(darkMode = Appearance.m3colors.darkmode) {
if (folderModel.count === 0) return;
const randomIndex = Math.floor(Math.random() * folderModel.count);
const filePath = folderModel.get(randomIndex, "filePath");
print("Randomly selected wallpaper:", filePath);
root.select(filePath, darkMode);
}
Process {
id: validateDirProc
property string nicePath: ""
function setDirectoryIfValid(path) {
validateDirProc.nicePath = FileUtils.trimFileProtocol(path).replace(/\/+$/, "")
if (/^\/*$/.test(validateDirProc.nicePath)) validateDirProc.nicePath = "/";
validateDirProc.exec([
"bash", "-c",
`if [ -d "${validateDirProc.nicePath}" ]; then echo dir; elif [ -f "${validateDirProc.nicePath}" ]; then echo file; else echo invalid; fi`
])
}
stdout: StdioCollector {
onStreamFinished: {
root.directory = Qt.resolvedUrl(validateDirProc.nicePath)
const result = text.trim()
if (result === "dir") {
} else if (result === "file") {
root.directory = Qt.resolvedUrl(FileUtils.parentDirectory(validateDirProc.nicePath))
} else {
// Ignore
}
}
}
}
function setDirectory(path) {
validateDirProc.setDirectoryIfValid(path)
}
function navigateUp() {
folderModel.navigateUp()
}
function navigateBack() {
folderModel.navigateBack()
}
function navigateForward() {
folderModel.navigateForward()
}
// Folder model
FolderListModelWithHistory {
id: folderModel
folder: Qt.resolvedUrl(root.defaultFolder)
caseSensitive: false
nameFilters: root.extensions.map(ext => `*${searchQuery.split(" ").filter(s => s.length > 0).map(s => `*${s}*`)}*.${ext}`)
showDirs: true
showDotAndDotDot: false
showOnlyReadable: true
sortField: FolderListModel.Time
sortReversed: false
onCountChanged: {
root.wallpapers = []
for (let i = 0; i < folderModel.count; i++) {
const path = folderModel.get(i, "filePath") || FileUtils.trimFileProtocol(folderModel.get(i, "fileURL"))
if (path && path.length) root.wallpapers.push(path)
}
}
}
// Thumbnail generation
function generateThumbnail(size: string) {
if (!["normal", "large", "x-large", "xx-large"].includes(size)) throw new Error("Invalid thumbnail size");
thumbgenProc.directory = root.directory
thumbgenProc.running = false
thumbgenProc.command = [
"bash", "-c",
`${thumbgenScriptPath} --size ${size} --machine_progress -d ${FileUtils.trimFileProtocol(root.directory)} || ${generateThumbnailsMagickScriptPath} --size ${size} -d ${FileUtils.trimFileProtocol(root.directory)}`,
]
// console.log("[Wallpapers] Updating thumbnails with command ", thumbgenProc.command.join(" "))
root.thumbnailGenerationProgress = 0
thumbgenProc.running = true
}
Process {
id: thumbgenProc
property string directory
stdout: SplitParser {
onRead: data => {
// print("thumb gen proc:", data)
let match = data.match(/PROGRESS (\d+)\/(\d+)/)
if (match) {
const completed = parseInt(match[1])
const total = parseInt(match[2])
root.thumbnailGenerationProgress = completed / total
}
match = data.match(/FILE (.+)/)
if (match) {
const filePath = match[1]
root.thumbnailGeneratedFile(filePath)
}
}
}
onExited: (exitCode, exitStatus) => {
// print("[Wallpapers] Thumbnail generation completed with exit code", exitCode)
root.thumbnailGenerated(thumbgenProc.directory)
}
}
IpcHandler {
target: "wallpapers"
function apply(path: string): void {
root.apply(path);
}
}
}

View file

@ -0,0 +1,167 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
import QtPositioning
import qs.modules.common
Singleton {
id: root
// 10 minute
readonly property int fetchInterval: Config.options.bar.weather.fetchInterval * 60 * 1000
readonly property string city: Config.options.bar.weather.city
readonly property bool useUSCS: Config.options.bar.weather.useUSCS
property bool gpsActive: Config.options.bar.weather.enableGPS
onUseUSCSChanged: {
root.getData();
}
onCityChanged: {
root.getData();
}
property var location: ({
valid: false,
lat: 0,
lon: 0
})
property var data: ({
uv: 0,
humidity: 0,
sunrise: 0,
sunset: 0,
windDir: 0,
wCode: 0,
city: 0,
wind: 0,
precip: 0,
visib: 0,
press: 0,
temp: 0,
tempFeelsLike: 0,
lastRefresh: 0,
})
function refineData(data) {
let temp = {};
temp.uv = data?.current?.uvIndex || 0;
temp.humidity = (data?.current?.humidity || 0) + "%";
temp.sunrise = data?.astronomy?.sunrise || "0.0";
temp.sunset = data?.astronomy?.sunset || "0.0";
temp.windDir = data?.current?.winddir16Point || "N";
temp.wCode = data?.current?.weatherCode || "113";
temp.city = data?.location?.areaName[0]?.value || "City";
temp.temp = "";
temp.tempFeelsLike = "";
if (root.useUSCS) {
temp.wind = (data?.current?.windspeedMiles || 0) + " mph";
temp.precip = (data?.current?.precipInches || 0) + " in";
temp.visib = (data?.current?.visibilityMiles || 0) + " m";
temp.press = (data?.current?.pressureInches || 0) + " psi";
temp.temp += Math.round(data?.current?.temp_F || 0) + 0;
temp.tempFeelsLike += Math.round(data?.current?.FeelsLikeF || 0) + 0;
temp.temp += "°F";
temp.tempFeelsLike += "°F";
} else {
temp.wind = (data?.current?.windspeedKmph || 0) + " km/h";
temp.precip = (data?.current?.precipMM || 0) + " mm";
temp.visib = (data?.current?.visibility || 0) + " km";
temp.press = (data?.current?.pressure || 0) + " hPa";
temp.temp += Math.round(data?.current?.temp_C || 0) + 0;
temp.tempFeelsLike += Math.round(data?.current?.FeelsLikeC || 0) + 0;
temp.temp += "°C";
temp.tempFeelsLike += "°C";
}
temp.lastRefresh = DateTime.time + " • " + DateTime.date;
root.data = temp;
}
function getData() {
let command = "curl -s wttr.in";
if (root.gpsActive && root.location.valid) {
command += `/${root.location.lat},${root.location.long}`;
} else {
command += `/${formatCityName(root.city)}`;
}
// format as json
command += "?format=j1";
command += " | ";
// only take the current weather, location, asytronmy data
command += "jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'";
fetcher.command[2] = command;
fetcher.running = true;
}
function formatCityName(cityName) {
return cityName.trim().split(/\s+/).join('+');
}
Component.onCompleted: {
if (!root.gpsActive) return;
console.info("[WeatherService] Starting the GPS service.");
positionSource.start();
}
Process {
id: fetcher
command: ["bash", "-c", ""]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0)
return;
try {
const parsedData = JSON.parse(text);
root.refineData(parsedData);
// console.info(`[ data: ${JSON.stringify(parsedData)}`);
} catch (e) {
console.error(`[WeatherService] ${e.message}`);
}
}
}
}
PositionSource {
id: positionSource
updateInterval: root.fetchInterval
onPositionChanged: {
// update the location if the given location is valid
// if it fails getting the location, use the last valid location
if (position.latitudeValid && position.longitudeValid) {
root.location.lat = position.coordinate.latitude;
root.location.long = position.coordinate.longitude;
root.location.valid = true;
// console.info(`📍 Location: ${position.coordinate.latitude}, ${position.coordinate.longitude}`);
root.getData();
// if can't get initialized with valid location deactivate the GPS
} else {
root.gpsActive = root.location.valid ? true : false;
console.error("[WeatherService] Failed to get the GPS location.");
}
}
onValidityChanged: {
if (!positionSource.valid) {
positionSource.stop();
root.location.valid = false;
root.gpsActive = false;
Quickshell.execDetached(["notify-send", Translation.tr("Weather Service"), Translation.tr("Cannot find a GPS service. Using the fallback method instead."), "-a", "Shell"]);
console.error("[WeatherService] Could not aquire a valid backend plugin.");
}
}
}
Timer {
running: !root.gpsActive
repeat: true
interval: root.fetchInterval
triggeredOnStart: !root.gpsActive
onTriggered: root.getData()
}
}

View file

@ -0,0 +1,47 @@
pragma Singleton
import qs.modules.common
import Quickshell
Singleton {
id: root
property int shiftMode: 0 // 0: off, 1: on, 2: lock
property list<int> shiftKeys: [42, 54] // Keycodes for Shift keys (left and right)
property list<int> altKeys: [56, 100] // Keycodes for Alt keys (left and right)
property list<int> ctrlKeys: [29, 97] // Keycodes for Ctrl keys (left and right)
function releaseAllKeys() {
const keycodes = Array.from(Array(249).keys());
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
...keycodes.map(keycode => `${keycode}:0`)
])
root.shiftMode = 0; // Reset shift mode
}
function releaseShiftKeys() {
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
...root.shiftKeys.map(keycode => `${keycode}:0`)
])
root.shiftMode = 0; // Reset shift mode
}
function press(keycode) {
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
`${keycode}:1`
]);
}
function release(keycode) {
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
`${keycode}:0`
]);
}
}

View file

@ -0,0 +1,29 @@
import QtQuick;
/**
* Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure.
*/
QtObject {
property string role
property string content
property string rawContent
// First-class stream blocks. The Souveraine adapter fills these from the
// typed SSE events; legacy/provider messages leave this empty and the
// existing markdown splitter remains their renderer.
property var segments: []
property string fileMimeType
property string fileUri
property string localFilePath
property string model
property bool thinking: true
property bool done: false
property var annotations: []
property var annotationSources: []
property list<string> searchQueries: []
property string functionName
property var functionCall
property string thoughtSignature
property string functionResponse
property bool functionPending: false
property bool visibleToUser: true
}

View file

@ -0,0 +1,32 @@
import QtQuick;
/**
* An AI model representation.
* - name: Friendly name of the model
* - icon: Icon name of the model
* - description: Description of the model
* - endpoint: Endpoint of the model
* - model: Model code (like gpt-4.1 or gemini-2.5-flash)
* - requires_key: Whether the model requires an API key
* - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key.
* - key_get_link: Link to get an API key
* - key_get_description: Description of pricing and how to get an API key
* - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai".
* - extraParams: Extra parameters to be passed to the model. This is a JSON object.
*/
QtObject {
property string name
property string icon
property string description
property string homepage
property string endpoint
property string model
property bool requires_key: true
property string key_id
property string key_get_link
property string key_get_description
property string api_format: "openai"
property var tools
property var extraParams: ({})
}

View file

@ -0,0 +1,12 @@
import QtQuick
QtObject {
function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") }
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) { throw new Error("Not implemented") }
function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") }
function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") }
function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling
function reset() { } // Reset any internal state if needed
function buildScriptFileSetup(filePath) { return "" } // Default: no setup
function finalizeScriptContent(scriptContent: string): string { return scriptContent } // Optionally modify/finalize script
}

View file

@ -0,0 +1,272 @@
import QtQuick
import qs.modules.common.functions as CF
ApiStrategy {
readonly property string apiKeyEnvVarName: "API_KEY"
readonly property string fileListVarName: "UPLOADED_FILES_JSON"
readonly property string fileListSubstitutionString: "{{ uploadedFilesJson }}"
property string buffer: ""
function buildEndpoint(model: AiModel): string {
const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`
// console.log("[AI] Endpoint: " + result);
return result;
}
function attachmentPart(attachment) {
return {
"file_data": {
"mime_type": attachment.fileMimeType,
"file_uri": attachment.fileUri
}
};
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, pendingFiles: var) {
let contents = messages.map(message => {
// console.log("[AI] Building request data for message:", JSON.stringify(message, null, 2));
const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role;
const usingSearch = tools[0]?.google_search !== undefined
if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) {
const part = { functionCall: message.functionCall };
if (message.thoughtSignature && message.thoughtSignature.length > 0) {
part.thoughtSignature = message.thoughtSignature;
}
return {
"role": geminiApiRoleName,
"parts": [part]
}
}
if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionResponse: {
"name": message.functionName,
"response": { "content": message.functionResponse }
}
}]
}
}
const messageAttachments = (message.attachments ?? [])
.filter(attachment => attachment.fileUri && attachment.fileUri.length > 0)
.map(attachment => attachmentPart(attachment));
return {
"role": geminiApiRoleName,
"parts": [
...messageAttachments,
{ text: message.rawContent },
...(messageAttachments.length === 0 && message.fileUri && message.fileUri.length > 0 ? [attachmentPart(message)] : [])
]
}
})
if (pendingFiles && pendingFiles.length > 0) {
contents[contents.length - 1].parts = [
...contents[contents.length - 1].parts,
fileListSubstitutionString,
];
}
let baseData = {
"contents": contents,
"tools": tools,
"system_instruction": {
"parts": [{ text: systemPrompt }]
},
"generationConfig": {
"temperature": temperature,
},
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return "";
}
function parseResponseLine(line, message) {
if (line.startsWith("[")) {
buffer += line.slice(1).trim();
} else if (line === "]") {
buffer += line.slice(0, -1).trim();
return parseBuffer(message);
} else if (line.startsWith(",")) {
return parseBuffer(message);
} else {
buffer += line.trim();
}
return {};
}
function parseBuffer(message) {
let finished = false;
try {
if (buffer.length === 0) return {};
const dataJson = JSON.parse(buffer);
if (dataJson.uploadedFile) {
const targetMessage = root.attachmentTargetMessage || message;
if (!targetMessage) return {};
const attachments = [...(targetMessage.attachments ?? [])];
const attachmentIndex = attachments.findIndex(attachment => !attachment.fileUri || attachment.fileUri.length === 0);
if (attachmentIndex !== -1) {
attachments[attachmentIndex] = Object.assign({}, attachments[attachmentIndex], {
"fileUri": dataJson.uploadedFile.uri,
"fileMimeType": dataJson.uploadedFile.mimeType,
});
targetMessage.attachments = attachments;
if (attachmentIndex === 0) {
targetMessage.fileUri = dataJson.uploadedFile.uri;
targetMessage.fileMimeType = dataJson.uploadedFile.mimeType;
}
} else {
targetMessage.fileUri = dataJson.uploadedFile.uri;
targetMessage.fileMimeType = dataJson.uploadedFile.mimeType;
}
targetMessage.localFilePath = targetMessage.attachments?.[0]?.localFilePath ?? targetMessage.localFilePath;
return ({})
}
if (dataJson.error) {
const errorMsg = `**Error ${dataJson.error.code}**: ${dataJson.error.message}`;
message.rawContent += errorMsg;
message.content += errorMsg;
return { finished: true };
}
if (!dataJson.candidates) return {};
if (dataJson.candidates[0]?.finishReason) {
finished = true;
}
if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) {
const part = dataJson.candidates[0].content.parts[0];
const functionCall = part.functionCall;
message.functionName = functionCall.name;
message.functionCall = functionCall;
if (part.thoughtSignature) {
message.thoughtSignature = part.thoughtSignature;
}
const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`
message.rawContent += newContent;
message.content += newContent;
return { functionCall: functionCall, finished: finished };
}
const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text
message.rawContent += responseContent;
message.content += responseContent;
const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => {
return {
"type": "url_citation",
"text": chunk?.web?.title,
"url": chunk?.web?.uri,
}
}) ?? [];
const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => {
return {
"type": "url_citation",
"start_index": citation.segment?.startIndex,
"end_index": citation.segment?.endIndex,
"text": citation?.segment.text,
"url": annotationSources[citation.groundingChunkIndices[0]]?.url,
"sources": citation.groundingChunkIndices
}
});
message.annotationSources = annotationSources;
message.annotations = annotations;
message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? [];
if (dataJson.usageMetadata) {
return {
tokenUsage: {
input: dataJson.usageMetadata.promptTokenCount ?? -1,
output: dataJson.usageMetadata.candidatesTokenCount ?? -1,
total: dataJson.usageMetadata.totalTokenCount ?? -1
},
finished: finished
};
}
} catch (e) {
console.log("[AI] Gemini: Could not parse buffer: ", e);
message.rawContent += buffer;
message.content += buffer;
} finally {
buffer = "";
}
return { finished: finished };
}
function onRequestFinished(message) {
return parseBuffer(message);
}
function reset() {
buffer = "";
}
function buildScriptFileSetup(pendingFiles: var) {
if (!pendingFiles || pendingFiles.length === 0) return "";
let content = ""
content += `${fileListVarName}=""\n`;
content += 'mkdir -p "/tmp/quickshell/ai"\n';
pendingFiles.forEach((filePath, index) => {
const trimmedFilePath = CF.FileUtils.trimFileProtocol(filePath);
const imagePathVarName = `IMAGE_PATH_${index}`;
const fileMimeTypeVarName = `MIME_TYPE_${index}`;
const fileUriVarName = `FILE_URI_${index}`;
const numBytesVarName = `NUM_BYTES_${index}`;
const tmpHeaderVarName = `TMP_HEADER_FILE_${index}`;
const tmpFileInfoVarName = `TMP_FILE_INFO_${index}`;
const uploadUrlVarName = `UPLOAD_URL_${index}`;
const uploadErrorVarName = `UPLOAD_ERROR_${index}`;
content += `${imagePathVarName}='${CF.StringUtils.shellSingleQuoteEscape(trimmedFilePath)}'\n`;
content += `if [ ! -f "$${imagePathVarName}" ] || [ ! -s "$${imagePathVarName}" ]; then printf '{"error": {"code": 400, "message": "Attached file is missing or unreadable: %s"}}\n' "$${imagePathVarName}"; exit 1; fi\n`;
content += `${fileMimeTypeVarName}=$(file -b --mime-type "$${imagePathVarName}")\n`;
content += `${numBytesVarName}=$(wc -c < "$${imagePathVarName}")\n`;
content += `${tmpHeaderVarName}="/tmp/quickshell/ai/upload-header-${index}.tmp"\n`;
content += `${tmpFileInfoVarName}="/tmp/quickshell/ai/file-info-${index}.json.tmp"\n`;
content += 'curl "https://generativelanguage.googleapis.com/upload/v1beta/files"'
+ ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"`
+ ` -D "$${tmpHeaderVarName}"`
+ ' -H "X-Goog-Upload-Protocol: resumable"'
+ ' -H "X-Goog-Upload-Command: start"'
+ ` -H "X-Goog-Upload-Header-Content-Length: \$\{${numBytesVarName}\}"`
+ ` -H "X-Goog-Upload-Header-Content-Type: \$\{${fileMimeTypeVarName}\}"`
+ ' -H "Content-Type: application/json"'
+ ` -d '{"file": {"display_name": "Attachment ${index + 1}"}}' 2> /dev/null`
+ '\n';
content += `${uploadUrlVarName}=$(grep -i "x-goog-upload-url: " "$${tmpHeaderVarName}" | cut -d" " -f2 | tr -d "\r")\n`;
content += `rm "$${tmpHeaderVarName}"\n`;
content += `if [ -z "$${uploadUrlVarName}" ]; then printf '{"error": {"code": 400, "message": "Failed to start Gemini file upload for %s"}}\n' "$${imagePathVarName}"; exit 1; fi\n`;
content += 'curl "$'
+ `{${uploadUrlVarName}}"`
+ ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"`
+ ` -H "Content-Length: \$\{${numBytesVarName}\}"`
+ ' -H "X-Goog-Upload-Offset: 0"'
+ ' -H "X-Goog-Upload-Command: upload, finalize"'
+ ` --data-binary "@$${imagePathVarName}" 2> /dev/null > "$${tmpFileInfoVarName}"`
+ '\n';
content += `${fileUriVarName}=$(jq -r '.file.uri // empty' "$${tmpFileInfoVarName}")\n`;
content += `${uploadErrorVarName}=$(jq -r '.error.message // empty' "$${tmpFileInfoVarName}")\n`;
content += `if [ -z "$${fileUriVarName}" ]; then [ -n "$${uploadErrorVarName}" ] || ${uploadErrorVarName}='No file URI returned from Gemini file upload'; printf '{"error": {"code": 400, "message": "Gemini file upload failed for %s: %s"}}\n' "$${imagePathVarName}" "$${uploadErrorVarName}"; exit 1; fi\n`;
content += `${fileListVarName}+=$(jq -cn --arg uri "$${fileUriVarName}" --arg mimeType "$${fileMimeTypeVarName}" '{"file_data": {"mime_type": $mimeType, "file_uri": $uri}}')\n`;
content += `${fileListVarName}+=','\n`;
content += `printf '{"uploadedFile": {"uri": "%s", "mimeType": "%s"}}\n,\n' "$${fileUriVarName}" "$${fileMimeTypeVarName}"\n`;
});
return content
}
function finalizeScriptContent(scriptContent: string): string {
const uploadedPartsReference = "'\"${" + fileListVarName + "%,}\"'";
return scriptContent.replace(`"${fileListSubstitutionString}"`, uploadedPartsReference);
}
}

View file

@ -0,0 +1,144 @@
import QtQuick
ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: systemPrompt},
...messages.map(message => {
const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0
let messageData = {
"role": message.role,
"content": message.rawContent,
}
if (hasFunctionCall) {
if (message.functionResponse?.length > 0) {
messageData.name = message.functionName; // Does the func call also need this name? or just the func output?
messageData.role = "tool";
messageData.content = message.functionResponse;
messageData.tool_call_id = message.functionCall.id
}
}
return messageData
}),
],
"stream": true,
"temperature": temperature,
"tools": tools,
};
// console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2));
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
}
function parseResponseLine(line, message) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// Handle special cases
if (!cleanData || cleanData.startsWith(":")) return {};
if (cleanData === "[DONE]") {
return { finished: true };
}
// Real stuff
try {
const dataJson = JSON.parse(cleanData);
// Error response handling
if (dataJson.error) {
const errorMsg = `**Error**: ${dataJson.error.message || JSON.stringify(dataJson.error)}`;
message.rawContent += errorMsg;
message.content += errorMsg;
return { finished: true };
}
let newContent = "";
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
// Function call
if (dataJson.choices[0]?.delta?.tool_calls) {
const functionCall = dataJson.choices[0].delta.tool_calls[0];
const functionName = functionCall.function.name;
const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string???
const functionId = functionCall.id;
const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`;
message.rawContent += newContent;
message.content += newContent;
message.functionName = functionName;
message.functionCall = functionName;
return { functionCall: { name: functionName, args: functionArgs, id: functionId } };
}
// Thinking?
if (responseContent && responseContent.length > 0) {
if (isReasoning) {
isReasoning = false;
const endBlock = "\n\n</think>\n\n";
message.content += endBlock;
message.rawContent += endBlock;
}
newContent = responseContent;
} else if (responseReasoning && responseReasoning.length > 0) {
if (!isReasoning) {
isReasoning = true;
const startBlock = "\n\n<think>\n\n";
message.rawContent += startBlock;
message.content += startBlock;
}
newContent = responseReasoning;
}
// Text
message.content += newContent;
message.rawContent += newContent;
// Usage metadata
if (dataJson.usage) {
return {
tokenUsage: {
input: dataJson.usage.prompt_tokens ?? -1,
output: dataJson.usage.completion_tokens ?? -1,
total: dataJson.usage.total_tokens ?? -1
}
};
}
if (`dataJson`.done) {
return { finished: true };
}
} catch (e) {
console.log("[AI] Mistral: Could not parse line: ", e);
message.rawContent += line;
message.content += line;
}
return {};
}
function onRequestFinished(message) {
return {};
}
function reset() {
isReasoning = false;
}
}

View file

@ -0,0 +1,120 @@
import QtQuick
ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: systemPrompt},
...messages.map(message => {
return {
"role": message.role,
"content": message.rawContent,
}
}),
],
"stream": true,
"tools": tools,
"temperature": temperature,
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
}
function parseResponseLine(line, message) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// console.log("[AI] OpenAI: Data:", cleanData);
// Handle special cases
if (!cleanData || cleanData.startsWith(":")) return {};
if (cleanData === "[DONE]") {
return { finished: true };
}
// Real stuff
try {
const dataJson = JSON.parse(cleanData);
// Error response handling
if (dataJson.error) {
const errorMsg = `**Error**: ${dataJson.error.message || JSON.stringify(dataJson.error)}`;
message.rawContent += errorMsg;
message.content += errorMsg;
return { finished: true };
}
let newContent = "";
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
if (responseContent && responseContent.length > 0) {
if (isReasoning) {
isReasoning = false;
const endBlock = "\n\n</think>\n\n";
message.content += endBlock;
message.rawContent += endBlock;
}
newContent = responseContent;
} else if (responseReasoning && responseReasoning.length > 0) {
if (!isReasoning) {
isReasoning = true;
const startBlock = "\n\n<think>\n\n";
message.rawContent += startBlock;
message.content += startBlock;
}
newContent = responseReasoning;
}
message.content += newContent;
message.rawContent += newContent;
// Usage metadata
if (dataJson.usage) {
return {
tokenUsage: {
input: dataJson.usage.prompt_tokens ?? -1,
output: dataJson.usage.completion_tokens ?? -1,
total: dataJson.usage.total_tokens ?? -1
}
};
}
if (dataJson.done) {
return { finished: true };
}
} catch (e) {
console.log("[AI] OpenAI: Could not parse line: ", e);
message.rawContent += line;
message.content += line;
}
return {};
}
function onRequestFinished(message) {
// OpenAI format doesn't need special finish handling
return {};
}
function reset() {
isReasoning = false;
}
}

View file

@ -0,0 +1,123 @@
import QtQuick
ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
let baseData = {
"model": model.model,
"instructions": systemPrompt,
"input": [...messages.map(message => ({
role: message.role,
content: message.rawContent
}))],
"stream": true,
"tools": tools,
"temperature": temperature
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
}
function parseResponseLine(line, message) {
let cleanData = line.trim();
// event line
if (cleanData.startsWith("event:")) {
return {};
}
// Remove 'data: ' prefix if present and trim whitespace
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// console.log("[AI] OpenAI: Data:", cleanData);
// Handle special cases
if (!cleanData || cleanData.startsWith(":"))
return {};
// Real stuff
try {
const dataJson = JSON.parse(cleanData);
// Error response handling
if (dataJson.error) {
const errorMsg = `**Error**: ${dataJson.error.message || JSON.stringify(dataJson.error)}`;
message.rawContent += errorMsg;
message.content += errorMsg;
return {
finished: true
};
}
let newContent = "";
if (dataJson.type === "response.output_text.delta") {
if (isReasoning) {
isReasoning = false;
const endBlock = "\n\n</think>\n\n";
message.content += endBlock;
message.rawContent += endBlock;
}
newContent = dataJson.delta ?? "";
} else if (dataJson.type === "response.output_item.added") {
if (dataJson.item.type === "reasoning") {
if (!isReasoning) {
isReasoning = true;
const startBlock = "\n\n<think>\n\n";
message.content += startBlock;
message.rawContent += startBlock;
}
newContent = dataJson.item.summary ?? "";
}
}
message.content += newContent;
message.rawContent += newContent;
if (dataJson.type === "response.completed") {
let result = {
finished: true
};
if (dataJson.response && dataJson.response.usage) {
const usage = dataJson.response.usage;
result.tokenUsage = {
input: usage.input_tokens ?? -1,
output: usage.output_tokens ?? -1,
total: usage.total_tokens ?? -1
};
}
return result;
}
} catch (e) {
console.log("[AI] OpenAI: Could not parse line: ", e);
message.rawContent += line;
message.content += line;
}
return {};
}
function onRequestFinished(message) {
// OpenAI format doesn't need special finish handling
return {};
}
function reset() {
isReasoning = false;
}
}

View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HOME/.local/state/quickshell/.venv/bin/activate"
"$SCRIPT_DIR/token_from_key.py" "$@"
deactivate

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
import calendar
import sys
import json
import google.auth.transport.requests
import google.oauth2.service_account
def get_token(json_str):
try:
# Load the string into a dictionary
info = json.loads(json_str)
# Initialize credentials
creds = google.oauth2.service_account.Credentials.from_service_account_info(info)
scoped_creds = creds.with_scopes(['https://www.googleapis.com/auth/cloud-platform'])
# Refresh to get the access token
request = google.auth.transport.requests.Request()
scoped_creds.refresh(request)
token = scoped_creds.token
expiry = int(calendar.timegm(scoped_creds.expiry.utctimetuple()))
print(json.dumps({
"token": token,
"expiry": expiry
}))
except Exception as e:
sys.stderr.write(f"Error: {str(e)}\n")
sys.exit(1)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.stderr.write("Usage: python3 get_token.py '<json_string>'\n")
sys.exit(1)
get_token(sys.argv[1])

View file

@ -0,0 +1,47 @@
#version 300 es
precision highp float;
in vec2 v_texcoord;
uniform sampler2D tex;
out vec4 fragColor;
float overlayOpacityForBrightness(float x) {
// Note: range 0 to 1
// Will a fancy curve help?... I'll have to experiment more at night
// float y = pow(x, 2.0) * 0.75;
// float y = (1.0 - exp(-x))*1.19;
// float y = (1.0 - exp(-pow((x-0.15), 0.6)))*1.18;
float y = x*0.42;
return min(max(y, 0.001), 1.0);
}
void main() {
// 1. Get the current pixel color
vec4 pixColor = texture(tex, v_texcoord);
// 2. Calculate average screen brightness
vec3 totalRGB = vec3(0.0);
float samples = 0.0;
// We use a nested loop to create a 10x10 grid (100 samples)
// This is dense enough to catch small icons/text but light enough to run fast.
for(float x = 0.05; x < 1.0; x += 0.1) {
for(float y = 0.05; y < 1.0; y += 0.1) {
totalRGB += texture(tex, vec2(x, y)).rgb;
samples++;
}
}
vec3 avgColor = totalRGB / samples;
float globalBrightness = dot(avgColor, vec3(0.2126, 0.7152, 0.0722));
// 3. Get the specific opacity for this brightness level
float opacity = overlayOpacityForBrightness(globalBrightness);
// 4. Apply the "black overlay" effect
vec3 outColor = mix(pixColor.rgb, vec3(0.0), opacity);
fragColor = vec4(outColor, pixColor.a);
}

View file

@ -0,0 +1,47 @@
#version 300 es
precision highp float;
in vec2 v_texcoord;
uniform sampler2D tex;
out vec4 fragColor;
float overlayOpacityForBrightness(float x) {
// Note: range 0 to 1
// Will a fancy curve help?... I'll have to experiment more at night
// float y = pow(x, 2.0) * 0.75;
// float y = (1.0 - exp(-x))*1.15;
// float y = (1.0 - exp(-pow((x-0.15), 0.6)))*1.18;
float y = x*0.75;
return min(max(y, 0.001), 1.0);
}
void main() {
// 1. Get the current pixel color
vec4 pixColor = texture(tex, v_texcoord);
// 2. Calculate average screen brightness
vec3 totalRGB = vec3(0.0);
float samples = 0.0;
// We use a nested loop to create a 10x10 grid (100 samples)
// This is dense enough to catch small icons/text but light enough to run fast.
for(float x = 0.05; x < 1.0; x += 0.1) {
for(float y = 0.05; y < 1.0; y += 0.1) {
totalRGB += texture(tex, vec2(x, y)).rgb;
samples++;
}
}
vec3 avgColor = totalRGB / samples;
float globalBrightness = dot(avgColor, vec3(0.2126, 0.7152, 0.0722));
// 3. Get the specific opacity for this brightness level
float opacity = overlayOpacityForBrightness(globalBrightness);
// 4. Apply the "black overlay" effect
vec3 outColor = mix(pixColor.rgb, vec3(0.0), opacity);
fragColor = vec4(outColor, pixColor.a);
}

View file

@ -0,0 +1,14 @@
import QtQuick
QtObject {
required property var lastIpcObject
readonly property string ssid: lastIpcObject.ssid
readonly property string bssid: lastIpcObject.bssid
readonly property int strength: lastIpcObject.strength
readonly property int frequency: lastIpcObject.frequency
readonly property bool active: lastIpcObject.active
readonly property string security: lastIpcObject.security
readonly property bool isSecure: security.length > 0
property bool askingPassword: false
}