diff --git a/src/labelle/cli/cli.py b/src/labelle/cli/cli.py index 7f16b0c..15fab81 100755 --- a/src/labelle/cli/cli.py +++ b/src/labelle/cli/cli.py @@ -6,6 +6,7 @@ # this notice are preserved. # === END LICENSE STATEMENT === import logging +import math from pathlib import Path from typing import List, NoReturn, Optional @@ -53,12 +54,12 @@ LOG = logging.getLogger(__name__) -def mm_to_payload_px(labeler: DymoLabeler, mm: float, margin: float) -> float: +def mm_to_payload_px(labeler: DymoLabeler, mm: float, margin: int) -> int: """Convert a length in mm to a number of pixels of payload. Margin is subtracted from each side. """ - return max(0, (mm * labeler.pixels_per_mm()) - margin * 2) + return max(0, math.ceil(mm * labeler.pixels_per_mm()) - margin * 2) def version_callback(value: bool) -> None: @@ -96,7 +97,7 @@ def list_devices() -> NoReturn: console = Console() headers = ["Manufacturer", "Product", "Serial Number", "USB"] table = Table(*headers, show_header=True) - for device in device_manager.devices: + for device in device_manager.get_devices_from_last_scan(): table.add_row( device.manufacturer, device.product, device.serial_number, device.usb_id ) @@ -220,7 +221,7 @@ def default( Optional[Path], typer.Option(help="Picture", rich_help_panel="Elements") ] = None, margin_px: Annotated[ - float, + int, typer.Option( help="Horizontal margins [px]", rich_help_panel="Label Dimensions" ), @@ -535,7 +536,7 @@ def default( render_engine=render_engine, justify=justify, visible_horizontal_margin_px=margin_px, - labeler_margin_px=dymo_labeler.labeler_margin_px, + labeler_margin_px=dymo_labeler.get_labeler_margin_px(), max_width_px=max_payload_len_px, min_width_px=min_payload_len_px, ) @@ -547,7 +548,7 @@ def default( dymo_labeler=dymo_labeler, justify=justify, visible_horizontal_margin_px=margin_px, - labeler_margin_px=dymo_labeler.labeler_margin_px, + labeler_margin_px=dymo_labeler.get_labeler_margin_px(), max_width_px=max_payload_len_px, min_width_px=min_payload_len_px, ) diff --git a/src/labelle/gui/gui.py b/src/labelle/gui/gui.py index 8bc6ce1..34ff8a4 100644 --- a/src/labelle/gui/gui.py +++ b/src/labelle/gui/gui.py @@ -116,10 +116,9 @@ def _on_settings_changed(self, settings: Settings) -> None: justify=settings.justify, ) - is_ready = self._dymo_labeler.is_ready - self._settings_toolbar.setEnabled(is_ready) - self._label_list.setEnabled(is_ready) - self._render_widget.setEnabled(is_ready) + self._settings_toolbar.setEnabled(True) + self._label_list.setEnabled(True) + self._render_widget.setEnabled(self._dymo_labeler.device is not None) def _update_preview_render(self, preview_bitmap: Image.Image) -> None: self._render.update_preview_render(preview_bitmap) diff --git a/src/labelle/gui/q_device_selector.py b/src/labelle/gui/q_device_selector.py index 0465cb7..2565cbd 100644 --- a/src/labelle/gui/q_device_selector.py +++ b/src/labelle/gui/q_device_selector.py @@ -48,6 +48,7 @@ def _init_connections(self) -> None: self._devices.currentIndexChanged.connect(self._index_changed) def repopulate(self) -> None: + """Update the device selector.""" old_hashes = {device.hash for device in self.device_manager.devices} self._devices.clear() for idx, device in enumerate(self.device_manager.devices): diff --git a/src/labelle/gui/q_labels_list.py b/src/labelle/gui/q_labels_list.py index bbaa133..5bcc33d 100644 --- a/src/labelle/gui/q_labels_list.py +++ b/src/labelle/gui/q_labels_list.py @@ -156,12 +156,14 @@ def _payload_render_engine(self): def render_preview(self) -> None: assert self.dymo_labeler is not None assert self.render_context is not None + assert self.h_margin_mm is not None + assert self.min_label_width_mm is not None render_engine = PrintPreviewRenderEngine( render_engine=self._payload_render_engine, dymo_labeler=self.dymo_labeler, justify=self.justify, visible_horizontal_margin_px=self.dymo_labeler.mm_to_px(self.h_margin_mm), - labeler_margin_px=self.dymo_labeler.labeler_margin_px, + labeler_margin_px=self.dymo_labeler.get_labeler_margin_px(), max_width_px=None, min_width_px=self.dymo_labeler.mm_to_px(self.min_label_width_mm), ) @@ -176,11 +178,13 @@ def render_preview(self) -> None: def render_print(self) -> None: assert self.dymo_labeler is not None assert self.render_context is not None + assert self.h_margin_mm is not None + assert self.min_label_width_mm is not None render_engine = PrintPayloadRenderEngine( render_engine=self._payload_render_engine, justify=self.justify, visible_horizontal_margin_px=self.dymo_labeler.mm_to_px(self.h_margin_mm), - labeler_margin_px=self.dymo_labeler.labeler_margin_px, + labeler_margin_px=self.dymo_labeler.get_labeler_margin_px(), max_width_px=None, min_width_px=self.dymo_labeler.mm_to_px(self.min_label_width_mm), ) diff --git a/src/labelle/lib/devices/device_manager.py b/src/labelle/lib/devices/device_manager.py index 1a66a2f..1375c31 100644 --- a/src/labelle/lib/devices/device_manager.py +++ b/src/labelle/lib/devices/device_manager.py @@ -24,15 +24,27 @@ class DeviceManagerNoDevices(DeviceManagerError): class DeviceManager: + """Incrementally maintain a list of connected USB devices. + + The list begins empty. It is updated whenever scan() is called. The list is + accessible via get_devices_from_last_scan(). + """ + _devices: dict[str, UsbDevice] def __init__(self) -> None: self._devices = {} def scan(self) -> bool: + """Check for devices being connected or disconnected. + + Returns true if the list of devices has changed. + """ prev = self._devices try: - cur = {dev.hash: dev for dev in UsbDevice.supported_devices() if dev.hash} + cur = { + dev.hash: dev for dev in UsbDevice.find_supported_devices() if dev.hash + } except POSSIBLE_USB_ERRORS as e: self._devices.clear() raise DeviceManagerError(f"Failed scanning devices: {e}") from e @@ -44,15 +56,16 @@ def scan(self) -> bool: cur_set = set(cur) for dev in prev_set - cur_set: + # Pop removed devices self._devices.pop(dev) for dev in cur_set - prev_set: + # Add new devices self._devices[dev] = cur[dev] changed = prev_set != cur_set return changed - @property - def devices(self) -> list[UsbDevice]: + def get_devices_from_last_scan(self) -> list[UsbDevice]: try: return sorted(self._devices.values(), key=lambda dev: dev.hash) except POSSIBLE_USB_ERRORS: @@ -94,7 +107,6 @@ def get_device_config_by_id(product_id: int) -> DeviceConfig: :param idValue: USB ID value :return: Device config, None if not found """ - # for device in SUPPORTED_PRODUCTS: if device.matches_device_id(product_id): return device diff --git a/src/labelle/lib/devices/dymo_labeler.py b/src/labelle/lib/devices/dymo_labeler.py index 4b6d644..e989756 100755 --- a/src/labelle/lib/devices/dymo_labeler.py +++ b/src/labelle/lib/devices/dymo_labeler.py @@ -20,6 +20,7 @@ from labelle.lib.devices.device_config import DeviceConfig from labelle.lib.devices.device_manager import get_device_config_by_id from labelle.lib.devices.usb_device import UsbDevice, UsbDeviceError +from labelle.lib.margins import LabelMarginsPx LOG = logging.getLogger(__name__) POSSIBLE_USB_ERRORS = (UsbDeviceError, NoBackendError, USBError) @@ -258,8 +259,12 @@ def __init__( raise ValueError("No device config") if tape_size_mm is None: - # Select highest supported tape size as default, if not set - tape_size_mm = max(self.device_config.supported_tape_sizes_mm) + if device is None: + # If there's no device, then use the most common tape size + tape_size_mm = 12 + else: + # Select highest supported tape size as default, if not set + tape_size_mm = max(self.device_config.supported_tape_sizes_mm) # Check if selected tape size is supported if tape_size_mm not in self.device_config.supported_tape_sizes_mm: @@ -271,16 +276,7 @@ def __init__( def get_label_height_px(self): """Get the (usable) tape height in pixels.""" - return self.tape_print_properties.usable_tape_height_px - - @property - def _functions(self) -> DymoLabelerFunctions: - assert self._device is not None - return DymoLabelerFunctions( - devout=self._device.devout, - devin=self._device.devin, - synwait=64, - ) + return self.compute_tape_print_properties().usable_tape_height_px @property def minimum_horizontal_margin_mm(self): @@ -290,15 +286,14 @@ def minimum_horizontal_margin_mm(self): self.device_config.distance_between_print_head_and_cutter_px ) - @property - def labeler_margin_px(self) -> tuple[float, float]: - return ( - self.device_config.distance_between_print_head_and_cutter_px, - self.tape_print_properties.top_margin_px, + def get_labeler_margin_px(self) -> LabelMarginsPx: + tape_print_properties = self.compute_tape_print_properties() + return LabelMarginsPx( + horizontal=self.device_config.distance_between_print_head_and_cutter_px, + vertical=tape_print_properties.top_margin_px, ) - @property - def tape_print_properties(self) -> TapePrintProperties: + def compute_tape_print_properties(self) -> TapePrintProperties: # Check if selected tape size supported if self.tape_size_mm not in self.device_config.supported_tape_sizes_mm: raise ValueError( @@ -374,25 +369,21 @@ def set_device(self, device: UsbDevice | None): LOG.error(e) self._device = device - @property - def is_ready(self) -> bool: - return self.device is not None - def pixels_per_mm(self) -> float: # Calculate the pixels per mm for this printer # Example: printhead of 128 Pixels, distributed over 18 mm of active area. # Makes 7.11 pixels/mm return self.device_config.print_head_px / self.device_config.print_head_mm - def px_to_mm(self, px) -> float: + def px_to_mm(self, px: int) -> float: """Convert pixels to millimeters for the current printer.""" mm = px / self.pixels_per_mm() # Round up to nearest 0.1mm return math.ceil(mm * 10) / 10 - def mm_to_px(self, mm) -> float: + def mm_to_px(self, mm: float) -> int: """Convert millimeters to pixels for the current printer.""" - return mm * self.pixels_per_mm() + return math.ceil(mm * self.pixels_per_mm()) def print( self, @@ -431,7 +422,13 @@ def print( try: LOG.debug("Printing label..") - self._functions.print_label(label_matrix) + assert self._device is not None + functions = DymoLabelerFunctions( + devout=self._device.devout, + devin=self._device.devin, + synwait=64, + ) + functions.print_label(label_matrix) LOG.debug("Done printing.") if self._device is not None: self._device.dispose() diff --git a/src/labelle/lib/devices/online_device_manager.py b/src/labelle/lib/devices/online_device_manager.py index a8e0394..7348224 100644 --- a/src/labelle/lib/devices/online_device_manager.py +++ b/src/labelle/lib/devices/online_device_manager.py @@ -15,7 +15,7 @@ class OnlineDeviceManager(QWidget): - _last_scan_error: DeviceManagerError | None + last_scan_error: DeviceManagerError | None _status_time: QTimer _device_manager: DeviceManager last_scan_error_changed_signal = QtCore.pyqtSignal( @@ -26,20 +26,20 @@ class OnlineDeviceManager(QWidget): def __init__(self) -> None: super().__init__() self._device_manager = DeviceManager() - self._last_scan_error = None + self.last_scan_error = None self._init_timers() def _refresh_devices(self) -> None: - prev = self._last_scan_error + prev = self.last_scan_error try: changed = self._device_manager.scan() - self._last_scan_error = None + self.last_scan_error = None if changed: self.devices_changed_signal.emit() except DeviceManagerError as e: - self._last_scan_error = e + self.last_scan_error = e - if str(prev) != str(self._last_scan_error): + if str(prev) != str(self.last_scan_error): self.devices_changed_signal.emit() self.last_scan_error_changed_signal.emit() @@ -49,10 +49,6 @@ def _init_timers(self) -> None: self._status_time.start(2000) self._refresh_devices() - @property - def last_scan_error(self) -> DeviceManagerError | None: - return self._last_scan_error - @property def devices(self) -> list[UsbDevice]: - return self._device_manager.devices + return self._device_manager.get_devices_from_last_scan() diff --git a/src/labelle/lib/devices/usb_device.py b/src/labelle/lib/devices/usb_device.py index 113d5a7..7e9751d 100644 --- a/src/labelle/lib/devices/usb_device.py +++ b/src/labelle/lib/devices/usb_device.py @@ -98,7 +98,7 @@ def is_supported(self) -> bool: return False @staticmethod - def supported_devices() -> set[UsbDevice]: + def find_supported_devices() -> set[UsbDevice]: return { UsbDevice(dev) for dev in usb.core.find( diff --git a/src/labelle/lib/margins.py b/src/labelle/lib/margins.py new file mode 100644 index 0000000..37575f0 --- /dev/null +++ b/src/labelle/lib/margins.py @@ -0,0 +1,6 @@ +from typing import NamedTuple + + +class LabelMarginsPx(NamedTuple): + horizontal: int + vertical: int diff --git a/src/labelle/lib/render_engines/margins.py b/src/labelle/lib/render_engines/margins.py index a634920..68e8bb3 100644 --- a/src/labelle/lib/render_engines/margins.py +++ b/src/labelle/lib/render_engines/margins.py @@ -1,12 +1,12 @@ from __future__ import annotations -import math from typing import Literal from PIL import Image from labelle.lib.constants import Direction from labelle.lib.env_config import is_dev_mode_no_margins +from labelle.lib.margins import LabelMarginsPx from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import ( RenderEngine, @@ -21,21 +21,28 @@ def __init__(self, width_px, max_width_px): class MarginsRenderEngine(RenderEngine): + label_margins_px: LabelMarginsPx + min_width_px: int + max_width_px: int | None + visible_horizontal_margin_px: int + render_engine: RenderEngine + def __init__( self, render_engine: RenderEngine, mode: Literal["print", "preview"], justify: Direction = Direction.CENTER, - visible_horizontal_margin_px: float = 0, - labeler_margin_px: tuple[float, float] = (0, 0), - max_width_px: float | None = None, - min_width_px: float | None = 0, + visible_horizontal_margin_px: int = 0, + labeler_margin_px: LabelMarginsPx | None = None, + max_width_px: int | None = None, + min_width_px: int | None = None, ): super().__init__() - labeler_horizontal_margin_px, labeler_vertical_margin_px = labeler_margin_px + if labeler_margin_px is None: + labeler_margin_px = LabelMarginsPx(horizontal=0, vertical=0) assert visible_horizontal_margin_px >= 0 - assert labeler_horizontal_margin_px >= 0 - assert labeler_vertical_margin_px >= 0 + assert labeler_margin_px.horizontal >= 0 + assert labeler_margin_px.vertical >= 0 assert not max_width_px or max_width_px >= 0 if min_width_px is None: min_width_px = 0 @@ -43,20 +50,24 @@ def __init__( self.mode = mode self.justify = justify if is_dev_mode_no_margins(): - self.visible_horizontal_margin_px = 0.0 - self.labeler_horizontal_margin_px = 0.0 - self.min_width_px = 0.0 + self.visible_horizontal_margin_px = 0 + labeler_horizontal_margin_px = 0 + self.min_width_px = 0 else: self.visible_horizontal_margin_px = visible_horizontal_margin_px - self.labeler_horizontal_margin_px = labeler_horizontal_margin_px + labeler_horizontal_margin_px = labeler_margin_px.horizontal self.min_width_px = min_width_px - self.labeler_vertical_margin_px = labeler_vertical_margin_px + labeler_vertical_margin_px = labeler_margin_px.vertical + self.label_margins_px = LabelMarginsPx( + horizontal=labeler_horizontal_margin_px, + vertical=labeler_vertical_margin_px, + ) self.max_width_px = max_width_px self.render_engine = render_engine - def _calculate_visible_width(self, payload_width_px: int) -> float: + def _calculate_visible_width(self, payload_width_px: int) -> int: minimal_label_width_px = ( - payload_width_px + self.visible_horizontal_margin_px * 2.0 + payload_width_px + self.visible_horizontal_margin_px * 2 ) if self.max_width_px is not None and minimal_label_width_px > self.max_width_px: raise BitmapTooBigError(minimal_label_width_px, self.max_width_px) @@ -72,7 +83,7 @@ def render(self, _: RenderContext) -> Image.Image: def render_with_meta( self, context: RenderContext - ) -> tuple[Image.Image, dict[str, float]]: + ) -> tuple[Image.Image, dict[str, int]]: payload_bitmap = self.render_engine.render(context) payload_width_px = payload_bitmap.width label_width_px = self._calculate_visible_width(payload_width_px) @@ -81,9 +92,11 @@ def render_with_meta( if self.justify == Direction.LEFT: horizontal_offset_px = self.visible_horizontal_margin_px elif self.justify == Direction.CENTER: - horizontal_offset_px = padding_px / 2 + horizontal_offset_px = padding_px // 2 elif self.justify == Direction.RIGHT: horizontal_offset_px = padding_px - self.visible_horizontal_margin_px + else: + raise ValueError(f"Invalid justify value: {self.justify}") assert horizontal_offset_px >= self.visible_horizontal_margin_px # In print mode: @@ -107,20 +120,18 @@ def render_with_meta( if self.mode == "print": # print head is already in offset from label's edge under the cutter - horizontal_offset_px -= self.labeler_horizontal_margin_px + horizontal_offset_px -= self.label_margins_px.horizontal # add vertical margins to bitmap - bitmap_height = ( - float(payload_bitmap.height) + self.labeler_vertical_margin_px * 2.0 - ) + bitmap_height = payload_bitmap.height + self.label_margins_px.vertical * 2 - bitmap = Image.new("1", (math.ceil(label_width_px), math.ceil(bitmap_height))) + bitmap = Image.new("1", (label_width_px, bitmap_height)) bitmap.paste( payload_bitmap, - box=(round(horizontal_offset_px), round(self.labeler_vertical_margin_px)), + box=(horizontal_offset_px, self.label_margins_px.vertical), ) meta = { "horizontal_offset_px": horizontal_offset_px, - "vertical_offset_px": self.labeler_vertical_margin_px, + "vertical_offset_px": self.label_margins_px.vertical, } return bitmap, meta diff --git a/src/labelle/lib/render_engines/print_payload.py b/src/labelle/lib/render_engines/print_payload.py index a8388af..c51fd08 100644 --- a/src/labelle/lib/render_engines/print_payload.py +++ b/src/labelle/lib/render_engines/print_payload.py @@ -3,6 +3,7 @@ from PIL import Image from labelle.lib.constants import Direction +from labelle.lib.margins import LabelMarginsPx from labelle.lib.render_engines.margins import MarginsRenderEngine from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import RenderEngine @@ -13,10 +14,10 @@ def __init__( self, render_engine: RenderEngine, justify: Direction = Direction.CENTER, - visible_horizontal_margin_px: float = 0, - labeler_margin_px: tuple[float, float] = (0, 0), - max_width_px: float | None = None, - min_width_px: float | None = 0, + visible_horizontal_margin_px: int = 0, + labeler_margin_px: LabelMarginsPx | None = None, + max_width_px: int | None = None, + min_width_px: int | None = 0, ): super().__init__() self.render_engine = MarginsRenderEngine( @@ -34,5 +35,5 @@ def render(self, _: RenderContext) -> Image.Image: def render_with_meta( self, context: RenderContext - ) -> tuple[Image.Image, dict[str, float]]: + ) -> tuple[Image.Image, dict[str, int]]: return self.render_engine.render_with_meta(context) diff --git a/src/labelle/lib/render_engines/print_preview.py b/src/labelle/lib/render_engines/print_preview.py index 4cbc32c..3e49bae 100644 --- a/src/labelle/lib/render_engines/print_preview.py +++ b/src/labelle/lib/render_engines/print_preview.py @@ -1,20 +1,23 @@ from __future__ import annotations +import math + from darkdetect import isDark from PIL import Image, ImageColor, ImageDraw, ImageOps from labelle.lib.constants import Direction from labelle.lib.devices.dymo_labeler import DymoLabeler +from labelle.lib.margins import LabelMarginsPx from labelle.lib.render_engines.margins import MarginsRenderEngine from labelle.lib.render_engines.render_context import RenderContext from labelle.lib.render_engines.render_engine import RenderEngine class PrintPreviewRenderEngine(RenderEngine): - X_MARGIN_PX = 80 - Y_MARGIN_PX = 30 - DX = X_MARGIN_PX * 0.3 - DY = Y_MARGIN_PX * 0.3 + X_MARGIN_PX: int = 80 + Y_MARGIN_PX: int = 30 + DX: int = math.floor(X_MARGIN_PX * 0.3) + DY: int = math.floor(Y_MARGIN_PX * 0.3) dymo_labeler: DymoLabeler def __init__( @@ -22,11 +25,11 @@ def __init__( render_engine: RenderEngine, dymo_labeler: DymoLabeler, justify: Direction = Direction.CENTER, - visible_horizontal_margin_px: float = 0, - labeler_margin_px: tuple[float, float] = (0, 0), - max_width_px: float | None = None, - min_width_px: float = 0, - ): + visible_horizontal_margin_px: int = 0, + labeler_margin_px: LabelMarginsPx | None = None, + max_width_px: int | None = None, + min_width_px: int = 0, + ) -> None: super().__init__() self.dymo_labeler = dymo_labeler self.render_engine = MarginsRenderEngine( @@ -165,32 +168,40 @@ def _show_margins(self, label_bitmap, preview_bitmap, meta, context): fill=mark_color, ) + full_width_mm: float = self.dymo_labeler.px_to_mm(label_width) + full_height_mm: float = self.dymo_labeler.px_to_mm(label_height) + printable_width_mm: float = self.dymo_labeler.px_to_mm( + label_width - x_margin * 2 + ) + printable_height_mm: float = self.dymo_labeler.px_to_mm( + label_height - y_margin * 2 + ) labels = [ # payload width { "xy": (self.X_MARGIN_PX + label_width / 2, preview_width_mark_y), - "text": f"{self.dymo_labeler.px_to_mm(label_width - x_margin * 2)} mm", + "text": f"{printable_width_mm} mm", "anchor": "mm", "align": "center", }, # label width { "xy": (self.X_MARGIN_PX + label_width / 2, label_width_mark_y), - "text": f"{self.dymo_labeler.px_to_mm(label_width)} mm", + "text": f"{full_width_mm} mm", "anchor": "mm", "align": "center", }, # payload height { "xy": (preview_width_mark_x, self.DY + label_height / 2 - self.DY), - "text": f"{self.dymo_labeler.px_to_mm(label_height - y_margin * 2)} mm", + "text": f"{printable_height_mm} mm", "anchor": "mm", "align": "center", }, # label height { "xy": (label_width_mark_x, self.DY + label_height / 2 + self.DY), - "text": f"{self.dymo_labeler.px_to_mm(label_height)} mm", + "text": f"{full_height_mm} mm", "anchor": "mm", "align": "center", }, diff --git a/src/labelle/lib/render_engines/render_engine.py b/src/labelle/lib/render_engines/render_engine.py index 71aea98..d7687fb 100644 --- a/src/labelle/lib/render_engines/render_engine.py +++ b/src/labelle/lib/render_engines/render_engine.py @@ -18,5 +18,5 @@ def render(self, context: RenderContext) -> Image.Image: def render_with_meta( self, context: RenderContext - ) -> tuple[Image.Image, dict[str, float] | None]: + ) -> tuple[Image.Image, dict[str, int] | None]: return self.render(context), None