"Scan dialog"

import re
import weakref
from copy import copy
import logging
from gi.repository import Gdk, Gtk, GObject
from comboboxtext import ComboBoxText
from dialog.paperlist import PaperList
from dialog.pagecontrols import PageControls, MAX_PAGES
from scanner.profile import Profile
from scanner.options import Options, within_tolerance
from docthread import INSERT_AT_START
from i18n import _, d_sane
from const import POINTS_PER_INCH
from frontend import enums
from helpers import _weak_callback
from . import Dialog

PAPER_TOLERANCE = 1
OPTION_TOLERANCE = 0.001
CANVAS_SIZE = 200
CANVAS_BORDER = 10
CANVAS_POINT_SIZE = 10
CANVAS_MIN_WIDTH = 1
NO_INDEX = -1

logger = logging.getLogger(__name__)


class Scan(PageControls):
    "Scan dialog"

    __gsignals__ = {
        "new-scan": (
            GObject.SignalFlags.RUN_FIRST,
            None,
            (
                object,  # Image object
                object,  # insert_after - page id, INSERT_AT_START, or None to append
                str,  # side to scan - "facing" or "reverse"
                float,  # x-resolution
                float,  # y-resolution
            ),
        ),
        "changed-device": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
        "changed-device-list": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
        "changed-scan-option": (
            GObject.SignalFlags.RUN_FIRST,
            None,
            (
                object,
                object,
                object,
            ),
        ),
        "changed-option-visibility": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
        "changed-current-scan-options": (
            GObject.SignalFlags.RUN_FIRST,
            None,
            (
                object,
                str,
            ),
        ),
        "reloaded-scan-options": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "changed-profile": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
        "added-profile": (
            GObject.SignalFlags.RUN_FIRST,
            None,
            (
                object,
                object,
            ),
        ),
        "removed-profile": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
        "changed-paper": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
        "changed-paper-formats": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
        "started-process": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
        "changed-progress": (
            GObject.SignalFlags.RUN_FIRST,
            None,
            (
                object,
                object,
            ),
        ),
        "finished-process": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
        "process-error": (
            GObject.SignalFlags.RUN_FIRST,
            None,
            (
                str,
                str,
            ),
        ),
        "clicked-scan-button": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }
    _device = ""
    _device_list = []
    dir = GObject.Property(
        type=object, nick="Directory", blurb="Directory in which to store scans"
    )
    _profile = None

    @GObject.Property(type=object, nick="Profile", blurb="Name of current profile")
    def profile(self):
        "getter for profile attribute"
        return self._profile

    @profile.setter
    def profile(self, newval):
        if newval == self._profile:
            return
        signal = None

        def do_changed_profile(_arg1, _arg2):
            self.disconnect(signal)
            self.combobsp.set_active_by_text(newval)

        signal = self.connect("changed-profile", do_changed_profile)
        self.set_profile(newval)

    _paper = ""

    @GObject.Property(
        type=str,
        default="",
        nick="Paper",
        blurb="Name of currently selected paper format",
    )
    def paper(self):
        "getter for paper attribute"
        return self._paper

    @paper.setter
    def paper(self, newval):
        if newval == self._paper:
            return
        if newval is not None:
            for fmt in self.ignored_paper_formats:
                if fmt == newval:
                    logger.info("Ignoring unsupported paper %s", newval)
                    return

        signal = None

        def do_changed_paper(_arg1, _arg2):
            nonlocal signal
            self.disconnect(signal)
            paper = _("Manual") if newval is None else newval
            self.combobp.set_active_by_text(paper)

        signal = self.connect("changed-paper", do_changed_paper)
        self._set_paper(newval)

    _paper_formats = {}

    @GObject.Property(
        type=object,
        nick="Paper formats",
        blurb="Hash of arrays defining paper formats, e.g. A4, Letter, etc.",
    )
    def paper_formats(self):
        "getter for paper_formats attribute"
        return self._paper_formats

    @paper_formats.setter
    def paper_formats(self, newval):
        self._paper_formats = newval
        self._set_paper_formats(newval)
        self.emit("changed-paper-formats", newval)

    _available_scan_options = Options([])
    _current_scan_options = Profile()

    progress_pulse_step = GObject.Property(
        type=float,
        minimum=0.0,
        maximum=1.0,
        default=0.1,
        nick="Progress pulse step",
        blurb="Pulse step of progress bar",
    )
    _allow_batch_flatbed = False
    reload_recursion_limit = GObject.Property(
        type=int,
        minimum=0,
        maximum=MAX_PAGES,
        default=0,
        nick="Reload recursion limit",
        blurb="More reloads than this are considered infinite loop",
    )
    num_reloads = GObject.Property(
        type=int,
        minimum=0,
        maximum=MAX_PAGES,
        default=0,
        nick="Number of reloads",
        blurb="To compare against reload-recursion-limit",
    )
    _cursor = "default"
    _ignore_duplex_capabilities = False

    @GObject.Property(type=str, default="", nick="Device", blurb="Device name")
    def device(self):
        "getter for device attribute"
        return self._device

    @device.setter
    def device(self, newval):
        if self._device != newval:
            self._device = newval
            self.set_device(newval)
            self.emit("changed-device", newval)

    @GObject.Property(
        type=object, nick="Device list", blurb="Array of hashes of available devices"
    )
    def device_list(self):
        "getter for device_list attribute"
        return self._device_list

    @device_list.setter
    def device_list(self, newval):
        self._device_list = newval
        self.set_device_list(newval)
        self.emit("changed-device-list", newval)

    @GObject.Property(
        type=bool,
        default=False,
        nick="Allow batch scanning from flatbed",
        blurb="Allow batch scanning from flatbed",
    )
    def allow_batch_flatbed(self):
        "getter for allow_batch_flatbed attribute"
        return self._allow_batch_flatbed

    @allow_batch_flatbed.setter
    def allow_batch_flatbed(self, newval):
        self._allow_batch_flatbed = newval
        if not hasattr(self, "framen"):
            return
        if newval:
            self.framen.set_sensitive(True)
        else:
            options = self.available_scan_options

            # on startup, self.thread doesn't get defined until later
            if (
                hasattr(self, "thread")
                and options is not None
                and options.flatbed_selected(self.thread.get_option_value)
            ):
                self.framen.set_sensitive(False)

                # emits changed-num-pages signal, allowing us to test
                # for $self->{framen}->set_sensitive(FALSE)
                self.num_pages = 1

    @GObject.Property(
        type=bool,
        default=False,
        nick="Ignore duplex capabilities",
        blurb="Ignore duplex capabilities",
    )
    def ignore_duplex_capabilities(self):
        "getter for ignore_duplex_capabilities attribute"
        return self._ignore_duplex_capabilities

    @ignore_duplex_capabilities.setter
    def ignore_duplex_capabilities(self, newval):
        self._ignore_duplex_capabilities = newval
        self._flatbed_or_duplex_callback()

    @GObject.Property(
        type=object,
        nick="Scan options available",
        blurb="Scan options currently available, whether active, selected, or not",
    )
    def available_scan_options(self):
        "getter for available_scan_options attribute"
        return self._available_scan_options

    @available_scan_options.setter
    def available_scan_options(self, newval):
        self._available_scan_options = newval
        if hasattr(self, "framen") and hasattr(self, "thread"):
            if not self.allow_batch_flatbed and newval.flatbed_selected(
                self.thread.get_option_value
            ):
                if self.num_pages != 1:
                    self.num_pages = 1
                self.framen.set_sensitive(False)
            else:
                self.framen.set_sensitive(True)

        self._flatbed_or_duplex_callback()

        # reload-recursion-limit is read-only
        # Triangular number n + n-1 + n-2 + ... + 1 = n*(n+1)/2
        num = newval.num_options()
        self.reload_recursion_limit = num * (num + 1) // 2
        self.emit("reloaded-scan-options")

    @GObject.Property(type=object, nick="Cursor", blurb="name of current cursor")
    def cursor(self):
        "getter for cursor attribute"
        return self._cursor

    @cursor.setter
    def cursor(self, newval):
        "set the cursor"
        win = self.get_window()
        if newval is None:
            return
        self._cursor = newval

        if win is not None:
            display = Gdk.Display.get_default()
            win.set_cursor(Gdk.Cursor.new_from_name(display, newval))

        self.scan_button.set_sensitive(newval == "default")

    @GObject.Property(
        type=object,
        nick="Current scan options",
        blurb="Scan options making up current profile",
    )
    def current_scan_options(self):
        "getter for current_scan_options attribute"
        return self._current_scan_options

    @current_scan_options.setter
    def current_scan_options(self, newval):
        self._current_scan_options = newval

    combobp = None

    def __init__(self, *args, **kwargs):
        profiles = {}
        if "profiles" in kwargs:
            profiles = kwargs.pop("profiles")
        super().__init__(*args, **kwargs)

        self.ignored_paper_formats = []
        self.option_widgets = {}
        self._geometry_boxes = {}

        self.connect("show", self.show)
        self._add_device_combobox()

        # Scan profiles
        self.profiles = {}
        framesp = Gtk.Frame(label=_("Scan profiles"))
        self._notebook_pages[0].pack_start(framesp, False, False, 0)
        vboxsp = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        border_width = (
            self.get_style_context().get_border(Gtk.StateFlags.NORMAL).left
        )  # ._get('content-area-border')
        vboxsp.set_border_width(border_width)
        framesp.add(vboxsp)
        self.combobsp = ComboBoxText()
        for profile in profiles.keys():
            self._add_profile(
                profile,
                Profile(
                    frontend=profiles[profile]["frontend"],
                    backend=profiles[profile]["backend"],
                ),
            )
        self.combobsp_changed_signal = self.combobsp.connect(
            "changed", _weak_callback(self, "_do_profile_changed")
        )
        vboxsp.pack_start(self.combobsp, False, False, 0)
        hboxsp = Gtk.Box()
        vboxsp.pack_end(hboxsp, False, False, 0)

        # Save button
        ref = weakref.ref(self)
        icon = Gtk.Image.new_from_icon_name("document-save", Gtk.IconSize.BUTTON)
        vbutton = Gtk.Button()
        vbutton.set_image(icon)
        vbutton.connect("clicked", lambda w: _save_profile_callback(w, ref()))
        hboxsp.pack_start(vbutton, True, True, 0)

        # Edit button
        icon = Gtk.Image.new_from_icon_name("document-edit", Gtk.IconSize.BUTTON)
        ebutton = Gtk.Button()
        ebutton.set_image(icon)
        ebutton.connect("clicked", lambda w: _edit_profile_callback(w, ref()))
        hboxsp.pack_start(ebutton, False, False, 0)

        # Delete button
        icon = Gtk.Image.new_from_icon_name("edit-delete", Gtk.IconSize.BUTTON)
        dbutton = Gtk.Button()
        dbutton.set_image(icon)
        dbutton.connect(
            "clicked",
            lambda x: ref() and ref()._remove_profile(ref().combobsp.get_active_text()),
        )
        hboxsp.pack_start(dbutton, False, False, 0)

        self.scan_button = self.add_actions(
            [(_("Scan"), self._do_scan), ("gtk-close", self.hide)]
        )[0]

        # initialise stack of uuids - needed for cases where setting a profile
        # requires several reloads, and therefore reapplying the same profile
        # several times. Tested by t/06198_Dialog_Scan_Image_Sane.t
        self.setting_profile = []
        self.setting_current_scan_options = []
        self.connect(
            "changed-scan-option", self._changed_scan_option_callback, self._bscannum
        )

    def _add_device_combobox(self):
        self.hboxd = Gtk.Box()
        labeld = Gtk.Label(label=_("Device"))
        self.hboxd.pack_start(labeld, False, False, 0)
        self.combobd = ComboBoxText()
        self.combobd.append_text(_("Rescan for devices"))

        ref = weakref.ref(self)

        def do_device_dropdown_changed(_arg):
            self = ref()
            if self is None:
                return
            index = self.combobd.get_active()
            device_list = self.device_list
            if index > len(device_list) - 1:
                self.combobd.hide()
                labeld.hide()
                self.device = None  # to make sure that the device is reloaded
                self.get_devices()

            elif index > NO_INDEX:
                self.device = device_list[index].name

        self.combobd_changed_signal = self.combobd.connect(
            "changed", do_device_dropdown_changed
        )

        def do_changed_device(self, device):
            device_list = self.device_list
            if device not in [None, ""]:
                for dev in device_list:
                    if dev.name == device:
                        self.combobd.set_active_by_text(dev.label)
                        self.scan_options(device)
                        return
            else:
                self.combobd.set_active(NO_INDEX)

        self.connect("changed-device", do_changed_device)
        self.combobd.set_tooltip_text(_("Sets the device to be used for the scan"))
        self.hboxd.pack_end(self.combobd, False, False, 0)
        self.get_content_area().pack_start(self.hboxd, False, False, 0)

    def _do_scan(self):
        self.emit("clicked-scan-button")
        self.scan()

    def _do_profile_changed(self, combobsp):
        self.num_reloads = 0  # num-reloads is read-only
        self.profile = combobsp.get_active_text()

    def show(self, *args, **kwargs):
        PageControls.show(self, **kwargs)
        self.framex.hide()
        self._flatbed_or_duplex_callback()
        if (
            self.combobp is not None
            and self.combobp.get_active_text() is not None
            and self.combobp.get_active_text() != _("Manual")
        ):
            self._hide_geometry(self.available_scan_options)
        self.cursor = "default"

    def set_device(self, device):
        "set the active device"
        if device not in [None, ""]:
            idev = None
            device_list = self.device_list
            if len(device_list):
                for i, dev in enumerate(device_list):
                    if device == dev.name:
                        idev = i

                # Set the device dependent options after the number of pages
                #  to scan so that the source button callback can ghost the
                #  all button.
                # This then fires the callback, updating the options,
                #  so no need to do it further down.
                if idev is not None:
                    self.combobd.set_active(idev)
                else:
                    self.emit(
                        "process-error",
                        "open_device",
                        _("Error: unknown device: %s") % (device),
                    )

    def set_device_list(self, device_list):
        "fill the combobox with the list of devices"
        # Note any duplicate device names and delete if necessary
        seen = {}
        i = 0
        while i < len(device_list):
            if device_list[i].name not in seen:
                seen[device_list[i].name] = 0
            seen[device_list[i].name] += 1
            if seen[device_list[i].name] > 1:
                del device_list[i]

            else:
                i += 1

        # Note any duplicate model names and add the device if necessary
        seen = {}
        for dev in device_list:
            if not hasattr(dev, "model") or dev.model in [None, ""]:
                dev.model = dev.name
            if dev.model not in seen:
                seen[dev.model] = 0
            seen[dev.model] += 1

        for dev in device_list:
            if hasattr(dev, "vendor") and dev.vendor not in [None, ""]:
                dev.label = f"{dev.vendor} {dev.model}"
            else:
                dev.label = dev.model

            if seen[dev.model] > 1:
                dev.label += f" on {dev.name}"

        self.combobd.handler_block(self.combobd_changed_signal)

        # Remove all entries apart from rescan
        num_rows = self.combobd.get_num_rows()
        while num_rows > 1:
            num_rows -= 1
            self.combobd.remove(0)

        # read the model names into the combobox
        for i, dev in enumerate(device_list):
            self.combobd.insert_text(i, dev.label)

        self.combobd.handler_unblock(self.combobd_changed_signal)

    def _pack_widget(self, widget, data):
        "pack the given widget in the dialog"
        options, opt, hbox, hboxp = data
        if widget is not None:

            # Add label for units
            if opt.unit != enums.UNIT_NONE:
                text = None
                if opt.unit == enums.UNIT_PIXEL:
                    text = _("pel")

                elif opt.unit == enums.UNIT_BIT:
                    text = _("bit")

                elif opt.unit == enums.UNIT_MM:
                    text = _("mm")

                elif opt.unit == enums.UNIT_DPI:
                    text = _("ppi")

                elif opt.unit == enums.UNIT_PERCENT:
                    text = _("%")

                elif opt.unit == enums.UNIT_MICROSECOND:
                    text = _("μs")

                label = Gtk.Label(label=text)
                hbox.pack_end(label, False, False, 0)

            self.option_widgets[opt.name] = widget
            if opt.type == enums.TYPE_BUTTON:
                hbox.pack_end(widget, True, True, 0)

            else:
                hbox.pack_end(widget, False, False, 0)

            widget.set_tooltip_text(d_sane(opt.desc))

            # Look-up to hide/show the box if necessary
            if _geometry_option(opt):
                self._geometry_boxes[opt.name] = hbox

            self._create_paper_widget(options, hboxp)

        else:
            logger.warning("Unknown type %s", opt.type)

    def _create_paper_widget(self, options, hboxp):
        "create the paper widget"
        # Only define the paper size once the rest of the geometry widgets
        # have been created
        if (
            all(key in self._geometry_boxes for key in ["br-x", "br-y", "tl-x", "tl-y"])
            and all(
                (options.by_name(key) is None or key in self._geometry_boxes)
                for key in ["page-height", "page-width"]
            )
            and (not hasattr(self, "combobp") or self.combobp is None)
            and hboxp is not None
        ):

            # Paper list
            label = Gtk.Label(label=_("Paper size"))
            hboxp.pack_start(label, False, False, 0)
            self.combobp = ComboBoxText()
            self.combobp.append_text(_("Manual"))
            self.combobp.append_text(_("Edit"))
            self.combobp.set_tooltip_text(_("Selects or edits the paper size"))
            hboxp.pack_end(self.combobp, False, False, 0)
            self.combobp.set_active(0)

            def do_paper_size_changed(_arg):
                combobp_active_text = self.combobp.get_active_text()
                if not combobp_active_text:
                    return
                if combobp_active_text == _("Edit"):
                    self._edit_paper()
                elif combobp_active_text == _("Manual"):
                    for option in (
                        "tl-x",
                        "tl-y",
                        "br-x",
                        "br-y",
                        "page-height",
                        "page-width",
                    ):
                        if option in self._geometry_boxes:
                            self._geometry_boxes[option].show_all()

                    self.paper = None
                else:
                    self.paper = combobp_active_text

            self.combobp.connect("changed", do_paper_size_changed)

            # If the geometry is changed and we are not setting a profile,
            # unset the paper size,
            for option in ("tl-x", "tl-y", "br-x", "br-y", "page-height", "page-width"):
                if option in self.option_widgets:
                    widget = self.option_widgets[option]

                    def do_paper_dimension_changed(_data):
                        if not (
                            self.setting_current_scan_options or self.paper is None
                        ):
                            self.paper = None

                    widget.connect("changed", do_paper_dimension_changed)

    def _hide_geometry(self, _options):
        "hide geometry options"
        for option in ("tl-x", "tl-y", "br-x", "br-y", "page-height", "page-width"):
            if option in self._geometry_boxes:
                self._geometry_boxes[option].hide()

    def _get_paper_by_geometry(self):
        "return the paper size that matches the current geometry settings"
        formats = self.paper_formats
        if formats is None:
            return None
        options = self.available_scan_options
        current = {
            "l": self.thread.get_option_value("tl-x"),
            "t": self.thread.get_option_value("tl-y"),
        }
        current["x"] = current["l"] + self.thread.get_option_value("br-x")
        current["y"] = current["t"] + self.thread.get_option_value("br-y")
        for name, value in formats.items():
            match = True
            for edge in ["l", "t", "x", "y"]:
                if value[edge] != current[edge]:
                    match = False
                    break

            if match:
                return name
        return None

    def _update_options(self, new_options):
        """If setting an option triggers a reload, the widgets must be updated to reflect
        the new options"""
        logger.debug("Sane.get_option_descriptor() returned: %s", new_options)
        loops = self.num_reloads
        loops += 1
        self.num_reloads = loops  # num-reloads is read-only
        limit = self.reload_recursion_limit
        if self.num_reloads > limit:
            logger.error("reload-recursion-limit (%s) exceeded.", limit)
            self.emit(
                "process-error",
                "update_options",
                _(
                    "Reload recursion limit (%d) exceeded. Please file a bug, "
                    "attaching a log file reproducing the problem."
                )
                % (limit),
            )
            return

        # Clone the current scan options in case they are changed by the reload,
        # so that we can reapply it afterwards to ensure the same values are still
        # set.
        current_scan_options = copy(self.current_scan_options)

        # walk the widget tree and update them from the hash
        num_dev_options = new_options.num_options()
        options = self.available_scan_options
        for i in range(1, num_dev_options):
            if self._update_option(options.by_index(i), new_options.by_index(i)):
                return

        # This fires the reloaded-scan-options signal,
        # so don't set this until we have finished
        self.available_scan_options = new_options

        # Remove buttons from $current_scan_options to prevent buttons which cause
        # reloads from setting off infinite loops
        buttons = []
        for i in current_scan_options.each_backend_option():
            name, _val = current_scan_options.get_backend_option_by_index(i)
            opt = options.by_name(name)
            if opt.type == enums.TYPE_BUTTON:
                buttons.append(name)

        for button in buttons:
            current_scan_options.remove_backend_option_by_name(button)

        # Reapply current options to ensure the same values are still set.
        self._add_current_scan_options(current_scan_options)

        # In case the geometry values have changed,
        # update the available paper formats
        self._set_paper_formats(self.paper_formats)

    def _update_single_option(self, opt):
        widget = self.option_widgets[opt.name]
        value = None
        if opt.type != enums.TYPE_BUTTON:
            value = self.thread.get_option_value(opt.name)

        # Switch
        if opt.type == enums.TYPE_BOOL:
            if _value_for_active_option(value, opt):
                widget.set_active(value)

        else:
            if isinstance(opt.constraint, tuple):
                step, page = widget.get_increments()
                step = 1
                if opt.constraint[2] > 0:
                    step = opt.constraint[2]

                widget.set_range(opt.constraint[0], opt.constraint[1])
                widget.set_increments(step, page)
                if _value_for_active_option(value, opt):
                    widget.set_value(value)

            elif isinstance(opt.constraint, list):
                widget.get_model().clear()
                index = 0
                for i, entry in enumerate(opt.constraint):
                    widget.append_text(d_sane(str(entry)))
                    if entry == value:
                        index = i

                if index is not None:
                    widget.set_active(index)

            elif opt.constraint is None and opt.type != enums.TYPE_BUTTON:  # entry
                if _value_for_active_option(value, opt):
                    widget.set_text(value)

    def _update_option(self, opt, new_opt):

        # could be undefined for !(new_opt.cap & SANE_CAP_SOFT_DETECT)
        # or where opt.name is not defined
        # e.g. opt.type == SANE_TYPE_GROUP
        if opt.type == enums.TYPE_GROUP or opt.name not in self.option_widgets:
            return False

        widget = self.option_widgets[opt.name]
        if new_opt.name != opt.name:
            logger.error(
                "Error updating options: reloaded options are numbered differently"
            )
            return True

        if opt.type != new_opt.type:
            logger.error(
                "Error updating options: reloaded options have different types"
            )
            return True

        # Block the signal handler for the widget to prevent infinite
        # loops of the widget updating the option, updating the widget, etc.
        blocked = widget.signal is not None and widget.handler_is_connected(
            widget.signal
        )
        if blocked:
            widget.handler_block(widget.signal)
        opt = new_opt

        # HBox for option
        hbox = widget.get_parent()
        hbox.set_sensitive(
            (not opt.cap & enums.CAP_INACTIVE) and opt.cap & enums.CAP_SOFT_SELECT
        )

        # TODO: test options with multiple values in more detail
        if opt.size < 2:
            self._update_single_option(opt)

        if blocked:
            widget.handler_unblock(widget.signal)
        return False

    def _set_paper_formats(self, formats):
        "Add paper size to combobox if scanner large enough"
        if self.combobp is not None:

            # Remove all formats, leaving Manual and Edit
            num = self.combobp.get_num_rows()
            while num > 2:
                num -= 1
                self.combobp.remove(0)
            self.ignored_paper_formats = []
            options = self.available_scan_options
            for fmt in formats:
                if options.supports_paper(formats[fmt], PAPER_TOLERANCE):
                    logger.debug("Options support paper size '%s'.", fmt)
                    self.combobp.prepend_text(fmt)

                else:
                    logger.debug("Options do not support paper size '%s'.", fmt)
                    self.ignored_paper_formats.append(fmt)

            # Set the combobox back from Edit to the previous value
            paper = self.paper
            if paper is None:
                paper = _("Manual")
            self.combobp.set_active_by_text(paper)

    def _set_paper(self, paper):
        """Treat a paper size as a profile, so build up the required profile of
        geometry settings and apply it"""
        if not self.setting_profile:
            self.profile = None
        if paper is None:
            self._paper = paper
            self.current_scan_options.remove_frontend_option("paper")
            self.emit("changed-paper", paper)
            return

        for name in self.ignored_paper_formats:
            if name == paper:
                if logger is not None:
                    logger.info("Ignoring unsupported paper %s", paper)
                return

        formats = self.paper_formats
        options = self.available_scan_options
        paper_profile = Profile()
        if (
            (options.by_name("page-height") is not None)
            and not options.by_name("page-height").cap & enums.CAP_INACTIVE
            and (options.by_name("page-width") is not None)
            and not options.by_name("page-width").cap & enums.CAP_INACTIVE
        ):
            paper_profile.add_backend_option(
                "page-height",
                formats[paper]["y"] + formats[paper]["t"],
                self.thread.get_option_value("page-height"),
            )
            paper_profile.add_backend_option(
                "page-width",
                formats[paper]["x"] + formats[paper]["l"],
                self.thread.get_option_value("page-width"),
            )

        paper_profile.add_backend_option(
            "tl-x", formats[paper]["l"], self.thread.get_option_value("tl-x")
        )
        paper_profile.add_backend_option(
            "tl-y", formats[paper]["t"], self.thread.get_option_value("tl-y")
        )
        paper_profile.add_backend_option(
            "br-x",
            formats[paper]["x"] + formats[paper]["l"],
            self.thread.get_option_value("br-x"),
        )
        paper_profile.add_backend_option(
            "br-y",
            formats[paper]["y"] + formats[paper]["t"],
            self.thread.get_option_value("br-y"),
        )

        # forget the previous option info calls, as these are only interesting
        # *whilst* setting a profile, and now we are starting from scratch
        self._option_info = {}
        if not paper_profile.num_backend_options():
            self._hide_geometry(options)
            self._paper = paper
            self.current_scan_options.add_frontend_option("paper", paper)
            self.emit("changed-paper", paper)
            return

        signal = None

        def do_changed_current_scan_options(_dialog, _profile, uuid):
            if paper_profile.uuid == uuid:
                self.disconnect(signal)
                self._hide_geometry(options)
                self._paper = paper
                self.current_scan_options.add_frontend_option("paper", paper)
                self.emit("changed-paper", paper)

        signal = self.connect(
            "changed-current-scan-options", do_changed_current_scan_options
        )

        # Don't trigger the changed-paper signal
        # until we have finished setting the profile
        self._add_current_scan_options(paper_profile)

    def _edit_paper(self):
        "Paper editor"
        combobp = self.combobp
        window = Dialog(
            transient_for=self,
            title=_("Edit paper size"),
        )
        vbox = window.get_content_area()

        hboxl = Gtk.Box()
        vbox.pack_start(hboxl, False, False, 0)
        vboxb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        hboxl.pack_start(vboxb, False, False, 0)
        icon = Gtk.Image.new_from_icon_name("list-add", Gtk.IconSize.BUTTON)
        dbutton = Gtk.Button()
        dbutton.set_image(icon)
        vboxb.pack_start(dbutton, True, False, 0)
        icon = Gtk.Image.new_from_icon_name("list-remove", Gtk.IconSize.BUTTON)
        rbutton = Gtk.Button()
        rbutton.set_image(icon)
        vboxb.pack_end(rbutton, True, False, 0)

        slist = PaperList(self.paper_formats)
        dbutton.connect("clicked", slist.do_add_clicked)
        rbutton.connect("clicked", slist.do_remove_paper, self)
        slist.get_model().connect("row-changed", slist.do_paper_sizes_row_changed)

        hboxl.pack_end(slist, False, False, 0)

        # Buttons
        hboxb = Gtk.Box()
        vbox.pack_start(hboxb, False, False, 0)
        abutton = Gtk.Button.new_with_label(_("Apply"))

        def do_apply_paper_sizes(_widget):
            formats = {}
            for row in slist.data:
                if row[0] not in formats:
                    formats[row[0]] = {}
                j = 0
                for _side in ["x", "y", "l", "t"]:
                    j += 1
                    formats[row[0]][_side] = row[j]

            # Add new definitions
            # TODO: fix main reference
            self.paper_formats = formats
            if self.ignored_paper_formats:
                main.show_message_dialog(
                    parent=window,
                    type="warning",
                    buttons="close",
                    text=_(
                        "The following paper sizes are too big to be scanned by"
                        " the selected device:"
                    )
                    + " "
                    + ", ".join(self.ignored_paper_formats),
                )

            window.destroy()

        abutton.connect("clicked", do_apply_paper_sizes)
        hboxb.pack_start(abutton, True, False, 0)
        cbutton = Gtk.Button.new_with_label(_("Cancel"))

        def do_cancel_paper_sizes():

            # Set the combobox back from Edit to the previous value
            combobp.set_active_by_text(self.paper)
            window.destroy()

        cbutton.connect("clicked", do_cancel_paper_sizes)
        hboxb.pack_end(cbutton, True, False, 0)
        window.show_all()

    def save_current_profile(self, name):
        "keeping this as a separate sub allows us to test it"
        self._add_profile(name, self.current_scan_options)

        # Block signal or else we fire another round of profile loads
        self.combobsp.handler_block(self.combobsp_changed_signal)
        self.combobsp.set_active(self.combobsp.get_num_rows() - 1)
        self.combobsp.handler_unblock(self.combobsp_changed_signal)
        self._profile = name

    def _add_profile(self, name, profile):
        "apply the given profile without resetting the current one"
        if name is None:
            logger.error("Cannot add profile with no name")
            return

        if profile is None:
            logger.error("Cannot add undefined profile")
            return

        if not isinstance(profile, Profile):
            logger.error("%s is not a Profile object", type(profile))
            return

        # if we don't clone the profile,
        # we get strange action-at-a-distance problems
        self.profiles[name] = copy(profile)
        self.combobsp.remove_item_by_text(name)
        self.combobsp.append_text(name)
        logger.debug("Saved profile '%s': %s", name, self.profiles[name])
        self.emit("added-profile", name, self.profiles[name])

    def set_option(self, option, value, uuid=None):
        "placeholder to be overrided by subclass"

    def scan_options(self, device=None):
        "placeholder to be overrided by subclass"

    def get_devices(self):
        "placeholder to be overrided by subclass"

    def scan(self):
        "placeholder to be overrided by subclass"

    def _insert_target(self, k):
        "return (insert_after, side) for the k-th page scanned in this pass"
        side = self.side_to_scan
        if self.checkx.get_active():
            position = self.page_number_start + (k - 1) * self.page_number_increment
            return self._uuid_before_position(position), side
        if self.sided == "single":
            return None, "facing"
        if side == "facing":
            if self._batch_start is None:
                slist = self.document
                self._batch_start = len(slist.data) + 1 if slist else 1
            self._batch_n += 1
            return None, "facing"
        if self._batch_n == 0:
            return None, "reverse"
        position = self._batch_start + self._batch_n - k
        return self._uuid_at_position(position), "reverse"

    def _uuid_at_position(self, position):
        "return the page id at the given 1-based position, or None"
        slist = self.document
        if slist is None or position is None:
            return None
        if position < 1 or position > len(slist.data):
            return None
        return slist.data[position - 1][2]

    def _uuid_before_position(self, position):
        """return the page id to insert after so that a new page lands at the
        given 1-based position, or None to append"""
        if position is None or position < 1:
            return None
        if position == 1:
            return INSERT_AT_START
        return self._uuid_at_position(position - 1)

    def set_profile(self, name):
        "apply the give profile"
        if name is not None and name != "":

            # Only emit the changed-profile signal when the GUI has caught up
            signal = None

            def do_changed_current_scan_options(_1, _2, uuid_found):

                uuid = self.setting_profile[0]

                # there seems to be a race condition in t/0621_Dialog_Scan_CLI.t
                # where the uuid set below is not set in time to be tested in
                # this if.
                if uuid == uuid_found:
                    self.disconnect(signal)
                    self.setting_profile = []

                    # set property before emitting signal to ensure callbacks
                    # receive correct value
                    self._profile = name
                    self.emit("changed-profile", name)

            signal = self.connect(
                "changed-current-scan-options", do_changed_current_scan_options
            )

            # Add UUID to the stack and therefore don't unset the profile name
            self.setting_profile.append(self.profiles[name].uuid)
            self.set_current_scan_options(self.profiles[name])

        # no need to wait - nothing to do
        else:
            # set property before emitting signal to ensure callbacks
            # receive correct value
            self._profile = name
            self.emit("changed-profile", name)

    def _remove_profile(self, name):
        """Remove the profile. If it is active, deselect it first."""
        if (name is not None) and name in self.profiles:
            self.combobsp.remove_item_by_text(name)
            self.emit("removed-profile", name)
            del self.profiles[name]

    def set_current_scan_options(self, profile):
        "Set options to given profile"
        if profile is None:
            logger.error("Cannot add undefined profile")
            return

        if not isinstance(profile, Profile):
            logger.error("%s is not a Profile object", type(profile))
            return

        # forget the previous option info calls, as these are only interesting
        # *whilst* setting a profile, and now we are starting from scratch
        self._option_info = {}

        # If we have no options set, no need to reset to defaults
        if self.current_scan_options.num_backend_options() == 0:
            self._add_current_scan_options(profile)
            return

        # reload to get defaults before applying profile
        signal = None
        self.current_scan_options = copy(profile)

        def do_reloaded_scan_options(_widget):
            nonlocal signal
            self.disconnect(signal)
            self._add_current_scan_options(profile)

        signal = self.connect("reloaded-scan-options", do_reloaded_scan_options)
        self.scan_options(self.device)

    def _add_current_scan_options(self, profile):
        "Apply options referenced by hashref without resetting existing options"
        if profile is None:
            logger.error("Cannot add undefined profile")
            return

        if not isinstance(profile, Profile):
            logger.error("%s is not a Profile object", type(profile))
            return

        # First clone the profile, as otherwise it would be self-modifying
        clone = copy(profile)
        self.setting_current_scan_options.append(clone.uuid)

        # Give the GUI a chance to catch up between settings,
        # in case they have to be reloaded.
        # Use the callback to trigger the next loop
        self._set_option_profile(clone, profile.each_backend_option())

    def _set_option_profile(self, profile, itr):

        self.cursor = "wait"
        try:
            i = next(itr)
            name, val = profile.get_backend_option_by_index(i)
            options = self.available_scan_options
            opt = options.by_name(name)
            if opt is None or opt.cap & enums.CAP_INACTIVE:
                logger.warning("Ignoring inactive option '%s'.", name)
                self._set_option_profile(profile, itr)
                return

            # if we have a profile from a pre-v3 gscan2pdf config, the types
            # are likely wrong, so force the conversion
            if opt.type == enums.TYPE_INT:
                val = int(val)
            elif opt.type == enums.TYPE_FIXED:
                val = float(val)
            elif opt.type == enums.TYPE_BOOL:
                val = bool(val)

            # Don't try to set invalid option
            if isinstance(opt.constraint, list):
                if val not in opt.constraint:
                    logger.warning(
                        "Ignoring invalid argument '%s' for option '%s'.", val, name
                    )
                    self._set_option_profile(profile, itr)
                    return

            # Ignore option if info from previous set_option() reported SANE_INFO_INEXACT
            if (
                opt.name in self._option_info
                and self._option_info[opt.name] & enums.INFO_INEXACT
            ):
                logger.warning(
                    "Skip setting option '%s' to '%s', as previous call"
                    " set SANE_INFO_INEXACT",
                    name,
                    val,
                )
                self._set_option_profile(profile, itr)
                return

            # Avoid a race condition where device_handle is None after a reload
            # Tested by test_race_condition_device_switching() and
            # test_infinite_loop_reproduction()
            if self.thread.device_handle is None:
                logger.warning("Device handle is None. Skipping option '%s'.", name)

                # Cleanup logic similar to StopIteration block
                if not self.setting_profile:
                    self.profile = None

                if self.setting_current_scan_options:
                    self.setting_current_scan_options.pop()
                self.emit(
                    "changed-current-scan-options",
                    self.current_scan_options,
                    profile.uuid,
                )
                self.cursor = "default"
                return

            # Ignore option if value already within tolerance
            curval = self.thread.get_option_value(opt.name)
            if within_tolerance(opt, curval, val, OPTION_TOLERANCE):
                logger.info(
                    "No need to set option '%s': already within tolerance.", name
                )
                self._set_option_profile(profile, itr)
                return

            logger.debug(
                f"Setting option '{name}'"
                + (
                    ""
                    if opt.type == enums.TYPE_BUTTON
                    else f" from '{curval}' to '{val}'."
                )
            )
            signal = None

            def do_changed_scan_option(_widget, _optname, _optval, uuid):

                # With multiple reloads, this can get called several times,
                # so only react to signal from the correct profile
                if uuid == profile.uuid:
                    self.disconnect(signal)
                    self._set_option_profile(profile, itr)

            signal = self.connect("changed-scan-option", do_changed_scan_option)

            self.set_option(opt, val, profile.uuid)

        except StopIteration:

            # Having set all backend options, set the frontend options
            # Set paper formats first to make sure that any paper required is
            # available
            self._set_paper_formats(self.paper_formats)
            for key in profile.each_frontend_option():
                setattr(self, key, profile.get_frontend_option(key))

            if self.setting_current_scan_options:
                self.setting_current_scan_options.pop()
            self.emit(
                "changed-current-scan-options",
                self.current_scan_options,
                profile.uuid,
            )
            self.cursor = "default"

    def _update_widget_value(self, opt, val):
        "update widget with value"
        if opt.name in self.option_widgets:
            widget = self.option_widgets[opt.name]
            logger.debug(
                f"Setting widget '{opt.name}'"
                + ("" if opt.type == enums.TYPE_BUTTON else f" to '{val}'.")
            )
            blocked = widget.signal is not None and widget.handler_is_connected(
                widget.signal
            )
            if blocked:
                widget.handler_block(widget.signal)
            if isinstance(widget, (Gtk.CheckButton, Gtk.Switch)):
                if widget.get_active() != val:
                    widget.set_active(val)
            elif isinstance(widget, Gtk.SpinButton):
                if widget.get_value() != val:
                    widget.set_value(val)
            elif isinstance(widget, Gtk.ComboBox):
                if opt.constraint[widget.get_active()] != val:
                    index = opt.constraint.index(val)
                    if index > NO_INDEX:
                        widget.set_active(index)
            elif isinstance(widget, Gtk.Entry):
                if widget.get_text() != val:
                    widget.set_text(val)

            if blocked:
                widget.handler_unblock(widget.signal)

        else:
            logger.warning("Widget for option '%s' undefined.", opt.name)

    def _get_xy_resolution(self):
        "return x and y values for resolution"
        options = self.available_scan_options
        if not options:
            return None, None
        resolutions = []
        for name in ["resolution", "x-resolution", "y-resolution"]:
            try:
                resolutions.append(self.thread.get_option_value(name))
            except AttributeError:
                resolutions.append(0)
        resolution, xres, yres = resolutions

        # Potentially, a scanner could offer all three options, but then unset
        # resolution once the other two have been set.
        if resolution:

            # The resolution option, plus one of the other two, is defined.
            # Most sensibly, we should look at the order they were set.
            # However, if none of them are in current-scan-options, they still have
            # their default setting, and which of those gets priority is certainly
            # scanner specific.
            if xres == 0 and yres == 0:
                return resolution, resolution
            current_scan_options = self.current_scan_options
            for i in current_scan_options.each_backend_option():
                name, val = current_scan_options.get_backend_option_by_index(i)
                if name == "resolution":
                    xres = val
                    yres = val
                elif name == "x-resolution":
                    xres = val
                elif name == "y-resolution":
                    yres = val

        if xres == 0:
            xres = POINTS_PER_INCH
        if yres == 0:
            yres = POINTS_PER_INCH
        return xres, yres

    def _get_label_for_option(self, name):
        "return the label text of the option"
        widget = self.option_widgets[name]
        hbox = widget.get_parent()
        for child in hbox.get_children():
            if isinstance(child, Gtk.Label):
                return child.get_text()
        return None

    def _changed_scan_option_callback(self, _dialog, name, value, _uuid, bscannum):
        options = self.available_scan_options
        opt = options.by_name("source")
        if opt is not None and name == opt.name:
            if self.allow_batch_flatbed or not options.flatbed_selected(
                self.thread.get_option_value
            ):
                self.framen.set_sensitive(True)
            else:
                bscannum.set_active(True)
                self.num_pages = 1
                self.sided = "single"
                self.framen.set_sensitive(False)

            if self.adf_defaults_scan_all_pages and re.search(
                r"(ADF|Automatic[ ]Document[ ]Feeder)",
                value,
                re.IGNORECASE | re.MULTILINE | re.DOTALL | re.VERBOSE,
            ):
                self.num_pages = 0

        self._flatbed_or_duplex_callback()


