Skip to content

Latest commit

 

History

History
999 lines (820 loc) · 77.8 KB

api-reference.md

File metadata and controls

999 lines (820 loc) · 77.8 KB

LinPrinter API reference

Generated by tools/gen_api_docs.py from the source. Don't edit by hand; re-run the tool.

src/main.py

LinPrinter Document printer for Linux (driverless IPP over USB, CUPS, Print to PDF). Entry point.

Constants: PAGE_ALIASES

Symbol Purpose
parse_args(argv) Parse command-line options (--version, --test-printer, --page, --quit-after, --debug)
main(argv=None) Build services and the window, run the GTK loop, clean up temporary files on exit

src/config/config_layout.py

Layout Configuration Centralized layout dimensions and spacing constants

Symbol Purpose
class Dimensions() Layout dimension constants
class Spacing() Spacing constants
class Layout() Main layout configuration

src/config/config_print.py

Print Configuration Friendly names and groups for what printers report. Nothing here is printer-specific: the Print page shows only what the chosen printer supports.

Constants: COLOR_MODES, QUALITIES, DEFAULT_QUALITY, PAGE_CHOICES, PAGES_MAIN, PAGES_WHICH, SCALING, PAPER_GROUPS, FRIENDLY_SIZES, DEFAULT_PAPER, FRIENDLY_TYPES, TYPE_ORDER, PDF_PRINTER_ID, DEFAULT_PDF_FOLDER, PDF_SIZES, IPP_USB_PORTS, IPP_PATHS, NETWORK_PRINTING, STATUS_EVERY, RECONNECT_AFTER_MISSES, RECONNECT_EVERY, STATUS_EVERY_WHILE_PRINTING

src/backends/backend_base.py

Printer model What a printer can do, normalised from its IPP attributes (IPP Everywhere), so pages never deal with protocol details. Backends: backend_ipp (direct IPP over ipp-usb), backend_cups (CUPS queues), backend_pdf (Print to PDF).

Constants: USER_ACTION_REASONS

Symbol Purpose
class PrintError(Exception) A print problem in plain words; code tells the engine whether to try another method
  .__init__(self, message, code='error')
  .needs_user(self) True if the user must fix something (paper, jam, cover, ink, a failing USB link).
class PaperSize() One paper size the printer offers
  .label(self) Friendly name, e.g. "A4 (210 × 297 mm)"
