diff --git a/README.md b/README.md index d646843..f2aec06 100644 --- a/README.md +++ b/README.md @@ -120,16 +120,15 @@ ## Installation from Git repository + +### Ubuntu, Debian, Windows + Install dependencies: ```sh sudo apt install qt6-tools-dev-tools libqt6* ``` - - -### Ubuntu, Debian, Windows - - Install `poetry` and run `bitcoin_safe` ```sh diff --git a/bitcoin_safe/config.py b/bitcoin_safe/config.py index f5fdf01..625f5ca 100644 --- a/bitcoin_safe/config.py +++ b/bitcoin_safe/config.py @@ -28,11 +28,12 @@ import logging -from collections import deque from pathlib import Path from packaging import version +from bitcoin_safe.gui.qt.unique_deque import UniqueDeque + from .execute_config import DEFAULT_MAINNET logger = logging.getLogger(__name__) @@ -56,6 +57,9 @@ NO_FEE_WARNING_BELOW = 10 # sat/vB +RECENT_WALLET_MAXLEN = 15 + + class UserConfig(BaseSaveableClass): known_classes = {**BaseSaveableClass.known_classes, "NetworkConfigs": NetworkConfigs} VERSION = "0.1.6" @@ -79,17 +83,13 @@ def __init__(self) -> None: self.opened_txlike: Dict[str, List[str]] = {} # network:[serializedtx, serialized psbt] self.data_dir = appdirs.user_data_dir(self.app_name) self.is_maximized = False - self.recently_open_wallets: Dict[bdk.Network, deque[str]] = { - network: deque(maxlen=15) for network in bdk.Network + self.recently_open_wallets: Dict[bdk.Network, UniqueDeque[str]] = { + network: UniqueDeque(maxlen=RECENT_WALLET_MAXLEN) for network in bdk.Network } self.language_code: Optional[str] = None def add_recently_open_wallet(self, file_path: str) -> None: - # ensure that the newest open file moves to the top of the queue, but isn't added multiple times - recent_wallets = self.recently_open_wallets[self.network] - if file_path in recent_wallets: - recent_wallets.remove(file_path) - recent_wallets.append(file_path) + self.recently_open_wallets[self.network].append(file_path) @property def network_config(self) -> NetworkConfig: @@ -122,9 +122,10 @@ def dump(self) -> Dict[str, Any]: def from_dump(cls, dct: Dict, class_kwargs=None) -> "UserConfig": super()._from_dump(dct, class_kwargs=class_kwargs) dct["recently_open_wallets"] = { - bdk.Network._member_map_[k]: deque(v, maxlen=5) + bdk.Network._member_map_[k]: UniqueDeque(v, maxlen=RECENT_WALLET_MAXLEN) for k, v in dct.get( - "recently_open_wallets", {network.name: deque(maxlen=5) for network in bdk.Network} + "recently_open_wallets", + {network.name: UniqueDeque(maxlen=RECENT_WALLET_MAXLEN) for network in bdk.Network}, ).items() } # for better portability between computers the saved string is relative to the home folder diff --git a/bitcoin_safe/gui/qt/data_tab_widget.py b/bitcoin_safe/gui/qt/data_tab_widget.py index 865afc0..1df83f1 100644 --- a/bitcoin_safe/gui/qt/data_tab_widget.py +++ b/bitcoin_safe/gui/qt/data_tab_widget.py @@ -30,17 +30,19 @@ import logging from typing import Dict, Generic, Type, TypeVar +from bitcoin_safe.gui.qt.histtabwidget import HistTabWidget + logger = logging.getLogger(__name__) from typing import Dict, Generic, Type, TypeVar from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import QApplication, QTabWidget, QWidget +from PyQt6.QtWidgets import QApplication, QWidget T = TypeVar("T") -class DataTabWidget(Generic[T], QTabWidget): +class DataTabWidget(Generic[T], HistTabWidget): def __init__(self, data_class: Type[T], parent=None) -> None: super().__init__(parent) self._data_class = data_class diff --git a/bitcoin_safe/gui/qt/histtabwidget.py b/bitcoin_safe/gui/qt/histtabwidget.py new file mode 100644 index 0000000..a9d3586 --- /dev/null +++ b/bitcoin_safe/gui/qt/histtabwidget.py @@ -0,0 +1,125 @@ +# +# Bitcoin Safe +# Copyright (C) 2024 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from typing import Optional + +from PyQt6.QtWidgets import ( + QApplication, + QLabel, + QMainWindow, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from bitcoin_safe.gui.qt.unique_deque import UniqueDeque + + +class HistTabWidget(QTabWidget): + """Stores the closing activation history of the tabs and upon close, activates the last active one. + + Args: + QTabWidget: Inherits from QTabWidget. + """ + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self._tab_history: UniqueDeque[int] = UniqueDeque(maxlen=100000) # History of activated tab indices + self.currentChanged.connect(self.on_current_changed) + + def on_current_changed(self, index: int) -> None: + """Updates the tab history when the current tab is changed. + + Args: + index (int): The index of the newly activated tab. + """ + if index >= 0: + self._tab_history.append(index) + + def remove_tab_from_history(self, index: int) -> None: + """Handles the tab close request, updating history and setting the last active tab. + + Args: + index (int): The index of the tab that is being closed. + """ + # Remove the closed tab from history and adjust the indices + if index in self._tab_history: + self._tab_history = UniqueDeque([i for i in self._tab_history if i != index]) + self._tab_history = UniqueDeque([i - 1 if i > index else i for i in self._tab_history]) + + def get_last_active_tab(self) -> int: + if len(self._tab_history) >= 2: + return self._tab_history[-2] + elif len(self._tab_history) >= 1: + return self._tab_history[-1] + return self.currentIndex() + + def jump_to_last_active_tab(self) -> None: + """Sets the current tab to the last active one from history or to the first tab if history is empty.""" + index = self.get_last_active_tab() + if index >= 0: + self.setCurrentIndex(index) + + def removeTab(self, index: int) -> None: + self.remove_tab_from_history(index) + return super().removeTab(index) + + +if __name__ == "__main__": + + class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.tab_widget = HistTabWidget() + self.tab_widget.setTabsClosable(True) + + def remove(index): + self.tab_widget.jump_to_last_active_tab() + self.tab_widget.removeTab(index) + + self.tab_widget.tabCloseRequested.connect(remove) + self.tab_widget.currentChanged.connect( + lambda: print(f"on_currentChanged = {self.tab_widget._tab_history}") + ) + self.setCentralWidget(self.tab_widget) + # Adding example tabs + for i in range(5): + tab = QWidget() + layout = QVBoxLayout() + label = QLabel(f"Content of tab {i + 1}") + layout.addWidget(label) + tab.setLayout(layout) + self.tab_widget.addTab(tab, f"Tab {i + 1}") + + self.setGeometry(300, 300, 400, 300) + + app = QApplication([]) + window = MainWindow() + window.show() + app.exec() diff --git a/bitcoin_safe/gui/qt/main.py b/bitcoin_safe/gui/qt/main.py index 29a0a7b..8c5ce50 100644 --- a/bitcoin_safe/gui/qt/main.py +++ b/bitcoin_safe/gui/qt/main.py @@ -1318,6 +1318,8 @@ def remove_all_qt_wallet(self) -> None: self.remove_qt_wallet(qt_wallet) def close_tab(self, index: int) -> None: + self.tab_wallets.jump_to_last_active_tab() + # qt_wallet qt_wallet = self.get_qt_wallet(tab=self.tab_wallets.widget(index)) if qt_wallet: diff --git a/bitcoin_safe/gui/qt/unique_deque.py b/bitcoin_safe/gui/qt/unique_deque.py new file mode 100644 index 0000000..eff1e31 --- /dev/null +++ b/bitcoin_safe/gui/qt/unique_deque.py @@ -0,0 +1,52 @@ +# +# Bitcoin Safe +# Copyright (C) 2024 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import logging +from collections import deque +from typing import Generic, MutableSequence, TypeVar + +_T = TypeVar("_T") + +logger = logging.getLogger(__name__) + +from collections import deque +from typing import Any + + +class UniqueDeque( + deque, + MutableSequence[_T], + Generic[_T], +): + def append(self, item: Any) -> None: + # If the item is already in the deque, remove it + while item in self: + self.remove(item) + # Append the new item (deque automatically handles maxlen overflow) + super().append(item) diff --git a/tools/release.py b/tools/release.py index 13c4a76..9ca3a4f 100644 --- a/tools/release.py +++ b/tools/release.py @@ -27,6 +27,7 @@ # SOFTWARE. +import argparse import datetime import getpass import hashlib @@ -239,7 +240,29 @@ def get_input_with_default(prompt: str, default: str = "") -> str: return user_input if user_input else default +RELEASE_INSTRUCTIONS = """ +1. Manually create a git tag +2. Build all artifacts +3. Sign all artifacts (build.py --sign) +4. release.py (will also create and publish a pypi package) +5. Manually update the description of the release and click publish +""" + + +def parse_args() -> argparse.Namespace: + + parser = argparse.ArgumentParser(description="Release Bitcoin Safe") + parser.add_argument("--more_help", action="store_true", help=RELEASE_INSTRUCTIONS) + + return parser.parse_args() + + def main() -> None: + args = parse_args() + if args.more_help: + print(RELEASE_INSTRUCTIONS) + return + get_checkout_main() print("Running tests before proceeding...")