forked from Shinonome/dots-hyprland
Merge remote-tracking branch 'origin/main' into addon-i18n
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user