def _geometry_option(opt):
    "Return true if we have a valid geometry option"
    return (
        opt.type in [enums.TYPE_FIXED, enums.TYPE_INT]
        and opt.unit in [enums.UNIT_MM, enums.UNIT_PIXEL]
        and opt.name in ["tl-x", "tl-y", "br-x", "br-y", "page-height", "page-width"]
    )


def _value_for_active_option(value, opt):
    "return if the value is defined and the option is active"
    return not value and not opt.cap & enums.CAP_INACTIVE


def _save_profile_callback(_widget, parent):
    dialog = Gtk.Dialog(
        _("Name of scan profile"),
        parent=parent,
        destroy_with_parent=True,
    )
    dialog.add_buttons(
        Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL
    )
    hbox = Gtk.Box()
    label = Gtk.Label(label=_("Name of scan profile"))
    hbox.pack_start(label, False, False, 0)
    entry = Gtk.Entry()
    entry.set_activates_default(True)
    hbox.pack_end(entry, True, True, 0)
    dialog.get_content_area().add(hbox)
    dialog.set_default_response(Gtk.ResponseType.OK)
    dialog.show_all()
    flag = True
    while flag:
        if dialog.run() == Gtk.ResponseType.OK:
            name = entry.get_text()
            if not re.search(r"^\s*$", name, re.MULTILINE | re.DOTALL | re.VERBOSE):
                if name in parent.profiles:
                    warning = _("Profile '%s' exists. Overwrite?") % (name)
                    dialog2 = Gtk.Dialog(
                        title=warning,
                        transient_for=dialog,
                        destroy_with_parent=True,
                    )
                    dialog2.add_buttons(
                        _("OK"),
                        Gtk.ResponseType.OK,
                        _("Cancel"),
                        Gtk.ResponseType.CANCEL,
                    )
                    label = Gtk.Label(label=warning)
                    dialog2.get_content_area().add(label)
                    label.show()
                    if dialog2.run() == Gtk.ResponseType.OK:
                        parent.save_current_profile(entry.get_text())
                        flag = False

                    dialog2.destroy()

                else:
                    parent.save_current_profile(entry.get_text())
                    flag = False

        else:
            flag = False

    dialog.destroy()