parse_pwg_size(keyword) (width_mm, height_mm) from a PWG self-describing media name, or None
size_group(keyword) Documents / Photos / Envelopes / Cards / Other for a size keyword
type_label(keyword) Friendly paper-type name
class PrinterCapabilities() Everything the Print page needs to know about one printer
  .size(self, keyword) PaperSize by keyword (None if the printer doesn't offer it)
  .can_borderless(self, size, media_type) True if this size / type combination prints edge to edge
  .margins_for(self, size, media_type, borderless=False) Unprintable margins in mm (top, right, bottom, left)
_dims(coll) (w_mm, h_mm) from a media-size collection (hundredths of mm)
capabilities_from_ipp(attrs) PrinterCapabilities from Get-Printer-Attributes
printer_status(attrs) (state name, reasons, markers) from printer attributes; markers: [{name, color, level, type}]
class PrinterDevice() A physical printer and every way to reach it (best first), like LinScanner's PhysicalDevice
  .id(self) Stable id for the UI
  .label(self) Display name
class Method() One way to reach a printer: code (P1 direct IPP, P2 CUPS, PDF, T test), backend, target
  .label(self) e.g. "Driverless IPP over USB (ipp-usb) · ipp://127.0.0.1:60000/ipp/print"

src/backends/backend_cups.py

CUPS backend (method P2) The system print spooler: permanent queues and the driverless queues CUPS creates on demand (e.g. "Canon_TR150_series_USB"). Used when direct IPP isn't possible, and for printers that need a CUPS driver (Gutenprint, HPLIP, vendor PPDs). Only local USB queues are used: network queues are skipped (NETWORK_PRINTING is off). Commands: lpstat (destinations, jobs, state), lp (submit), cancel.

Constants: QUALITY_ENUM, MISDIRECTED_SCHEMES

Symbol Purpose
_run(args, timeout=20) Run a CUPS command; PrintError if CUPS isn't available
is_local_uri(uri) True for USB queues: usb://, ipp-usb driverless ("(USB)" in the DNS-SD name), or loopback
resolve_implicit(uri, announced) The printer behind a cups-browsed queue (implicitclass://NAME/ stands for the
sort_queues(permanent, announced) (usable [(queue, uri)], misdirected [(queue, uri)]) from lpstat -v and lpstat -l -e.
parse_destinations(text) [(queue, uri)] from lpstat -l -e output ("name type info uri")
parse_device_uris(text) [(queue, uri)] from lpstat -v output ("device for NAME: URI")
queue_model(queue) A readable model name from a queue name (e.g. "Canon TR150 series")
parse_jobs(text) [{id, name, user, size}] from lpstat -o lines ("Queue-12 user 1024 Mon 22 Sep ...")
lp_options(ticket) lp -o options for a ticket (standard IPP job-attribute names that CUPS accepts)
class CupsBackend() Local CUPS queues (USB only)
  .available(self) True if the CUPS client tools are installed
  .list_printers(self) [(queue, uri)] of local USB queues, permanent and driverless
  .submit(self, queue, ticket, caps, pdf, pages, work_dir, job_name) Render the chosen pages to PDF and hand them to CUPS; returns the CUPS job id
  .jobs(self, queue, which='not-completed') Jobs in a queue: [{id, name, state, done}]
  .job(self, queue, job_id) (state, pages done, reasons): pending while listed, completed once gone
  .cancel(self, queue, job_id) Cancel a CUPS job
  .status(self, queue) (state, reasons, markers) from lpstat -p (CUPS has no ink levels for most queues)

src/backends/backend_ipp.py

Direct IPP backend (method P1) Talks IPP Everywhere straight to USB printers through ipp-usb, which listens on 127.0.0.1 from port 60000. Loopback only: LinPrinter never reaches the network (config_print.NETWORK_PRINTING is off). Why first: it controls every job attribute exactly (paper type, borderless margins through media-col), validates a job before sending it, reports the job's state, and can make the printer identify itself. CUPS (backend_cups) is the fallback.

Constants: QUALITY_ENUM

Symbol Purpose
is_loopback(uri) True if an IPP URI points at this computer
ipp_usb_uris() IPP URIs of USB printers served by ipp-usb (open ports on 127.0.0.1:60000+)
_error(e) PrintError for an IppError
job_attributes(ticket, caps) IPP job attributes for a ticket, limited to what the printer supports
class IppBackend() Driverless IPP Everywhere over USB (ipp-usb), plus the built-in test printer
  .__init__(self, extra_uris=(), probe_usb=True) extra_uris: more IPP printers on 127.0.0.1 (the test printer)
  .candidates(self) IPP URIs to try
  .attributes(self, uri, timeout=10) Every printer attribute (PrintError if unreachable)
  .list_printers(self) [(uri, attributes)] of the IPP printers that answer
  .capabilities(self, uri) PrinterCapabilities for a printer
  .ping(self, uri) One small question (the printer's state): used by the link test
  .status(self, uri) (state, reasons, markers)
  .submit(self, uri, ticket, caps, pdf, pages, work_dir, job_name) Render to PWG raster, validate, and send; returns the printer's job id
  .job(self, uri, job_id) (state name, pages done, reasons) of a job
  .jobs(self, uri, which='not-completed') Jobs on the printer: [{id, name, state, done}]
  .cancel(self, uri, job_id) Cancel a job
  .identify(self, uri) Make the printer flash (Identify-Printer)

src/backends/backend_pdf.py

Print to PDF (method PDF) A built-in destination that saves exactly what would print (the chosen pages, paper, fitting and colour) as a PDF in a folder, ~/Documents/prints by default (Settings). Always available, no printer or paper needed.

Symbol Purpose
pdf_capabilities() What Print to PDF offers: common sizes, colour or black & white, borderless
unique_path(folder, stem) folder/stem.pdf, or stem-2.pdf, stem-3.pdf, … if taken
class PdfBackend() Saves print jobs as PDF files
  .submit(self, folder, ticket, caps, pdf, pages, work_dir, job_name) Write the PDF; returns its path
  .status(self, folder) Ready when the folder can be written

src/backends/ipp.py

IPP (Internet Printing Protocol) codec and client, standard library only. Encoding follows RFC 8010 (binary message format); operations and attributes follow RFC 8011 and PWG 5100.x (IPP Everywhere). Only what LinPrinter needs: Get-Printer-Attributes, Validate-Job, Print-Job, Get-Jobs, Get-Job-Attributes, Cancel-Job, Identify-Printer Attributes are written as (name, tag, values). Values are Python values; a collection's value is a list of member attributes in the same form. Decoded responses become dicts: {name: value} (single value) or {name: [values]} (several), with collections as dicts.

Constants: GROUP_TAGS, OUT_OF_BAND, STRING_TAGS, IDENTIFY_PRINTER, OPERATION_NAMES, JOB_STATES, PRINTER_STATES, QUALITY, TRUNCATED

Symbol Purpose
class IppError(Exception) An IPP request failed: status code, message and a short code for the UI
  .__init__(self, message, status=None, code='error')
_value(tag, value) Bytes of one attribute value
_attribute(name, tag, values) Bytes of one attribute with its values (additional values have an empty name)
encode_request(operation, request_id, groups) An IPP request: groups is [(group tag, [(name, tag, values), ...]), ...]
encode_response(status, request_id, groups) An IPP response (used by the test printer)
_decode_value(tag, data) Python value of one attribute value
_add(target, name, value) Store a value: single values stay single, repeats become a list
class _Multi(list) A list of several values of one attribute (a real list value, not a list of lists)
_short(data, pos, need) True when the answer stops before need more bytes (a cut-short response)
_read_length(data, pos) A 2-byte length at pos; raises IppError rather than struct.error when it isn't there
_read_collection(data, pos) Decode collection members after a begCollection value; returns (dict, new position)
decode_message(data) (status or operation, request id, [(group tag, {name: value}), ...])
values(attrs, name) An attribute's values as a list (empty if missing)
_base_operation_attrs(uri, requesting_user='linprinter') The operation attributes every request starts with
status_text(status) Plain name of an IPP status code
class IppClient() Talks IPP to one printer URI (only loopback addresses: USB printers through ipp-usb)
  .__init__(self, uri, timeout=15) uri: e.g. ipp://127.0.0.1:60000/ipp/print
  ._next_id(self) A new request id
  .request(self, operation, groups, document=None, timeout=None) Send a request (optionally streaming a document file); returns (status, groups)
  ._ok(self, operation, status, groups) Raise IppError unless the status is successful
  ._group(groups, tag) Merged attributes of one group tag
  .get_printer_attributes(self, requested=('all',), extra=('media-col-database',)) All printer attributes as {name: value}.
  ._attributes(self, requested) One Get-Printer-Attributes request
  .validate_job(self, job_attrs, document_format) Check a job ticket; returns (status, unsupported attributes)
  .print_job(self, path, document_format, job_attrs, job_name='LinPrinter job', timeout=600) Send a document; returns the job attributes (job-id, job-state, ...)
  .get_jobs(self, which='not-completed', limit=50) Jobs on the printer: [{job-id, job-name, job-state, ...}]
  .get_job(self, job_id) One job's attributes
  .cancel_job(self, job_id) Cancel a job
  .identify(self, action='flash') Make the printer identify itself (flash its lights / beep)
decode_typed(data) [(group tag, [(name, tag, values), ...]), ...] keeping IPP value tags (for fixtures / the test printer)

src/backends/test_printer.py

Test printer A small IPP Everywhere printer inside LinPrinter, for tests and --test-printer (the printing counterpart of SANE's virtual scanner). It answers with a real printer's attributes (the Canon TR150's, identifiers removed), listens on 127.0.0.1 only, keeps print jobs as files, and lets tests set printer states (paper out, jam, low ink, ...). (CUPS' ippeveprinter would do the same, but it needs IPv6, which is switched off on the reference machine.)

Constants: FIXTURE

Symbol Purpose
load_fixture(path=FIXTURE) [(name, tag, values)] printer attributes from a fixture file
class TestPrinter() Start with .start(); .uri is the printer URI; jobs are saved in .spool
  .__init__(self, fixture=FIXTURE, spool=None, name='LinPrinter Test Printer')
  .uri(self) ipp://127.0.0.1:/ipp/print
  .start(self) Serve in a background thread on a free 127.0.0.1 port
  .stop(self) Stop serving
  .set_state(self, reasons=('none',), state=None) Simulate printer-state-reasons, e.g. ("media-empty-error",)
  .printer_attributes(self) The attribute list with the live state
  ._job_attrs(self, job) A job's attributes for responses
  ._advance(self, job) Jobs finish after job_seconds (unless the printer has an error)
  .handle(self, body) One IPP request -> response bytes
  ._end_of_attributes(body) Offset of the document data after the IPP attributes

src/backends/usb_link.py

USB Link How the USB link to a printer has behaved lately, read from the kernel log: error -71 (EPROTO, a transfer that failed electrically), failed enumeration and disconnects, per port. No driver, setting or service cures these - they come from the cable, the socket, power or the device's own USB port - so LinPrinter says that in plain words instead of searching for a printer that keeps vanishing. Reads journalctl -k, which needs the adm or systemd-journal group; without it the answer is simply "nothing known". Only port numbers and USB ids are kept, never serial numbers.

Constants: WINDOW_MINUTES, ERRORS_FAILING, DISCONNECTS_FAILING, FAILURE, PORT, IDS, CACHE_SECONDS, CABLE_ADVICE

Symbol Purpose
parse(text) {port: {"errors", "disconnects", "ids"}} from kernel log lines
kernel_log(minutes=WINDOW_MINUTES) Kernel messages of the last few minutes ("" if the journal can't be read; see log_readable)
recent(minutes=WINDOW_MINUTES, max_age=CACHE_SECONDS) parse() of the recent kernel log (a reading up to max_age seconds old is reused; tests: max_age=0)
cached() The last reading, without reading the journal (for the GTK thread; workers call recent())
log_readable() True / False, or None before the first reading (then say "checking", not "can't be read")
failing(port, stats) True if the link on this port has kept failing
failing_ports(stats, usb_id='', port='') Failing ports that held this device (by USB id) or are its known port
unidentified_failing_ports(stats) Failing ports where no device ever got far enough to say what it is.
_counts(port, stats) 'N connection errors and M disconnects'
advice(port, stats, minutes=WINDOW_MINUTES) What to tell the user about a failing link to their printer, in plain words
advice_unidentified(port, stats, minutes=WINDOW_MINUTES) The same for a device that never identified itself

src/backends/usb_probe.py

USB Probe Driver-independent USB facts for printer detection, read straight from sysfs and the udev database (no subprocesses, no root): identity, speed, interface classes (7 = printer; 7/1/4 = IPP-over-USB), the kernel driver in use (usblp) and device-node access. sysfs shows only each interface's current alternate setting, and printers routinely hide IPP-over-USB on an alternate setting (the Canon TR150 offers 7/1/4 on alt 1 of interfaces 1 and 2, while alt 0 is vendor-specific). The device's raw descriptor blob (descriptors, world-readable) lists every alternate setting, so it is parsed too - otherwise a driverless printer looks like a plain USB printer and gets the wrong advice.

Constants: SYS_USB, UDEV_DATA

Symbol Purpose
class UsbDevice() One USB device and what its interfaces suggest
  .usb_id(self) VID:PID string
  .has_class(self, cls, sub=None, proto=None) True if any interface matches the class (and optional subclass/protocol).
  .kinds(self) Why this device is a printer (empty list = probably not one)
  .drivers(self) Kernel drivers bound to its interfaces (e.g. usblp)
_read(path, default='') Contents of a small sysfs/udev file, stripped (default if unreadable)
_udev_properties(sys_path) E: properties from the udev database for a device (empty if unavailable)
descriptor_interfaces(sys_path) (class, subclass, protocol) of every interface descriptor, alternate settings included.
probe(sys_root=SYS_USB) All USB devices (not hubs' interfaces) with printer-relevant facts
likely_printers(devices) USB devices that are printers (or printer-capable multifunction devices)
speed_label(mbps) Human USB speed name

src/backends/usb_reset.py

USB reconnect Re-attaches a USB printer that the kernel left unconfigured (the classic "printer is plugged in, nothing sees it" state: bConfigurationValue empty, ipp-usb retrying "unable to find current configuration"). Detaching and re-attaching a USB device is a machine-wide kernel operation, so it needs root. LinPrinter never holds privileges itself: it asks pkexec, which shows the desktop's own password dialog, runs the small helper the installer places in /usr/local/lib/linprinter, and exits. Nothing is left running. If something has already made the device's authorized file writable by your user (a udev rule of your own, for example), it is re-attached in place and no password is asked. LinPrinter's installer does not add such a rule: granting a user permanent control over a device's USB attachment is a system-wide decision that belongs to whoever administers the machine, not to an app installer.

Constants: SYS_USB, HELPER, PRINTER_CLASS, PORT_RE, SETTLE_SECONDS

Symbol Purpose
sysfs_path(port_path, sys_usb=SYS_USB) /sys path of a USB device from its port ("3-4")
usb_id_at(port_path, sys_usb=SYS_USB) "vvvv:pppp" of the device on that port ("" if the port is empty)
find_port(usb_id, sys_usb=SYS_USB) Where that USB id is plugged in now ("" if nowhere). Survives a move to another port.
is_usb_printer(port_path, sys_usb=SYS_USB) True if that port holds a device with a USB printer interface (class 07)
configured(port_path, sys_usb=SYS_USB) True when the kernel has set a configuration (the device is usable)
writable(port_path, sys_usb=SYS_USB) True when this user may re-attach the device without a password (permissions allow it)
shell_command(port_path, sys_usb=SYS_USB) The command that re-attaches the device (also shown to the user for a terminal)
_run(argv, timeout=120)
reset(port_path, usb_id='', sys_usb=SYS_USB) Re-attach one printer's USB device. Returns (ok, message for the user).

src/modules/app_context.py

App Context Shared services handed to every page (settings, print manager, navigation, theme) plus a tiny event hub so pages can react to each other without importing each other. Events: "printers-changed", "printer-selected", "printer-status", "document-changed", "ticket-changed", "jobs-changed", "documents-changed", "features-changed", "settings-changed", "open-document", "request-printer-refresh"

Symbol Purpose
class AppContext() Service container + publish/subscribe event hub
  .__init__(self, settings, print_manager, nav_manager, theme_applicator) Hold the shared services; window is set later by AppWindow
  .on(self, event, callback) Subscribe callback to an event name
  .emit(self, event, *args) Call every subscriber of event with args

src/modules/manager_documents.py

Recent (printed documents) Every document LinPrinter prints (or saves with Print to PDF) is remembered, newest first, in ~/.local/share/linprinter/recent.json: the file's path, when, where to, pages and options. The list stays on this computer.

Constants: RECENT_MAX

Symbol Purpose
recent_path() The recent-documents list file
_saved_ts(entry) When an entry was printed (epoch seconds; 0 if unknown)
recent_entries(existing_only=True) Recent documents, newest first: [{path, printed_at, printer, pages, summary}]
add_recent(path, printer, pages, summary, result='printed', choices=None) Remember a printed document (a re-printed file moves to the top).
forget_recent(path) Remove one file from the list (the file itself is not touched)
clear_recent(older_than_days=None) Empty the list, or drop entries older than N days; returns how many were removed

src/modules/manager_navigation.py

Navigation Manager Handles page routing and navigation state

Symbol Purpose
class NavigationManager() Manages navigation state and page switching
  .__init__(self) Initialize navigation manager
  .register_page(self, page_id, page_widget) Register a page with the navigation system
  .set_page_stack(self, stack) Set the GTK Stack widget for page switching
  .navigate_to(self, page_id) Navigate to a specific page
  .on_navigate(self, callback) Register a callback for navigation events
  .get_current_page(self) Get current page identifier
  .get_page_widget(self, page_id) Get widget for a specific page

src/modules/manager_print.py

Print Manager Owns the printers (discovery, remembered printer, capabilities), the open document, job tickets, printing with fallback across methods, job monitoring, status and the Recent list. Pages call it; it calls the backends. Methods, best first (as LinScanner's connection engine): P1 direct IPP over ipp-usb exact options, validation, job state, identify P2 CUPS queue spooler; printers that need a driver PDF Print to PDF ~/Documents/prints (Settings) T test printer --test-printer and tests No fallback when the printer needs the user (paper, jam, cover, empty ink).

Constants: FINAL_JOB_STATES, JOB_TIMEOUT, JOB_RESULT_WORDS

Symbol Purpose
refused_words(error) Plain words for a job the printer refused (the IPP status stays in the log)
ticket_choices(ticket) The user-facing choices of a ticket (Activity → Print again restores them)
model_key(name) Normalised model name used to match one printer offered by several methods
generic_capabilities() Capabilities for a CUPS-only printer that can't be asked over IPP
ipp_usb_installed() True when ipp-usb is installed (it lives in sbin, which is not always on PATH)
unusable_hint(dev) Why a connected USB printer can't be printed to yet, and what to do about it
class PrintManager() Printers, document, tickets, jobs
  .__init__(self, settings, test_printer_uri=None, use_cups=True, probe_usb=True) test_printer_uri: add the built-in test printer (--test-printer, tests)
  ._in_thread(work, on_done, on_error) Run work() in a thread; deliver the result or a plain error on the GTK thread (once)
  .pdf_printer(self) The built-in Print to PDF destination
  .discover(self) Every printer (grouped, methods ranked) plus Print to PDF
  ._link_notes(self, printers, stats) Warn about a USB link that keeps failing - on the printer, or, when the remembered
  ._misdirected_notes(printers, misdirected) Warn about a CUPS queue named for a printer that sends jobs somewhere else
  .should_reconnect(level, misses, seconds_since_last_try, busy, virtual=False) Search for the printer again? (unreachable a few times, not printing, not too often)
  .refresh_printers(self, on_done, on_error) Find printers in the background
  .remembered_usb(self) {"id", "port"} of the last USB printer used ({} if none) - for Reconnect
  .reconnect_usb(self, printer=None) Re-attach the chosen printer (or the remembered one) on USB: (ok, message)
  .printer(self, printer_id) A printer by id (None if unknown)
  .remember(self, printer) Store the printer in use, so the next start reaches it without a full search
  .restore(self, on_done, on_error) Reach the remembered printer directly (one IPP request instead of a full search)
  .status(self, printer) (level, message, state, reasons, markers) for a printer (in a worker thread)
  ._status(self, printer)
  .link_test(self, printer, rounds=20, give_up_after=4) Ask the printer rounds small questions over its direct connection and count failures.
  .status_async(self, printer, on_done) status() in the background
  .identify(self, printer) Make the printer flash (direct IPP only)
  .jobs(self, printer) Jobs waiting or printing, then finished ones: [{id, name, state, done, method}]
  .cancel_job(self, method, job_id) Cancel a job
  .open_document(self, path) Prepare a file for printing (PDF, image or text): {"path", "pdf", "pages"}
  .open_async(self, path, on_done, on_error) open_document() in the background
  .default_size(caps) The size to start with: A4 / Letter if offered, else the printer's default
  .ticket(self, printer, choices) A job ticket from the user's choices, fitted to what the printer supports
  .settings_summary(ticket, printer) The settings in words, without copies and pages (Activity, the Print bar)
  .summary(ticket, pages, printer) One line of what will print
  .preview(self, ticket, pages) Preview images of the chosen pages as they will print
  .preview_async(self, ticket, pages, on_done, on_error) preview() in the background
  .print_async(self, printer, ticket, pages, on_progress, on_done, on_error, document=None, recent=True) Print the open document (or document): try each method in order; follow the job; add it to Recent
  .printer_defaults(self, printer) The printer's own defaults as LinPrinter choices ({} if unknown)
  .use_printer_defaults(self, printer) Store the printer's defaults as LinPrinter's own; returns the choices applied
  .test_ticket(self, printer, kind='quality') Plain paper, colour, normal quality at the printer's usual size; actual size (no scaling)
  .print_test_page(self, printer, kind, on_progress, on_done, on_error, ticket=None) Draw a built-in test page for this printer's paper and print it (not added to Recent)
  ._follow(self, method, job_id, impressions, progress) Watch a job until it's done; returns {"state", "method", "job_id", "file"}
  .cancel(self) Stop the job being printed
  .cleanup(self) Delete the session's temporary files

src/modules/manager_render.py

Render Manager Turns what the user opens into what the printer receives: any input (PDF / image / text) -> normalise to PDF -> page selection (all / range / odd / even / current) -> fit onto the chosen paper (landscape pages turn to portrait automatically) -> PWG raster (the IPP Everywhere format) for the printer, PDF for CUPS and for Print to PDF, PNG images for the print preview Ghostscript does the rendering (-dPDFFitPage also rotates a landscape page to fill portrait paper, which the TR150 needs: it only prints portrait). Everything happens in the session's temporary folder.

Constants: POINTS_PER_MM, IMAGE_TYPES, TEXT_TYPES, OPENABLE

Symbol Purpose
class RenderError(Exception) The document couldn't be prepared (plain message for the user)
_gs(args, timeout=300) Run Ghostscript; RenderError with its last message on failure
normalise(path, work_dir) A PDF for any supported input (PDFs are used as they are)
_images_to_pdf(path, out) Every frame of an image file as a PDF page (at its own resolution, 300 dpi if unknown)
text_to_pdf(path, out, paper_mm=(215.9, 279.4), font_pt=10, dpi=150) A plain text file as monospaced PDF pages (margins 20 mm, lines wrapped)
page_count(pdf) Number of pages in a PDF
_ps_string(path) A path escaped for a PostScript string
select_pages(choice, count, text='', current=1) 1-based pages to print: choice all
_media_args(ticket) Ghostscript arguments for the paper size and fitting
render_pwg(pdf, pages, ticket, out) PWG raster for the printer: 600 dpi, sRGB or sGray, only the chosen pages
render_pdf(pdf, pages, ticket, out) A PDF of exactly what will print (Print to PDF, and the CUPS route)
render_preview(pdf, pages, ticket, out_dir, dpi=60) PNG images of the chosen pages as they will print (paper, fit, colour), with the margins shown
_shade_margins(path, margins_mm, dpi) Grey the area the printer can't print on (top, right, bottom, left margins)
_rgb(hex_color) '#rrggbb' -> (r, g, b)
_dashed_rect(draw, box, colour, dash=6, gap=4) The printable-edge guide: a dashed rectangle

src/modules/manager_settings.py

Settings Manager Loads and saves user preferences as JSON in ~/.config/linprinter/settings.json

Constants: DEFAULTS

Symbol Purpose
default_path() Settings file path (honours XDG_CONFIG_HOME)
class SettingsManager() Dictionary-style access to persisted settings with defaults
  .__init__(self, path=None) Load settings from path (default ~/.config/linprinter/settings.json)
  .load(self) Merge stored values over defaults, accepting only known keys of the right type
  .save(self) Write settings atomically (temp file + rename)
  .get(self, key) Value for key: session override, then saved value, then default
  .override(self, key, value) Use value for this session only; it is not written to disk
  .set(self, key, value) Store a value and save immediately

src/modules/manager_status.py

Status messages Printer states (IPP printer-state-reasons) and job states in plain words, with a level for the power / status icon: ok (green), warn (amber), error (red). Reasons end in -report, -warning or -error (RFC 8011 §5.4.12); the level follows the suffix, the words follow the reason.

Constants: POWER_OFF, MESSAGES, JOB_WORDS

Symbol Purpose
split_reason(reason) (base reason, level) for a printer-state-reason keyword
describe(state, reasons, reachable=True) (level, message) for the printer: ok / warn / error
user_must_act(reasons) True if a reason needs the user (paper, jam, cover, empty ink): don't try another method
ink_warnings(markers, threshold) Plain warnings for cartridges at or below threshold percent
chip_key(level, state=None, reasons=()) lintheme status key (ok / busy / attention / error) for a printer status.
needs_you_words(reasons) The short reason beside a disabled Print button

src/modules/manager_testpage.py

Test Page Manager Built-in test pages for checking a printer (Printers → Test the printer): quality colour and grey blocks, fine lines, a gradient, text sizes and a frame at the printable edge — shows streaks, gaps (blocked nozzles), colour casts, banding and whether margins are right lines alignment page: thin horizontal and vertical line fields per ink, for spotting misaligned or missing nozzles Drawn with Pillow at 300 dpi and saved as a one-page PDF, then printed like any document (not added to Recent).

Constants: DPI, KINDS, CMYK, RGB

Symbol Purpose
_font(size_pt, bold=False) DejaVu Sans at size_pt (Pillow's default font if missing)
_mm(v) Millimetres to pixels
_tint(rgb, amount) rgb mixed with white: amount 1 = full colour, 0 = white
_header(d, x, y, title, info) Title and the printer details; returns the y below it
_info_lines(printer_name, ticket, firmware='') Who printed what, when
quality_page(d, w, h, left, top, right, bottom, info) Colour blocks, grey ramp, fine lines, gradient, text sizes
lines_page(d, w, h, left, top, right, bottom, info) Line fields per ink and alignment crosses
make_test_page(kind, out, size_mm, margins_mm, printer_name, ticket=None, firmware='') Draw test page kind for paper size_mm (w, h) and margins (top, right, bottom, left); returns out

src/ui/app_css.py

App CSS LinPrinter's own classes on top of lintheme (Graphite Night): the few class names the preview component and the optional features still use. Colours come only from lintheme.tokens, so there is still exactly one theme.

Constants: C

Symbol Purpose
css() The stylesheet text
install() Load the app CSS above lintheme's

src/ui/app_window.py

Main Window The 2026 redesign shell (lintheme, Graphite Night): a header bar with the brand, the current page, the printer status chip and the menu; the navigation rail; the page stack. The status chip shows the chosen printer in the shared vocabulary (Ready · Busy · Needs you · Can't reach) on every page and opens the Printer page. Keyboard: Alt+1…4 pages, Ctrl+P print, Ctrl+O open, F5 check the printer again.

Constants: ICON_SIZES, TITLES

Symbol Purpose
set_app_icon(window=None) The LinPrinter icon in several sizes, for every window (panel, Alt+Tab, dialogs)
class AppWindow(Gtk.Window) Main application window
  .__init__(self, ctx) Header bar, rail and content area; registers itself as dialog parent
  ._on_printer(self, printer)
  ._on_status(self, printer, status)
  ._on_printing(self, busy) A job started or finished: chip and Activity badge
  ._menu(self)
  ._settings_section(self, section)
  .show_shortcuts(self) The keyboard shortcuts, in a small dialog
  ._on_key(self, _w, event)
  ._on_size(self, _w, alloc)

src/ui/components/component_preview.py

Preview Component Large view of the selected page (fit to window, or zoomed) plus a thumbnail strip that scrolls sideways and shows 1 or 2 rows. Fast page switching: pages are drawn from small display copies (utils/util_display.DisplayCache, made in the background as pages arrive), thumbnails are cached, and selecting a page only moves the highlight instead of rebuilding the strip. Zoom: the - / Fit / + buttons, Ctrl + mouse wheel, or Ctrl + plus / minus / 0; drag the zoomed page to pan.

Constants: ZOOM_STEPS, THUMB_CACHE_MAX, LARGE_CACHE_MAX

Symbol Purpose
to_pixbuf(img) Pillow RGB image -> GdkPixbuf
class PagePreview(Gtk.Box) Selected-page view + thumbnail strip; on_select(index) when the page changes
  .__init__(self, on_select=None, cache_dir=None, rows=1, on_zoom=None, label_for=None) Large view (scrolled, zoomable) plus the thumbnail strip (rows: 1 or 2)
  .show_message(self, words) No pages, and why (e.g. a page list that matches none): not the "no document" state
  .set_pages(self, pages, selected=None) Show a page list and select one (keeps selection if possible)
  .refresh_selected(self) Re-render after the selected page changed (rotation, Quick Edit)
  .select(self, index) Show another page (only the highlight moves; nothing is rebuilt)
  .set_rows(self, rows) 1 or 2 rows of thumbnails
  .set_zoom(self, zoom) Zoom relative to fit-to-window (1.0 = fit)
  .zoom_in(self) Next zoom step
  .zoom_out(self) Previous zoom step
  .zoom_fit(self) Fit the whole page in the window
  .zoom_fit_width(self) Fill the window's width with the page (tall pages then scroll)
  ._apply_strip_height(self) Strip tall enough for 1 or 2 rows, plus the scroll bar
  ._thumb_pixbuf(self, page) Cached thumbnail for a page (redrawn only when the page changed)
  ._thumb_button(self, i, page) Button with a page thumbnail and its number
  ._set_thumb(self, i) Draw (or redraw) thumbnail i
  ._rebuild_strip(self) Lay out the thumbnails column by column (1 or 2 rows), from the left
  ._scroll_to_thumb(self, index) Keep the selected thumbnail visible
  ._on_resize(self, _widget, alloc) Re-render the large view after resizing (debounced)
  ._render_large(self) Render the selected page at fit x zoom (one-shot timeout)
  ._on_scroll(self, _widget, event) Ctrl + wheel zooms; the plain wheel scrolls as usual
  ._pan_start(self, _widget, event) Start dragging the zoomed page
  ._pan_move(self, _widget, event) Pan while dragging
  ._pan_end(self, *_) Stop panning

src/ui/components/component_segmented.py

Segmented Control Linked group of mutually exclusive toggle buttons (e.g. Colour | Black & white), styled as lintheme's segmented choice (lt-seg). Arrow keys move between options.

Symbol Purpose
class SegmentedControl(Gtk.Box) Radio-style toggle buttons; on_changed(key) fires on user selection
  .__init__(self, items, active=None, on_changed=None, button_width=None) items: [(key, label)]; button_width: same width for every button (uniform rows)
  ._toggled(self, button, key) Keep exactly one button active; report user changes
  .set_active(self, key) Select a key without firing on_changed
  .get_active(self) Currently selected key

src/ui/content_area.py

Content Area Component Stack of pages. Pages are registered from a list, so adding a page is a one-line change (plus one line in ui/sidebar.NAV_ITEMS).

Constants: PAGES

Symbol Purpose
class ContentArea(Gtk.Box) Page stack registered with the navigation manager
  .__init__(self, ctx) Create every page in PAGES, add to the stack, register for navigation

src/ui/sidebar.py

Sidebar (navigation rail) The four destinations of the 2026 redesign, as lintheme's NavRail: Print, Activity, Printer, and Settings at the bottom. Adding a page = one line here and one line in ui/content_area.PAGES.

Constants: NAV_ITEMS

Symbol Purpose
class Sidebar(NavRail) The rail, wired to the navigation manager
  .__init__(self, nav)

src/pages/page_activity.py

Activity Page (2026 redesign: Queue and Recent merged) Both answer "what happened to my print?": - Now printing: the chosen printer's jobs that are waiting or printing, with progress and Cancel print (asks first); - History: what was printed (or didn't print, and why), newest first, with the settings used; Print again (same settings, still editable), Show file, Remove; Clear older than… (asks first; files are never touched). The list is stored on this computer only (~/.local/share/linprinter/recent.json).

Constants: ACTIVE, JOB_WORDS, NOT_FAILURES, FILTERS, CLEAR_DAYS

Symbol Purpose
when_words(iso) 'Today 00:54', 'Yesterday 18:20', '28 Sep'
class ActivityPage(BasePage) Now printing + History
  .build_content(self)
  ._tick(self)
  .printer(self)
  .refresh_jobs(self) Read the chosen printer's jobs in the background
  .show_jobs(self, printer, jobs)
  .cancel(self, job) Ask, then cancel one job
  .refresh(self, *_) Rebuild the history table (newest first), filtered
  ._show_now(self) Now printing shows while something prints, or above a history (not on the empty page)
  ._row(self, r, e)
  .print_again(self, path, choices)
  .troubleshoot(self)
  .forget(self, path) Remove one entry (the file is not touched)
  .clear(self) Clear entries older than 30 days, 90 days or a year (asks first, names how many)
  .on_shown(self)

src/pages/page_base.py

Base Page Class Base class for all pages (from gtk-python-dashboard-starter), extended with the app context and card helpers.

Symbol Purpose
class BasePage(Gtk.Box) All pages inherit from this class and implement build_content()
  .__init__(self, ctx, spacing=None, margin=None) Apply margins/spacing, keep the app context, then build_content()
  .build_content(self) Create the page's widgets (subclasses must implement)
  .on_shown(self) Called by the navigation manager when the page becomes visible
  .label(text, css=None, xalign=0, wrap=False, selectable=False) Create a label with optional CSS classes, alignment and wrapping
  .add_title(self, text, subtitle=None) Add the page title and an optional muted subtitle
  .add_paragraph(self, text) Add wrapped secondary text
  .make_card(self, title=None) A rounded card; returns (card, inner vertical box)
  .add_card(self, title=None, expand=False) Add a card to the page and return its inner box
  .form_row(label_text, widget) Label on the left, widget on the right

src/pages/page_print.py

Print Page (2026 redesign) The home view: options on the left in four cards (Printer, Document, Paper, Output, with "More options"), the live preview on the right, and a sticky action bar with the summary and the one main action, Print. Behaviour kept from 0.2.x: the remembered printer is reached first, status is checked every few seconds, a quiet search runs when the printer stops answering, only options the printer reports are offered, page selection and fitting are done by LinPrinter, and features hook in (profiles add a row to More options; ink alerts are asked before printing). Status follows the shared vocabulary (Ready · Busy · Needs you · Can't reach) with its fix in place; Print is disabled with the reason beside it; nothing is ever re-sent another way without telling the user.

Constants: PREVIEW_DPI, NO_DOC

Symbol Purpose
class PrintPage(BasePage) Printer, document, options, live preview and the Print bar
  .__init__(self, ctx)
  .build_content(self)
  ._card_head(self, title, extra=None)
  ._printer_card(self)
  ._document_card(self)
  ._paper_card(self)
  ._output_card(self)
  ._fill(control, stepper=False) Make a segmented control (or the stepper) fill the field column with equal segments
  ._uniform_row(self, label, control) A field row whose control fills the column, as the Paper selects do: every Output control
  ._labelled_row(label, control, caption) A uniform row with a small caption under its label (the control keeps the full width)
  .form_row(self, label_text, widget) Rows that features add to More options (profiles): the kit's field row
  .toggle_more(self) Show or hide More options (remembered)
  ._preview_pane(self)
  .on_layout(self, mode) Wide: two panes. Compact (< 960 px): options only, the preview on request.
  .set_preview_mode(self, preview) In a compact window, show the preview in place of the options (or back)
  ._preview_keys(self, _w, event)
  .set_notice(self, kind=None, message='', actions=()) A message above the cards (print results and problems); kind None clears it
  .set_status(self, text, css='muted') 0.2.x API (features, tools): a notice in the house style
  .set_busy(self, busy, printing=False) Lock the controls while searching or printing; Cancel only while printing
  .remember(self, key, value) Persist an option choice and refresh the summary
  ._set_status_line(self, key, words)
  ._set_printer_banner(self, kind=None, message='', actions=())
  ._ready_words(self, status) 'Ready · Letter loaded · ink 60% / 80%' from what the printer reports
  .show_power(self, level, message, state=None, reasons=()) The printer's state in the shared vocabulary, with its fix in place
  ._cant_reach(self, message) (status words, banner message) for Can't reach: the evidence first, then the cable
  .test_connection(self) Run the link test on the Printer page (it shows the result)
  .open_troubleshooter(self)
  .looking(self) While searching
  .on_reconnect(self) Re-attach the printer's USB device (asks first; the system asks for the password)
  .startup(self) At start: reach the remembered printer directly; otherwise search
  .refresh_printers(self, *_) Search for printers in the background (not while printing)
  .printers_loaded(self, printers) Keep the list; select the last used printer (else the first real one)
  .printers_failed(self, message) A search error: plain words, the details in the log
  ._build_printer_menu(self) The Change popover: every printer (with why it can't print yet), search again, manage
  ._choose(self, printer)
  .current_printer(self) The chosen PrinterDevice (or None)
  .select_printer(self, printer) Offer the chosen printer's options; check its status
  .check_status(self) Ask the printer how it is (in the background)
  .maybe_reconnect(self, printer) The printer stopped answering: look for it again, quietly, now and then
  .reconnected(self, printers) A quiet search finished: keep the chosen printer; say so if it is back
  .poll_status(self) Every few seconds; while printing only every third time (paper out mid-job must show, but
  .fill_options(self, printer) Paper sizes (grouped), types, colours, qualities for this printer
  .on_size_or_type(self) Paper size or type changed: remember it and update Borderless
  .update_availability(self) Borderless only where the printer allows it for this size and type (the reason beside it)
  .on_pages_choice(self, key) Show the page box for Custom; Which (odd/even) applies to All
  .page_choice(self) The select_pages() choice from Pages and Which
  .choices(self) The current choices as a dict (also what profiles store)
  .apply_choices(self, choices) Set the controls from a dict (profiles, printer defaults); unsupported choices are skipped
  .selected_pages(self) 1-based pages to print (RenderError for a bad range)
  .ticket(self) The job ticket for the current choices
  .update_summary(self) The action bar says exactly what will print
  .update_print_button(self) Print is enabled only when it can work; otherwise the reason sits beside it
  .schedule_preview(self, *_) Re-render shortly after the options change (only while the preview is on screen)
  .render_preview(self) Render the chosen pages in the background (page 1 first)
  .on_preview_select(self, index) Remember the page shown (Pages → Current prints it)
  .update_preview_info(self)
  .choose_document(self) File dialog for a document
  .on_drop(self, _widget, _ctx, _x, _y, data, _info, _time) A file dropped on the page
  .reprint(self, path, choices=None) Activity → Print again: the document with the options it was printed with (still editable)
  .open_document(self, path, choices=None) Prepare a document (in the background) and show it
  .on_print(self) Check, confirm warnings, and print
  ._on_progress(self, text)
  ._tick(self)
  ._stop_pulse(self)
  .on_cancel(self) Ask, then cancel the running job
  ._show_secondary(self, label, action)
  .on_done(self, printer, ticket, result) The end moment: what happened, how long it took, and what next
  .on_error(self, message, printer=None, ticket=None, pages=None) A print problem: said where it happened, with the next step; kept in Activity
  .on_shown(self) Refresh the status and the preview when the page is opened

src/pages/page_printer.py

Printer Page (2026 redesign: health first, then upkeep and details) The chosen printer (the one on the Print page): - the status hero: Ready · Busy · Needs you · Can't reach, what it means and the next step; - Ink and paper, Connection (port, route, link errors from the kernel log, the last link test, the system queue), Look after the printer (test pages, the printer's own settings page); - the printer's own defaults (use them in LinPrinter) and the details; - a stray system queue, with the command that removes it; - the Troubleshooter: guided steps, cheapest first (cable first), each with what LinPrinter measures right now. Nothing here changes the system without asking: Reconnect and removing a queue state their scope first, and the system asks for the password itself.

Constants: EVERY_WORDS, STEP_TITLES, STEPS, INK

Symbol Purpose
marker_colours(marker) A cartridge's colours for its gauge (the printer reports them; black stays black)
class PrinterPage(BasePage) Health, upkeep, details, and the Troubleshooter
  .build_content(self)
  .on_printer(self, printer)
  .on_status(self, printer, status)
  .tick(self) Keep "Checked N s ago" true while the page is open (the Print page asks the printer)
  .key(self) The lintheme status key of the chosen printer
  .rebuild(self) Redraw whichever view is showing
  ._clear(box)
  .build_overview(self)
  .hero(self, p, pdf)
  .set_notice(self, kind, message) A message kept across rebuilds, shown in the overview and in the Troubleshooter
  ._show_notice(self, box)
  ._w(words, *classes) Wrapped text that doesn't push cards wider (card rows reflow by natural width)
  .ink_card(self, p)
  .connection_card(self, p)
  .care_card(self, p)
  .defaults_card(self, p)
  .details_card(self, p)
  .describe_defaults(p, d) Printer defaults in words
  .rows(self, p) (section, [(key, value)]) for the details
  ._clock(ts)
  .open_uri(self, uri)
  .check_again(self)
  .identify(self)
  .reconnect(self)
  .run_test(self, then=None) The link test (read-only); the result goes in Connection and the Troubleshooter
  .remove_queue(self, command) A stray system queue: show the exact command; it needs the administrator's password
  .use_defaults(self, p)
  .print_test(self, p, kind)
  .open_test(self, p)
  .start_troubleshooter(self, step=0)
  .close_troubleshooter(self)
  .build_trouble(self)
  .evidence_card(self) What LinPrinter measures right now (honest evidence, not guesses)
  .fill_step(self, card)
  .next_step(self)
  .test_step(self)
  .check_and_continue(self) Search again and wait for the printer's own answer; only that counts as fixed
  ._no_answer(self) No answer in time: say so; the step stays
  ._cancel_give_up(self)
  .on_shown(self)

src/pages/page_settings.py

Settings Page (2026 redesign; About folded in) Sections, chosen on the left: - Printing: Print to PDF folder, the low-ink warning level, what to do at start, and the optional features (switches with one-line descriptions); - Privacy and diagnostics: what stays on this computer (USB only, no network, no telemetry, no AI), the files LinPrinter keeps, Save diagnostics; - About: version, what it is, compatibility, licence, system, credits, shortcuts. There is one theme (Graphite Night), so there is no theme choice.

Constants: SECTIONS

Symbol Purpose
class SettingsPage(BasePage) User preferences (saved to ~/.config/linprinter/settings.json) and About
  .build_content(self)
  .show_section(self, key) Open a section (the header menu's About uses this)
  .show_licence(self) The full licence text (LICENSE.md, shipped with the app), in a dialog
  .save(self, key, value) Persist a setting and broadcast settings-changed
  .printing_section(self)
  .choose_pdf_folder(self) Folder dialog for Print to PDF
  .set_pdf_folder(self, path) Store the Print to PDF folder and show it
  .on_feature(self, feature, on) Enable or disable a feature module
  .privacy_section(self)
  .open_log_folder(self)
  .save_diagnostics(self) Save a zip with recent logs, system info, printers and settings
  .about_section(self)

src/utils/util_display.py

Display images for the Preview A 600 dpi colour page is ~100 MB once decoded, so showing it straight from the scan is slow. Each page gets a small display copy ("proxy", at most PROXY_MAX_SIDE pixels, JPEG) made once, in the background as pages arrive. The large view and the thumbnails are drawn from it; only a deep zoom goes back to the original scan. Quick Edit layers and rotation are applied at display time, so the proxy never goes stale.

Constants: PROXY_MAX_SIDE, MAX_RENDER_PIXELS

Symbol Purpose
_fit_within(size, max_w, max_h) The size that fits max_w x max_h keeping the aspect ratio, capped by MAX_RENDER_PIXELS.
class DisplayCache() Proxy files for pages (thread-safe), kept in a cache folder
  .__init__(self, folder) folder: where proxies are written (the session's temp folder)
  .proxy(self, path) (proxy path, factor) for a page image, creating it if needed.
  ._proxy_locked(self, path) proxy() body, with the page's lock held
  .warm(self, paths) Create missing proxies in a background thread
  .original(self, path) The full-size page image (the most recent one is kept)
  .render(self, page, max_w, max_h, full_size=None) The page as an RGB image fitting max_w x max_h, rotated and with Quick Edit layers.
  .size(self, page) Full-resolution (w, h) of a page after rotation
page_key(page) Identity of what a page looks like (image and its version, rotation, Quick Edit layers)

src/utils/util_files.py

Files Small helpers for paths the user sees: the home folder shown as ~, and opening the file manager at a file (highlighting it where the file manager supports it).

Symbol Purpose
tilde(text) Text with the home folder shown as ~
short_folder(path) Folder part of a path, home shown as ~, ending in /
show_in_file_manager(path, window=None) Open the file manager at the file's folder, highlighting the file if possible

src/utils/util_icons.py

Icons Phosphor Icons v2.0.8 (Helena Zhang and Tobias Fried, MIT): resources/icons/phosphor/regular/.svg (other weights: /-.svg). The SVGs draw with currentColor, which is replaced by the theme's text colour (or a given colour) before rendering, so icons suit light and dark themes. Rendered pixbufs are cached per (name, size, colour, weight). To add an icon, copy its SVG from ~/projects/assets/Icons/phosphoricons (see its INDEX.txt for names and search tags) into resources/icons/phosphor/regular/.

Constants: DEFAULT_COLOR

Symbol Purpose
set_icon_color(color) Colour for icons without an explicit colour (set from the active theme)
icon_path(name, style='regular') SVG path of a bundled icon in a Phosphor weight (regular, bold, fill, ...)
icon_pixbuf(name, size=20, color=None, style='regular') The icon as a pixbuf of size x size pixels, drawn in color (theme text colour by default)
icon_image(name, size=20, color=None, style='regular') A Gtk.Image of the icon (a generic icon if the file is missing)
icon_button(name, tooltip, on_click=None, size=18, toggle=False, color=None) A compact, square button showing only an icon (the tooltip names the action)
icon_label_button(name, label, tooltip=None, on_click=None, size=16) A button with an icon followed by a short label

src/utils/util_imaging.py

Imaging helpers Rotation for display. Print jobs are rendered by Ghostscript (modules/manager_render.py); this module only prepares preview images.

Symbol Purpose
flatten(page, img=None) Page image with its rotation applied (print previews have no editing layers yet)

src/utils/util_logging.py

Logging One log file per day in /.local/state/linprinter/logs/ (or $XDG_STATE_HOME), kept for 14 days and capped in size. Every line is redacted: printer serial numbers become "…" and the home folder becomes "". Logging problems never stop the app: if the folder can't be written, logging quietly goes nowhere. Use: from utils.util_logging import get_logger; log = get_logger("scan")

Constants: KEEP_DAYS, MAX_BYTES, ROOT, _SERIAL, _DEVICE_SN

Symbol Purpose
log_dir() Folder holding the log files
redact(text) Hide serial numbers and similar identifiers
redact_text(text) Remove serial numbers and the home folder path from a log line
class RedactingFormatter(logging.Formatter) Formatter that redacts every formatted line (incl. tracebacks)
  .format(self, record) Format, then redact
_prune(folder) Delete log files older than KEEP_DAYS
setup_logging(debug=False) Configure the 'linprinter' logger (idempotent); returns the log file path or None
get_logger(name) Logger for one part of the app, e.g. get_logger('scan') -> 'linprinter.scan'
timed(logger, what, level=logging.DEBUG) Log how long a block took: 'what took 1.23 s'
install_excepthook() Log uncaught exceptions (main thread and worker threads) before the default handling
package_version(pkg) Installed Debian package version, or 'not installed'
system_info() Multi-line description of the environment (versions, desktop, tools)
write_diagnostics(path, extra_sections=None, days=3) Zip the recent logs + system info (+ extra sections) for sending; returns path

src/utils/util_paths.py

Path helpers Locations of the app root and bundled resources, independent of the cwd.

Constants: APP_ROOT

Symbol Purpose
resource(*parts) Absolute path of a file under resources/
read_version() Version string from the VERSION file
data_dir(*parts) Folder under ~/.local/share/linprinter (honours XDG_DATA_HOME); created on demand

src/features/__init__.py

Feature modules Optional features live here as feature_.py files. The core never imports them directly: it calls the FeatureRegistry at a few hook points, and every hook call is isolated, so a feature that is disabled, deleted or broken can't affect printing. Hooks a feature may implement (all optional): before_print(ctx, printer, ticket, status) -> [warning, ...] asked before printing; warnings are shown and the user confirms after_print(ctx, printer, ticket, result) after a job finished extend_print_page(page), extend_preview(page) add widgets to those pages settings_widget(ctx) -> Gtk.Widget | None per-feature settings (Settings page)

Symbol Purpose
class BaseFeature() Base class for features; override the hooks you need
  .__init__(self, settings) settings: the SettingsManager (feature options live under feature_settings[id])
  .option(self, key, default) This feature's stored option (or default)
  .set_option(self, key, value) Store one of this feature's options
class FeatureRegistry() Discovers feature_*.py modules and dispatches hooks to the enabled ones
  .__init__(self, settings, folder=None) Load every feature module in folder (default: this package)
  ._load(name, path) Import a feature module from exactly this file (not whatever shares its name)
  .is_enabled(self, feature) Enabled per settings, else the feature's default
  .set_enabled(self, feature_id, enabled) Turn a feature on or off (persisted)
  .get(self, feature_id) Feature by id (loaded, enabled or not), or None
  .enabled(self, hook=None) Enabled features, optionally only those implementing a hook
  ._call(self, feature, hook, *args) Call one hook, isolating failures; returns (ok, result)
  .before_print(self, ctx, printer, ticket, status) Warnings from features before a job is sent (the user confirms them)
  .after_print(self, ctx, printer, ticket, result) Notify features that a job finished
  .extend(self, hook, page) Let features add widgets to a page (hook: extend_print_page / extend_preview).

src/features/feature_ink_alerts.py

Ink alerts Before printing, warns when a cartridge is at or below the low-ink level set in Settings (15 % by default), or empty, so a long job isn't started on an empty cartridge. The user can print anyway.

Symbol Purpose
class Feature(BaseFeature) Warn about low ink before printing
  .before_print(self, ctx, printer, ticket, status) Low-ink warnings for the chosen printer

src/features/feature_profiles.py

Print profiles One-click presets on the Print page ("Everyday", "Draft (saves ink)", "Photo borderless", "Envelope", ...) plus "Save as profile…" for your own. A preset only sets choices the printer supports; the rest stay as they are.

Constants: BUILT_IN

Symbol Purpose
class Feature(BaseFeature) Presets for common jobs
  .profiles(self) Built-in and saved profiles
  .extend_print_page(self, page) A Profile row at the end of More options
  ._visibility(self, page) Show the row only while the feature is on
  .apply(self, page, name) Set the Print page's choices from a profile
  .save_current(self, page) Store the current choices under a name

src/lintheme/__init__.py

lintheme: the shared look and building blocks of the Lin* utility apps. tokens colours, type, space, sizes, motion: the single source (Graphite Night) css the stylesheet for GTK 3 or GTK 4, generated from the tokens apply install the stylesheet in a running app status the four-state vocabulary and the message pattern icons bundled Phosphor icons, recoloured to the theme gtk3 components and the dashboard shell for GTK 3 apps (LinPrinter, LinScanner, …) Copy this package into an app (the universal rule is copy, not reference), then see docs/06-ADOPTING.md.

Constants: THEME

src/lintheme/apply.py

Install the theme in a running GTK 3 or GTK 4 app. from lintheme import apply apply.install(window) # after Gtk is imported, before or after show It loads the generated CSS at USER priority (above the system theme, so Mint-Y, Adwaita or any other theme can't override it), asks for the dark variant of the system theme (dialogs, scrollbars and menus then match), adds lt-root to the window, and reports CSS parsing errors instead of hiding them.

Symbol Purpose
_gtk() The Gtk module the app already imported (3 or 4)
toolkit() 3 or 4: the GTK major version loaded in this process
font_available(family=None) True if the theme's font is installed (Ubuntu ships with Linux Mint and Ubuntu)
install(window=None, errors=None) Load the CSS for the running toolkit; returns the provider.
animations_enabled() False when the system turned animations off: then use 0 ms instead of tokens.MOTION

src/lintheme/css.py

CSS for GTK 3 or GTK 4, generated from tokens.py. Every class starts with lt- so an app's own CSS never collides with the kit's. The two toolkits read almost the same CSS; the differences that matter, found by rendering the mockups in both (docs/04-CSS.md), are: | What | GTK 3.24 | GTK 4.14 | |---|---|---| | Keyboard focus ring | :focus + outline longhands | :focus-visible + outline shorthand | | Theme gradients on buttons | must be cleared: background-image: none | same (harmless) | | Text shadows from the theme | must be cleared: text-shadow: none | none by default | | Symbolic icons follow color | -gtk-icon-style: symbolic | default | | CSS custom properties (var()) | not supported | only from 4.16 (Mint 22 has 4.14) | So values are substituted here, in Python, rather than with CSS variables.

Constants: C, R, S, T, CLASSES

Symbol Purpose
_type(name)
_common() Rules both toolkits read the same way
_gtk3()
_gtk4()
build_css(toolkit=3) The whole stylesheet for GTK toolkit (3 or 4)

src/lintheme/gtk3/components.py

Components for GTK 3 Lin* apps: the building blocks of the mockups, each a plain Gtk widget (subclass or factory) styled only through lt- CSS classes. Rules every component keeps (docs/05-UX.md): - a status is an icon AND a word, never colour alone; - every control has an accessible name; icon-only buttons get one explicitly; - targets are at least SIZE['target_min'] px; the one main action per view is big; - a disabled control says why, next to it; - destructive actions go through confirm(..., destructive=True), which names what goes. Widgets are created visible-ready: call show_all() on the window as usual.

Constants: C

Symbol Purpose
css(widget, *classes) Add lt- classes (or any) to a widget; returns it
uncss(widget, *classes)
name(widget, accessible_name) Set the accessible name screen readers announce
text(label, *classes, wrap=False, xalign=0.0, selectable=False) A Label with classes; wrap for prose
markup(m, *classes, wrap=True)
icon(icon_name, size=20, color=None, *classes) A bundled Phosphor icon (recoloured; default theme text colour)
status_icon(key, size=18) The icon of a status in its colour
hbox(*children, spacing=12, expand_last=False)
vbox(*children, spacing=8)
spacer()
button(label, on_click=None, kind=None, small=False, big=False, tooltip=None, icon_name=None) kind: None (secondary), 'primary', 'danger', 'link'
icon_button(icon_name, accessible_name, on_click=None) Icon-only button: always named for screen readers and tooltips
with_shortcut(btn, keys) Show a shortcut hint (e.g. 'Ctrl+P') inside a button
disabled_with_reason(btn, reason) Disable a button and return a row that says why, beside it
class StatusChip(Gtk.Button) Header chip: device + status word + icon. Click opens the device page.
  .__init__(self, device='', key='none', on_click=None)
  .set_status(self, device, key, detail=None)
class NavRail(Gtk.Box) Vertical navigation: icon + label items, one active, optional count badge.
  .__init__(self, on_select=None)
  .add_item(self, page_id, label, icon_name, bottom=False)
  .set_active(self, page_id)
  .set_badge(self, page_id, count=None, spoken=None) A count badge on an item (None hides it)
  .set_compact(self, compact)
class Card(Gtk.Box) A titled card: one concern per card. action=(label, callback) adds a text button.
  .__init__(self, title=None, action=None, large=False, spacing=10)
  .add_row(self, widget, expand=False)
field_row(label, widget, hint=None, label_width=84) 'Label [control] hint' on one line
key_values(pairs, value_classes=None) A two-column list of (key, value); value_classes: {key: css class}
icon_tile(icon_name)
item_row(icon_name, title, detail=None) Icon tile + title + muted detail (a device, a document)
status_line(key, words) Status icon + words, e.g. 'Ready · Letter loaded'
class Segmented(Gtk.Box) 2 to 4 mutually exclusive options as linked buttons (radio semantics, arrow keys move).
  .__init__(self, options, active=None, on_change=None, accessible_name='')
  .get_value(self)
  .set_value(self, value)
  .set_option_sensitive(self, value, sensitive, reason=None)
class Stepper(Gtk.Box) − value + (e.g. copies 1 to 99); typing is not needed, keys: +/- buttons, focusable
  .__init__(self, value=1, minimum=1, maximum=99, on_change=None, accessible_name='Value', unit='')
  .get_value(self)
  .set_range(self, minimum, maximum) New limits (e.g. the printer's copies-supported); the value is clamped
  .set_value(self, value, notify=False)
no_wheel(widget) The mouse wheel never changes this control: it scrolls the page around it instead.
select(options, active=None, on_change=None, accessible_name='') A dropdown (Gtk.ComboBoxText) for 5+ options; options: [(id, label)]
class SwitchRow(Gtk.Box) Title + one-line description + switch (named after the title)
  .__init__(self, title, description=None, active=False, on_toggle=None)
entry(value='', placeholder='', accessible_name='', width_chars=12)
set_invalid(widget, invalid, message_label=None, message='') Mark an entry invalid and show the fix in message_label
class Banner(Gtk.Box) Inline message: kind 'ok'
  .__init__(self, kind, message_markup, actions=())
pill(words, kind=None) A small status word in a rounded outline (lists, tables)
class Gauge(Gtk.Box) A level (ink, toner, battery, disk): coloured fill + outline + the number in words.
  .__init__(self, title, percent, colours=None, low_at=None, note=None)
  ._draw(self, area, cr)
_rounded(cr, x, y, w, h, r)
card_flow(cards, columns=3) Cards side by side that reflow into fewer columns in a narrower window
scrolled(widget, min_height=-1) Vertical scrolling for a pane that can outgrow the window (the options column)
progress(fraction, accessible_name='Progress', width=320)
class Hero(Gtk.Box) The big status block at the top of a device page
  .__init__(self, key, headline, lines=(), actions=())
class EmptyState(Gtk.Box) Nothing here yet: icon, what will appear, and the one next action
  .__init__(self, icon_name, title, body, action=None)
class StepLadder(Gtk.Box) Guided steps, cheapest first: done ✓, now (highlighted), next (muted)
  .__init__(self, steps, current=0, done=False)
  .set_current(self, index)
class ActionBar(Gtk.Box) The sticky bottom bar: summary on the left, the main action on the right.
  .__init__(self)
  .add_action(self, widget)
  .clear_actions(self)
  .idle(self, head, detail='')
  .busy(self, words, fraction)
  .pulse(self, words) Busy with no known fraction: call repeatedly (e.g. every 200 ms)
  .state(self) 'idle', 'busy' or 'done'
  .done(self, head, detail='')
confirm(parent, title, body, confirm_label, destructive=False, cancel_label='Cancel', detail_mono=None) Ask before acting. The title asks; the buttons answer with verbs. Esc cancels.

src/lintheme/gtk3/shell.py

The dashboard shell for GTK 3 Lin* apps: header bar with the device status chip, navigation rail, stacked pages. Every Lin* app is this shell plus its pages. win = DashboardWindow(app, "LinPrinter", "printer") win.add_page("print", "Print", "printer", PrintPage()) win.add_page("activity", "Activity", "clock-counter-clockwise", ActivityPage()) win.add_page("device", "Printer", "usb", DevicePage()) win.add_page("settings", "Settings", "gear-six", SettingsPage(), bottom=True) win.chip.set_status("Canon TR150", "ok") win.show_all() Keyboard: Alt+1…Alt+9 switch pages in rail order. Below LAYOUT['two_pane'] px the rail goes compact and on_layout(mode) is called with 'wide' or 'compact', so a page can fold its second pane (the Print page folds its preview).

Symbol Purpose
class DashboardWindow(Gtk.ApplicationWindow)
  .__init__(self, application, app_name, app_icon='printer', on_chip=None, on_menu=None)
  .add_page(self, page_id, label, icon_name, widget, bottom=False, crumb=None)
  .show_page(self, page_id)
  .current_page(self)
  .on_layout(self, callback) callback(mode): 'wide' or 'compact', now and on every change
  ._on_size(self, _w, alloc)
  ._on_key(self, _w, event)

src/lintheme/icons.py

Icons: Phosphor v2.0.8 (regular weight, MIT, lintheme/icons/LICENSE-phosphor), bundled. The SVGs draw with currentColor; it is replaced by the requested colour (by default the theme's text colour) before rendering, so icons follow the theme and need no icon theme installed. Toolkit-neutral: returns a GdkPixbuf, which GTK 3 shows with Gtk.Image.new_from_pixbuf and GTK 4 with Gtk.Picture / Gtk.Image.new_from_paintable(Gdk.Texture.new_for_pixbuf(p)). Add an icon: copy its SVG from ~/projects/assets/Icons/phosphoricons/regular/ into lintheme/icons/ (names and search tags: that folder's INDEX.txt).

Constants: ICON_DIR

Symbol Purpose
names() Bundled icon names
pixbuf(name, size=20, color=None) The icon name at size px in color (default: theme text colour)

src/lintheme/status.py

The status vocabulary and the message pattern, shared by every Lin* app. Four device states, each always shown as an icon AND a word (never colour alone), the same everywhere: header chip, page cards, device page. Messages follow one pattern: what happened · why (with evidence) · the next step. The rules come from LinPrinter's 2026 USB incident: say where the fault is, lead with the cheapest physical check, and never present a guess as fact.

Constants: STATUSES, URGENCY_WORDS, CABLE_FIRST

Symbol Purpose
status(key) The Status for 'ok', 'busy', 'attention', 'error' or 'none'
chip_text(device, key) 'Canon TR150 · Ready'
message(what, why=None, next_step=None) One message in the house pattern: 'What happened. Why. Next step.'
problems(text) Reasons a user-facing message breaks the house rules ([] = fine)

src/lintheme/tokens.py

Design tokens: the single source for colour, type, space, size, shape and motion. One theme, Graphite Night. Everything else in lintheme (the CSS for GTK 3 and GTK 4, the components, the docs' tables, the contrast test) reads these values; nothing else hard-codes a colour. Two kinds of colour are deliberately NOT themed: ink-cartridge colours (INK) and the paper of a preview (PAPER), because they stand for physical things. Contrast (WCAG 2.2, checked by tests/test_tokens.py and tools/contrast.py): text and muted text >= 4.5:1 on every surface they sit on; control borders, focus ring and accent >= 3:1; status colours >= 4.5:1 on the surface.

Constants: NAME, COLOR, INK, PAPER, FONT, TYPE, SPACE, RADIUS, SIZE, LAYOUT, MOTION, CONTRAST_PAIRS

Symbol Purpose
luminance(hex_color) WCAG relative luminance of '#rrggbb'
contrast(fg, bg) WCAG contrast ratio between two '#rrggbb' colours
contrast_report() [(fg role, bg role, ratio, minimum)] for every pair the UI uses