// 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: Printers.refresh() TextRow { visible: Printers.lastError !== "" label: "Printing needs attention" detail: Printers.lastError value: "" divider: false } // ── 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") detail: String(jobRow.modelData.printer ?? "") + " · " + String(jobRow.modelData.state ?? "") + (Number(jobRow.modelData.pages ?? 0) > 0 ? " · " + jobRow.modelData.pages + " pages" : "") controlWidth: 175 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)) } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: "Cancel" enabled: !Printers.busy onClicked: 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 } } }