1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-04 04:42:05 -04:00
Files
Giorgio De Trane 20d383d4ab feat(cups): add manual printer addition by IP/hostname (#1868)
Add a new "Add by Address" flow in the printer settings that allows
users to manually add printers by IP address or hostname, enabling
printing to devices not visible via mDNS/Avahi discovery (e.g.,
printers behind Tailscale subnet routers, VPNs, or across network
boundaries).

Go backend:
- New cups.testConnection IPC method that probes remote printers via
  IPP Get-Printer-Attributes with /ipp/print then / fallback
- Input validation with host sanitization and protocol allowlist
- Auth-aware probing (HTTP 401/403 reported as reachable)
- lpadmin CLI fallback for CreatePrinter/DeletePrinter when
  cups-pk-helper polkit authorization fails

QML frontend:
- "Add by Address" toggle alongside existing device discovery
- Manual entry form with host, port, protocol fields
- Test Connection button with loading state and result display
- Smart PPD auto-selection by probed makeModel with driverless fallback
- All strings use I18n.tr() with translator context

Includes 20+ unit tests covering validation, probe delegation, TLS
flag propagation, auth error detection, and handler routing.
2026-02-28 20:36:16 -05:00

1703 lines
92 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
Item {
id: printerTab
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property bool showAddPrinter: false
property bool manualEntryMode: false
property string manualHost: ""
property string manualPort: "631"
property string manualProtocol: "ipp"
property bool testingConnection: false
property var testConnectionResult: null
property string newPrinterName: ""
property string selectedDeviceUri: ""
property var selectedDevice: null
property string selectedPpd: ""
property string newPrinterLocation: ""
property string newPrinterInfo: ""
property var suggestedPPDs: []
function resetAddPrinterForm() {
manualEntryMode = false;
manualHost = "";
manualPort = "631";
manualProtocol = "ipp";
testingConnection = false;
testConnectionResult = null;
newPrinterName = "";
selectedDeviceUri = "";
selectedDevice = null;
selectedPpd = "";
newPrinterLocation = "";
newPrinterInfo = "";
suggestedPPDs = [];
}
Connections {
target: CupsService
function onPpdsChanged() {
if (printerTab.manualEntryMode && printerTab.testConnectionResult?.success)
printerTab.selectDriverlessPPD();
}
}
function selectDriverlessPPD() {
if (printerTab.selectedPpd || CupsService.ppds.length === 0)
return;
const probeModel = printerTab.testConnectionResult?.data?.makeModel || "";
let suggested = [];
// Try to find a model-specific PPD match
if (probeModel) {
const normalizedModel = probeModel.toLowerCase().replace(/[^a-z0-9]/g, "");
const modelMatches = CupsService.ppds.filter(p => {
const normalizedPPD = (p.makeModel || "").toLowerCase().replace(/[^a-z0-9]/g, "");
return normalizedPPD.includes(normalizedModel) || normalizedModel.includes(normalizedPPD);
});
if (modelMatches.length > 0)
suggested = suggested.concat(modelMatches);
}
// Always include driverless as an option
const driverless = CupsService.ppds.filter(p => p.name === "driverless" || p.name === "everywhere");
for (const d of driverless) {
if (!suggested.find(s => s.name === d.name))
suggested.push(d);
}
if (suggested.length > 0) {
printerTab.selectedPpd = suggested[0].name;
printerTab.suggestedPPDs = suggested;
}
}
function selectDevice(device) {
if (!device)
return;
selectedDevice = device;
selectedDeviceUri = device.uri;
if (!newPrinterName) {
newPrinterName = CupsService.suggestPrinterName(device);
}
if (device.location && !newPrinterLocation) {
newPrinterLocation = CupsService.decodeUri(device.location);
}
suggestedPPDs = CupsService.getMatchingPPDs(device);
if (suggestedPPDs.length > 0 && !selectedPpd) {
selectedPpd = suggestedPPDs[0].name;
}
}
Component.onCompleted: {
CupsService.getClasses();
}
ConfirmModal {
id: deletePrinterConfirm
}
ConfirmModal {
id: purgeJobsConfirm
}
ConfirmModal {
id: deleteClassConfirm
}
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: mainColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: mainColumn
topPadding: 4
width: Math.min(600, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingL
StyledRect {
width: parent.width
height: overviewSection.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Column {
id: overviewSection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "print"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("CUPS Print Server")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
}
Rectangle {
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
}
Grid {
columns: 2
columnSpacing: Theme.spacingL
rowSpacing: Theme.spacingS
width: parent.width
StyledText {
text: I18n.tr("Status")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
}
Row {
spacing: Theme.spacingS
Rectangle {
width: 8
height: 8
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: CupsService.cupsAvailable ? Theme.success : Theme.error
}
StyledText {
text: CupsService.cupsAvailable ? I18n.tr("Available") : I18n.tr("Unavailable")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
}
StyledText {
text: I18n.tr("Printers")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
}
StyledText {
text: CupsService.printerNames.length.toString()
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
StyledText {
text: I18n.tr("Total Jobs")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
}
StyledText {
text: CupsService.getTotalJobsNum().toString()
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
}
}
}
StyledRect {
width: parent.width
height: addPrinterSection.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
visible: CupsService.cupsAvailable
Column {
id: addPrinterSection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "add_circle"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - Theme.spacingM - addPrinterToggleBtn.width - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Add Printer")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: I18n.tr("Configure a new printer")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
Rectangle {
id: addPrinterToggleBtn
width: 28
height: 28
radius: 14
color: addPrinterToggleArea.containsMouse ? Theme.surfacePressed : "transparent"
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: printerTab.showAddPrinter ? "expand_less" : "expand_more"
size: 18
color: Theme.surfaceText
}
MouseArea {
id: addPrinterToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
printerTab.showAddPrinter = !printerTab.showAddPrinter;
if (printerTab.showAddPrinter) {
if (CupsService.devices.length === 0) {
CupsService.getDevices();
CupsService.getPPDs();
}
} else {
printerTab.resetAddPrinterForm();
}
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingM
visible: printerTab.showAddPrinter
Rectangle {
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
}
Row {
width: parent.width
spacing: Theme.spacingS
Rectangle {
width: discoverRow.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius
color: !printerTab.manualEntryMode ? Theme.primary : (discoverArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: discoverRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "search"
size: 16
color: !printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
}
StyledText {
text: I18n.tr("Discover Devices", "Toggle button to scan for printers via mDNS/Avahi")
font.pixelSize: Theme.fontSizeSmall
color: !printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: discoverArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
printerTab.manualEntryMode = false;
printerTab.testConnectionResult = null;
printerTab.testingConnection = false;
}
}
}
Rectangle {
width: manualRow.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius
color: printerTab.manualEntryMode ? Theme.primary : (manualArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: manualRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "edit"
size: 16
color: printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
}
StyledText {
text: I18n.tr("Add by Address", "Toggle button to manually add a printer by IP or hostname")
font.pixelSize: Theme.fontSizeSmall
color: printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: manualArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
printerTab.manualEntryMode = true;
printerTab.selectedDevice = null;
printerTab.selectedDeviceUri = "";
if (CupsService.ppds.length === 0)
CupsService.getPPDs();
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: !printerTab.manualEntryMode
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Device")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankDropdown {
id: deviceDropdown
dropdownWidth: parent.width - 80 - scanDevicesBtn.width - Theme.spacingS * 2
popupWidth: parent.width - 80 - scanDevicesBtn.width - Theme.spacingS * 2
enableFuzzySearch: true
emptyText: I18n.tr("No devices found")
currentValue: {
if (CupsService.loadingDevices)
return I18n.tr("Scanning...");
if (printerTab.selectedDevice)
return CupsService.getDeviceDisplayName(printerTab.selectedDevice);
return I18n.tr("Select device...");
}
options: CupsService.filteredDevices.map(d => CupsService.getDeviceDisplayName(d))
onValueChanged: value => {
const filtered = CupsService.filteredDevices;
const device = filtered.find(d => CupsService.getDeviceDisplayName(d) === value);
if (device)
printerTab.selectDevice(device);
}
}
DankActionButton {
id: scanDevicesBtn
iconName: "refresh"
buttonSize: 32
anchors.verticalCenter: parent.verticalCenter
enabled: !CupsService.loadingDevices
onClicked: CupsService.getDevices()
RotationAnimation on rotation {
running: CupsService.loadingDevices
loops: Animation.Infinite
from: 0
to: 360
duration: 1000
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
visible: printerTab.selectedDevice !== null
Item {
width: 80
height: 1
}
StyledText {
text: CupsService.getDeviceSubtitle(printerTab.selectedDevice)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width - 80 - Theme.spacingS
elide: Text.ElideRight
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: printerTab.manualEntryMode
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Host", "Label for printer IP address or hostname input field")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: parent.width - 80 - Theme.spacingS
placeholderText: I18n.tr("IP address or hostname", "Placeholder text for manual printer address input")
text: printerTab.manualHost
onTextEdited: {
printerTab.manualHost = text;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Port", "Label for printer port number input field")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: 80
placeholderText: "631"
text: printerTab.manualPort
onTextEdited: {
printerTab.manualPort = text;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Protocol", "Label for printer protocol selector, e.g. ipp, ipps, lpd, socket")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankDropdown {
id: protocolDropdown
dropdownWidth: 120
popupWidth: 120
currentValue: printerTab.manualProtocol
options: ["ipp", "ipps", "lpd", "socket"]
onValueChanged: value => {
printerTab.manualProtocol = value;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
Item {
width: 80
height: 1
}
DankButton {
text: printerTab.testingConnection ? I18n.tr("Testing...", "Button state while testing printer connection") : I18n.tr("Test Connection", "Button to test connection to a printer by IP address")
iconName: printerTab.testingConnection ? "sync" : "lan"
buttonHeight: 36
enabled: printerTab.manualHost.length > 0 && !printerTab.testingConnection
onClicked: {
printerTab.testingConnection = true;
printerTab.testConnectionResult = null;
const port = parseInt(printerTab.manualPort) || 631;
CupsService.testConnection(printerTab.manualHost, port, printerTab.manualProtocol, response => {
printerTab.testingConnection = false;
if (response.error) {
printerTab.testConnectionResult = {
"success": false,
"error": response.error
};
} else if (response.result) {
printerTab.testConnectionResult = {
"success": response.result.reachable === true,
"data": response.result
};
if (response.result.reachable) {
if (response.result.uri)
printerTab.selectedDeviceUri = response.result.uri;
if (response.result.name && !printerTab.newPrinterName)
printerTab.newPrinterName = response.result.name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 32) || "Printer";
// Load PPDs if not loaded yet, then select driverless
if (CupsService.ppds.length === 0) {
CupsService.getPPDs();
}
selectDriverlessPPD();
}
}
});
}
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
visible: printerTab.testConnectionResult !== null
Row {
spacing: Theme.spacingS
Item {
width: 80
height: 1
}
Rectangle {
width: 8
height: 8
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: printerTab.testConnectionResult?.success ? Theme.success : Theme.error
}
StyledText {
text: printerTab.testConnectionResult?.success ? I18n.tr("Printer reachable", "Status message when test connection to printer succeeds") : I18n.tr("Connection failed", "Status message when test connection to printer fails")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: printerTab.testConnectionResult?.success ? Theme.success : Theme.error
}
}
Row {
spacing: Theme.spacingS
visible: printerTab.testConnectionResult?.success && (printerTab.testConnectionResult?.data?.makeModel || printerTab.testConnectionResult?.data?.info)
Item {
width: 80
height: 1
}
StyledText {
text: printerTab.testConnectionResult?.data?.makeModel || printerTab.testConnectionResult?.data?.info || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
Row {
spacing: Theme.spacingS
visible: !printerTab.testConnectionResult?.success && printerTab.testConnectionResult?.data?.error
Item {
width: 80
height: 1
}
StyledText {
text: printerTab.testConnectionResult?.data?.error || printerTab.testConnectionResult?.error || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.parent.width - 80 - Theme.spacingS
wrapMode: Text.WordWrap
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Driver")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankDropdown {
id: ppdDropdown
dropdownWidth: parent.width - 80 - refreshPpdsBtn.width - Theme.spacingS * 2
popupWidth: parent.width - 80 - refreshPpdsBtn.width - Theme.spacingS * 2
enableFuzzySearch: true
emptyText: I18n.tr("No drivers found")
currentValue: {
if (CupsService.loadingPPDs)
return I18n.tr("Loading...");
if (printerTab.selectedPpd) {
const ppd = CupsService.ppds.find(p => p.name === printerTab.selectedPpd);
if (ppd) {
const isSuggested = printerTab.suggestedPPDs.some(s => s.name === ppd.name);
return (isSuggested ? "★ " : "") + (ppd.makeModel || ppd.name);
}
return printerTab.selectedPpd;
}
return printerTab.suggestedPPDs.length > 0 ? I18n.tr("Recommended available") : I18n.tr("Select driver...");
}
options: {
const suggested = printerTab.suggestedPPDs.map(p => "★ " + (p.makeModel || p.name));
const others = CupsService.ppds.filter(p => !printerTab.suggestedPPDs.some(s => s.name === p.name)).map(p => p.makeModel || p.name);
return suggested.concat(others);
}
onValueChanged: value => {
const cleanValue = value.replace(/^★ /, "");
const ppd = CupsService.ppds.find(p => (p.makeModel || p.name) === cleanValue);
if (ppd)
printerTab.selectedPpd = ppd.name;
}
}
DankActionButton {
id: refreshPpdsBtn
iconName: "refresh"
buttonSize: 32
anchors.verticalCenter: parent.verticalCenter
enabled: !CupsService.loadingPPDs
onClicked: CupsService.getPPDs()
RotationAnimation on rotation {
running: CupsService.loadingPPDs
loops: Animation.Infinite
from: 0
to: 360
duration: 1000
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Name")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: parent.width - 80 - Theme.spacingS
placeholderText: I18n.tr("Printer name (no spaces)")
text: printerTab.newPrinterName
onTextEdited: printerTab.newPrinterName = text.replace(/\s/g, "-")
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Location")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: parent.width - 80 - Theme.spacingS
placeholderText: I18n.tr("Optional location")
text: printerTab.newPrinterLocation
onTextEdited: printerTab.newPrinterLocation = text
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Description")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: parent.width - 80 - Theme.spacingS
placeholderText: I18n.tr("Optional description")
text: printerTab.newPrinterInfo
onTextEdited: printerTab.newPrinterInfo = text
}
}
}
Row {
LayoutMirroring.enabled: false
width: parent.width
spacing: Theme.spacingS
layoutDirection: Qt.RightToLeft
DankButton {
text: CupsService.creatingPrinter ? I18n.tr("Creating...") : I18n.tr("Create Printer")
iconName: CupsService.creatingPrinter ? "sync" : "add"
buttonHeight: 36
enabled: printerTab.newPrinterName.length > 0 && printerTab.selectedDeviceUri.length > 0 && printerTab.selectedPpd.length > 0 && !CupsService.creatingPrinter
onClicked: {
CupsService.createPrinter(printerTab.newPrinterName, printerTab.selectedDeviceUri, printerTab.selectedPpd, {
location: printerTab.newPrinterLocation,
information: printerTab.newPrinterInfo
});
printerTab.resetAddPrinterForm();
printerTab.showAddPrinter = false;
}
}
}
}
}
}
StyledRect {
width: parent.width
height: printersSection.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
visible: CupsService.cupsAvailable
Column {
id: printersSection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "print"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - Theme.spacingM - refreshBtn.width - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Printers")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: {
const count = CupsService.printerNames.length;
if (count === 0)
return I18n.tr("No printers configured");
return I18n.ntr("%1 printer", "%1 printers", count).arg(count);
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankActionButton {
id: refreshBtn
iconName: "refresh"
buttonSize: 32
anchors.verticalCenter: parent.verticalCenter
onClicked: CupsService.getState()
}
}
Rectangle {
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
}
Item {
width: parent.width
height: 80
visible: CupsService.printerNames.length === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "print_disabled"
size: 32
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("No printers found")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
Column {
width: parent.width
spacing: 4
visible: CupsService.printerNames.length > 0
Repeater {
model: CupsService.printerNames
delegate: Rectangle {
id: printerDelegate
required property string modelData
required property int index
readonly property var printerData: CupsService.getPrinterData(modelData)
readonly property bool isExpanded: CupsService.expandedPrinter === modelData || hasJobs
readonly property bool hasJobs: (printerData?.jobs?.length ?? 0) > 0
readonly property bool isIdle: printerData?.state === "idle"
readonly property bool isStopped: printerData?.state === "stopped"
width: parent.width
height: isExpanded ? 56 + expandedContent.height : 56
radius: Theme.cornerRadius
color: printerMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
border.width: CupsService.selectedPrinter === modelData ? 2 : 0
border.color: Theme.primary
clip: true
Behavior on height {
NumberAnimation {
duration: 150
easing.type: Easing.OutQuad
}
}
Column {
anchors.fill: parent
spacing: 0
Item {
width: parent.width
height: 56
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
anchors.right: printerActions.left
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: isStopped ? "print_disabled" : "print"
size: 20
color: isStopped ? Theme.error : (isIdle ? Theme.primary : Theme.warning)
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
width: parent.width - 20 - Theme.spacingS
StyledText {
text: modelData
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: CupsService.selectedPrinter === modelData ? Font.Medium : Font.Normal
elide: Text.ElideRight
width: parent.width
horizontalAlignment: Text.AlignLeft
}
Row {
anchors.left: parent.left
spacing: Theme.spacingXS
StyledText {
text: CupsService.getPrinterStateTranslation(printerData?.state || "")
font.pixelSize: Theme.fontSizeSmall
color: {
switch (printerData?.state) {
case "idle":
return Theme.primary;
case "stopped":
return Theme.error;
case "processing":
return Theme.warning;
default:
return Theme.surfaceVariantText;
}
}
}
StyledText {
text: "•"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: (printerData?.jobs?.length ?? 0) > 0
}
StyledText {
text: I18n.ntr("%1 job", "%1 jobs", printerData?.jobs?.length ?? 0).arg(printerData?.jobs?.length ?? 0)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: (printerData?.jobs?.length ?? 0) > 0
}
}
}
}
Row {
id: printerActions
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Rectangle {
width: 28
height: 28
radius: 14
color: expandBtn.containsMouse ? Theme.surfacePressed : "transparent"
DankIcon {
anchors.centerIn: parent
name: isExpanded ? "expand_less" : "expand_more"
size: 18
color: Theme.surfaceText
}
MouseArea {
id: expandBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
CupsService.expandedPrinter = isExpanded ? "" : modelData;
}
}
}
Rectangle {
width: 28
height: 28
radius: 14
color: deleteBtn.containsMouse ? Theme.errorHover : "transparent"
DankIcon {
anchors.centerIn: parent
name: "delete"
size: 18
color: deleteBtn.containsMouse ? Theme.error : Theme.surfaceVariantText
}
MouseArea {
id: deleteBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
deletePrinterConfirm.showWithOptions({
title: I18n.tr("Delete Printer"),
message: I18n.tr("Delete \"%1\"?").arg(modelData),
confirmText: I18n.tr("Delete"),
confirmColor: Theme.error,
onConfirm: () => CupsService.deletePrinter(modelData)
});
}
}
}
}
MouseArea {
id: printerMouseArea
anchors.fill: parent
anchors.rightMargin: printerActions.width + Theme.spacingM
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
CupsService.setSelectedPrinter(modelData);
}
}
}
Column {
id: expandedContent
width: parent.width
visible: isExpanded
Rectangle {
width: parent.width - Theme.spacingM * 2
height: 1
x: Theme.spacingM
color: Theme.outlineLight
}
Item {
width: parent.width
height: detailsColumn.implicitHeight + Theme.spacingM * 2
Column {
id: detailsColumn
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
Flow {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: {
const fields = [];
const p = printerData;
if (!p)
return fields;
fields.push({
label: I18n.tr("State"),
value: CupsService.getPrinterStateTranslation(p.state)
});
if (p.stateReason && p.stateReason !== "none")
fields.push({
label: I18n.tr("Reason"),
value: CupsService.getPrinterStateReasonTranslation(p.stateReason)
});
if (p.makeModel)
fields.push({
label: I18n.tr("Model"),
value: p.makeModel
});
if (p.location)
fields.push({
label: I18n.tr("Location"),
value: p.location
});
fields.push({
label: I18n.tr("Accepting"),
value: p.accepting ? I18n.tr("Yes") : I18n.tr("No")
});
return fields;
}
delegate: Rectangle {
required property var modelData
required property int index
width: fieldContent.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius - 2
color: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineLight
Row {
id: fieldContent
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: modelData.label + ":"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData.value
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
Rectangle {
height: 28
width: pauseResumeRow.width + Theme.spacingM * 2
radius: 14
color: pauseResumeArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
Row {
id: pauseResumeRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: isStopped ? "play_arrow" : "pause"
size: 16
color: Theme.surfaceText
}
StyledText {
text: isStopped ? I18n.tr("Resume") : I18n.tr("Pause")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: pauseResumeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (isStopped) {
CupsService.resumePrinter(modelData);
} else {
CupsService.pausePrinter(modelData);
}
}
}
}
Rectangle {
height: 28
width: testPageRow.width + Theme.spacingM * 2
radius: 14
color: testPageArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
Row {
id: testPageRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "description"
size: 16
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Test Page")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: testPageArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: CupsService.printTestPage(modelData)
}
}
Rectangle {
height: 28
width: acceptRejectRow.width + Theme.spacingM * 2
radius: 14
color: acceptRejectArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
Row {
id: acceptRejectRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: printerData?.accepting ? "block" : "check_circle"
size: 16
color: Theme.surfaceText
}
StyledText {
text: printerData?.accepting ? I18n.tr("Reject Jobs") : I18n.tr("Accept Jobs")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: acceptRejectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (printerData?.accepting) {
CupsService.rejectJobs(modelData);
} else {
CupsService.acceptJobs(modelData);
}
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
visible: (printerData?.jobs?.length ?? 0) > 0
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Jobs")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: 1
height: 1
Layout.fillWidth: true
}
Rectangle {
height: 24
width: purgeRow.width + Theme.spacingM * 2
radius: 12
color: purgeArea.containsMouse ? Theme.errorHover : Theme.surfaceLight
Row {
id: purgeRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "delete_sweep"
size: 14
color: purgeArea.containsMouse ? Theme.error : Theme.surfaceText
}
StyledText {
text: I18n.tr("Clear All")
font.pixelSize: Theme.fontSizeSmall - 1
color: purgeArea.containsMouse ? Theme.error : Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: purgeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
purgeJobsConfirm.showWithOptions({
title: I18n.tr("Clear All Jobs"),
message: I18n.tr("Cancel all jobs for \"%1\"?").arg(modelData),
confirmText: I18n.tr("Clear"),
confirmColor: Theme.error,
onConfirm: () => CupsService.purgeJobs(modelData)
});
}
}
}
}
Repeater {
model: printerData?.jobs ?? []
delegate: Rectangle {
required property var modelData
required property int index
width: parent.width
height: 44
radius: Theme.cornerRadius - 2
color: Theme.surfaceContainerHighest
border.width: 1
border.color: Theme.outlineLight
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.right: jobActions.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "description"
size: 18
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 1
width: parent.width - 18 - Theme.spacingS
StyledText {
text: "[" + modelData.id + "] " + CupsService.getJobStateTranslation(modelData.state)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
elide: Text.ElideRight
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: {
const size = Math.round((modelData.size || 0) / 1024);
const date = new Date(modelData.timeCreated);
return size + " KB • " + date.toLocaleString(Qt.locale(), Locale.ShortFormat);
}
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
anchors.left: parent.left
}
}
}
Row {
id: jobActions
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Rectangle {
width: 24
height: 24
radius: 12
color: holdJobBtn.containsMouse ? Theme.surfacePressed : "transparent"
visible: modelData.state === "pending"
DankIcon {
anchors.centerIn: parent
name: "pause"
size: 14
color: Theme.surfaceVariantText
}
MouseArea {
id: holdJobBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: CupsService.holdJob(modelData.id)
}
}
Rectangle {
width: 24
height: 24
radius: 12
color: restartJobBtn.containsMouse ? Theme.surfacePressed : "transparent"
visible: modelData.state === "pending-held" || modelData.state === "completed" || modelData.state === "aborted"
DankIcon {
anchors.centerIn: parent
name: "replay"
size: 14
color: Theme.surfaceVariantText
}
MouseArea {
id: restartJobBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: CupsService.restartJob(modelData.id)
}
}
Rectangle {
width: 24
height: 24
radius: 12
color: cancelJobBtn.containsMouse ? Theme.errorHover : "transparent"
DankIcon {
anchors.centerIn: parent
name: "close"
size: 14
color: cancelJobBtn.containsMouse ? Theme.error : Theme.surfaceVariantText
}
MouseArea {
id: cancelJobBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: CupsService.cancelJob(printerDelegate.modelData, modelData.id)
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
StyledRect {
width: parent.width
height: classesSection.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
visible: CupsService.cupsAvailable && CupsService.printerClasses.length > 0
Column {
id: classesSection
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "workspaces"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - Theme.spacingM - refreshClassesBtn.width - Theme.spacingM
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Printer Classes")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: I18n.ntr("%1 class", "%1 classes", CupsService.printerClasses.length).arg(CupsService.printerClasses.length)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
DankActionButton {
id: refreshClassesBtn
iconName: "refresh"
buttonSize: 32
anchors.verticalCenter: parent.verticalCenter
onClicked: CupsService.getClasses()
}
}
Rectangle {
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
}
Column {
width: parent.width
spacing: 4
Repeater {
model: CupsService.printerClasses
delegate: Rectangle {
required property var modelData
required property int index
width: parent.width
height: 48
radius: Theme.cornerRadius
color: classMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.right: classActions.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "workspaces"
size: 20
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
StyledText {
text: modelData.name || I18n.tr("Unknown")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
StyledText {
text: I18n.ntr("%1 printer", "%1 printers", modelData.members?.length ?? 0).arg(modelData.members?.length ?? 0)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
Row {
id: classActions
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Rectangle {
width: 28
height: 28
radius: 14
color: deleteClassBtn.containsMouse ? Theme.errorHover : "transparent"
DankIcon {
anchors.centerIn: parent
name: "delete"
size: 18
color: deleteClassBtn.containsMouse ? Theme.error : Theme.surfaceVariantText
}
MouseArea {
id: deleteClassBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
deleteClassConfirm.showWithOptions({
title: I18n.tr("Delete Class"),
message: I18n.tr("Delete class \"%1\"?").arg(modelData.name),
confirmText: I18n.tr("Delete"),
confirmColor: Theme.error,
onConfirm: () => CupsService.deleteClass(modelData.name)
});
}
}
}
}
MouseArea {
id: classMouseArea
anchors.fill: parent
anchors.rightMargin: classActions.width + Theme.spacingM
hoverEnabled: true
}
}
}
}
}
}
}
}
}