예제 #1
0
class Florodoro(QWidget):

    def parseArguments(self):
        parser = argparse.ArgumentParser(
            description="A pomodoro timer that grows procedurally generated trees and flowers while you're studying.",
        )

        parser.add_argument(
            "-d",
            "--debug",
            action="store_true",
            help="run the app in debug mode",
        )

        return parser.parse_args()

    def __init__(self):
        super().__init__()

        arguments = self.parseArguments()

        self.DEBUG = arguments.debug

        os.chdir(os.path.dirname(os.path.realpath(__file__)))

        self.MIN_WIDTH = 600
        self.MIN_HEIGHT = 350

        self.setMinimumWidth(self.MIN_WIDTH)
        self.setMinimumHeight(self.MIN_HEIGHT)

        self.ROOT_FOLDER = os.path.expanduser("~/.florodoro/")

        self.HISTORY_FILE_PATH = self.ROOT_FOLDER + "history" + ("" if not self.DEBUG else "-debug") + ".yaml"
        self.CONFIGURATION_FILE_PATH = self.ROOT_FOLDER + "config" + ("" if not self.DEBUG else "-debug") + ".yaml"

        self.history = History(self.HISTORY_FILE_PATH)

        self.SOUNDS_FOLDER = "sounds/"
        self.PLANTS_FOLDER = "plants/"
        self.IMAGE_FOLDER = "images/"

        self.TEXT_COLOR = self.palette().text().color()
        self.BREAK_COLOR = "#B37700"

        self.APP_NAME = "Florodoro"

        self.STUDY_ICON = qtawesome.icon('fa5s.book', color=self.TEXT_COLOR)
        self.BREAK_ICON = qtawesome.icon('fa5s.coffee', color=self.BREAK_COLOR)
        self.CONTINUE_ICON = qtawesome.icon('fa5s.play', color=self.TEXT_COLOR)
        self.PAUSE_ICON = qtawesome.icon('fa5s.pause', color=self.TEXT_COLOR)
        self.RESET_ICON = qtawesome.icon('fa5s.undo', color=self.TEXT_COLOR)

        self.PLANTS = [GreenTree, DoubleGreenTree, OrangeTree, CircularFlower]
        self.PLANT_NAMES = ["Spruce", "Double spruce", "Maple", "Flower"]

        self.MAX_PLANT_AGE = 90  # maximum number of minutes to make the plant optimal in size

        self.WIDGET_SPACING = 10

        self.MAX_TIME = 180
        self.STEP = 5

        self.INITIAL_TEXT = "Start!"

        self.menuBar = QMenuBar(self)
        self.presets_menu = self.menuBar.addMenu('&Presets')

        self.presets = {
            "Classic": (25, 5, 4),
            "Extended": (45, 12, 2),
            "Sitcomodoro": (65, 25, 1),
        }

        for name in self.presets:
            study_time, break_time, cycles = self.presets[name]

            self.presets_menu.addAction(
                QAction(f"{name} ({study_time} : {break_time} : {cycles})", self,
                        triggered=partial(self.load_preset, study_time, break_time, cycles)))

        self.DEFAULT_PRESET = "Classic"

        self.options_menu = self.menuBar.addMenu('&Options')

        self.notify_menu = self.options_menu.addMenu("&Notify")

        self.sound_action = QAction("&Sound", self, checkable=True, checked=not self.DEBUG,
                                    triggered=lambda _: self.volume_slider.setDisabled(
                                        not self.sound_action.isChecked()))

        self.notify_menu.addAction(self.sound_action)

        self.volume_slider = QSlider(Qt.Horizontal, minimum=0, maximum=100, value=85)
        slider_action = QWidgetAction(self)
        slider_action.setDefaultWidget(SpacedQWidget(self.volume_slider))
        self.notify_menu.addAction(slider_action)

        self.popup_action = QAction("&Pop-up", self, checkable=True, checked=True)
        self.notify_menu.addAction(self.popup_action)

        self.menuBar.addAction(
            QAction(
                "&Statistics",
                self,
                triggered=lambda: self.statistics.show() if self.statistics.isHidden() else self.statistics.hide()
            )
        )

        self.menuBar.addAction(
            QAction(
                "&About",
                self,
                triggered=lambda: QMessageBox.information(
                    self,
                    "About",
                    "This application was created by Tomáš Sláma. It is heavily inspired by the Android app Forest, "
                    "but with all of the plants generated procedurally. It's <a href='https://github.com/xiaoxiae/Florodoro'>open source</a> and licensed "
                    "under MIT, so do as you please with the code and anything else related to the project.",
                ),
            )
        )

        self.plant_menu = self.options_menu.addMenu("&Plants")

        self.overstudy_action = QAction("Overstudy", self, checkable=True)
        self.options_menu.addAction(self.overstudy_action)

        self.plant_images = []
        self.plant_checkboxes = []

        # dynamically create widgets for each plant
        for plant, name in zip(self.PLANTS, self.PLANT_NAMES):
            self.plant_images.append(tempfile.NamedTemporaryFile(suffix=".svg"))
            tmp = plant()
            tmp.set_max_age(1)
            tmp.set_age(1)
            tmp.save(self.plant_images[-1].name, 200, 200)

            setattr(self.__class__, name,
                    QAction(self, icon=QIcon(self.plant_images[-1].name), text=name, checkable=True, checked=True))

            action = getattr(self.__class__, name)

            self.plant_menu.addAction(action)
            self.plant_checkboxes.append(action)

        # the current plant that we're growing
        # if set to none, no plant is growing
        self.plant = None

        self.menuBar.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Maximum)

        main_vertical_layout = QVBoxLayout(self)
        main_vertical_layout.setContentsMargins(0, 0, 0, 0)
        main_vertical_layout.setSpacing(0)
        main_vertical_layout.addWidget(self.menuBar)

        self.canvas = Canvas(self)

        self.statistics = Statistics(self.history)

        font = self.font()
        font.setPointSize(100)

        self.main_label = QLabel(self, alignment=Qt.AlignCenter)
        self.main_label.setFont(font)
        self.main_label.setText(self.INITIAL_TEXT)

        font.setPointSize(26)
        self.cycle_label = QLabel(self)
        self.cycle_label.setAlignment(Qt.AlignTop)
        self.cycle_label.setMargin(20)
        self.cycle_label.setFont(font)

        main_horizontal_layout = QHBoxLayout(self)

        self.study_time_spinbox = QSpinBox(self, prefix="Study for: ", suffix="min.", minimum=1, maximum=self.MAX_TIME,
                                           singleStep=self.STEP)

        self.break_time_spinbox = QSpinBox(self, prefix="Break for: ", suffix="min.", minimum=1, maximum=self.MAX_TIME,
                                           singleStep=self.STEP,
                                           styleSheet=f'color:{self.BREAK_COLOR};')

        self.cycles_spinbox = QSpinBox(self, prefix="Cycles: ", minimum=1, value=1)

        # keep track of remaining number of cycles and the starting number of cycles
        self.remaining_cycles = 0
        self.total_cycles = 0

        # whether we're currently studying
        self.is_study_ongoing = False

        # whether we notified the user already during overstudy
        self.already_notified_during_overstudy = False

        stacked_layout = QStackedLayout(self, stackingMode=QStackedLayout.StackAll)
        stacked_layout.addWidget(self.main_label)
        stacked_layout.addWidget(self.cycle_label)
        stacked_layout.addWidget(self.canvas)

        main_vertical_layout.addLayout(stacked_layout)

        self.setStyleSheet("")

        self.study_button = QPushButton(self, clicked=self.start, icon=self.STUDY_ICON)
        self.break_button = QPushButton(self, clicked=self.start_break, icon=self.BREAK_ICON)
        self.pause_button = QPushButton(self, clicked=self.toggle_pause, icon=self.PAUSE_ICON)
        self.reset_button = QPushButton(self, clicked=self.reset, icon=self.RESET_ICON)

        main_horizontal_layout.addWidget(self.study_time_spinbox)
        main_horizontal_layout.addWidget(self.break_time_spinbox)
        main_horizontal_layout.addWidget(self.cycles_spinbox)
        main_horizontal_layout.addWidget(self.study_button)
        main_horizontal_layout.addWidget(self.break_button)
        main_horizontal_layout.addWidget(self.pause_button)
        main_horizontal_layout.addWidget(self.reset_button)

        main_vertical_layout.addLayout(main_horizontal_layout)

        self.setLayout(main_vertical_layout)

        self.study_timer_frequency = 1 / 60 * 1000
        self.study_timer = QTimer(self, interval=int(self.study_timer_frequency), timeout=self.decrease_remaining_time)

        self.player = QMediaPlayer(self)

        self.setWindowIcon(QIcon(self.IMAGE_FOLDER + "icon.svg"))
        self.setWindowTitle(self.APP_NAME)

        # set initial UI state
        self.reset()

        # a list of name, getter and setter things to load/save when the app opens/closes
        # also dynamically get settings for selecting/unselecting plants
        self.CONFIGURATION_ATTRIBUTES = [("study-time", self.study_time_spinbox.value,
                                          self.study_time_spinbox.setValue),
                                         ("break-time", self.break_time_spinbox.value,
                                          self.break_time_spinbox.setValue),
                                         ("cycles", self.cycles_spinbox.value, self.cycles_spinbox.setValue),
                                         ("sound", self.sound_action.isChecked, self.sound_action.setChecked),
                                         ("sound-volume", self.volume_slider.value, self.volume_slider.setValue),
                                         ("pop-ups", self.popup_action.isChecked, self.popup_action.setChecked),
                                         ("overstudy", self.overstudy_action.isChecked,
                                          self.overstudy_action.setChecked)] + \
                                        [(name.lower(), getattr(self.__class__, name).isChecked,
                                          getattr(self.__class__, name).setChecked) for _, name in
                                         zip(self.PLANTS, self.PLANT_NAMES)]
        # load the default preset
        self.load_preset(*self.presets[self.DEFAULT_PRESET])

        self.load_settings()
        self.show()

    def load_settings(self):
        """Loads the settings file (if it exists)."""
        if os.path.exists(self.CONFIGURATION_FILE_PATH):
            with open(self.CONFIGURATION_FILE_PATH) as file:
                configuration = yaml.load(file, Loader=yaml.FullLoader)

                # don't crash if config is broken
                if not isinstance(configuration, dict):
                    return

                for key in configuration:
                    for name, _, setter in self.CONFIGURATION_ATTRIBUTES:
                        if key == name:
                            setter(configuration[key])

    def save_settings(self):
        """Saves the settings file (if it exists)."""
        if not os.path.exists(self.ROOT_FOLDER):
            os.mkdir(self.ROOT_FOLDER)

        with open(self.CONFIGURATION_FILE_PATH, 'w') as file:
            configuration = {}

            for name, getter, _ in self.CONFIGURATION_ATTRIBUTES:
                configuration[name] = getter()

            file.write(yaml.dump(configuration))

    def closeEvent(self, event):
        """Called when the app is being closed. Overridden to also save Florodoro settings."""
        self.save_settings()
        super().closeEvent(event)

    def load_preset(self, study_value: int, break_value: int, cycles: int):
        """Load a pomodoro preset."""
        self.study_time_spinbox.setValue(study_value)
        self.break_time_spinbox.setValue(break_value)
        self.cycles_spinbox.setValue(cycles)

    def keyPressEvent(self, event: QKeyEvent) -> None:
        """Debug-related keyboard controls."""
        if self.DEBUG:
            if event.key() == Qt.Key_Escape:
                possible_plants = [plant for i, plant in enumerate(self.PLANTS) if self.plant_checkboxes[i].isChecked()]

                if len(possible_plants) != 0:
                    self.plant = choice(possible_plants)()
                    self.canvas.set_drawable(self.plant)
                    self.plant.set_max_age(1)
                    self.plant.set_age(1)
                    self.canvas.update()

    def start_break(self):
        """Starts the break, instead of the study."""
        # if we're overstudying, this can be pressed when studying, so save that we did so
        if self.overstudy_action.isChecked() and self.is_study_ongoing:
            self.save_study(ignore_remainder=False)

        self.start(do_break=True)

    def start(self, do_break=False):
        """The function for starting either the study or break timer (depending on do_break)."""
        self.study_button.setEnabled(do_break)
        self.break_button.setEnabled(self.overstudy_action.isChecked() and not do_break)
        self.reset_button.setDisabled(False)

        self.pause_button.setDisabled(False)
        self.pause_button.setIcon(self.PAUSE_ICON)

        # if we're initially starting to do cycles, reset their count
        # don't reset on break, because we could be doing a standalone break
        if self.remaining_cycles == 0 and not do_break:
            self.remaining_cycles = self.cycles_spinbox.value()
            self.total_cycles = self.remaining_cycles
        else:
            # if we're not studing and are about to when there is still leftover time, we're ending the break quicker than intended
            # therefore, reduce cycles by 1, since they would have been if the timer were to run out during break
            if not self.is_study_ongoing and not do_break and self.get_leftover_time() / self.total_time > 0:
                self.remaining_cycles -= 1

                if self.remaining_cycles == 0:
                    self.reset()
                    return

        # set depending on whether we are currently studying or not
        self.is_study_ongoing = not do_break
        self.already_notified_during_overstudy = False

        self.main_label.setStyleSheet('' if not do_break else f'color:{self.BREAK_COLOR};')

        # the total time to study for (spinboxes are minutes)
        # since it's rounded down and it looks better to start at the exact time, 0.99 is added
        self.total_time = (self.study_time_spinbox if not do_break else self.break_time_spinbox).value() * 60 + 0.99
        self.ending_time = datetime.now() + timedelta(minutes=self.total_time / 60)

        # so it's displayed immediately
        self.update_time_label(self.total_time)
        self.update_cycles_label()

        # don't start showing canvas and growing the plant when we're not studying
        if not do_break:
            possible_plants = [plant for i, plant in enumerate(self.PLANTS) if self.plant_checkboxes[i].isChecked()]

            if len(possible_plants) != 0:
                self.plant = choice(possible_plants)()
                self.canvas.set_drawable(self.plant)
                self.plant.set_max_age(min(1, (self.total_time / 60) / self.MAX_PLANT_AGE))
                self.plant.set_age(0)
            else:
                self.plant = None

        self.study_timer.stop()  # it could be running - we could be currently in a break
        self.study_timer.start()

    def toggle_pause(self):
        """Called when the pause button is pressed. Either stops the timer or starts it again, while also doing stuff
        to the pause icons."""
        # stop the timer, if it's running
        if self.study_timer.isActive():
            self.study_timer.stop()
            self.pause_button.setIcon(self.CONTINUE_ICON)
            self.pause_time = datetime.now()

        # if not, resume
        else:
            self.ending_time += datetime.now() - self.pause_time
            self.study_timer.start()
            self.pause_button.setIcon(self.PAUSE_ICON)

    def reset(self):
        """Reset the UI."""
        self.study_timer.stop()
        self.pause_button.setIcon(self.PAUSE_ICON)

        self.main_label.setStyleSheet('')
        self.study_button.setDisabled(False)
        self.break_button.setDisabled(False)
        self.pause_button.setDisabled(True)
        self.reset_button.setDisabled(True)

        if self.plant is not None:
            self.plant.set_age(0)

        self.remaining_cycles = 0

        self.main_label.setText(self.INITIAL_TEXT)
        self.cycle_label.setText('')

    def update_time_label(self, time):
        """Update the text of the time label, given some time in seconds."""
        sign = -1 if time < 0 else 1

        time = abs(time)

        hours = int(time // 3600)
        minutes = int((time // 60) % 60)
        seconds = int(time % 60)

        # smooth timer: hide minutes/hours if there are none
        result = "-" if sign == -1 else ""
        if hours == 0:
            if minutes == 0:
                result += str(seconds)
            else:
                result += str(minutes) + QTime(0, 0, seconds).toString(":ss")
        else:
            result += str(hours) + QTime(0, minutes, seconds).toString(":mm:ss")

        self.main_label.setText(result)

    def play_sound(self, name: str):
        """Play a file from the sound directory. Extension is not included, will be added automatically."""
        for file in os.listdir(self.SOUNDS_FOLDER):
            # if the file starts with the provided name and only contains an extension after, try to play it
            if file.startswith(name) and file[len(name):][0] == ".":
                path = QDir.current().absoluteFilePath(self.SOUNDS_FOLDER + file)
                url = QUrl.fromLocalFile(path)
                content = QMediaContent(url)
                self.player.setMedia(content)
                self.player.setVolume(self.volume_slider.value())
                self.player.play()

    def show_notification(self, message: str):
        """Show the specified notification using plyer."""
        notification.notify(self.APP_NAME, message, self.APP_NAME, os.path.abspath(self.IMAGE_FOLDER + "icon.svg"))

    def update_cycles_label(self):
        """Update the cycles label, if we're currently studying and it wouldn't be 1/1."""
        if self.total_cycles != 1 and self.is_study_ongoing:
            self.cycle_label.setText(f"{self.total_cycles - self.remaining_cycles + 1}/{self.total_cycles}")

    def get_leftover_time(self):
        """Return time until the timer runs out."""
        return (self.ending_time - datetime.now()).total_seconds()

    def decrease_remaining_time(self):
        """Decrease the remaining time by the timer frequency. Updates clock/plant growth."""
        if self.DEBUG:
            self.ending_time -= timedelta(seconds=30)

        self.update_time_label(self.get_leftover_time())

        if self.get_leftover_time() <= 0:
            if self.is_study_ongoing:
                # only notify once per study, since this would be called all the time during overstudy
                if not self.already_notified_during_overstudy:
                    if self.sound_action.isChecked():
                        self.play_sound("study_done")

                    if self.popup_action.isChecked():
                        self.show_notification("Studying finished, take a break!")

                    self.already_notified_during_overstudy = True

                if not self.overstudy_action.isChecked():
                    self.save_study()  # save before break!
                    self.start_break()
            else:
                self.history.add_break(datetime.now(), self.total_time // 60)
                self.statistics.refresh()

                if self.sound_action.isChecked():
                    self.play_sound("break_done")

                if self.popup_action.isChecked():
                    self.show_notification("Break is over!")

                self.remaining_cycles -= 1
                if self.remaining_cycles <= 0:  # <=, because we could have just started a simple break
                    self.reset()
                else:
                    self.start()
                    self.update_cycles_label()

        else:
            # if there is leftover time and we haven't finished studying, grow the plant
            if self.is_study_ongoing:
                if self.plant is not None:
                    self.plant.set_age(1 - (self.get_leftover_time() / self.total_time))

                self.canvas.update()

    def save_study(self, ignore_remainder=True):
        """Save the record of the current study to the history file. By default, ignore the leftover time, since it
        will be a tiny number."""
        date = datetime.now()

        duration = (self.total_time - self.get_leftover_time()) / 60

        if ignore_remainder:
            duration = float(int(duration))

        self.history.add_study(date, duration, self.plant)

        self.statistics.move()  # move to the last plant
        self.statistics.refresh()
예제 #2
0
class OskbEdit(QWidget):
    def __init__(self):
        super().__init__()
        # Some variables need initialising
        self._doubletimer = QTimer()
        self._doubletimer.setSingleShot(True)
        self._mode = "edit"
        self._changed = False
        self._lastclicked = None
        self._copypaste = []
        # Size window at half width and third of height of screen, positioned in the middle
        av_height = QDesktopWidget().availableGeometry(self).size().height()
        av_width = QDesktopWidget().availableGeometry(self).size().width()
        self.setGeometry(
            QStyle.alignedRect(
                Qt.LeftToRight,
                Qt.AlignCenter,
                QSize(int(av_width / 2), int(av_height / 3)),
                QDesktopWidget().availableGeometry(self),
            )
        )
        # Get a keyboard widget instance and set it up a tiny bit
        global g_oskbwidget
        g_oskbwidget = oskb.Keyboard()
        g_oskbwidget.setButtonHandler(self._buttonHandler)
        g_oskbwidget.setStyleSheet(pkg_resources.resource_string("oskb", "oskbedit.css").decode("utf-8"))
        # Set up elements on the screen, must be done before _loadFile()
        layout = QVBoxLayout(self)
        frame = QWidget()
        self._kbdlayout = QHBoxLayout()
        frame.setLayout(self._kbdlayout)
        self._kbdlayout.addWidget(g_oskbwidget)
        layout.addWidget(frame)
        self.setWindowTitle("oskbedit")
        layout.addWidget(frame)
        self._menu = QMenuBar()
        self._menu.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        self.layout().setMenuBar(self._menu)
        self.show()
        # Load the file from the command line, or the blank keyboard if none specified
        if g_cmdline.keyboard:
            if not self._loadFile(g_cmdline.keyboard):
                sys.exit(-1)
        else:
            self._loadFile("_new")

    def _fixMenu(self):
        self._menu.clear()
        self._menu_file()
        self._menu_edit()
        self._menu_insert()
        self._menu_view()

    #
    # "File" menu
    #

    def _menu_file(self):
        filemenu = self._menu.addMenu("&File")
        newitem = filemenu.addAction("&New")
        newitem.setShortcut("Ctrl+N")
        newitem.triggered.connect(partial(self._loadFile, "_new"))
        loaditem = filemenu.addAction("&Open file")
        loaditem.triggered.connect(self._file_open)
        loaditem.setShortcut("Ctrl+O")
        builtinmenu = filemenu.addMenu("open &Builtin")
        for k in pkg_resources.resource_listdir("oskb", "keyboards"):
            if not k.startswith("_"):
                builtinitem = QAction(k, self)
                builtinitem.triggered.connect(partial(self._loadFile, k))
                builtinmenu.addAction(builtinitem)
        saveitem = filemenu.addAction("&Save")
        if not self._savefilename or not self._changed:
            saveitem.setEnabled(False)
        else:
            saveitem.triggered.connect(partial(self._saveFile, self._savefilename))
            saveitem.setShortcut("Ctrl+S")
        saveasitem = filemenu.addAction("Save &As")
        if self._changed:
            saveasitem.triggered.connect(self._file_save_as)
            if not self._savefilename:
                saveasitem.setShortcut("Ctrl+S")
        else:
            saveasitem.setEnabled(False)
        filemenu.addSeparator()
        exitButton = QAction("&Quit", self)
        exitButton.setShortcut("Ctrl+Q")
        exitButton.setStatusTip("Exit application")
        exitButton.triggered.connect(self.close)
        filemenu.addAction(exitButton)

    def _file_open(self):
        if self._changed and not self._areyousure():
            return
        self._changed = False
        dialog = QFileDialog()
        dialog.setFileMode(QFileDialog.ExistingFile)
        dialog.setViewMode(QFileDialog.Detail)
        if dialog.exec_():
            self._loadFile(*dialog.selectedFiles())

    def _loadFile(self, f):
        if self._changed and not self._areyousure():
            return False
        if os.path.isfile(f):
            self._savefilename = f
        else:
            self._savefilename = None
        try:
            self._kbdname = g_oskbwidget.readKeyboard(f)
        except:
            QMessageBox.warning(
                self,
                "read failed",
                "File read failed: " + str(sys.exc_info()[1]),
                QMessageBox.Ok,
                QMessageBox.Ok,
            )
            return False
        g_oskbwidget.setKeyboard(self._kbdname)
        self._changed = False
        self._kbds = g_oskbwidget.getRawKbds()
        try:
            del self._kbds["_minimized"]
            del self._kbds["_chooser"]
        except:
            pass
        self._kbd = self._kbds[self._kbdname]
        self._viewname = g_oskbwidget.getView()
        self._view = self._kbd["views"][self._viewname]
        g_oskbwidget.updateKeyboard()
        g_oskbwidget.show()
        self._undo = []
        self._redo = []
        self._previouskbd = oskb.oskbCopy(self._kbd)
        self._stir()
        return True

    def _areyousure(self):
        if (
            QMessageBox.warning(
                self,
                "Almost losing changes",
                "You have made changes, are you sure you want to do this? Hit 'No' and save if not.",
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No,
            )
            == QMessageBox.Yes
        ):
            return True
        else:
            return False

    def _file_save_as(self):
        dialog = QFileDialog()
        dialog.setFileMode(QFileDialog.AnyFile)
        dialog.setAcceptMode(QFileDialog.AcceptSave)
        dialog.setViewMode(QFileDialog.Detail)
        if dialog.exec_():
            filenames = dialog.selectedFiles()
            self._saveFile(*filenames)

    def _saveFile(self, f):
        savecopy = oskb.oskbCopy(self._kbd)
        with open(f, "w") as outfile:
            json.dump(savecopy, outfile, ensure_ascii=False, indent=4)
        self._changed = False

    def closeEvent(self, event):
        if self._changed and not self._areyousure():
            event.ignore()
            return
        event.accept()

    #
    # "Edit" menu
    #

    def _menu_edit(self):
        selrows, selkeys = self._surveySelected()
        sel = selrows + selkeys
        editmenu = self._menu.addMenu("&Edit")
        if len(self._undo):
            undoitem = QAction("&Undo " + self._undo[0][0], self)
            undoitem.triggered.connect(self._edit_undo)
        else:
            undoitem = QAction("&Undo", self)
            undoitem.setEnabled(False)
        undoitem.setShortcut("Ctrl+Z")
        editmenu.addAction(undoitem)
        if len(self._redo):
            redoitem = QAction("&Redo " + self._redo[0][0], self)
            redoitem.triggered.connect(self._edit_redo)
        else:
            redoitem = QAction("Redo", self)
            redoitem.setEnabled(False)
        redoitem.setShortcut("Shift+Ctrl+Z")
        editmenu.addAction(redoitem)
        editmenu.addSeparator()
        cutitem = QAction("Cut", self)
        cutitem.setShortcut("Ctrl+X")
        cutitem.triggered.connect(self._edit_cut)
        cutitem.setEnabled(selkeys > 0 and selrows == 0)
        editmenu.addAction(cutitem)
        copyitem = QAction("Copy", self)
        copyitem.setShortcut("Ctrl+C")
        copyitem.triggered.connect(self._edit_copy)
        copyitem.setEnabled(selkeys > 0 and selrows == 0)
        editmenu.addAction(copyitem)
        pasteafteritem = QAction("&Paste", self)
        pasteafteritem.setShortcut("Ctrl+V")
        if not self._copypaste or sel == 0 or (selrows > 0 and selkeys > 0) or selrows > 1:
            pasteafteritem.setEnabled(False)
        else:
            if selkeys > 0:
                pasteafteritem.triggered.connect(partial(self._edit_paste, self._lastSelKey(), 1))
                editmenu.addAction(pasteafteritem)

                pastebeforeitem = QAction("Paste &Before Selected", self)
                pastebeforeitem.triggered.connect(partial(self._edit_paste, self._firstSelKey()))
                editmenu.addAction(pastebeforeitem)
            elif selrows == 1:
                pasteafteritem.triggered.connect(partial(self._edit_paste, self._firstSelRow()))
        editmenu.addAction(pasteafteritem)
        deleteitem = QAction("&Delete", self)
        deleteitem.setShortcut("Del")
        deleteitem.triggered.connect(self._edit_delete)
        deleteitem.setEnabled(sel > 0)
        editmenu.addAction(deleteitem)
        deleterowitem = QAction("Delete &Row", self)
        deleterowitem.triggered.connect(partial(self._edit_delete_row, self._firstSel()))
        deleterowitem.setEnabled(sel == 1)
        editmenu.addAction(deleterowitem)
        deletecolumnitem = QAction("Delete &Column", self)
        deletecolumnitem.triggered.connect(partial(self._edit_delete_column, self._firstSel()))
        deletecolumnitem.setEnabled(sel == 1)
        editmenu.addAction(deletecolumnitem)
        editmenu.addSeparator()
        editkeyitem = QAction("Edit &Key/Spacer", self)
        if sel != 1 or selrows > 0:
            editkeyitem.setEnabled(False)
        else:
            w = self._firstSelWidget()
            if w.data.get("type", "key") == "key":
                editkeyitem.triggered.connect(partial(self._edit_key, w))
            elif w.data.get("type", "key") == "spacer":
                editkeyitem.triggered.connect(partial(self._edit_spacer, w))
        editmenu.addAction(editkeyitem)
        editrowitem = QAction("Edit &Row", self)
        if sel != 1:
            editrowitem.setEnabled(False)
        else:
            if selkeys == 1:
                _, ri, _ = self._firstSelKey()
            else:
                _, ri, _ = self._firstSelRow()
            # heights are only stored in first column
            editrowitem.triggered.connect(partial(self._edit_row, ri))
        editmenu.addAction(editrowitem)
        propitem = QAction("&Keyboard Properties", self)
        propitem.triggered.connect(self._edit_properties)
        editmenu.addAction(propitem)

    def _edit_undo(self):
        actionname, actionview, kbd = self._undo.pop(0)
        self._redo.insert(0, (actionname, self._viewname, oskb.oskbCopy(self._kbd)))
        oskb.oskbCopy(kbd, self._kbd)
        self._stir()

    def _edit_redo(self):
        actionname, actionview, kbd = self._redo.pop(0)
        self._undo.insert(0, (actionname, self._viewname, oskb.oskbCopy(self._kbd)))
        oskb.oskbCopy(kbd, self._kbd)
        self._stir()

    def _edit_delete(self):
        self._copyCut(True)
        self._stir("Delete")

    def _edit_delete_row(self, tuple):
        if len(self._view["columns"][0]["rows"]) == 1:
            QMessageBox.warning(self, "Delete", "You cannot delete the last row", QMessageBox.Ok)
            return
        _, ri, _ = tuple
        for ci, column in enumerate(self._view.get("columns", [])):
            column["rows"].pop(ri)
        self._stir("Delete Row")

    def _edit_delete_column(self, tuple):
        if len(self._view["columns"]) == 1:
            QMessageBox.warning(self, "Delete", "You cannot delete the last column", QMessageBox.Ok)
            return
        ci, _, _ = tuple
        self._view["columns"].pop(ci)
        self._stir("Delete Column")

    def _edit_copy(self):
        self._copypaste = self._copyCut(False)

    def _edit_cut(self):
        self._copypaste = self._copyCut(True)
        self._stir("Cut")

    def _edit_paste(self, tuple, after=0):
        ci, ri, ki = tuple
        for ins in self._copypaste:
            self._view["columns"][ci]["rows"][ri]["keys"].insert(ki + after, oskb.oskbCopy(ins))
        self._stir("Paste")

    def _copyCut(self, cut=False):
        buffer = []
        for ci, ri, ki, keydata in self._reverseIterateKeys():
            if keydata.get("_selected", False):
                buffer.append(oskb.oskbCopy(keydata))
                if cut:
                    del self._view["columns"][ci]["rows"][ri]["keys"][ki]
        return buffer

    def _edit_properties(self):
        if KbdProperties(self._kbd).exec():
            self._stir("Edit Properties")

    def _edit_spacer(self, widget):
        if ValueEdit(widget.data, "width", 0.5).exec():
            self._stir("Edit Spacer")

    def _edit_row(self, ri):
        dict = self._view["columns"][0]["rows"][ri]
        if ValueEdit(dict, "height").exec():
            self._stir("Edit Row")

    def _edit_key(self, widget):
        if EditKey(widget).exec():
            self._stir("Edit Key")

    #
    # "Insert" menu
    #

    def _menu_insert(self):
        selrows, selkeys = self._surveySelected()
        sel = selrows + selkeys
        keymenu = self._menu.addMenu("&Insert")
        insertkmenu = keymenu.addMenu("&Key")
        insertsmenu = keymenu.addMenu("&Spacer")
        if sel == 0 or (selrows > 0 and selkeys > 0) or selrows > 1:
            insertkmenu.setEnabled(False)
            insertsmenu.setEnabled(False)
        elif selkeys > 0:
            addkbefore = QAction("&Before Selected", self)
            addkbefore.triggered.connect(partial(self._insert_key, self._firstSelKey()))
            insertkmenu.addAction(addkbefore)
            addkafter = QAction("&After Selected", self)
            addkafter.triggered.connect(partial(self._insert_key, self._lastSelKey(), 1))
            addkafter.setShortcut("Ctrl+K")
            insertkmenu.addAction(addkafter)
            addsbefore = QAction("&Before Selected", self)
            addsbefore.triggered.connect(partial(self._insert_spacer, self._firstSelKey()))
            insertsmenu.addAction(addsbefore)
            addsafter = QAction("&After Selected", self)
            addsafter.triggered.connect(partial(self._insert_spacer, self._lastSelKey(), 1))
            insertsmenu.addAction(addsafter)
        elif selrows == 1:
            addkon = QAction("&On Selected Row", self)
            addkon.triggered.connect(partial(self._insert_key, self._firstSelRow()))
            addkon.setShortcut("Ctrl+K")
            insertkmenu.addAction(addkon)
            addson = QAction("&On Selected Row", self)
            addson.triggered.connect(partial(self._insert_spacer, self._firstSelRow()))
            insertsmenu.addAction(addson)
        insertrmenu = keymenu.addMenu("&Row")
        insertcmenu = keymenu.addMenu("&Column")
        if sel != 1:
            insertrmenu.setEnabled(False)
            insertcmenu.setEnabled(False)
        else:
            curpos = self._firstSel()
            addrbefore = QAction("&Before Selected", self)
            addrbefore.triggered.connect(partial(self._insert_row, curpos))
            insertrmenu.addAction(addrbefore)
            addrafter = QAction("&After Selected", self)
            addrafter.triggered.connect(partial(self._insert_row, curpos, 1))
            insertrmenu.addAction(addrafter)
            addcbefore = QAction("&Before Selected", self)
            addcbefore.triggered.connect(partial(self._insert_column, curpos))
            insertcmenu.addAction(addcbefore)
            addcafter = QAction("&After Selected", self)
            addcafter.triggered.connect(partial(self._insert_column, curpos, 1))
            insertcmenu.addAction(addcafter)

    def _insert_key(self, tuple, after=0):
        ci, ri, ki = tuple
        k = {"type": "key"}
        rowkeys = self._view["columns"][ci]["rows"][ri]["keys"]
        rowkeys.insert(ki + after, k)
        wiz = None
        if g_kbdinput:
            wiz = KeyWizard()
            if wiz.exec():
                k["caption"] = wiz.caption
                k["single"] = {"send": {}}
                k["single"]["send"]["name"] = wiz.keyname
                k["single"]["send"]["keycode"] = wiz.keycode
                if not wiz.printable:
                    k["single"]["send"]["printable"] = False
            else:
                wiz = None
        if not wiz:
            g_oskbwidget.initKeyboards()
            self._edit_key(rowkeys[ki + after]["_QWidget"])
        self._stir("Insert Key")
        self._selectState(False)
        self._selectState(True, rowkeys[ki + after]["_QWidget"])
        g_oskbwidget.updateKeyboard()
        self._fixMenu()

    def _insert_spacer(self, tuple, after=0):
        ci, ri, ki = tuple
        rowkeys = self._view["columns"][ci]["rows"][ri]["keys"]
        rowkeys.insert(ki + after, {"type": "spacer", "width": 0.5})
        self._stir("Insert Spacer")
        self._selectState(False)
        self._selectState(True, rowkeys[ki + after]["_QWidget"])
        g_oskbwidget.updateKeyboard()
        self._fixMenu()

    def _insert_row(self, tuple, after=0):
        _, ri, _ = tuple
        for ci, column in enumerate(self._view.get("columns", [])):
            column["rows"].insert(ri + after, {"keys": []})
        self._stir("Insert Row")

    def _insert_column(self, tuple, after=0):
        ci, _, _ = tuple
        self._view["columns"].insert(ci + after, {"rows": [{"keys": []}]})
        self._stir("Insert Column")

    #
    # "View" menu
    #

    def _menu_view(self):
        viewmenu = self._menu.addMenu("&View")
        mag = QActionGroup(self)
        editmodeitem = QAction("&Edit mode", self)
        editmodeitem.triggered.connect(self._view_editmode)
        editmodeitem.setCheckable(True)
        editmodeitem.setChecked(self._mode == "edit")
        editmodeitem.setShortcut("Ctrl+E")
        viewmenu.addAction(editmodeitem)
        mag.addAction(editmodeitem)
        testmodeitem = QAction("&Test mode", self)
        testmodeitem.triggered.connect(self._view_testmode)
        testmodeitem.setCheckable(True)
        testmodeitem.setChecked(self._mode == "test")
        testmodeitem.setShortcut("Ctrl+T")
        viewmenu.addAction(testmodeitem)
        mag.addAction(testmodeitem)
        if self._mode == "edit":
            viewmenu.addSeparator()
            vag = QActionGroup(self)
            for vn, v in self._kbd["views"].items():
                va = QAction(vn, vag)
                va.setCheckable(True)
                if v == self._view:
                    va.setChecked(True)
                va.triggered.connect(partial(self._view_switch, vn))
                viewmenu.addAction(va)
            viewmenu.addSeparator()
        addviewitem = QAction("&Add New View", self)
        addviewitem.triggered.connect(self._view_add)
        viewmenu.addAction(addviewitem)
        delviewitem = QAction("&Delete Current View", self)
        delviewitem.triggered.connect(self._view_delete)
        viewmenu.addAction(delviewitem)

    def _view_editmode(self):
        self._mode = "edit"
        self._view_switch(g_oskbwidget.getView())
        g_oskbwidget.setButtonHandler(self._buttonHandler)
        self._stir()

    def _view_testmode(self):
        self._mode = "test"
        self._selectState(False)
        g_oskbwidget.setButtonHandler()
        self._stir()

    def _view_switch(self, viewname):
        g_oskbwidget.setView(viewname)
        self._view = self._kbd["views"][viewname]
        self._viewname = viewname
        self._fixMenu()

    def _view_delete(self):
        delview = g_oskbwidget.getView()
        if delview == "default":
            QMessageBox.warning(self, "New View", "Cannot delete 'default' view", QMessageBox.Ok)
            return
        self._view_switch("default")
        del self._kbd["views"][delview]
        self._stir("Delete View")

    def _view_add(self):
        while True:
            viewname, result = QInputDialog.getText(self, "New View", "Name:")
            if not result:
                break
            if not re.fullmatch("[a-z_]+", viewname):
                QMessageBox.warning(
                    self,
                    "New View",
                    "View name can only contain lower case letters and underscores",
                    QMessageBox.Ok,
                )
                continue
            if self._kbd["views"].get(viewname):
                QMessageBox.warning(self, "New View", "View " + viewname + " already exists", QMessageBox.Ok)
                continue
            self._kbd["views"][viewname] = {"columns": [{"rows": [{"keys": []}]}]}
            self._stir("Add View")
            self._view_switch(viewname)
            self._fixMenu()
            break

    #
    # Button Handler
    #

    def _buttonHandler(self, widget, direction):
        if direction == oskb.PRESSED:
            # Read only once, is not state now but of last click event
            mod = QGuiApplication.keyboardModifiers()
            if mod & Qt.ControlModifier:
                self._selectState(not widget.data.get("_selected", False), widget)
            elif mod & Qt.ShiftModifier:
                if self._lastclicked:
                    if widget.data.get("type") == "emptyrow":
                        return
                    selecting = False
                    for ci, ri, ki, keydata in self._iterateKeys():
                        thisone = keydata["_QWidget"]
                        if thisone == self._lastclicked:
                            selecting = not selecting
                        if thisone == widget:
                            selecting = not selecting
                        if selecting or thisone == widget:
                            self._selectState(True, thisone)
            else:
                if self._doubletimer.isActive():
                    self._doubleClick(widget)
                else:
                    self._doubletimer.start(DOUBLECLICK_TIMEOUT)
                    self._selectState(False)
                    self._selectState(True, widget)
        if widget.data.get("type") == "emptyrow":
            self._lastclicked = None
        else:
            self._lastclicked = widget
        g_oskbwidget.updateKeyboard()
        self._fixMenu()

    def _doubleClick(self, widget):
        if widget.data.get("type") == "spacer":
            self._edit_spacer(widget)
        elif widget.data.get("type", "key") == "key":
            self._edit_key(widget)

    #
    # Various functions
    #

    # Redoes the keyboard. When called with an actionname string, it will store an undo state.
    def _stir(self, actionname=None):
        if actionname:
            self._changed = True
            self._undo.insert(0, (actionname, self._viewname[:], oskb.oskbCopy(self._previouskbd)))
            self._previouskbd = oskb.oskbCopy(self._kbd)
            while len(self._undo) > MAX_UNDO:
                self._undo.pop(len(self._undo) - 1)
            self._redo = []
        g_oskbwidget.initKeyboards()
        # g_oskbwidget.updateKeyboard()
        self._view_switch(g_oskbwidget.getView())
        self._fixMenu()

    def _listViews(self):
        viewlist = []
        for view in self._kbd["views"]:
            viewlist.append(view["name"])
        return viewlist

    # Calling without widget selects or deselects everything
    def _selectState(self, newstate, widget=None):
        for _, _, row in self._iterateRows():
            if row.get("_QWidget"):
                if not widget or row.get("_QWidget") == widget:
                    row["_selected"] = newstate
        for _, _, _, keydata in self._iterateKeys():
            if not widget or widget == keydata.get("_QWidget"):
                keydata["_selected"] = newstate

    def _surveySelected(self):
        selrows, selkeys = 0, 0
        for _, _, row in self._iterateRows():
            if row.get("_QWidget") and row.get("_selected"):
                selrows += 1
        for _, _, _, keydata in self._iterateKeys():
            if keydata.get("_selected"):
                selkeys += 1
        return selrows, selkeys

    def _firstSelWidget(self):
        for ci, ri, ki, keydata in self._iterateKeys():
            if keydata.get("_selected"):
                return keydata.get("_QWidget")

    def _firstSelKey(self):
        for ci, ri, ki, keydata in self._iterateKeys():
            if keydata.get("_selected"):
                return (ci, ri, ki)

    def _lastSelKey(self):
        for ci, ri, ki, keydata in self._reverseIterateKeys():
            if keydata.get("_selected"):
                return (ci, ri, ki)

    def _firstSelRow(self):
        for ci, ri, row in self._iterateRows():
            if row.get("_QWidget") and row.get("_selected"):
                return (ci, ri, 0)

    def _firstSel(self):
        p = self._firstSelKey()
        if p:
            return p
        return self._firstSelRow()

    def _iterateKeys(self):
        for ci, column in enumerate(self._view.get("columns", [])):
            for ri, row in enumerate(column.get("rows", [])):
                for ki, keydata in enumerate(row.get("keys", [])):
                    yield ci, ri, ki, keydata

    def _reverseIterateKeys(self):
        for ci, column in reversed(list(enumerate(self._view.get("columns", [])))):
            for ri, row in reversed(list(enumerate(column.get("rows", [])))):
                for ki, keydata in reversed(list(enumerate(row.get("keys", [])))):
                    yield ci, ri, ki, keydata

    def _iterateRows(self):
        for ci, column in enumerate(self._view.get("columns", [])):
            for ri, row in enumerate(column.get("rows", [])):
                yield ci, ri, row