def _edit_profile_callback(_widget, parent):

    name = parent.profile
    msg, profile = None, None
    if name is None or name == "":
        msg = _("Editing current scan options")
        profile = parent.current_scan_options

    else:
        msg = _('Editing scan profile "%s"') % (name)
        profile = parent.profiles[name]

    dialog = Gtk.Dialog(
        title=msg,
        transient_for=parent,
        destroy_with_parent=True,
    )
    dialog.add_buttons(
        _("OK"), Gtk.ResponseType.OK, _("Cancel"), Gtk.ResponseType.CANCEL
    )
    label = Gtk.Label(label=msg)
    dialog.get_content_area().pack_start(label, True, True, 0)

    # Clone so that we can cancel the changes, if necessary
    profile = copy(profile)
    _build_profile_table(
        profile, parent.available_scan_options, dialog.get_content_area()
    )
    dialog.set_default_response(Gtk.ResponseType.OK)
    dialog.show_all()

    # save the profile and reload
    if dialog.run() == Gtk.ResponseType.OK:
        if (name is None) or name == "":
            parent.set_current_scan_options(profile)

        else:
            parent.profiles[name] = profile

            # unset profile to allow us to set it again on reload
            parent.profile = None

            # emit signal to update settings
            parent.emit("added-profile", name, parent.profiles[name])
            signal = None

            def do_parent_reloaded_scan_options(_widget):
                parent.disconnect(signal)
                parent.set_profile(name)

            signal = parent.connect(
                "reloaded-scan-options", do_parent_reloaded_scan_options
            )
            parent.scan_options(parent.device)

    dialog.destroy()


