Files
Panama/config/dot/quickshell/services/Printers.qml
T

269 lines
9.8 KiB
QML

pragma Singleton
// Printers, through CUPS' own API.
//
// Driverless only: this adds printers that describe their own capabilities over
// IPP. Choosing PPDs and downloading vendor drivers is most of what the panel
// this replaces does, and getting it wrong yields a printer that accepts jobs
// and silently prints nothing -- so the page says a printer needs a driver
// rather than guessing one.
//
// Discovery is separate from the snapshot because it takes seconds of network
// waiting, and a settings page should open immediately.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-printers"
property var printers: []
property var jobs: []
property var service: ({})
property var discovered: []
property bool scanned: false
property bool searching: false
property bool searched: false
property string lastError: ""
// Guards read the Process objects directly. A derived binding is stale
// inside the handler that changes it, which silently drops the refresh
// after a successful write. See DefaultApps.qml.
readonly property bool busy: query.running || mutation.running
readonly property var defaultPrinter: {
for (const printer of root.printers) {
if (printer.isDefault)
return printer;
}
return null;
}
readonly property bool anyPrinters: root.printers.length > 0
// Printers found on the network that are not already set up here.
readonly property var addable: root.discovered.filter(entry => {
for (const printer of root.printers) {
if (String(printer.uri ?? "") === String(entry.uri ?? ""))
return false;
}
return true;
})
function stateSummary(printer: var): string {
const message = String(printer?.stateMessage ?? "").trim();
const state = String(printer?.state ?? "");
if (state === "stopped")
return message !== "" ? "Paused — " + message : "Paused";
if (state === "printing")
return message !== "" ? "Printing — " + message : "Printing";
if (printer?.accepting === false)
return "Not accepting jobs";
return message !== "" ? message : "Ready";
}
function jobsFor(printerName: string): int {
let count = 0;
for (const job of root.jobs) {
if (job.printer === printerName)
count += 1;
}
return count;
}
// printer name -> the helper's get-options shape. Cached because a pair of
// dropdowns asks for the same printer on every repaint, and each answer is
// a round trip to CUPS.
property var options: ({})
property var pendingOptions: []
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
// Paper size and two-sided, as the printer reports them. Returns the cached
// answer, or null while the first one is on its way -- and asks for it, so
// a dropdown that binds to this fills itself in.
function optionsFor(name: string): var {
if (name === "")
return null;
if (root.options[name] !== undefined)
return root.options[name];
root.requestOptions(name);
return null;
}
function requestOptions(name: string): void {
if (name === "" || root.pendingOptions.indexOf(name) >= 0)
return;
root.pendingOptions = root.pendingOptions.concat([name]);
root.drainOptions();
}
function refreshOptions(name: string): void { root.requestOptions(name); }
function drainOptions(): void {
if (optionsQuery.running || root.pendingOptions.length === 0)
return;
optionsQuery.subject = root.pendingOptions[0];
optionsQuery.command = [root.helperPath, "get-options", optionsQuery.subject];
optionsQuery.running = true;
}
// Reassigned rather than mutated: QML does not notice a property change
// made inside a var object.
function absorbOptions(parsed: var): void {
const name = String(parsed?.printer ?? "");
if (name === "")
return;
const next = Object.assign({}, root.options);
next[name] = parsed;
root.options = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
}
function search(): void {
if (root.searching)
return;
root.searching = true;
discovery.command = [root.helperPath, "discover"];
discovery.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.printers = Array.isArray(parsed.printers) ? parsed.printers : [];
root.jobs = Array.isArray(parsed.jobs) ? parsed.jobs : [];
root.service = parsed.service ?? ({});
root.lastError = String(parsed.error ?? "");
// set-option answers with the snapshot AND the printer it touched,
// re-read -- so the dropdown that made the change updates from the
// reply rather than from a second round trip.
if (parsed.options)
root.absorbOptions(parsed.options);
} catch (error) {
root.lastError = "Could not read the printing service's answer.";
console.warn("Printers: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function add(uri: string, name: string): void { root.run(["add", uri, name]); }
function remove(name: string): void { root.run(["remove", name]); }
function setDefault(name: string): void { root.run(["set-default", name]); }
function pause(name: string): void { root.run(["pause", name]); }
function resume(name: string): void { root.run(["resume", name]); }
function cancel(jobId: int): void { root.run(["cancel", String(jobId)]); }
function testPage(name: string): void { root.run(["test-page", name]); }
// Holding keeps the job in the queue; cancelling throws it away. The
// difference matters because "stop this print" and "reprint fifty pages"
// are not meant to be the same button.
function hold(jobId: int): void { root.run(["hold", String(jobId)]); }
function release(jobId: int): void { root.run(["release", String(jobId)]); }
// One printer default, from the helper's closed vocabulary: media is
// Letter, A4 or Legal; sides is one-sided or one of the two two-sided
// bindings. Anything else the helper refuses -- there is no passthrough to
// lpadmin here.
function setOption(name: string, key: string, value: string): void {
root.run(["set-option", name, key, value]);
}
// Whether a job is held, from the state the helper reports.
function isHeld(job: var): bool { return String(job?.state ?? "") === "held"; }
// A queue name CUPS will accept, derived from what the printer calls itself.
function suggestedName(label: string): string {
const cleaned = String(label).replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned === "" ? "printer" : cleaned.slice(0, 60);
}
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
// Answers with the fresh state, so the page updates from the change
// itself rather than asking again afterwards.
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
// Kept out of `busy`: reading a printer's defaults changes nothing, so it
// must not disable the controls that do.
Process {
id: optionsQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: {
try {
root.absorbOptions(JSON.parse(this.text));
} catch (error) {
root.lastError = "Could not read that printer's settings.";
console.warn("Printers: could not parse get-options output:", error);
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingOptions = root.pendingOptions.filter(
name => name !== optionsQuery.subject);
optionsDrain.restart();
}
}
// One tick later, because `running` has not gone false inside onExited and
// the queue would stall on its own guard.
Timer {
id: optionsDrain
interval: 0
onTriggered: root.drainOptions()
}
Process {
id: discovery
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.discovered = Array.isArray(parsed.found) ? parsed.found : [];
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the discovery result.";
}
root.searched = true;
}
}
onExited: root.searching = false
}
}