440 lines
19 KiB
QML
440 lines
19 KiB
QML
// Printers, and one queue across all of them.
|
||
//
|
||
// The list answers "which printers exist"; the queue answers "where is my
|
||
// document", which is the question that actually brings someone here. They are
|
||
// separate cards because they are separate questions -- a job that has not come
|
||
// out is not necessarily a problem with the printer it was sent to.
|
||
//
|
||
// Driverless only. A printer old enough to need a PPD is named as such rather
|
||
// than offered and then failing at the moment it is added.
|
||
|
||
import Quickshell
|
||
import QtQuick
|
||
import qs.config
|
||
import qs.services
|
||
|
||
SettingsPage {
|
||
id: root
|
||
|
||
objectName: "printers"
|
||
title: "Printers"
|
||
lede: "Printers this machine can use, and what they are waiting on."
|
||
|
||
property string expandedPrinter: ""
|
||
property string confirmingRemoval: ""
|
||
property bool addingByAddress: false
|
||
property string manualUri: ""
|
||
property string manualName: ""
|
||
|
||
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
|
||
&& root.manualName.trim() !== ""
|
||
|
||
// The two defaults a queue can be told to change, dressed for reading. The
|
||
// values are the helper's closed vocabulary and nothing else reaches
|
||
// lpadmin; the choices come from what the printer said it supports, so a
|
||
// queue that cannot print two-sided never grows a two-sided dropdown.
|
||
//
|
||
// Empty while get-options is still on its way, which is why the rows are
|
||
// gated on the list rather than on the current value: an empty dropdown
|
||
// above a printer is worse than no dropdown at all.
|
||
function mediaChoices(reported: var): var {
|
||
const detail = {
|
||
"Letter": "8.5 × 11 in",
|
||
"A4": "210 × 297 mm",
|
||
"Legal": "8.5 × 14 in"
|
||
};
|
||
return (reported?.choices?.media ?? []).map(choice => ({
|
||
value: String(choice),
|
||
label: String(choice),
|
||
detail: detail[String(choice)] ?? ""
|
||
}));
|
||
}
|
||
|
||
function sidesChoices(reported: var): var {
|
||
const named = {
|
||
"one-sided": { label: "Off", detail: "One page per sheet" },
|
||
"two-sided-long-edge": { label: "Long edge", detail: "Bound like a book" },
|
||
"two-sided-short-edge": { label: "Short edge", detail: "Bound like a notepad" }
|
||
};
|
||
return (reported?.choices?.sides ?? []).map(choice => {
|
||
const known = named[String(choice)];
|
||
return {
|
||
value: String(choice),
|
||
label: known ? known.label : String(choice),
|
||
detail: known ? known.detail : ""
|
||
};
|
||
});
|
||
}
|
||
|
||
Component.onCompleted: if (!Printers.scanned) Printers.refresh()
|
||
|
||
ErrorRow {
|
||
label: "Printing needs attention"
|
||
message: Printers.lastError
|
||
}
|
||
|
||
// ── Which printers exist ─────────────────────────────────────────────────
|
||
|
||
SettingsCard {
|
||
visible: Printers.anyPrinters
|
||
title: "Printers"
|
||
subtitle: "Open one to change what it does by default."
|
||
|
||
Repeater {
|
||
model: Printers.printers
|
||
|
||
delegate: Column {
|
||
id: printerBlock
|
||
|
||
required property var modelData
|
||
required property int index
|
||
|
||
readonly property string name: String(printerBlock.modelData.name ?? "")
|
||
readonly property bool open: root.expandedPrinter === printerBlock.name
|
||
readonly property bool paused: String(printerBlock.modelData.state ?? "") === "stopped"
|
||
|
||
width: parent.width
|
||
|
||
SettingRow {
|
||
width: printerBlock.width
|
||
icon: printerBlock.paused ? "\u{F0026}" : "\u{F042A}"
|
||
label: String(printerBlock.modelData.description ?? printerBlock.name)
|
||
+ (printerBlock.modelData.isDefault ? " · Default" : "")
|
||
detail: Printers.stateSummary(printerBlock.modelData)
|
||
+ " · " + String(printerBlock.modelData.uri ?? "")
|
||
value: Printers.jobsFor(printerBlock.name) + " job"
|
||
+ (Printers.jobsFor(printerBlock.name) === 1 ? "" : "s")
|
||
activatable: !Printers.busy
|
||
divider: !printerBlock.open
|
||
&& printerBlock.index < Printers.printers.length - 1
|
||
onActivated: {
|
||
root.confirmingRemoval = "";
|
||
root.expandedPrinter = printerBlock.open ? "" : printerBlock.name;
|
||
}
|
||
}
|
||
|
||
Column {
|
||
id: printerBody
|
||
|
||
width: printerBlock.width
|
||
visible: printerBlock.open
|
||
|
||
// What this queue is set to, as CUPS reports it. Null until
|
||
// the read comes back, and a printer that reports neither
|
||
// key simply has no dropdowns -- an option offered for a
|
||
// printer that does not have it is a setting that silently
|
||
// does nothing.
|
||
readonly property var reported: Printers.optionsFor(printerBlock.name)
|
||
|
||
TextRow {
|
||
width: printerBlock.width
|
||
label: "Model"
|
||
detail: "Reported by the printer itself"
|
||
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
|
||
}
|
||
|
||
OptionPickerRow {
|
||
width: printerBlock.width
|
||
visible: root.mediaChoices(printerBody.reported).length > 0
|
||
label: "Paper size"
|
||
detail: "What applications print onto unless they ask for something else"
|
||
enabled: !Printers.busy
|
||
options: root.mediaChoices(printerBody.reported)
|
||
current: String(printerBody.reported?.options?.media ?? "")
|
||
onPicked: value => Printers.setOption(printerBlock.name, "media", String(value))
|
||
}
|
||
|
||
OptionPickerRow {
|
||
width: printerBlock.width
|
||
visible: root.sidesChoices(printerBody.reported).length > 0
|
||
label: "Two-sided"
|
||
detail: "Long edge is the usual choice for text; short edge flips like a notepad"
|
||
enabled: !Printers.busy
|
||
options: root.sidesChoices(printerBody.reported)
|
||
current: String(printerBody.reported?.options?.sides ?? "")
|
||
onPicked: value => Printers.setOption(printerBlock.name, "sides", String(value))
|
||
}
|
||
|
||
ActionRow {
|
||
width: printerBlock.width
|
||
visible: !printerBlock.modelData.isDefault
|
||
label: "Use by default"
|
||
detail: "Applications print here unless they are told otherwise"
|
||
action: "Make default"
|
||
enabled: !Printers.busy
|
||
onTriggered: Printers.setDefault(printerBlock.name)
|
||
}
|
||
|
||
ActionRow {
|
||
width: printerBlock.width
|
||
label: printerBlock.paused ? "Resume printing" : "Pause printing"
|
||
detail: printerBlock.paused
|
||
? "Queued jobs start again"
|
||
: "Jobs keep queuing, but nothing is sent until it resumes"
|
||
action: printerBlock.paused ? "Resume" : "Pause"
|
||
enabled: !Printers.busy
|
||
onTriggered: printerBlock.paused
|
||
? Printers.resume(printerBlock.name)
|
||
: Printers.pause(printerBlock.name)
|
||
}
|
||
|
||
ActionRow {
|
||
width: printerBlock.width
|
||
label: "Print a test page"
|
||
detail: "Confirms the printer answers and puts ink on paper"
|
||
action: "Print"
|
||
enabled: !Printers.busy && !printerBlock.paused
|
||
onTriggered: Printers.testPage(printerBlock.name)
|
||
}
|
||
|
||
SettingRow {
|
||
width: printerBlock.width
|
||
label: "Remove this printer"
|
||
detail: root.confirmingRemoval === printerBlock.name
|
||
? "Anything still queued for it is cancelled."
|
||
: "It can be added again later"
|
||
controlWidth: 200
|
||
divider: printerBlock.index < Printers.printers.length - 1
|
||
|
||
Row {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 8
|
||
|
||
SettingsButton {
|
||
text: root.confirmingRemoval === printerBlock.name ? "Keep" : "Remove…"
|
||
enabled: !Printers.busy
|
||
onClicked: root.confirmingRemoval =
|
||
root.confirmingRemoval === printerBlock.name ? "" : printerBlock.name
|
||
}
|
||
|
||
SettingsButton {
|
||
visible: root.confirmingRemoval === printerBlock.name
|
||
text: "Remove"
|
||
tone: "danger"
|
||
enabled: !Printers.busy
|
||
onClicked: {
|
||
root.confirmingRemoval = "";
|
||
root.expandedPrinter = "";
|
||
Printers.remove(printerBlock.name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Where my document is ─────────────────────────────────────────────────
|
||
|
||
SettingsCard {
|
||
visible: Printers.anyPrinters
|
||
title: "Print queue"
|
||
subtitle: Printers.jobs.length === 0
|
||
? "Nothing is waiting."
|
||
: "Everything waiting, across every printer."
|
||
|
||
Repeater {
|
||
model: Printers.jobs
|
||
|
||
delegate: SettingRow {
|
||
id: jobRow
|
||
|
||
required property var modelData
|
||
required property int index
|
||
|
||
readonly property bool held: Printers.isHeld(jobRow.modelData)
|
||
|
||
width: parent.width
|
||
label: String(jobRow.modelData.name ?? "Untitled")
|
||
// Armed, the row makes the Hold/Cancel distinction the service
|
||
// was built around: holding keeps the job, cancelling throws it
|
||
// away and the document has to be sent from the app again.
|
||
detail: cancelJob.armed
|
||
? "The job is thrown away — printing it means sending it from the application again. Hold keeps it in the queue instead."
|
||
: String(jobRow.modelData.printer ?? "") + " · "
|
||
+ String(jobRow.modelData.state ?? "")
|
||
+ (Number(jobRow.modelData.pages ?? 0) > 0
|
||
? " · " + jobRow.modelData.pages + " pages" : "")
|
||
controlWidth: 265
|
||
divider: jobRow.index < Printers.jobs.length - 1
|
||
|
||
Row {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 8
|
||
|
||
// Holding is the answer to "stop this one, I have not
|
||
// finished with it" -- cancelling was the only way out of
|
||
// the queue before, and it is not reversible.
|
||
SettingsButton {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: jobRow.held ? "Release" : "Hold"
|
||
enabled: !Printers.busy
|
||
onClicked: jobRow.held
|
||
? Printers.release(Number(jobRow.modelData.id))
|
||
: Printers.hold(Number(jobRow.modelData.id))
|
||
}
|
||
|
||
ConfirmAction {
|
||
id: cancelJob
|
||
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
actionId: "cancel-job:" + String(jobRow.modelData.id ?? "")
|
||
armText: "Cancel…"
|
||
confirmText: "Cancel it"
|
||
// Not "Keep": the job is not being kept in a drawer, it
|
||
// carries on out of the printer.
|
||
cancelText: "Keep printing"
|
||
enabled: !Printers.busy
|
||
onConfirmed: Printers.cancel(Number(jobRow.modelData.id))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
TextRow {
|
||
visible: Printers.jobs.length === 0
|
||
label: "Queue is empty"
|
||
detail: "Jobs appear here while they wait, and can be cancelled from here"
|
||
value: ""
|
||
divider: false
|
||
}
|
||
}
|
||
|
||
// ── Adding ───────────────────────────────────────────────────────────────
|
||
//
|
||
// One card, not two. "No printers yet" and "Add a printer" carried the same
|
||
// Search row with two different wordings, so a machine with no printers
|
||
// offered the identical button twice under two headings.
|
||
|
||
SettingsCard {
|
||
title: Printers.anyPrinters ? "Add a printer" : "No printers yet"
|
||
subtitle: Printers.anyPrinters
|
||
? "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
|
||
: "Printers on this network that speak modern IPP appear here on their own. Driverless only."
|
||
|
||
ActionRow {
|
||
label: "Search the network"
|
||
detail: Printers.searching
|
||
? "Listening for printers that announce themselves…"
|
||
: (Printers.searched
|
||
? Printers.addable.length + " printer"
|
||
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
|
||
: "Looks for printers over mDNS, the same way phones and laptops find them")
|
||
action: Printers.searching ? "Searching…" : "Search"
|
||
enabled: !Printers.searching
|
||
onTriggered: Printers.search()
|
||
}
|
||
|
||
Repeater {
|
||
model: Printers.addable
|
||
|
||
delegate: ActionRow {
|
||
id: foundRow
|
||
|
||
required property var modelData
|
||
|
||
width: parent.width
|
||
label: String(foundRow.modelData.name ?? "")
|
||
detail: foundRow.modelData.driverless === true
|
||
? String(foundRow.modelData.uri ?? "") + " · driverless"
|
||
: String(foundRow.modelData.uri ?? "") + " · needs a manufacturer driver"
|
||
action: foundRow.modelData.driverless === true ? "Add" : "Not supported"
|
||
enabled: !Printers.busy && foundRow.modelData.driverless === true
|
||
onTriggered: Printers.add(String(foundRow.modelData.uri ?? ""),
|
||
Printers.suggestedName(String(foundRow.modelData.name ?? "")))
|
||
}
|
||
}
|
||
|
||
TextRow {
|
||
visible: Printers.searched && Printers.addable.length === 0 && !Printers.searching
|
||
label: "Nothing found"
|
||
detail: "No printer announced itself. It may be asleep, on another network, or may not support network discovery."
|
||
value: ""
|
||
}
|
||
|
||
ActionRow {
|
||
label: "Add by address"
|
||
detail: "For a printer that does not announce itself"
|
||
action: root.addingByAddress ? "Cancel" : "Enter…"
|
||
enabled: !Printers.busy
|
||
divider: root.addingByAddress
|
||
onTriggered: {
|
||
root.addingByAddress = !root.addingByAddress;
|
||
root.manualUri = "";
|
||
root.manualName = "";
|
||
}
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
visible: root.addingByAddress
|
||
|
||
TextFieldRow {
|
||
width: parent.width
|
||
label: "Address"
|
||
detail: "ipp://, ipps://, or socket:// followed by the printer's host"
|
||
placeholder: "ipp://printer.local/ipp/print"
|
||
text: root.manualUri
|
||
onAccepted: value => root.manualUri = value
|
||
}
|
||
|
||
TextFieldRow {
|
||
width: parent.width
|
||
label: "Name"
|
||
detail: "What this printer is called on this machine"
|
||
placeholder: "office-printer"
|
||
text: root.manualName
|
||
onAccepted: value => root.manualName = value
|
||
}
|
||
|
||
ActionRow {
|
||
width: parent.width
|
||
label: "Add this printer"
|
||
detail: "The printer is asked what it can do; if it cannot answer, it is not added"
|
||
action: "Add"
|
||
enabled: root.manualReady && !Printers.busy
|
||
divider: false
|
||
onTriggered: {
|
||
Printers.add(root.manualUri, Printers.suggestedName(root.manualName));
|
||
root.addingByAddress = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── The service underneath ───────────────────────────────────────────────
|
||
|
||
SettingsCard {
|
||
title: "Printing service"
|
||
subtitle: "What has to be running for any of this to work."
|
||
|
||
TextRow {
|
||
label: "CUPS"
|
||
detail: Printers.service?.running === true
|
||
? "Running, so a printer added here works immediately"
|
||
: "Not running, so nothing can print"
|
||
value: Printers.service?.running === true ? "Running" : "Stopped"
|
||
}
|
||
|
||
TextRow {
|
||
label: "Starts at boot"
|
||
detail: Printers.service?.startsAtBoot === true
|
||
? "Started with the system"
|
||
: "Started on demand, when something prints. This is a normal configuration."
|
||
value: Printers.service?.startsAtBoot === true ? "Yes" : "On demand"
|
||
}
|
||
|
||
TextRow {
|
||
label: "Network discovery"
|
||
detail: Printers.service?.discoveryAvailable === true
|
||
? "Avahi is running, which is what finds printers that announce themselves"
|
||
: "Avahi is not running, so network printers cannot be discovered"
|
||
value: Printers.service?.discoveryAvailable === true ? "Available" : "Unavailable"
|
||
divider: false
|
||
}
|
||
}
|
||
}
|