def do_delete_profile_backend_item(_widget, data):
    "callback for delete profile button click"
    profile, options, vbox, frameb, framef, name, i = data
    logger.debug("removing option '%s' from profile", name)
    profile.remove_backend_option_by_index(i)
    frameb.destroy()
    framef.destroy()
    _build_profile_table(profile, options, vbox)


def _build_profile_table(profile, options, vbox):

    frameb = Gtk.Frame(label=_("Backend options"))
    framef = Gtk.Frame(label=_("Frontend options"))
    vbox.pack_start(frameb, True, True, 0)
    vbox.pack_start(framef, True, True, 0)

    # listbox to align widgets
    listbox = Gtk.ListBox()
    listbox.set_selection_mode(Gtk.SelectionMode.NONE)
    frameb.add(listbox)
    for i in profile.each_backend_option():
        name, _val = profile.get_backend_option_by_index(i)
        opt = options.by_name(name)
        row = Gtk.ListBoxRow()
        hbox = Gtk.Box()
        label = Gtk.Label(label=d_sane(opt.title))
        hbox.pack_start(label, False, True, 0)
        icon = Gtk.Image.new_from_icon_name("edit-delete", Gtk.IconSize.BUTTON)
        button = Gtk.Button()
        button.set_image(icon)
        hbox.pack_end(button, False, False, 0)

        button.connect(
            "clicked",
            do_delete_profile_backend_item,
            [profile, options, vbox, frameb, framef, name, i],
        )
        row.add(hbox)
        listbox.add(row)

    listbox = Gtk.ListBox()
    listbox.set_selection_mode(Gtk.SelectionMode.NONE)
    framef.add(listbox)

    for name in profile.each_frontend_option():
        row = Gtk.ListBoxRow()
        hbox = Gtk.Box()
        label = Gtk.Label(label=name)
        hbox.pack_start(label, False, True, 0)
        icon = Gtk.Image.new_from_icon_name("edit-delete", Gtk.IconSize.BUTTON)
        button = Gtk.Button()
        button.set_image(icon)
        hbox.pack_end(button, False, False, 0)

        def do_delete_profile_frontend_item(_widget, _name):
            logger.debug("removing option '%s' from profile", _name)
            profile.remove_frontend_option(_name)
            frameb.destroy()
            framef.destroy()
            _build_profile_table(profile, options, vbox)

        button.connect("clicked", do_delete_profile_frontend_item, name)
        row.add(hbox)
        listbox.add(row)

    vbox.show_all()


def _new_val(oldval, newval):
    return ((newval is not None) and (oldval is not None) and newval != oldval) or (
        (newval is not None) ^ (oldval is not None)
    )


def make_progress_string(i, num_pages):
    "return a progress string"
    if num_pages > 0:
        return _("Scanning page %d of %d") % (i, num_pages)
    return _("Scanning page %d") % (i)
