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()
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