Merge remote-tracking branch 'origin/main' into addon-i18n

This commit is contained in:
月月
2025-07-12 21:03:24 +08:00
254 changed files with 2744 additions and 955 deletions
+881
View File
@@ -0,0 +1,881 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/object_utils.js" as ObjectUtils
import "root:/modules/common"
import "root:/services/"
import Quickshell;
import Quickshell.Io;
import Qt.labs.platform
import QtQuick;
/**
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
*/
Singleton {
id: root
readonly property string interfaceRole: "interface"
readonly property string apiKeyEnvVarName: "API_KEY"
property Component aiMessageComponent: AiMessageData {}
property string systemPrompt: Config.options?.ai?.systemPrompt ?? ""
// property var messages: []
property var messageIDs: []
property var messageByID: ({})
readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {}
readonly property var apiKeysLoaded: KeyringStorage.loaded
property var postResponseHook
property real temperature: Persistent.states?.ai?.temperature ?? 0.5
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, "_")
}
property list<var> defaultPrompts: []
property list<var> userPrompts: []
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
property list<var> savedChats: []
// 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".
// - tools: List of tools that the model can use. Each tool is an object with the tool name as the key and an empty object as the value.
// - extraParams: Extra parameters to be passed to the model. This is a JSON object.
property var models: {
"gemini-2.0-flash-search": {
"name": "Gemini 2.0 Flash (Search)",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent",
"model": "gemini-2.0-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",
"tools": [
{
"google_search": {}
},
]
},
"gemini-2.0-flash-tools": {
"name": "Gemini 2.0 Flash (Tools)",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but doesn't search quickly"),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent",
"model": "gemini-2.0-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",
"tools": [
{
"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"]
}
},
]
}
]
},
"gemini-2.5-flash-search": {
"name": "Gemini 2.5 Flash (Search)",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:streamGenerateContent",
"model": "gemini-2.5-flash-preview-05-20",
"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",
"tools": [
{
"google_search": ({})
},
]
},
"gemini-2.5-flash-tools": {
"name": "Gemini 2.5 Flash (Tools)",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but doesn't search quickly"),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:streamGenerateContent",
"model": "gemini-2.5-flash-preview-05-20",
"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",
"tools": [
{
"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"]
}
},
]
}
]
},
"openrouter-llama4-maverick": {
"name": "Llama 4 Maverick",
"icon": "ollama-symbolic",
"description": StringUtils.format(Translation.tr("Online via {0} | {1}'s model"), "OpenRouter", "Meta"),
"homepage": "https://openrouter.ai/meta-llama/llama-4-maverick:free",
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
"model": "meta-llama/llama-4-maverick:free",
"requires_key": true,
"key_id": "openrouter",
"key_get_link": "https://openrouter.ai/settings/keys",
"key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"),
},
"openrouter-deepseek-r1": {
"name": "DeepSeek R1",
"icon": "deepseek-symbolic",
"description": StringUtils.format(Translation.tr("Online via {0} | {1}'s model"), "OpenRouter", "DeepSeek"),
"homepage": "https://openrouter.ai/deepseek/deepseek-r1:free",
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
"model": "deepseek/deepseek-r1:free",
"requires_key": true,
"key_id": "openrouter",
"key_get_link": "https://openrouter.ai/settings/keys",
"key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"),
},
}
property var modelList: Object.keys(root.models)
property var currentModelId: Persistent.states?.ai?.model || modelList[0]
Component.onCompleted: {
setModel(currentModelId, false, false); // Do necessary setup for model
}
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;
}
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.models[safeModelName] = {
"name": guessModelName(model),
"icon": guessModelLogo(model),
"description": StringUtils.format(Translation.tr("Local Ollama model | {0}"), model),
"homepage": `https://ollama.com/library/${model}`,
"endpoint": "http://localhost:11434/v1/chat/completions",
"model": model,
}
});
root.modelList = Object.keys(root.models);
} 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(StringUtils.format("Loaded the following system prompt\n\n---\n\n{0}", Config.options.ai.systemPrompt), root.interfaceRole);
}
}
function printPrompt() {
root.addMessage(StringUtils.format("The current system prompt is\n\n---\n\n{0}", 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(
StringUtils.format(Translation.tr('To set an API key, pass it with the command\n\nTo view the key, pass "get" with the command<br/>\n\n### For {0}:\n\n**Link**: {1}\n\n{2}'),
model.name, model.key_get_link, model.key_get_description ?? Translation.tr("<i>No further instruction provided</i>")),
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]
// Fetch API keys if needed
if (model?.requires_key) KeyringStorage.fetchKeyringData();
// See if policy prevents online models
if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) {
root.addMessage(StringUtils.format(StringUtils.format("Online models disallowed\n\nControlled by `policies.ai` config option"), model.name), root.interfaceRole);
return;
}
if (setPersistentState) Persistent.states.ai.model = modelId;
if (feedback) root.addMessage(StringUtils.format("Model set to {0}", model.name), root.interfaceRole);
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 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(StringUtils.format(Translation.tr("Temperature set to {0}"), value), Ai.interfaceRole);
}
function setApiKey(key) {
const model = models[currentModelId];
if (!model.requires_key) {
root.addMessage(StringUtils.format(Translation.tr("{0} does not require an API key"), 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(StringUtils.format(Translation.tr("API key set for {0}"), 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(StringUtils.format(Translation.tr("API key:\n\n```txt\n{0}\n```"), key), Ai.interfaceRole);
} else {
root.addMessage(StringUtils.format(Translation.tr("No API key set for {0}"), model.name), Ai.interfaceRole);
}
} else {
root.addMessage(StringUtils.format(Translation.tr("{0} does not require an API key"), model.name), Ai.interfaceRole);
}
}
function printTemperature() {
root.addMessage(StringUtils.format(Translation.tr("Temperature: {0}"), root.temperature), Ai.interfaceRole);
}
function clearMessages() {
root.messageIDs = [];
root.messageByID = ({});
}
Process {
id: requester
property var baseCommand: ["bash", "-c"]
property var message
property bool isReasoning
property string apiFormat: "openai"
property string geminiBuffer: ""
function buildGeminiEndpoint(model) {
// console.log("ENDPOINT: " + model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`)
return model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`;
}
function buildOpenAIEndpoint(model) {
return model.endpoint;
}
function markDone() {
requester.message.done = true;
if (root.postResponseHook) {
root.postResponseHook();
root.postResponseHook = null; // Reset hook after use
}
root.saveChat("lastSession")
}
function buildGeminiRequestData(model, messages) {
let baseData = {
"contents": messages.filter(message => (message.role != Ai.interfaceRole)).map(message => {
const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role;
const usingSearch = model.tools[0].google_search != undefined
if (!usingSearch && message.functionCall != undefined && message.functionCall.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionCall: {
"name": message.functionName,
}
}]
}
}
if (!usingSearch && message.functionResponse != undefined && message.functionResponse.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionResponse: {
"name": message.functionName,
"response": { "content": message.functionResponse }
}
}]
}
}
return {
"role": geminiApiRoleName,
"parts": [{
text: message.rawContent,
}]
}
}),
"tools": [
...model.tools,
],
"system_instruction": {
"parts": [{ text: root.systemPrompt }]
},
"generationConfig": {
// "temperature": root.temperature,
},
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildOpenAIRequestData(model, messages) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: root.systemPrompt},
...messages.filter(message => (message.role != Ai.interfaceRole)).map(message => {
return {
"role": message.role,
"content": message.rawContent,
}
}),
],
"stream": true,
// "temperature": root.temperature,
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function makeRequest() {
const model = models[currentModelId];
requester.apiFormat = model.api_format ?? "openai";
/* 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 = (apiFormat === "gemini") ? buildGeminiEndpoint(model) : buildOpenAIEndpoint(model);
const messageArray = root.messageIDs.map(id => root.messageByID[id]);
const data = (apiFormat === "gemini") ? buildGeminiRequestData(model, messageArray) : buildOpenAIRequestData(model, messageArray);
// console.log("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);
/* Create command string */
const requestCommandString = `curl --no-buffer "${endpoint}"`
+ ` ${headerString}`
+ ((apiFormat == "gemini") ? "" : ` -H "Authorization: Bearer \$\{${root.apiKeyEnvVarName}\}"`)
+ ` -d '${StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
// console.log("Request command: ", requestCommandString);
requester.command = baseCommand.concat([requestCommandString]);
/* Reset vars and make the request */
requester.isReasoning = false
requester.running = true
}
function parseGeminiBuffer() {
// console.log("BUFFER DATA: ", requester.geminiBuffer);
try {
if (requester.geminiBuffer.length === 0) return;
const dataJson = JSON.parse(requester.geminiBuffer);
if (!dataJson.candidates) return;
if (dataJson.candidates[0]?.finishReason) {
requester.markDone();
}
// Function call handling
if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) {
const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall;
requester.message.functionName = functionCall.name;
requester.message.functionCall = functionCall.name;
const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`
requester.message.rawContent += newContent;
requester.message.content += newContent;
root.handleGeminiFunctionCall(functionCall.name, functionCall.args);
return
}
// Normal text response
const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text
requester.message.rawContent += responseContent;
requester.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
}
});
requester.message.annotationSources = annotationSources;
requester.message.annotations = annotations;
// console.log(JSON.stringify(requester.message, null, 2));
} catch (e) {
console.log("[AI] Could not parse response from stream: ", e);
requester.message.rawContent += requester.geminiBuffer;
requester.message.content += requester.geminiBuffer
} finally {
requester.geminiBuffer = "";
}
}
function handleGeminiResponseLine(line) {
if (line.startsWith("[")) {
requester.geminiBuffer += line.slice(1).trim();
} else if (line == "]") {
requester.geminiBuffer += line.slice(0, -1).trim();
parseGeminiBuffer();
} else if (line.startsWith(",")) { // end of one entry
parseGeminiBuffer();
} else {
requester.geminiBuffer += line.trim();
}
}
function handleOpenAIResponseLine(line) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// console.log("Clean data: ", cleanData);
if (!cleanData || cleanData.startsWith(":")) return;
if (cleanData === "[DONE]") {
requester.markDone();
return;
}
const dataJson = JSON.parse(cleanData);
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 (requester.isReasoning) {
requester.isReasoning = false;
const endBlock = "\n\n</think>\n\n";
requester.message.content += endBlock;
requester.message.rawContent += endBlock;
}
newContent = dataJson.choices[0]?.delta?.content || dataJson.message.content;
} else if (responseReasoning && responseReasoning.length > 0) {
// console.log("Reasoning content: ", dataJson.choices[0].delta.reasoning);
if (!requester.isReasoning) {
requester.isReasoning = true;
const startBlock = "\n\n<think>\n\n";
requester.message.rawContent += startBlock;
requester.message.content += startBlock;
}
newContent = dataJson.choices[0].delta.reasoning || dataJson.choices[0].delta.reasoning_content;
}
requester.message.content += newContent;
if (dataJson.done) {
requester.markDone();
}
}
stdout: SplitParser {
onRead: data => {
// console.log("RAW DATA: ", data);
if (data.length === 0) return;
// Handle response line
if (requester.message.thinking) requester.message.thinking = false;
try {
if (requester.apiFormat === "gemini") {
requester.handleGeminiResponseLine(data);
}
else if (requester.apiFormat === "openai") {
requester.handleOpenAIResponseLine(data);
}
else {
console.log("Unknown API format: ", requester.apiFormat);
requester.message.rawContent += data;
requester.message.content += data;
}
} catch (e) {
console.log("[AI] Could not parse response from stream: ", e);
requester.message.rawContent += data;
requester.message.content += data;
}
}
}
onExited: (exitCode, exitStatus) => {
if (requester.apiFormat == "gemini") requester.parseGeminiBuffer();
else requester.markDone();
try { // to parse full response into json for error handling
// console.log("Full response: ", requester.message.content + "]");
const parsedResponse = JSON.parse(requester.message.rawContent + "]");
requester.message.rawContent = `\`\`\`json\n${JSON.stringify(parsedResponse, null, 2)}\n\`\`\``;
requester.message.content = requester.message.rawContent;
} catch (e) {
// console.log("[AI] Could not parse response on exit: ", e);
}
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 addFunctionOutputMessage(name, output) {
const aiMessage = aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]`,
"rawContent": `[[ Output of ${name} ]]`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"done": true,
"visibleToUser": false,
});
// console.log("Adding function output message: ", JSON.stringify(aiMessage));
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function buildGeminiFunctionOutput(name, output) {
const functionResponsePart = {
"name": name,
"response": { "content": output }
}
return {
"role": "user",
"parts": [{
functionResponse: functionResponsePart,
}]
}
}
function handleGeminiFunctionCall(name, args) {
if (name === "switch_to_search_mode") {
if (root.currentModelId === "gemini-2.5-flash-tools") {
root.setModel("gemini-2.5-flash-search", false);
root.postResponseHook = () => root.setModel("gemini-2.5-flash-tools", false);
} else if (root.currentModelId === "gemini-2.0-flash-tools") {
root.setModel("gemini-2.0-flash-search", false);
root.postResponseHook = () => root.setModel("gemini-2.0-flash-tools", false);
}
addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request."))
requester.makeRequest();
} else if (name === "get_shell_config") {
const configJson = 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 root.addMessage(Translation.tr("Unknown function call: {0}"), "assistant");
}
function chatToJson() {
return root.messageIDs.map(id => {
const message = root.messageByID[id]
return ({
"role": message.role,
"rawContent": message.rawContent,
"model": message.model,
"thinking": false,
"done": true,
"annotations": message.annotations,
"annotationSources": message.annotationSources,
"functionName": message.functionName,
"functionCall": message.functionCall,
"functionResponse": message.functionResponse,
"visibleToUser": message.visibleToUser,
})
})
}
FileView {
id: chatSaveFile
property string chatName: "chat"
path: `${Directories.aiChats}/${chatName}.json`
blockLoading: true
}
/**
* Saves chat to a JSON list of message objects.
* @param chatName name of the chat
*/
function saveChat(chatName) {
chatSaveFile.chatName = chatName.trim()
const saveContent = JSON.stringify(root.chatToJson())
chatSaveFile.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,
"model": message.model,
"thinking": message.thinking,
"done": message.done,
"annotations": message.annotations,
"annotationSources": message.annotationSources,
"functionName": message.functionName,
"functionCall": message.functionCall,
"functionResponse": message.functionResponse,
"visibleToUser": message.visibleToUser,
});
}
} catch (e) {
console.log("[AI] Could not load chat: ", e);
} finally {
getSavedChats.running = true;
}
}
}
@@ -0,0 +1,20 @@
import "root:/modules/common"
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
property string model
property bool thinking: true
property bool done: false
property var annotations: []
property var annotationSources: []
property string functionName
property string functionCall
property string functionResponse
property bool visibleToUser: true
}
@@ -0,0 +1,118 @@
pragma Singleton
import "root:/modules/common"
import "root:/modules/common/functions/fuzzysort.js" as Fuzzy
import "root:/modules/common/functions/levendist.js" as Levendist
import Quickshell
import Quickshell.Io
/**
* - 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",
"zen": "zen-browser",
"brave-browser": "brave-desktop"
})
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"
}
]
readonly property list<DesktopEntry> list: Array.from(DesktopEntries.applications.values)
.sort((a, b) => a.name.localeCompare(b.name))
readonly property var preppedNames: list.map(a => ({
name: Fuzzy.prepare(`${a.name} `),
entry: a
}))
function fuzzyQuery(search: string): var { // Idk why list<DesktopEntry> doesn't work
if (root.sloppySearch) {
const results = list.map(obj => ({
entry: obj,
score: Levendist.computeScore(obj.name.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, preppedNames, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
}
function iconExists(iconName) {
if (!iconName || iconName.length == 0) return false;
return (Quickshell.iconPath(iconName, true).length > 0)
&& !iconName.includes("image-missing");
}
function guessIcon(str) {
if (!str || str.length == 0) return "image-missing";
// Normal substitutions
if (substitutions[str])
return substitutions[str];
// 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;
}
// If it gets detected normally, no need to guess
if (iconExists(str)) return str;
let guessStr = str;
// Guess: Take only app name of reverse domain name notation
guessStr = str.split('.').slice(-1)[0].toLowerCase();
if (iconExists(guessStr)) return guessStr;
// Guess: normalize to kebab case
guessStr = str.toLowerCase().replace(/\s+/g, "-");
if (iconExists(guessStr)) return guessStr;
// Guess: First fuzzy desktop entry match
const searchResults = root.fuzzyQuery(str);
if (searchResults.length > 0) {
const firstEntry = searchResults[0];
guessStr = firstEntry.icon
if (iconExists(guessStr)) return guessStr;
}
// Give up
return str;
}
}
+54
View File
@@ -0,0 +1,54 @@
import "root:/modules/common"
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
pragma Singleton
pragma ComponentBehavior: Bound
/**
* A nice wrapper for default Pipewire audio sink and source.
*/
Singleton {
id: root
property bool ready: Pipewire.defaultAudioSink?.ready ?? false
property PwNode sink: Pipewire.defaultAudioSink
property PwNode source: Pipewire.defaultAudioSource
signal sinkProtectionTriggered(string reason);
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;
if (!lastReady) {
lastVolume = sink.audio.volume;
lastReady = true;
return;
}
const newVolume = sink.audio.volume;
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("Illegal increment");
} else if (newVolume > maxAllowed) {
root.sinkProtectionTriggered("Exceeded max allowed");
sink.audio.volume = Math.min(lastVolume, maxAllowed);
}
if (sink.ready && (isNaN(sink.audio.volume) || sink.audio.volume === undefined || sink.audio.volume === null)) {
sink.audio.volume = 0;
}
lastVolume = sink.audio.volume;
}
}
}
@@ -0,0 +1,40 @@
pragma Singleton
import "root:/modules/common"
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Services.UPower
Singleton {
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
readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend
property bool isLow: percentage <= Config.options.battery.low / 100
property bool isCritical: percentage <= Config.options.battery.critical / 100
property bool isSuspending: percentage <= Config.options.battery.suspend / 100
property bool isLowAndNotCharging: isLow && !isCharging
property bool isCriticalAndNotCharging: isCritical && !isCharging
property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging
onIsLowAndNotChargingChanged: {
if (available && isLowAndNotCharging)
Quickshell.execDetached(["bash", "-c", `notify-send "Low battery" "Consider plugging in your device" -u critical -a "Shell"`]);
}
onIsCriticalAndNotChargingChanged: {
if (available && isCriticalAndNotCharging)
Quickshell.execDetached(["bash", "-c", `notify-send "Critically low battery" "🙏 I beg for pleas charg\nAutomatic suspend triggers at ${Config.options.battery.suspend}%" -u critical -a "Shell"`]);
}
onIsSuspendingAndNotChargingChanged: {
if (available && isSuspendingAndNotCharging) {
Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]);
}
}
}
@@ -0,0 +1,73 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell;
import Quickshell.Io;
import QtQuick;
/**
* Basic polled Bluetooth state.
*/
Singleton {
id: root
property int updateInterval: 1000
property string bluetoothDeviceName: ""
property string bluetoothDeviceAddress: ""
property bool bluetoothEnabled: false
property bool bluetoothConnected: false
function update() {
updateBluetoothDevice.running = true
updateBluetoothStatus.running = true
updateBluetoothEnabled.running = true
}
Timer {
interval: 10
running: true
repeat: true
onTriggered: {
update()
interval = root.updateInterval
}
}
// Check if Bluetooth is enabled (controller powered on)
Process {
id: updateBluetoothEnabled
command: ["sh", "-c", "bluetoothctl show | grep -q 'Powered: yes' && echo 1 || echo 0"]
running: true
stdout: SplitParser {
onRead: data => {
root.bluetoothEnabled = (parseInt(data) === 1)
}
}
}
// Get the name and address of the first connected Bluetooth device
Process {
id: updateBluetoothDevice
command: ["sh", "-c", "bluetoothctl info | awk -F': ' '/Name: /{name=$2} /Device /{addr=$2} END{print name \":\" addr}'"]
running: true
stdout: SplitParser {
onRead: data => {
let parts = data.split(":")
root.bluetoothDeviceName = parts[0] || ""
root.bluetoothDeviceAddress = parts[1] || ""
}
}
}
// Check if any device is connected
Process {
id: updateBluetoothStatus
command: ["sh", "-c", "bluetoothctl info | grep -q 'Connected: yes' && echo 1 || echo 0"]
running: true
stdout: SplitParser {
onRead: data => {
root.bluetoothConnected = (parseInt(data) === 1)
}
}
}
}
+469
View File
@@ -0,0 +1,469 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common"
import "root:/services/"
import Quickshell;
import Quickshell.Io;
import Qt.labs.platform
import QtQuick;
/**
* A service for interacting with various booru APIs.
*/
Singleton {
id: root
property Component booruResponseDataComponent: BooruResponseData {}
signal tagSuggestion(string query, var suggestions)
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&name={{query}}*",
"tagMapFunc": (response) => {
return response.map(item => {
return {
"name": item.name,
"count": item.count
}
})
}
},
"konachan": {
"name": "Konachan",
"url": "https://konachan.com",
"api": "https://konachan.com/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.com/tag.json?order=count&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?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&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/search",
"description": Translation.tr("Waifus only | Excellent quality, limited quantity"),
"mapFunc": (response) => {
response = response.images
return response.map(item => {
return {
"id": item.image_id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags.map(tag => {return tag.name}).join(" "),
"rating": item.is_nsfw ? "e" : "s",
"is_nsfw": item.is_nsfw,
"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",
"tagMapFunc": (response) => {
return [...response.versatile.map(item => {return {"name": item}}),
...response.nsfw.map(item => {return {"name": item}})]
}
},
"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, // Default aspect ratio
"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("included_tags=" + encodeURIComponent(tag));
});
params.push("limit=" + Math.min(limit, 30)) // Only admin can do > 30
params.push("is_nsfw=" + (nsfw ? "null" : "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)
}
}
try {
// Required for danbooru
if (currentProvider == "danbooru") {
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
if (currentProvider == "danbooru") {
xhr.setRequestHeader("User-Agent", defaultUserAgent)
}
xhr.send()
} catch (error) {
console.log("Could not set User-Agent:", error)
}
}
}
@@ -0,0 +1,13 @@
import "root:/modules/common"
import QtQuick;
/**
* A booru response.
*/
QtObject {
property string provider
property var tags
property var page
property var images
property string message
}
@@ -0,0 +1,153 @@
pragma Singleton
pragma ComponentBehavior: Bound
// From https://github.com/caelestia-dots/shell/ (`quickshell` branch) with modifications.
// License: GPLv3
import "root:/services/"
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 {
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.setBrightness(monitor.brightness - 0.05);
}
reloadableId: "brightness"
onMonitorsChanged: {
ddcMonitors = [];
ddcProc.running = true;
}
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({
model: lines.find(l => l.startsWith("Monitor:")).split(":")[2],
busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1]
});
}
}
}
onExited: root.ddcMonitorsChanged()
}
Process {
id: setProc
}
component BrightnessMonitor: QtObject {
id: monitor
required property ShellScreen screen
readonly property bool isDdc: root.ddcMonitors.some(m => m.model === screen.model)
readonly property string busNum: root.ddcMonitors.find(m => m.model === screen.model)?.busNum ?? ""
property real brightness
property bool ready: false
onBrightnessChanged: {
if (monitor.ready) {
root.brightnessChanged();
}
}
function initialize() {
monitor.ready = false;
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.brightness = parseInt(current) / parseInt(max);
monitor.ready = true;
}
}
}
function setBrightness(value: real): void {
value = Math.max(0.01, Math.min(1, value));
const rounded = Math.round(value * 100);
if (Math.round(brightness * 100) === rounded)
return;
brightness = value;
setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`, "--quiet"];
setProc.startDetached();
}
Component.onCompleted: {
initialize();
}
onBusNumChanged: {
initialize();
}
}
Component {
id: monitorComp
BrightnessMonitor {}
}
IpcHandler {
target: "brightness"
function increment() {
onPressed: root.increaseBrightness()
}
function decrement() {
onPressed: root.decreaseBrightness()
}
}
GlobalShortcut {
name: "brightnessIncrease"
description: Translation.tr("Increase brightness")
onPressed: root.increaseBrightness()
}
GlobalShortcut {
name: "brightnessDecrease"
description: Translation.tr("Decrease brightness")
onPressed: root.decreaseBrightness()
}
}
@@ -0,0 +1,81 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common/functions/fuzzysort.js" as Fuzzy
import "root:/modules/common/functions/levendist.js" as Levendist
import "root:/modules/common"
import "root:/"
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
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 (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 refresh() {
readProc.buffer = []
readProc.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: ["cliphist", "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)
}
}
}
}
@@ -0,0 +1,52 @@
import "root:/modules/common"
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
/**
* A nice wrapper for date and time strings.
*/
Singleton {
property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm")
property string date: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM")
property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dd MMMM yyyy")
property string uptime: "0h, 0m"
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Timer {
interval: 10
running: true
repeat: 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"
}
}
+65
View File
@@ -0,0 +1,65 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common/functions/fuzzysort.js" as Fuzzy
import "root:/modules/common/functions/levendist.js" as Levendist
import "root:/modules/common"
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)
}
}
}
@@ -0,0 +1,44 @@
pragma Singleton
import "root:/modules/common/functions/file_utils.js" as FileUtils
import "root:/modules/common"
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
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.configPath("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(["bash", "-c", `swww query | grep 'image' || '${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()
}
}
}
}
@@ -0,0 +1,134 @@
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 monitors: []
property var layers: ({})
function updateWindowList() {
getClients.running = true;
}
function updateLayers() {
getLayers.running = true;
}
function updateMonitors() {
getMonitors.running = true;
}
function updateWorkspaces() {
getWorkspaces.running = true;
getActiveWorkspace.running = true;
}
function updateAll() {
updateWindowList();
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);
updateAll()
}
}
Process {
id: getClients
command: ["bash", "-c", "hyprctl clients -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.windowList = JSON.parse(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: getMonitors
command: ["bash", "-c", "hyprctl monitors -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.monitors = JSON.parse(data);
}
}
}
Process {
id: getLayers
command: ["bash", "-c", "hyprctl layers -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.layers = JSON.parse(data);
}
}
}
Process {
id: getWorkspaces
command: ["bash", "-c", "hyprctl workspaces -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.workspaces = JSON.parse(data);
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: ["bash", "-c", "hyprctl activeworkspace -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.activeWorkspace = JSON.parse(data);
}
}
}
}
@@ -0,0 +1,73 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common"
import "root:/modules/common/functions/file_utils.js" as FileUtils
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
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 string keybindParserPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/hyprland/get_keybinds.py`)
property string defaultKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/keybinds.conf`)
property string userKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/custom/keybinds.conf`)
property var defaultKeybinds: {"children": []}
property var userKeybinds: {"children": []}
property var keybinds: ({
children: [
...(defaultKeybinds.children ?? []),
...(userKeybinds.children ?? []),
]
})
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name == "configreloaded") {
getDefaultKeybinds.running = true
getUserKeybinds.running = true
}
}
}
Process {
id: getDefaultKeybinds
running: true
command: [root.keybindParserPath, "--path", root.defaultKeybindConfigPath,]
stdout: SplitParser {
onRead: data => {
try {
root.defaultKeybinds = JSON.parse(data)
} catch (e) {
console.error("[CheatsheetKeybinds] Error parsing keybinds:", e)
}
}
}
}
Process {
id: getUserKeybinds
running: true
command: [root.keybindParserPath, "--path", root.userKeybindConfigPath]
stdout: SplitParser {
onRead: data => {
try {
root.userKeybinds = JSON.parse(data)
} catch (e) {
console.error("[CheatsheetKeybinds] Error parsing keybinds:", e)
}
}
}
}
}
@@ -0,0 +1,119 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import Quickshell;
import Quickshell.Io;
import Qt.labs.platform
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
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: StringUtils.format(Translation.tr("{0} Safe Storage"), "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));
stdinEnabled = false // End input stream
}
}
}
Process {
id: getData
command: [ // We need to use echo for a newline so splitparser does parse
"bash", "-c", `echo $(secret-tool lookup 'application' 'illogical-impulse')`,
]
stdout: SplitParser {
onRead: data => {
if(data.length === 0) 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 !== 0) {
console.error("[KeyringStorage] Failed to get keyring data, reinitializing.");
root.keyringData = {};
saveKeyringData()
}
root.loaded = true;
}
}
}
@@ -0,0 +1,87 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/file_utils.js" as FileUtils
import "root:/modules/common"
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import Qt.labs.platform
/**
* 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]
}
}
@@ -0,0 +1,58 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common"
import QtQuick
import Quickshell
import Quickshell.Io
/**
* 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)
}
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)
}
}
}
@@ -0,0 +1,165 @@
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 "root:/services/"
import QtQml.Models
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
/**
* A service that provides easy access to the active Mpris player.
*/
Singleton {
id: root;
property MprisPlayer trackedPlayer: null;
property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null;
signal trackChanged(reverse: bool);
property bool __reverse: false;
property var activeTrack;
Instantiator {
model: Mpris.players;
Connections {
required property MprisPlayer modelData;
target: modelData;
Component.onCompleted: {
if (root.trackedPlayer == null || modelData.isPlaying) {
root.trackedPlayer = modelData;
}
}
Component.onDestruction: {
if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) {
for (const player of Mpris.players.values) {
if (player.playbackState.isPlaying) {
root.trackedPlayer = player;
break;
}
}
if (trackedPlayer == null && Mpris.players.values.length != 0) {
trackedPlayer = Mpris.players.values[0];
}
}
}
function onPlaybackStateChanged() {
if (root.trackedPlayer !== modelData) root.trackedPlayer = 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(); }
}
}
@@ -0,0 +1,93 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
/**
* Simple polled network state service.
*/
Singleton {
id: root
property bool wifi: true
property bool ethernet: false
property int updateInterval: 1000
property string networkName: ""
property int networkStrength
property string materialSymbol: ethernet ? "lan" :
(Network.networkName.length > 0 && Network.networkName != "lo") ? (
Network.networkStrength > 80 ? "signal_wifi_4_bar" :
Network.networkStrength > 60 ? "network_wifi_3_bar" :
Network.networkStrength > 40 ? "network_wifi_2_bar" :
Network.networkStrength > 20 ? "network_wifi_1_bar" :
"signal_wifi_0_bar"
) : "signal_wifi_off"
function update() {
updateConnectionType.startCheck();
updateNetworkName.running = true;
updateNetworkStrength.running = true;
}
Timer {
interval: 10
running: true
repeat: true
onTriggered: {
root.update();
interval = root.updateInterval;
}
}
Process {
id: updateConnectionType
property string buffer
command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE c show --active"]
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');
let hasEthernet = false;
let hasWifi = false;
lines.forEach(line => {
if (line.includes("ethernet"))
hasEthernet = true;
else if (line.includes("wireless"))
hasWifi = true;
});
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);
}
}
}
}
@@ -0,0 +1,276 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common"
import "root:/"
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 {
required property int id
property Notification notification
property list<var> actions: notification?.actions.map((action) => ({
"identifier": action.identifier,
"text": action.text,
})) ?? []
property bool popup: 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
}
function notifToJSON(notif) {
return {
"id": notif.id,
"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 id
interval: 5000
running: true
onTriggered: () => {
root.timeoutNotification(id);
destroy()
}
}
property bool silent: false
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 var appNameList: appNameListForGroups(root.groupsByAppName)
property var 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: var);
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, {
"id": notification.id + root.idOffset,
"notification": notification,
"time": Date.now(),
});
root.list = [...root.list, newNotifObject];
// Popup
if (!root.popupInhibited) {
newNotifObject.popup = true;
newNotifObject.timer = notifTimerComponent.createObject(root, {
"id": newNotifObject.id,
"interval": notification.expireTimeout < 0 ? 5000 : notification.expireTimeout,
});
}
root.notify(newNotifObject);
// console.log(notifToString(newNotifObject));
notifFileView.setText(stringifyList(root.list));
}
}
function discardNotification(id) {
const index = root.list.findIndex((notif) => notif.id === 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);
}
function discardAllNotifications() {
root.list = []
triggerListChange()
notifFileView.setText(stringifyList(root.list));
notifServer.trackedNotifications.values.forEach((notif) => {
notif.dismiss()
})
root.discardAll();
}
function timeoutNotification(id) {
const index = root.list.findIndex((notif) => notif.id === id);
if (root.list[index] != null)
root.list[index].popup = false;
root.timeout(id);
}
function timeoutAll() {
root.popupList.forEach((notif) => {
root.timeout(notif.id);
})
root.popupList.forEach((notif) => {
notif.popup = false;
});
}
function attemptInvokeAction(id, notifIdentifier) {
const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id);
if (notifServerIndex !== -1) {
const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex];
const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier);
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, {
"id": notif.id,
"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 id
let maxId = 0
root.list.forEach((notif) => {
maxId = Math.max(maxId, notif.id)
})
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)
}
}
}
}
@@ -0,0 +1,62 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common"
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Simple polled resource usage service with RAM, Swap, and CPU usage.
*/
Singleton {
property double memoryTotal: 1
property double memoryFree: 1
property double memoryUsed: memoryTotal - memoryFree
property double memoryUsedPercentage: memoryUsed / memoryTotal
property double swapTotal: 1
property double swapFree: 1
property double swapUsed: swapTotal - swapFree
property double swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0
property double cpuUsage: 0
property var previousCpuStats
Timer {
interval: 1
running: true
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 }
}
interval = Config.options?.resources?.updateInterval ?? 3000
}
}
FileView { id: fileMeminfo; path: "/proc/meminfo" }
FileView { id: fileStat; path: "/proc/stat" }
}
@@ -0,0 +1,90 @@
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: ""
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 (LOGO field, fallback to "unknown")
const logoMatch = textOsRelease.match(/^LOGO=(.+)$/m)
distroId = logoMatch ? logoMatch[1].replace(/"/g, "") : "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 "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;
default: distroIcon = "linux-symbolic"; break;
}
}
}
Process {
id: getUsername
command: ["whoami"]
stdout: SplitParser {
onRead: data => {
root.username = data.trim()
}
}
}
FileView {
id: fileOsRelease
path: "/etc/os-release"
}
}
+88
View File
@@ -0,0 +1,88 @@
pragma Singleton
pragma ComponentBehavior: Bound
import "root:/modules/common"
import Quickshell;
import Quickshell.Io;
import Qt.labs.platform
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)
}
}
}
}
@@ -0,0 +1,170 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "root:/modules/common/"
Singleton {
id: root
property var translations: ({})
property string currentLanguage: "en_US"
property var availableLanguages: ["en_US"]
property bool isScanning: false
property bool isLoading: false
Process {
id: scanLanguagesProcess
command: ["find", Qt.resolvedUrl(Directories.config + "/quickshell/translations/").toString().replace("file://", ""), "-name", "*.json", "-exec", "basename", "{}", ".json", ";"]
running: false
stdout: SplitParser {
onRead: data => {
if (data.trim().length === 0) return
var files = data.trim().split('\n')
for (var i = 0; i < files.length; i++) {
var lang = files[i].trim()
if (lang.length > 0 && root.availableLanguages.indexOf(lang) === -1) {
root.availableLanguages.push(lang)
}
}
}
}
onExited: (exitCode, exitStatus) => {
root.isScanning = false
if (exitCode !== 0) {
root.availableLanguages = ["en_US"]
}
root.loadTranslations()
}
}
FileView {
id: translationFileView
onLoaded: {
var textContent = ""
try {
textContent = text()
} catch (e) {
root.translations = {}
root.isLoading = false
return
}
if (textContent.length === 0) {
root.translations = {}
root.isLoading = false
return
}
try {
var jsonData = JSON.parse(textContent)
root.translations = jsonData
root.isLoading = false
} catch (e) {
root.translations = {}
root.isLoading = false
}
}
onLoadFailed: (error) => {
root.translations = {}
root.isLoading = false
}
}
function detectSystemLanguage() {
var locale = Qt.locale().name
return locale
}
function getLanguageCode() {
var configLang = "auto"
try {
configLang = ConfigOptions.language.ui
} catch (e) {
configLang = "auto"
}
if (configLang === "auto") {
return detectSystemLanguage()
} else {
if (root.availableLanguages.indexOf(configLang) !== -1) {
return configLang
} else {
return detectSystemLanguage()
}
}
}
function loadTranslations() {
if (root.isScanning) {
return
}
var targetLang = getLanguageCode()
root.currentLanguage = targetLang
// Use empty translations for English (default language)
if (targetLang === "en_US" || targetLang === "en") {
root.translations = {}
return
}
// Check if target language is available
if (root.availableLanguages.indexOf(targetLang) === -1) {
root.currentLanguage = "en_US"
root.translations = {}
return
}
// Load translation file
root.isLoading = true
var translationsPath = Qt.resolvedUrl(Directories.config + "/quickshell/translations/" + targetLang + ".json")
translationFileView.path = translationsPath
}
function tr(text) {
if (!text) {
return ""
}
var key = text.toString()
if (root.isLoading) {
return key
}
if (root.currentLanguage === "en_US" || root.currentLanguage === "en" || !root.translations) {
return key
}
if (root.translations.hasOwnProperty(key)) {
var translation = root.translations[key]
if (translation && translation.toString().trim().length > 0) {
return translation.toString()
} else {
return translation.toString()
}
}
return key // Fallback to key name
}
function reloadTranslations() {
root.scanLanguages()
}
function scanLanguages() {
var translationsDir = Qt.resolvedUrl(Directories.config + "/quickshell/translations/").toString().replace("file://", "")
root.isScanning = true
scanLanguagesProcess.running = true
}
Component.onCompleted: {
root.scanLanguages()
}
}
+155
View File
@@ -0,0 +1,155 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
import QtPositioning
import "root:/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
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
})
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 = "";
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 += (data?.current?.temp_F || 0);
temp.temp += " (" + (data?.current?.FeelsLikeF || 0) + ") ";
temp.temp += "\u{02109}";
} 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 += (data?.current?.temp_C || 0);
temp.temp += " (" + (data?.current?.FeelsLikeC || 0) + ") ";
temp.temp += "\u{02103}";
}
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(["bash", "-c", `notify-send WeatherService 'Can not find a GPS service. Using the fallback method instead.'`]);
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()
}
}
@@ -0,0 +1,55 @@
pragma Singleton
import "root:/modules/common"
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
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)
onShiftModeChanged: {
if (shiftMode === 0) {
}
}
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`
]);
}
}