From 94125ff47131dcf2d93fd7ea8c3ed2aad94d6617 Mon Sep 17 00:00:00 2001 From: Joe R Date: Sun, 21 Jun 2026 10:07:20 -0400 Subject: [PATCH 01/21] Use shift key for direct access to search tabs (#35) --- html-src/howtoplay.html | 2 ++ pysollib/app.py | 5 +++-- pysollib/tile/menubar.py | 5 +++-- pysollib/tile/selectcardset.py | 9 +++++++-- pysollib/tile/selectgame.py | 10 ++++++++-- pysollib/tile/selecttile.py | 9 +++++++-- pysollib/tile/tkwidget.py | 16 +++++++++++++--- pysollib/ui/tktile/menubar.py | 14 ++++++++++---- 8 files changed, 53 insertions(+), 17 deletions(-) diff --git a/html-src/howtoplay.html b/html-src/howtoplay.html index b94e40dc84..6bd79e9bd1 100644 --- a/html-src/howtoplay.html +++ b/html-src/howtoplay.html @@ -106,4 +106,6 @@

Undocumented key bindings

  • Ctrl-U - Play the next music song
  • Ctrl-W - Select game, using old game select window
  • F5 - Refresh the game layout +
  • Shift-Ctrl-V - Select game, using search tab +
  • Shift-Ctrl-E - Select cardset, using search tab diff --git a/pysollib/app.py b/pysollib/app.py index 910b37b06f..3f71851d7c 100644 --- a/pysollib/app.py +++ b/pysollib/app.py @@ -995,14 +995,15 @@ def isCardCountRelevant(self, gi): return (gi.category == GI.GC_TRUMP_ONLY or gi.category == GI.GC_MATCHING) - def selectCardset(self, title, key): + def selectCardset(self, title, key, initial_tab=None): wasPaused = False if not self.game.pause: self.game.doPause() wasPaused = True d = SelectCardsetDialogWithPreview( self.top, title=title, app=self, - manager=self.cardset_manager, key=key) + manager=self.cardset_manager, key=key, + initial_tab=initial_tab) if self.game.pause: if wasPaused: self.game.doPause() diff --git a/pysollib/tile/menubar.py b/pysollib/tile/menubar.py index 773822deee..be7f1772af 100644 --- a/pysollib/tile/menubar.py +++ b/pysollib/tile/menubar.py @@ -93,11 +93,12 @@ def _calc_MfxMessageDialog(self): # create the menubar # - def mSelectCardsetDialog(self, *event): + def mSelectCardsetDialog(self, *event, initial_tab=None): if self._cancelDrag(break_pause=False): return key = self.app.nextgame.cardset.index - cs = self.app.selectCardset(_("Select ")+CARDSET, key) + cs = self.app.selectCardset( + _("Select ")+CARDSET, key, initial_tab=initial_tab) if not cs: return self.app.nextgame.cardset = cs diff --git a/pysollib/tile/selectcardset.py b/pysollib/tile/selectcardset.py index 5fbbc4f9dd..8591e94d63 100644 --- a/pysollib/tile/selectcardset.py +++ b/pysollib/tile/selectcardset.py @@ -209,7 +209,8 @@ class SelectCardsetDialogWithPreview(MfxDialog): TreeDataHolder_Class = SelectCardsetTree TreeData_Class = SelectCardsetData - def __init__(self, parent, title, app, manager, key=None, **kw): + def __init__(self, parent, title, app, manager, key=None, + initial_tab=None, **kw): kw = self.initKw(kw) MfxDialog.__init__(self, parent, title, kw.resizable, kw.default) top_frame, bottom_frame = self.createFrames(kw) @@ -301,6 +302,10 @@ def __init__(self, parent, title, app, manager, key=None, **kw): self.list.config(yscrollcommand=self.list_scrollbar.set) self.list_scrollbar.config(command=self.list.yview) + self.notebook = notebook + search_focus = notebook.apply_initial_tab( + initial_tab, search_frame, self.list_searchtext) + if USE_PIL: size_frame = ttk.Frame(notebook) notebook.add(size_frame, text=_('Card Size')) @@ -406,7 +411,7 @@ def __init__(self, parent, title, app, manager, key=None, **kw): self.updatePreview(key, overrideScale=True) # focus = self.createButtons(bottom_frame, kw) - focus = self.tree.frame + focus = search_focus or self.tree.frame self.mainloop(focus, kw.timeout, geometry=geometry) def destroy(self): diff --git a/pysollib/tile/selectgame.py b/pysollib/tile/selectgame.py index 83e25bd1e6..9f3058a984 100644 --- a/pysollib/tile/selectgame.py +++ b/pysollib/tile/selectgame.py @@ -349,7 +349,8 @@ def mDone(self, button): class SelectGameDialogWithPreview(SelectGameDialog): Tree_Class = SelectGameTreeWithPreview - def __init__(self, parent, title, app, gameid, bookmark=None, **kw): + def __init__(self, parent, title, app, gameid, bookmark=None, + initial_tab=None, **kw): kw = self.initKw(kw) MfxDialog.__init__(self, parent, title, kw.resizable, kw.default) top_frame, bottom_frame = self.createFrames(kw) @@ -446,6 +447,10 @@ def __init__(self, parent, title, app, gameid, bookmark=None, **kw): self.list.config(yscrollcommand=self.list_scrollbar.set) self.list_scrollbar.config(command=self.list.yview) + self.notebook = notebook + search_focus = notebook.apply_initial_tab( + initial_tab, search_frame, self.list_searchtext) + # LabelFrame info_frame = ttk.LabelFrame(right_frame, text=_('About game')) info_frame.grid(row=0, column=0, padx=padx, pady=pady, @@ -498,7 +503,8 @@ def __init__(self, parent, title, app, gameid, bookmark=None, **kw): self.preview_game = None self.preview_app = None self.updatePreview(gameid, animations=0) - # focus = self.tree.frame + if search_focus is not None: + focus = search_focus self.mainloop(focus, kw.timeout, geometry=geometry) def initKw(self, kw): diff --git a/pysollib/tile/selecttile.py b/pysollib/tile/selecttile.py index 4306d487ae..80fef11957 100644 --- a/pysollib/tile/selecttile.py +++ b/pysollib/tile/selecttile.py @@ -133,7 +133,8 @@ class SelectTileDialogWithPreview(MfxDialog): TreeDataHolder_Class = SelectTileTree TreeData_Class = SelectTileData - def __init__(self, parent, title, app, manager, key=None, **kw): + def __init__(self, parent, title, app, manager, key=None, + initial_tab=None, **kw): kw = self.initKw(kw) MfxDialog.__init__(self, parent, title, kw.resizable, kw.default) top_frame, bottom_frame = self.createFrames(kw) @@ -227,6 +228,10 @@ def __init__(self, parent, title, app, manager, key=None, **kw): self.list.config(yscrollcommand=self.list_scrollbar.set) self.list_scrollbar.config(command=self.list.yview) + self.notebook = notebook + search_focus = notebook.apply_initial_tab( + initial_tab, search_frame, self.list_searchtext) + if USE_PIL: self.scaleOptions = {"Default": 0, "Tile": 1, @@ -366,7 +371,7 @@ def SetupPreview(): self.updatePreview(key, app.opt.tabletile_scale_method) # focus = self.createButtons(bottom_frame, kw) - focus = self.tree.frame + focus = search_focus or self.tree.frame self.mainloop(focus, kw.timeout, geometry=geometry) diff --git a/pysollib/tile/tkwidget.py b/pysollib/tile/tkwidget.py index b3fbd7f893..734509f7ae 100644 --- a/pysollib/tile/tkwidget.py +++ b/pysollib/tile/tkwidget.py @@ -34,7 +34,7 @@ from pysollib.settings import WIN_SYSTEM from pysollib.speech import Speech from pysollib.ui.tktile.tkcanvas import MfxCanvas -from pysollib.ui.tktile.tkutil import after, after_cancel +from pysollib.ui.tktile.tkutil import after, after_cancel, after_idle from pysollib.ui.tktile.tkutil import bind, unbind_destroy from pysollib.ui.tktile.tkutil import makeToplevel, setTransient @@ -71,8 +71,6 @@ def __init__(self, parent, title="", resizable=False, default=-1): def mainloop(self, focus=None, timeout=0, transient=True, geometry=""): bind(self.top, "", self.mCancel) bind(self.top, '', self.altKeyEvent) # for accelerators - if focus is not None: - focus.focus() if transient: setTransient(self.top, self.parent) try: @@ -82,6 +80,8 @@ def mainloop(self, focus=None, timeout=0, transient=True, geometry=""): traceback.print_exc() if geometry != "": self.top.geometry(geometry) + if focus is not None: + after_idle(self.top, focus.focus_set) if timeout > 0: self.timer = after(self.top, timeout, self.mTimeout) try: @@ -89,6 +89,8 @@ def mainloop(self, focus=None, timeout=0, transient=True, geometry=""): except SystemExit: pass self.destroy() + elif focus is not None: + focus.focus_set() def destroy(self): after_cancel(self.timer) @@ -1131,6 +1133,14 @@ def _focus(self, event): self.nametowidget(current_tab_id)) self.speech.speak(tab_text + " " + _("Tab") + " " + content_text) + def apply_initial_tab(self, initial_tab, search_frame, search_entry=None): + if initial_tab != 'search': + return None + self.select(search_frame) + if search_entry is not None: + after_idle(self.winfo_toplevel(), search_entry.focus_set) + return search_entry + def _get_screenreader_text(self, frame): for child in frame.winfo_children(): if isinstance(child, PysolScreenReaderText): diff --git a/pysollib/ui/tktile/menubar.py b/pysollib/ui/tktile/menubar.py index cc62d8671b..ac113a2392 100644 --- a/pysollib/ui/tktile/menubar.py +++ b/pysollib/ui/tktile/menubar.py @@ -948,6 +948,9 @@ def _createMenubar(self): self._bindKey("", "n", self.mNewGame) self._bindKey(ctrl, "w", self.mSelectGameDialog) self._bindKey(ctrl, "v", self.mSelectGameDialogWithPreview) + self._bindKey(ctrl + "Shift-", "v", + lambda e: self.mSelectGameDialogWithPreview( + initial_tab='search')) self._bindKey(ctrl, "r", lambda e: self.mSelectRandomGame()) self._bindKey(ctrl, "m", self.mSelectGameById) self._bindKey(ctrl, "n", self.mNewGameWithNextId) @@ -972,6 +975,9 @@ def _createMenubar(self): self._bindKey("", "F3", self.mFindCard) self._bindKey(ctrl, "d", self.mDemo) self._bindKey(ctrl, "e", self.mSelectCardsetDialog) + self._bindKey(ctrl + "Shift-", "e", + lambda e: self.mSelectCardsetDialog( + initial_tab='search')) if USE_PIL: self._bindKey(ctrl, "plus", self.mIncreaseCardset) self._bindKey(ctrl, "equal", self.mIncreaseCardset) @@ -1318,7 +1324,7 @@ def mSelectGameDialog(self, *event): app=self.app, gameid=self.game.id) return self._mSelectGameDialog(d) - def mSelectGameDialogWithPreview(self, *event): + def mSelectGameDialogWithPreview(self, *event, initial_tab=None): if self._cancelDrag(break_pause=False): return self.game.setCursor(cursor=CURSOR_WATCH) @@ -1336,7 +1342,7 @@ def mSelectGameDialogWithPreview(self, *event): d = self._calcSelectGameDialogWithPreview()( self.top, title=_("Select game"), app=self.app, gameid=self.game.id, - bookmark=bookmark) + bookmark=bookmark, initial_tab=initial_tab) return self._mSelectGameDialog(d) # @@ -1986,7 +1992,7 @@ def mOptChangeTableTile(self, *event): if self.app.setTile(i): self.tkopt.tabletile.set(i) - def mSelectTileDialog(self, *event): + def mSelectTileDialog(self, *event, initial_tab=None): if self._cancelDrag(break_pause=False): return key = self.app.tabletile_index @@ -2000,7 +2006,7 @@ def mSelectTileDialog(self, *event): self.top, app=self.app, title=_("Select table background"), manager=self.app.tabletile_manager, - key=key) + key=key, initial_tab=initial_tab) if self.game.pause: if wasPaused: self.game.doPause() From 5e38a60fa118fc85e1ae2986a8e320fbbe07e2ae Mon Sep 17 00:00:00 2001 From: Joe R Date: Sun, 21 Jun 2026 10:41:47 -0400 Subject: [PATCH 02/21] Improved Aces Up card count check. --- pysollib/games/acesup.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pysollib/games/acesup.py b/pysollib/games/acesup.py index 0fb675b6ab..da59428d7f 100644 --- a/pysollib/games/acesup.py +++ b/pysollib/games/acesup.py @@ -52,6 +52,9 @@ class AcesUp_Foundation(AbstractFoundationStack): def acceptsCards(self, from_stack, cards): + # Uncomment this if needed for testing - allows all cards to be + # moved to the foundation, due to the difficulty in winning. + # return cards[0].rank != ACE if not AbstractFoundationStack.acceptsCards(self, from_stack, cards): return False c = cards[0] @@ -101,7 +104,8 @@ def createGame(self, rows=4, reserve=False, **layout): x = x + l.XS x = x + l.XS//2 stack = self.Foundation_Class(x, y, self, suit=ANY_SUIT, max_move=0, - dir=0, base_rank=ANY_RANK, max_cards=48) + dir=0, base_rank=ANY_RANK, + max_cards=self.gameinfo.ncards - 4) l.createText(stack, "s") s.foundations.append(stack) @@ -120,7 +124,7 @@ def startGame(self): self._startAndDealRow() def isGameWon(self): - if len(self.s.foundations[0].cards) != 48: + if len(self.s.foundations[0].cards) != self.gameinfo.ncards - 4: return False for s in self.s.rows: if len(s.cards) != 1 or s.cards[0].rank != ACE: From 91e8d50008112196066e199544fc21600918e910 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:54:12 -0400 Subject: [PATCH 03/21] Bump actions/checkout from 6 to 7 in /.github/workflows (#575) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/macos-package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07b4a9f3d7..7d4d52ecaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: python-version: 3.7 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 0657b32993..d7c207cbf0 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -13,7 +13,7 @@ jobs: runs-on: macos-14 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up python env: PYVER: '3.9.7' From f267eec09583cd4b1c48615bfdd45a164e79ce27 Mon Sep 17 00:00:00 2001 From: Joe R Date: Tue, 23 Jun 2026 19:04:31 -0400 Subject: [PATCH 04/21] Add links for game types to Demons and Thieves/Antares game rule files --- html-src/rules/antares.html | 3 ++- html-src/rules/demonsandthieves.html | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/html-src/rules/antares.html b/html-src/rules/antares.html index 62b9a074d5..1762783286 100644 --- a/html-src/rules/antares.html +++ b/html-src/rules/antares.html @@ -25,5 +25,6 @@

    Rules

    History

    -Demons and Thieves is a combination of FreeCell-type and Scorpion-type +Antares is a combination of FreeCell type +and Scorpion type games. It was invented by Thomas Warfield. diff --git a/html-src/rules/demonsandthieves.html b/html-src/rules/demonsandthieves.html index e8e6ae5c6f..dc8dc3107a 100644 --- a/html-src/rules/demonsandthieves.html +++ b/html-src/rules/demonsandthieves.html @@ -30,5 +30,6 @@

    Rules

    History

    -Demons and Thieves is a combination of Canfield-type and Forty Thieves-type +Demons and Thieves is a combination of Canfield type +and Forty Thieves type games. It was invented by Thomas Warfield. From 64ff0d8be442fd6571b79c9d2697205d469362b4 Mon Sep 17 00:00:00 2001 From: Joe R Date: Tue, 23 Jun 2026 20:22:19 -0400 Subject: [PATCH 05/21] Disable card background option if there is only one back for the cardset --- pysollib/actions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pysollib/actions.py b/pysollib/actions.py index 4978513d2d..708b1c298d 100644 --- a/pysollib/actions.py +++ b/pysollib/actions.py @@ -82,6 +82,7 @@ def __init__(self, app, top, progress=None): shuffle=0, autodeal=0, quickplay=0, + cardbackground=0, demo=0, highlight_piles=0, autoscale=0, @@ -162,6 +163,8 @@ def _updateMenuState(self): ms.autodeal = 1 if autostacks[2]: ms.quickplay = 1 + if len(game.app.images.getCardbacks()) > 1: + ms.cardbackground = 1 if opt.highlight_piles and game.getHighlightPilesStacks(): ms.highlight_piles = 1 if opt.auto_scale: @@ -220,6 +223,7 @@ def _updateMenus(self): self.setMenuState(ms.autodrop, "options.automaticplay.autodrop") self.setMenuState(ms.autodeal, "options.automaticplay.autodeal") self.setMenuState(ms.quickplay, "options.automaticplay.quickplay") + self.setMenuState(ms.cardbackground, "options.cardbackground") if USE_PIL: self.setMenuState(ms.autoscale, "options.cardsize.preserveaspectratio") From b8289203deba9291831e0d4badebcb8c9969c32b Mon Sep 17 00:00:00 2001 From: Joe R Date: Wed, 24 Jun 2026 19:23:00 -0400 Subject: [PATCH 06/21] New locale logic that should address localization changes in installed version (#375) --- pysollib/init.py | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/pysollib/init.py b/pysollib/init.py index e86b49cadc..c9842ac4e1 100644 --- a/pysollib/init.py +++ b/pysollib/init.py @@ -39,6 +39,31 @@ # ************************************************************************ +def _find_locale_dir(): + # Locate the locale directory containing pysol.mo translations. + locale_locations = ( + # locale/ next to the pysol.py script + sys.path[0], + # locale/ next to library.zip (py2exe) + os.path.dirname(sys.path[0]), + # locale/ in curdir (works for e.g. py2app) + os.curdir) + if getattr(sys, 'frozen', False): + # PyInstaller: sys.path[0] is often empty, and locale/ is installed + # next to the executable (see setup.py install_data in AppVeyor). + frozen_dirs = [os.path.dirname(os.path.abspath(sys.executable))] + meipass = getattr(sys, '_MEIPASS', None) + if meipass: + frozen_dirs.extend((meipass, os.path.dirname(meipass))) + locale_locations = tuple(frozen_dirs) + locale_locations + + for par in locale_locations: + locale_dir = os.path.abspath(os.path.join(par, 'locale')) + if os.path.isdir(locale_dir): + return locale_dir + return None + + def init(): if 'LANG' not in os.environ: @@ -52,20 +77,10 @@ def init(): locale.setlocale(locale.LC_ALL, '') # install gettext - locale_locations = ( - # locale/ next to the pysol.py script - sys.path[0], - # locale/ next to library.zip (py2exe) - os.path.dirname(sys.path[0]), - # locale/ in curdir (works for e.g. py2app) - os.curdir) # leaving the domain unbound means sys.prefix+'/share/locale' - - for par in locale_locations: - locale_dir = os.path.join(par, 'locale') - if os.path.isdir(locale_dir): - gettext.bindtextdomain('pysol', locale_dir) - break + locale_dir = _find_locale_dir() + if locale_dir: + gettext.bindtextdomain('pysol', locale_dir) gettext.textdomain('pysol') From f33dc33e7813d83a052c50d5e00fa4fecabb1738 Mon Sep 17 00:00:00 2001 From: Joe R Date: Sat, 27 Jun 2026 14:16:09 -0400 Subject: [PATCH 07/21] Fix for Flamboyant and similar games handling of multiple cards flipped at once, so undo works properly --- pysollib/games/golf.py | 13 +++++++++++-- pysollib/games/interlock.py | 12 ++++++++++++ pysollib/games/threepeaks.py | 19 +++++++++++++++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/pysollib/games/golf.py b/pysollib/games/golf.py index b1bff52cdc..7f816a03ec 100644 --- a/pysollib/games/golf.py +++ b/pysollib/games/golf.py @@ -210,11 +210,20 @@ def getHighlightPilesStacks(self): def getAutoStacks(self, event=None): if event is None: - # disable auto drop - this would ruin the whole gameplay - return (self.sg.dropstacks, (), ()) + # disable auto drop and auto flip + # auto drop would ruin the gameplay + # auto flip causes issues with undo when multiple cards are flipped + return ([], (), ()) # rightclickHandler return (self.sg.dropstacks, self.sg.dropstacks, ()) + def fillStack(self, stack): + old_state = self.enterState(self.S_FILL) + for r in self.s.rows: + if r.canFlipCard(): + r.flipMove(animation=True) + self.leaveState(old_state) + # ************************************************************************ # * Double Golf diff --git a/pysollib/games/interlock.py b/pysollib/games/interlock.py index 20cfa751bd..b76bdbe98b 100644 --- a/pysollib/games/interlock.py +++ b/pysollib/games/interlock.py @@ -209,6 +209,18 @@ def _getClosestStack(self, cx, cy, stacks, dragstack): closest, cdist = stack, dist return closest + def getAutoStacks(self, event=None): + if event is None: + return ([], self.sg.dropstacks, self.sg.dropstacks) + return Game.getAutoStacks(self, event) + + def fillStack(self, stack): + old_state = self.enterState(self.S_FILL) + for r in self.s.rows: + if r.canFlipCard(): + r.flipMove(animation=True) + self.leaveState(old_state) + # ************************************************************************ # * Love A Duck diff --git a/pysollib/games/threepeaks.py b/pysollib/games/threepeaks.py index 4a5a2e9a67..ad2feb3a53 100644 --- a/pysollib/games/threepeaks.py +++ b/pysollib/games/threepeaks.py @@ -100,7 +100,22 @@ def canSelect(self): # * Three Peaks Game # ************************************************************************ -class ThreePeaks(Game): + +class ThreePeaksExposeFlip: + def getAutoStacks(self, event=None): + if event is None: + return ([], self.sg.dropstacks, self.sg.dropstacks) + return Game.getAutoStacks(self, event) + + def fillStack(self, stack): + old_state = self.enterState(self.S_FILL) + for r in self.s.rows: + if r.canFlipCard(): + r.flipMove(animation=True) + self.leaveState(old_state) + + +class ThreePeaks(ThreePeaksExposeFlip, Game): Waste_Class = StackWrapper(Golf_Waste, mod=13) Hint_Class = Golf_Hint @@ -365,7 +380,7 @@ def moveMove(self, ncards, to_stack, frames=-1, shadow=-1): self.game.leaveState(old_state) -class Ricochet(Game): +class Ricochet(ThreePeaksExposeFlip, Game): Waste_Class = StackWrapper(Ricochet_Waste, mod=13) Hint_Class = Golf_Hint From d5d24ea0c24e7ca8672475f420b6414b59209dd7 Mon Sep 17 00:00:00 2001 From: Joe R Date: Mon, 29 Jun 2026 18:14:09 -0400 Subject: [PATCH 08/21] Extend natural sort logic to cardset and tile selection --- pysollib/gamedb.py | 21 ++++++--------------- pysollib/resource.py | 11 ++++++++++- pysollib/tile/selectcardset.py | 4 ++-- pysollib/tile/selectgame.py | 4 ++-- pysollib/tile/selecttile.py | 4 ++-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pysollib/gamedb.py b/pysollib/gamedb.py index 79c2a12abb..65dc849e24 100644 --- a/pysollib/gamedb.py +++ b/pysollib/gamedb.py @@ -21,13 +21,12 @@ # # ---------------------------------------------------------------------------## -import re from importlib import util import pysollib.settings from pysollib.mfxutil import Struct, print_err from pysollib.mygettext import _, n_ -from pysollib.resource import CSI +from pysollib.resource import CSI, getNaturalSortKey # ************************************************************************ @@ -886,13 +885,6 @@ def register(self, gi): self.callback() self._num_games += 1 - def getNaturalSortKey(self, text): - # Sort numbers numerically, and strings alphabetically - def convert(text): - return int(text) if text.isdigit() else text.lower() - - return [convert(c) for c in re.split(r'(\d+)', text)] - # # access games database - we do not expose hidden games # @@ -910,14 +902,13 @@ def getGamesIdSortedByName(self): if self.__games_by_name is None: l1, l2, l3 = [], [], [] for id, gi in self.__games.items(): - name = gi.name.lower() - l1.append((self.getNaturalSortKey(name), id)) + l1.append((getNaturalSortKey(gi.name), id)) if gi.name != gi.short_name: - name = gi.short_name.lower() - l2.append((self.getNaturalSortKey(name), id)) + l2.append((getNaturalSortKey(gi.short_name), id)) + else: + l2.append((getNaturalSortKey(gi.name), id)) for n in gi.altnames: - name = n.lower() - l3.append((self.getNaturalSortKey(name), id, n)) + l3.append((getNaturalSortKey(n), id, n)) l1.sort(key=lambda x: x[0]) l2.sort(key=lambda x: x[0]) l3.sort(key=lambda x: x[0]) diff --git a/pysollib/resource.py b/pysollib/resource.py index ea90af6157..93eee0ac4a 100644 --- a/pysollib/resource.py +++ b/pysollib/resource.py @@ -23,6 +23,7 @@ import glob import os +import re import traceback from pysollib.mfxutil import Image, KwStruct, Struct, USE_PIL @@ -34,6 +35,14 @@ # ************************************************************************ +def getNaturalSortKey(text): + # Sort numbers numerically, and strings alphabetically + def convert(part): + return int(part) if part.isdigit() else part.lower() + + return [convert(c) for c in re.split(r'(\d+)', text)] + + class Resource(Struct): def __init__(self, **kw): kw = KwStruct( @@ -49,7 +58,7 @@ def __init__(self, **kw): Struct.__init__(self, **kw.getKw()) def getSortKey(self): - return self.name.lower() + return getNaturalSortKey(self.name) class ResourceManager: diff --git a/pysollib/tile/selectcardset.py b/pysollib/tile/selectcardset.py index 8591e94d63..97db001ab4 100644 --- a/pysollib/tile/selectcardset.py +++ b/pysollib/tile/selectcardset.py @@ -27,7 +27,7 @@ from pysollib.mfxutil import KwStruct, USE_PIL from pysollib.mygettext import _ -from pysollib.resource import CSI +from pysollib.resource import CSI, getNaturalSortKey from pysollib.speech import Speech from pysollib.ui.tktile.selecttree import SelectDialogTreeData from pysollib.ui.tktile.tkcanvas import MfxCanvasImage @@ -567,7 +567,7 @@ def performSearch(self): if self.app.checkSearchString(self.criteria.name, cardset.name): results.append(cardset.name) - results.sort(key=lambda x: x.lower()) + results.sort(key=getNaturalSortKey) pos = 0 for result in results: self.list.insert(pos, result) diff --git a/pysollib/tile/selectgame.py b/pysollib/tile/selectgame.py index 9f3058a984..bb44562710 100644 --- a/pysollib/tile/selectgame.py +++ b/pysollib/tile/selectgame.py @@ -31,7 +31,7 @@ from pysollib.mfxutil import KwStruct, Struct, destruct from pysollib.mfxutil import format_time from pysollib.mygettext import _ -from pysollib.resource import CSI +from pysollib.resource import CSI, getNaturalSortKey from pysollib.ui.tktile.selecttree import SelectDialogTreeData from pysollib.ui.tktile.tkutil import bind, unbind_destroy @@ -682,7 +682,7 @@ def performSearch(self): for altname in game.altnames: if self.app.checkSearchString(self.criteria.name, altname): results.append(altname) - results.sort(key=self.app.gdb.getNaturalSortKey) + results.sort(key=getNaturalSortKey) pos = 0 for result in results: self.list.insert(pos, result) diff --git a/pysollib/tile/selecttile.py b/pysollib/tile/selecttile.py index 80fef11957..3f0fa2e45f 100644 --- a/pysollib/tile/selecttile.py +++ b/pysollib/tile/selecttile.py @@ -30,7 +30,7 @@ from pysollib.mfxutil import KwStruct, USE_PIL from pysollib.mygettext import _ -from pysollib.resource import TTI, Tile +from pysollib.resource import TTI, Tile, getNaturalSortKey from pysollib.ui.tktile.selecttree import SelectDialogTreeData from pysollib.ui.tktile.tkutil import bind from pysollib.util import IMAGE_EXTENSIONS @@ -476,7 +476,7 @@ def performSearch(self): if self.app.checkSearchString(self.criteria.name, tile.name): results.append(tile.name) - results.sort(key=lambda x: x.lower()) + results.sort(key=getNaturalSortKey) pos = 0 for result in results: self.list.insert(pos, result) From 719bfc32715c58f36237097b7cd0709dd7bfad5b Mon Sep 17 00:00:00 2001 From: Joe R Date: Mon, 29 Jun 2026 22:02:22 -0400 Subject: [PATCH 09/21] Cleanup resource.py and resolved FIXMEs --- pysollib/resource.py | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/pysollib/resource.py b/pysollib/resource.py index 93eee0ac4a..db6c0d92d9 100644 --- a/pysollib/resource.py +++ b/pysollib/resource.py @@ -307,15 +307,15 @@ class CSI: # cardset styles STYLE = { 35: _("Abstract"), # - 1: _("Adult"), # - 2: _("Animals"), # - 3: _("Anime"), # - 4: _("Art"), # - 5: _("Cartoons"), # - 6: _("Children"), # - 7: _("Classic Look"), # - 8: _("Collectors"), # scanned collectors cardsets - 9: _("Computers"), # + 1: _("Adult"), # + 2: _("Animals"), # + 3: _("Anime"), # + 4: _("Art"), # + 5: _("Cartoons"), # + 6: _("Children"), # + 7: _("Classic Look"), # + 8: _("Collectors"), # scanned collectors cardsets + 9: _("Computers"), # 36: _("Divination"), # e.g. fortunetelling decks 10: _("Engines"), # 11: _("Fantasy"), # @@ -533,36 +533,20 @@ def _check(self, cs): elif s == CSI.TYPE_MUGHAL_GANJIFA: cs.nbottoms = 11 elif s == CSI.TYPE_NAVAGRAHA_GANJIFA: - # ???return 0 ## FIXME cs.nbottoms = 12 elif s == CSI.TYPE_DASHAVATARA_GANJIFA: cs.nbottoms = 13 elif s == CSI.TYPE_TRUMP_ONLY: - # ???return 0 ## FIXME - # cs.nbottoms = 7 - # cs.ranks = () - # cs.suits = "" - # cs.trumps = range(cs.ncards) cs.nbottoms = 1 cs.nletters = 0 cs.nshadows = 0 cs.trumps = list(range(cs.ncards)) elif s == CSI.TYPE_MATCHING: - # ???return 0 ## FIXME - # cs.nbottoms = 7 - # cs.ranks = () - # cs.suits = "" - # cs.trumps = range(cs.ncards) cs.nbottoms = 1 cs.nletters = 0 cs.nshadows = 0 cs.trumps = list(range(cs.ncards)) elif s == CSI.TYPE_PUZZLE: - # ???return 0 ## FIXME - # cs.nbottoms = 7 - # cs.ranks = () - # cs.suits = "" - # cs.trumps = range(cs.ncards) cs.nbottoms = 1 cs.nletters = 0 cs.nshadows = 0 From 04316d6d797a62499164fd25113a150889bad0df Mon Sep 17 00:00:00 2001 From: canu <4050064+canu1337@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:34:15 +0200 Subject: [PATCH 10/21] use the same seconds per frame with kivy as tk (#577) --- pysollib/game/__init__.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/pysollib/game/__init__.py b/pysollib/game/__init__.py index 08670542fc..14ce5b5609 100644 --- a/pysollib/game/__init__.py +++ b/pysollib/game/__init__.py @@ -1733,17 +1733,6 @@ def animatedMoveTo(self, from_stack, to_stack, cards, x, y, if self.app.opt.animations == 0 or frames == 0: return - if TOOLKIT == 'kivy': - c0 = cards[0] - dx, dy = (x - c0.x), (y - c0.y) - base = float(self.app.opt.animations) - duration = base*base/30.0 + 0.05 - for card in cards: - card.animatedMove(dx, dy, duration) - # self.top.waitAnimation(swallow=True, pickup=True) - # synchronise: ev. per option ? - return - # init timer - need a high resolution for this to work clock, delay, skip = None, 1, 1 if self.app.opt.animations >= 2: @@ -1767,6 +1756,15 @@ def animatedMoveTo(self, from_stack, to_stack, cards, x, y, # if self.moves.state == self.S_INIT and frames > 4: # frames //= 2 return + if TOOLKIT == 'kivy': + c0 = cards[0] + dx, dy = (x - c0.x), (y - c0.y) + for card in cards: + card.animatedMove(dx, dy, SPF * frames) + # self.top.waitAnimation(swallow=True, pickup=True) + # synchronise: ev. per option ? + return + if shadow < 0: shadow = self.app.opt.shadow shadows = () From dd1afd5401cccf0cb0a273909507304c083262ec Mon Sep 17 00:00:00 2001 From: Joe R Date: Fri, 3 Jul 2026 10:03:12 -0400 Subject: [PATCH 11/21] Completely rewrite install.html to give accurate, up-to-date installation information --- html-src/index.html | 2 +- html-src/install.html | 80 +++++++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/html-src/index.html b/html-src/index.html index f1e0f77c03..ed7e8d0cb5 100644 --- a/html-src/index.html +++ b/html-src/index.html @@ -1,7 +1,7 @@

    PySol - a Solitaire Game Collection

    diff --git a/html-src/install.html b/html-src/install.html index 8954baec4e..48f647a2aa 100644 --- a/html-src/install.html +++ b/html-src/install.html @@ -1,47 +1,61 @@

    Installation

    -There is no need to compile anything since the whole program is just -a Python script. Just run it, and that's all. -

    -PySol requires Python 2.3 and Tcl/Tk 8.4 or better. Both packages are -freely available for Unix, Windows and Macintosh platforms. -

    -PySol is free Open Source software distributed under the terms of the -GNU GPL. - +PySolFC is free and open source software, released under the GNU GPL. It will run on Windows, Mac, Linux, Android, etc. The easiest way to run is to use a self-contained installer, which is available for most supported operating systems. -

    Windows 95/98/ME/NT/2000

    - -PySol now ships as a completely self-contained setup file, so there's -no need to install anything else. +

    Windows

    -If you want to modify the PySol source code or write your own -Python programs you can get the development system from -http://www.python.org/download/download_windows.html +You can download the latest Windows installer from SourceForge. The installer includes the app, Python and all of the requirements to run PySolFC. A basic and a full installer are available - the full installer includes the app, plus the full set of cardsets and all of the extras. +

    Linux

    +

    +PySolFC is available from Flathub (includes the full cardsets collection). Your Linux distribution may also provide their own package, though note that these packages may not always be fully up-to-date. Additionally, you can download a tarball from SourceForge. -

    Unix

    +

    Mac

    +

    +You can download a .DMG package from SourceForge, and install it like any Mac application. Like with the Windows installer, a basic and full package are both available. -There are good chances that your system already ships with Python and Tcl/Tk.
    -Otherwise visit -http://www.python.org/download/ -for full source code. +

    Android

    -Also, installable packages exist for all major Linux distributions, -FreeBSD and HPUX. +An Android package is available to install from F-Droid. +

    Cardsets and Other Files

    +

    +Additional cardsets, music, and settings are stored under ~/.PySolFC/ (or %APPDATA%\PySolFC\ on Windows), organized as follows: +

      +
    • cardsets/ - cardsets and card images
    • +
    • music/ - background music tracks
    • +
    • tiles/ - table tile backgrounds
    • +
    • plugins/ - app plugins
    • +
    • Folders for saved games, options, and statistics
    • +
    +

    +The app's install directory also contains bundled cardsets and data; the folders above are where per-user content and settings are stored. +

    +You can download additional content from SourceForge, though the full collection is already included if you downloaded a full Windows/Mac package or the Flathub version: +

    -

    Macintosh

    +

    Updating

    +

    +You can check for new versions on the PySolFC website, the news page in the online documentation, along with the SourceForge/Flathub/F-Droid sites. Statistics, options, and user data in your PySolFC user folder will be retained across updates. -Self installing exectuables for Python and Tcl/Tk are available from
    -http://www.python.org/download/download_mac.html +

    Running from Source

    -As I don't have access to a Mac I'd appreciate any detailed feedback on -installation and look & feel. "Porting" from X11 to Windows only required some -minor changes in the default font settings, so I hope the situation on Macs is -similar. +Developers can also run PySolFC from the source code. You can clone the GitHub repository and run +python3 pysol.py after installing Python 3.7+, Tkinter, and the pysol-cards package. See the repository's README for the full details. + +

    Running from Command Line

    -[ I have been told that PySol works fine on a Mac - just drop "pysol.py" - on the Python interpreter and that's it. But for some reason you must - assign a large amount of memory to the Python interpreter. ] +A number of command line options are available for special run options, i.e. pysol.py --game="FreeCell". Run with the --help command parameter for more details on the different options. +

    Troubleshooting

    +

    +The following errors might occur during installation: +

      +
    • "No cardsets were found." - Install the cardsets package as described above.
    • +
    • If you are using a cardsets package from a Linux distribution, it may not align with the app if running from source or a different installer. Uninstall the distro's cardsets package and install the one from SourceForge.
    • +
    • If you get a missing cardset types warning, update the cardsets package. Certain games may not work properly otherwise.
    • +
    • See also the FAQ.
    • +
    From 948a41c169859468af6a89641e47f8a087fe73a0 Mon Sep 17 00:00:00 2001 From: Joe R Date: Fri, 3 Jul 2026 13:37:53 -0400 Subject: [PATCH 12/21] Use splitlist instead of split (see #576) --- pysollib/ui/tktile/Canvas2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pysollib/ui/tktile/Canvas2.py b/pysollib/ui/tktile/Canvas2.py index 8f52f2aa1d..e83ace4c91 100644 --- a/pysollib/ui/tktile/Canvas2.py +++ b/pysollib/ui/tktile/Canvas2.py @@ -25,7 +25,7 @@ def delete(self): self.canvas.delete(self.id) def __getitem__(self, key): - v = self.canvas.tk.split(self.canvas.tk.call( + v = self.canvas.tk.splitlist(self.canvas.tk.call( self.canvas._w, 'itemconfigure', self.id, '-' + key)) return v[4] From 8c96352204620cb36ee95e38ae3e0bd124aa617d Mon Sep 17 00:00:00 2001 From: Joe R Date: Fri, 3 Jul 2026 13:50:46 -0400 Subject: [PATCH 13/21] Use natural sort in Kivy menus --- html-src/install.html | 2 +- pysollib/kivy/menubar.py | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/html-src/install.html b/html-src/install.html index 48f647a2aa..ce46028fd5 100644 --- a/html-src/install.html +++ b/html-src/install.html @@ -48,7 +48,7 @@

    Running from Source

    Running from Command Line

    -A number of command line options are available for special run options, i.e. pysol.py --game="FreeCell". Run with the --help command parameter for more details on the different options. +A number of command line options are available for special run options, i.e. pysol.py --game="FreeCell". Run with the --help command parameter for more details on the different options.

    Troubleshooting

    diff --git a/pysollib/kivy/menubar.py b/pysollib/kivy/menubar.py index e1372aaecf..4902cf99cd 100644 --- a/pysollib/kivy/menubar.py +++ b/pysollib/kivy/menubar.py @@ -847,7 +847,7 @@ def buildTree(self, tv, node): # ------------------------------------------- # Cardsets and card backside options - from pysollib.resource import CSI + from pysollib.resource import CSI, getNaturalSortKey rg = tv.add_node( LTreeNode(text=_('Cardsets'))) @@ -861,6 +861,7 @@ def buildTree(self, tv, node): for k in sorted(cdict.keys()): name = CSI.TYPE_NAME[k] csl = cdict[k] + csl.sort(key=lambda cst: getNaturalSortKey(cst[1].name)) rg1 = tv.add_node(LTreeNode(text=name), rg) for cst in csl: @@ -1014,13 +1015,10 @@ def buildTree(self, tv, node): if rg1 or rg2: tm = self.app.tabletile_manager - # cnt = tm.len() - i = 1 - while True: - ti = tm.get(i) - - if ti is None: - break + for ti in tm.getAllSortedByName(): + if ti.index == 0: + continue + i = ti.index if ti.save_aspect == 0 and ti.stretch == 0 and rg1: self.addRadioNode(tv, rg1, ti.name, @@ -1031,7 +1029,6 @@ def buildTree(self, tv, node): ti.name, self.menubar.tkopt.tabletile, i, self.menubar.mOptTileSet) - i += 1 yield # ------------------------------------------- From 5f3d561a3e5c550f524a5a65abf20f31b1122957 Mon Sep 17 00:00:00 2001 From: Joe R Date: Sun, 5 Jul 2026 20:22:01 -0400 Subject: [PATCH 14/21] Include size category in cardset info window --- po/de_pysol.po | 18 ++++++++++++++++++ po/fr_pysol.po | 18 ++++++++++++++++++ po/it_pysol.po | 18 ++++++++++++++++++ po/pl_pysol.po | 18 ++++++++++++++++++ po/pt_BR_pysol.po | 18 ++++++++++++++++++ po/pysol.pot | 18 ++++++++++++++++++ po/ru_pysol.po | 18 ++++++++++++++++++ pysollib/resource.py | 9 +++++++++ pysollib/tile/selectcardset.py | 3 ++- 9 files changed, 137 insertions(+), 1 deletion(-) diff --git a/po/de_pysol.po b/po/de_pysol.po index dda63fa869..c387f4a68a 100644 --- a/po/de_pysol.po +++ b/po/de_pysol.po @@ -4030,6 +4030,24 @@ msgstr "Extra große Karten" msgid "Hi-Res cardsets" msgstr "" +msgid "Tiny" +msgstr "" + +msgid "Small" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "Large" +msgstr "" + +msgid "Extra Large" +msgstr "" + +msgid "Hi-Res" +msgstr "" + #: pysollib\pysolgtk\selectcardset.py:171 pysollib\tile\selectcardset.py:85 #: pysollib\tile\selectcardset.py:184 pysollib\tk\selectcardset.py:81 #: pysollib\tk\selectcardset.py:178 diff --git a/po/fr_pysol.po b/po/fr_pysol.po index 737cc77b4e..f3585052c0 100644 --- a/po/fr_pysol.po +++ b/po/fr_pysol.po @@ -4075,6 +4075,24 @@ msgstr "Cartes XL" msgid "Hi-Res cardsets" msgstr "" +msgid "Tiny" +msgstr "" + +msgid "Small" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "Large" +msgstr "" + +msgid "Extra Large" +msgstr "" + +msgid "Hi-Res" +msgstr "" + #: pysollib\pysolgtk\selectcardset.py:171 pysollib\tile\selectcardset.py:85 #: pysollib\tile\selectcardset.py:184 pysollib\tk\selectcardset.py:81 #: pysollib\tk\selectcardset.py:178 diff --git a/po/it_pysol.po b/po/it_pysol.po index 04e0468d40..9b9fb45025 100644 --- a/po/it_pysol.po +++ b/po/it_pysol.po @@ -4133,6 +4133,24 @@ msgstr "Grandissime" msgid "Hi-Res cardsets" msgstr "" +msgid "Tiny" +msgstr "" + +msgid "Small" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "Large" +msgstr "" + +msgid "Extra Large" +msgstr "" + +msgid "Hi-Res" +msgstr "" + #: pysollib\pysolgtk\selectcardset.py:171 pysollib\tile\selectcardset.py:85 #: pysollib\tile\selectcardset.py:184 pysollib\tk\selectcardset.py:81 #: pysollib\tk\selectcardset.py:178 diff --git a/po/pl_pysol.po b/po/pl_pysol.po index 9d590deb21..43e45f38b7 100644 --- a/po/pl_pysol.po +++ b/po/pl_pysol.po @@ -4101,6 +4101,24 @@ msgstr "Bardzo duże zestawy kart" msgid "Hi-Res cardsets" msgstr "Wysoka rozdzielczość zestawu kart" +msgid "Tiny" +msgstr "" + +msgid "Small" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "Large" +msgstr "" + +msgid "Extra Large" +msgstr "" + +msgid "Hi-Res" +msgstr "" + #: pysollib\pysolgtk\selectcardset.py:171 pysollib\tile\selectcardset.py:85 #: pysollib\tile\selectcardset.py:184 pysollib\tk\selectcardset.py:81 #: pysollib\tk\selectcardset.py:178 diff --git a/po/pt_BR_pysol.po b/po/pt_BR_pysol.po index 60cce0061d..2c4e561848 100644 --- a/po/pt_BR_pysol.po +++ b/po/pt_BR_pysol.po @@ -4105,6 +4105,24 @@ msgstr "Sets de cartas extra grandes" msgid "Hi-Res cardsets" msgstr "Sets de cartas em alta resolução" +msgid "Tiny" +msgstr "" + +msgid "Small" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "Large" +msgstr "" + +msgid "Extra Large" +msgstr "" + +msgid "Hi-Res" +msgstr "" + #: pysollib\pysolgtk\selectcardset.py:171 pysollib\tile\selectcardset.py:85 #: pysollib\tile\selectcardset.py:184 pysollib\tk\selectcardset.py:81 #: pysollib\tk\selectcardset.py:178 diff --git a/po/pysol.pot b/po/pysol.pot index 03b54a5504..0d56017c01 100644 --- a/po/pysol.pot +++ b/po/pysol.pot @@ -4436,6 +4436,24 @@ msgstr "" msgid "Hi-Res cardsets" msgstr "" +msgid "Tiny" +msgstr "" + +msgid "Small" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "Large" +msgstr "" + +msgid "Extra Large" +msgstr "" + +msgid "Hi-Res" +msgstr "" + #: pysollib\pysolgtk\selectcardset.py:171 #: pysollib\tile\selectcardset.py:85 #: pysollib\tile\selectcardset.py:184 diff --git a/po/ru_pysol.po b/po/ru_pysol.po index d798adae9f..e89da134d8 100644 --- a/po/ru_pysol.po +++ b/po/ru_pysol.po @@ -4143,6 +4143,24 @@ msgstr "Очень большие колоды" msgid "Hi-Res cardsets" msgstr "" +msgid "Tiny" +msgstr "" + +msgid "Small" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "Large" +msgstr "" + +msgid "Extra Large" +msgstr "" + +msgid "Hi-Res" +msgstr "" + #: pysollib\pysolgtk\selectcardset.py:171 pysollib\tile\selectcardset.py:85 #: pysollib\tile\selectcardset.py:184 pysollib\tk\selectcardset.py:81 #: pysollib\tk\selectcardset.py:178 diff --git a/pysollib/resource.py b/pysollib/resource.py index db6c0d92d9..ac05615c8a 100644 --- a/pysollib/resource.py +++ b/pysollib/resource.py @@ -389,6 +389,15 @@ class CSI: 22: "2200 - 2299", } + SIZE_NAME = { + 1: _("Tiny"), + 2: _("Small"), + 3: _("Medium"), + 4: _("Large"), + 5: _("Extra Large"), + 6: _("Hi-Res"), + } + class CardsetConfig(Struct): # see config.txt and _readCardsetConfig() diff --git a/pysollib/tile/selectcardset.py b/pysollib/tile/selectcardset.py index 97db001ab4..f6147e5b90 100644 --- a/pysollib/tile/selectcardset.py +++ b/pysollib/tile/selectcardset.py @@ -756,7 +756,8 @@ def __init__(self, parent, title, cardset, images, **kw): (_('Nationality:'), nationalities), (_('Year:'), year), (_('Num. cards:'), str(cardset.ncards)), - (_('Size:'), '%d x %d' % (cardset.CARDW, cardset.CARDH)), + (_('Size:'), '%d x %d (%s)' % (cardset.CARDW, cardset.CARDH, + CSI.SIZE_NAME.get(cardset.si.size))) ): if t is not None: label = ttk.Label(info_frame, text=n, From 5b34d6b0bbbf8f442b322a44e628896dc833569b Mon Sep 17 00:00:00 2001 From: Joe R Date: Wed, 8 Jul 2026 20:28:38 -0400 Subject: [PATCH 15/21] Add check to ensure moves can't be made until the current moves/animations are finished --- pysollib/game/__init__.py | 125 ++++++++++++++++++++------------------ pysollib/stack.py | 47 ++++++++------ 2 files changed, 96 insertions(+), 76 deletions(-) diff --git a/pysollib/game/__init__.py b/pysollib/game/__init__.py index 14ce5b5609..1e0a4e4ccf 100644 --- a/pysollib/game/__init__.py +++ b/pysollib/game/__init__.py @@ -1765,73 +1765,80 @@ def animatedMoveTo(self, from_stack, to_stack, cards, x, y, # synchronise: ev. per option ? return - if shadow < 0: - shadow = self.app.opt.shadow - shadows = () + # Block input while animating: canvas.update() in the loop below + # can re-enter mouse handlers and corrupt game state (#576). + old_busy = self.busy + self.busy = 1 + try: + if shadow < 0: + shadow = self.app.opt.shadow + shadows = () - # start animation - if tkraise: - for card in cards: - card.tkraise() + # start animation + if tkraise: + for card in cards: + card.tkraise() - # Recalculate every frame so the animation adapts - c0 = cards[0] - if to_stack: + # Recalculate every frame so the animation adapts + c0 = cards[0] + if to_stack: - def _dest_func(): - return to_stack.getPositionForNextCard() - else: - _static_dest = (x, y) + def _dest_func(): + return to_stack.getPositionForNextCard() + else: + _static_dest = (x, y) - def _dest_func(): - return _static_dest + def _dest_func(): + return _static_dest - i = 1 - if clock: - starttime = clock() - while i < frames: - # Determine current destination for this frame - dest_x, dest_y = _dest_func() - dx_remain = dest_x - c0.x - dy_remain = dest_y - c0.y - remaining_frames = frames - i + 1 - if remaining_frames <= 0: - remaining_frames = 1 - # Move a fraction of the remaining distance - mx = int(round(dx_remain / remaining_frames)) - my = int(round(dy_remain / remaining_frames)) - if i == 1 and shadow and from_stack: - # create shadows in the first frame - sx, sy = self.app.images.SHADOW_XOFFSET, \ - self.app.images.SHADOW_YOFFSET - shadows = from_stack.createShadows(cards, sx, sy) + i = 1 + if clock: + starttime = clock() + while i < frames: + # Determine current destination for this frame + dest_x, dest_y = _dest_func() + dx_remain = dest_x - c0.x + dy_remain = dest_y - c0.y + remaining_frames = frames - i + 1 + if remaining_frames <= 0: + remaining_frames = 1 + # Move a fraction of the remaining distance + mx = int(round(dx_remain / remaining_frames)) + my = int(round(dy_remain / remaining_frames)) + if i == 1 and shadow and from_stack: + # create shadows in the first frame + sx, sy = self.app.images.SHADOW_XOFFSET, \ + self.app.images.SHADOW_YOFFSET + shadows = from_stack.createShadows(cards, sx, sy) + for s in shadows: + s.move(mx, my) + for card in cards: + card.moveBy(mx, my) + # process events so resize handlers run + self.canvas.update() + step = 1 + if clock: + endtime = starttime + i * SPF + sleep = endtime - clock() + if delay and sleep >= 0.005: + usleep(sleep) + elif skip and sleep <= -0.75 * SPF: + step += 1 + if frames > 4 and sleep < -1.5 * SPF: + step += 1 + i += step + + # last frame: delete shadows and move cards to final destination for s in shadows: - s.move(mx, my) + s.delete() + dest_x, dest_y = _dest_func() + dx_final = dest_x - c0.x + dy_final = dest_y - c0.y for card in cards: - card.moveBy(mx, my) - # process events so resize handlers run + card.moveBy(dx_final, dy_final) self.canvas.update() - step = 1 - if clock: - endtime = starttime + i * SPF - sleep = endtime - clock() - if delay and sleep >= 0.005: - usleep(sleep) - elif skip and sleep <= -0.75 * SPF: - step += 1 - if frames > 4 and sleep < -1.5 * SPF: - step += 1 - i += step - - # last frame: delete shadows and move cards to final destination - for s in shadows: - s.delete() - dest_x, dest_y = _dest_func() - dx_final = dest_x - c0.x - dy_final = dest_y - c0.y - for card in cards: - card.moveBy(dx_final, dy_final) - self.canvas.update() + finally: + self.busy = old_busy def doAnimatedFlipAndMove(self, from_stack, to_stack=None, frames=-1): if self.app.opt.animations == 0 or frames == 0: diff --git a/pysollib/stack.py b/pysollib/stack.py index 44f676aebd..7e1927988e 100644 --- a/pysollib/stack.py +++ b/pysollib/stack.py @@ -643,25 +643,38 @@ def closeStack(self): # def playFlipMove(self, sound=True, animation=False): - if sound: - self.game.playSample("flip", 5) - self.flipMove(animation=animation) - if not self.game.checkForWin(): - self.game.autoPlay() - self.game.finishMove() + # Block input for the whole turn (move + autoplay + finishMove). + # Drag moves use frames=-2 (no animation) so animatedMoveTo's busy + # guard does not apply; autoPlay may still call canvas.update() + old_busy = self.game.busy + self.game.busy = 1 + try: + if sound: + self.game.playSample("flip", 5) + self.flipMove(animation=animation) + if not self.game.checkForWin(): + self.game.autoPlay() + self.game.finishMove() + finally: + self.game.busy = old_busy def playMoveMove(self, ncards, to_stack, frames=-1, shadow=-1, sound=True): - if sound: - if to_stack in self.game.s.foundations: - self.game.playSample("drop", priority=30) - else: - self.game.playSample("move", priority=10) - self.moveMove(ncards, to_stack, frames=frames, shadow=shadow) - if not self.game.checkForWin(): - # let the player put cards back from the foundations - if self not in self.game.s.foundations: - self.game.autoPlay() - self.game.finishMove() + old_busy = self.game.busy + self.game.busy = 1 + try: + if sound: + if to_stack in self.game.s.foundations: + self.game.playSample("drop", priority=30) + else: + self.game.playSample("move", priority=10) + self.moveMove(ncards, to_stack, frames=frames, shadow=shadow) + if not self.game.checkForWin(): + # let the player put cards back from the foundations + if self not in self.game.s.foundations: + self.game.autoPlay() + self.game.finishMove() + finally: + self.game.busy = old_busy # # Appearance {view} From d25273318d34dd3ebe6d3573f69b9909c2f86a24 Mon Sep 17 00:00:00 2001 From: Joe R Date: Tue, 14 Jul 2026 17:56:54 -0400 Subject: [PATCH 16/21] Address stuck checker regression --- pysollib/game/__init__.py | 30 +++++-- pysollib/games/interlock.py | 8 +- pysollib/games/special/matchthree.py | 20 +++++ pysollib/games/spider.py | 13 ++- pysollib/hint.py | 114 +++++++++++++++++++++------ 5 files changed, 151 insertions(+), 34 deletions(-) diff --git a/pysollib/game/__init__.py b/pysollib/game/__init__.py index 1e0a4e4ccf..7a2998836b 100644 --- a/pysollib/game/__init__.py +++ b/pysollib/game/__init__.py @@ -37,7 +37,7 @@ from pysollib.game.dump import pysolDumpGame from pysollib.gamedb import GI from pysollib.help import help_about -from pysollib.hint import DefaultHint +from pysollib.hint import DefaultHint, HINT_LEVEL_SOLVER, HINT_LEVEL_STUCK from pysollib.mfxutil import Image, ImageTk, USE_PIL from pysollib.mfxutil import Struct, SubclassResponsibility, destruct from pysollib.mfxutil import format_time, print_err @@ -666,7 +666,7 @@ def create(self, app): self.showHelp() # just in case hint_class = self.getHintClass() if hint_class is not None: - self.Stuck_Class = hint_class(self, 0) + self.Stuck_Class = hint_class(self, HINT_LEVEL_STUCK) self.busy = old_busy def _checkGame(self): @@ -2826,7 +2826,7 @@ def getGameBalance(self): # compute all hints for the current position # this is the only method that actually uses class Hint def getHints(self, level, taken_hint=None): - if level == 3: + if level == HINT_LEVEL_SOLVER: # if self.solver is None: # return None return self.solver.getHints(taken_hint) @@ -3220,14 +3220,34 @@ def createDemoLogo(self): self.demo_logo = self.app.gimages.demo[int(n)] self.canvas.setTopImage(self.demo_logo) + def _iterMovablePiles(self, stack): + # Yield every face-up suffix pile the stack may move (longest first). + cards = stack.cards + if not cards: + return + start = 0 + for i, c in enumerate(cards): + if c.face_up: + start = i + break + piles = [] + for i in range(start, len(cards)): + pile = cards[i:] + if stack.canMoveCards(pile): + piles.append(pile) + for pile in reversed(piles): + yield pile + def getStuck(self): - h = self.Stuck_Class.getHints(None) + h = self.Stuck_Class.getHints(None) or [] if h: self.failed_snapshots = [] return True if not self.canDealCards(): return False - # can deal cards: do we have any hints in previous deals ? + # No table/waste moves, but dealing (or a waste redeal) is still + # possible. Remember this layout; if we return here after dealing + # through the talon/waste with no progress, treat as stuck. sn = self.getSnapshot() if sn in self.failed_snapshots: return False diff --git a/pysollib/games/interlock.py b/pysollib/games/interlock.py index b76bdbe98b..2f985421e6 100644 --- a/pysollib/games/interlock.py +++ b/pysollib/games/interlock.py @@ -23,7 +23,7 @@ from pysollib.game import Game from pysollib.gamedb import GI, GameInfo, registerGame -from pysollib.hint import Yukon_Hint +from pysollib.hint import Yukon_Hint, hint_level_is_stuck from pysollib.layout import Layout from pysollib.mygettext import _ from pysollib.stack import \ @@ -67,6 +67,12 @@ def _shouldSkipWholePileToEmptyRow(self, r, t, lp, lr): return True return False + def step010b_getPiles(self, stack): + if hint_level_is_stuck(self.level): + p = stack.getPile() + return (p, ) if p else () + return Yukon_Hint.step010b_getPiles(self, stack) + class Interlock_StackMethods: STEP = ((9, 10), (9, 10), (9, 10), (9, 10), (9, 10), diff --git a/pysollib/games/special/matchthree.py b/pysollib/games/special/matchthree.py index f52ec194ea..ae561e2996 100644 --- a/pysollib/games/special/matchthree.py +++ b/pysollib/games/special/matchthree.py @@ -23,6 +23,7 @@ from pysollib.game import Game from pysollib.gamedb import GI, GameInfo, registerGame +from pysollib.hint import AbstractHint from pysollib.layout import Layout from pysollib.mfxutil import kwdefault from pysollib.mygettext import _ @@ -96,7 +97,26 @@ def getAdjacent(self, playSpace): return adjacentRows +class MatchThree_Hint(AbstractHint): + def computeHints(self): + game = self.game + seen = set() + for r in game.s.rows: + if not r.cards: + continue + for t in r.getAdjacent(r.id): + if not t.cards: + continue + pair = (min(r.id, t.id), max(r.id, t.id)) + if pair in seen: + continue + if t.acceptsCards(r, r.cards): + seen.add(pair) + self.addHint(1000, 1, r, t) + + class AbstractMatchThreeGame(Game): + Hint_Class = MatchThree_Hint RowStack_Class = MatchThree_RowStack NCOLORS = 4 diff --git a/pysollib/games/spider.py b/pysollib/games/spider.py index 8f685c4c72..2f1630b080 100644 --- a/pysollib/games/spider.py +++ b/pysollib/games/spider.py @@ -26,7 +26,12 @@ from pysollib.gamedb import GI, GameInfo, registerGame from pysollib.hint import CautiousDefaultHint from pysollib.hint import FreeCellSolverWrapper -from pysollib.hint import SpiderType_Hint, YukonType_Hint +from pysollib.hint import ( + HINT_LEVEL_DEBUG, + SpiderType_Hint, + YukonType_Hint, + hint_level_is_stuck, +) from pysollib.layout import Layout from pysollib.mfxutil import kwdefault from pysollib.stack import \ @@ -90,7 +95,11 @@ def shallMovePile(self, r, t, pile, rpile): # The pile will get moved onto the correct suit if len(rpile) == 0 or pile[0].suit != rpile[-1].suit: return True - if self.level <= 1 and len(rpile) == 0: + # Stuck must not accept reversible whole-pile shuffles, even + # though player hints (level <= 1) are more lenient. + if hint_level_is_stuck(self.level): + return False + if self.level <= HINT_LEVEL_DEBUG and len(rpile) == 0: return True return False return True diff --git a/pysollib/hint.py b/pysollib/hint.py index 40d94e5391..405c85a6a7 100644 --- a/pysollib/hint.py +++ b/pysollib/hint.py @@ -35,6 +35,31 @@ FCS_VERSION = None +# Hint levels form an ordinal capability ladder (except HINT_LEVEL_STUCK): +# >= HINT_LEVEL_DEBUG step020/030 (split-pile setup moves) +# >= HINT_LEVEL_DEMO flip, deal, demo loop guards +# HINT_LEVEL_STUCK is outside that ladder and used only for stuck checks. +HINT_LEVEL_STUCK = -1 +HINT_LEVEL_PLAYER = 0 +HINT_LEVEL_DEBUG = 1 +HINT_LEVEL_DEMO = 2 +HINT_LEVEL_SOLVER = 3 + + +def hint_level_is_stuck(level): + return level == HINT_LEVEL_STUCK + + +def hint_level_is_demo_or_above(level): + return level >= HINT_LEVEL_DEMO + + +def hint_level_avoids_move_loops(level): + # Demo avoids reversible shuffles so it can stop; stuck must do the same + # or it will treat ping-pong tableau moves as "not stuck". + return hint_level_is_demo_or_above(level) or hint_level_is_stuck(level) + + # ************************************************************************ # * HintInterface is an abstract class that defines the public # * interface - it only consists of the constructor @@ -45,9 +70,11 @@ class HintInterface: - # level == 0: show hint (key `H') - # level == 1: show hint and display score value (key `Ctrl-H') - # level == 2: demo + # HINT_LEVEL_PLAYER: show hint (key `H') + # HINT_LEVEL_DEBUG: show hint and display score value (key `Ctrl-H') + # HINT_LEVEL_DEMO: demo + # HINT_LEVEL_SOLVER: solver dialog + # HINT_LEVEL_STUCK: stuck check only (not used by getHints()) def __init__(self, game, level): pass @@ -65,7 +92,8 @@ def __init__(self, game, level): # A forced_move is the next move that must be taken after this move # in order to avoid endless loops during demo play. # - # Deal and flip may only happen if self.level >= 2 (i.e. demo). + # Deal may only happen if self.level >= HINT_LEVEL_DEMO (i.e. demo). + # Flip may happen at HINT_LEVEL_DEMO or HINT_LEVEL_STUCK. # # See Game.showHint() for more information. def getHints(self, taken_hint=None): @@ -83,7 +111,7 @@ def __init__(self, game, level): self.game = game self.level = level self.score_flatten_value = 0 - if self.level == 0: + if self.level == HINT_LEVEL_PLAYER: self.score_flatten_value = 10000 # temporaries within getHints() self.bonus_color = None @@ -178,7 +206,8 @@ def getHints(self, taken_hint=None): if taken_hint and taken_hint[6]: return [taken_hint[6]] # 2) try if we can flip a card - if self.level >= 2: + if hint_level_is_demo_or_above(self.level) or \ + hint_level_is_stuck(self.level): for r in game.allstacks: if r.canFlipCard(): self.addHint(self.SCORE_FLIP, 1, r, r) @@ -187,7 +216,7 @@ def getHints(self, taken_hint=None): # 3) ask subclass to do something useful self.computeHints() # 4) try if we can deal cards - if self.level >= 2: + if hint_level_is_demo_or_above(self.level): if game.canDealCards(): self.addHint(self.SCORE_DEAL, 0, game.s.talon, None) # A few games have multiple waste stacks. In these games, @@ -239,12 +268,12 @@ def _cautiousShallMovePile(self, from_stack, to_stack, pile, rpile): return 0 return 1 - # same, but only check for loops only when in demo mode + # same, but only check for loops in demo / stuck checks def _cautiousDemoShallMovePile(self, from_stack, to_stack, pile, rpile): if from_stack is to_stack or not \ to_stack.acceptsCards(from_stack, pile): return 0 - if self.level >= 2: + if hint_level_avoids_move_loops(self.level): # if len(rpile) == 0: return 1 @@ -454,27 +483,42 @@ def _getDropCardScore(self, score, color, r, t, ncards): # compute hints - main hint intelligence # + def shallMovePile(self, from_stack, to_stack, pile, rpile): + # Klondike-family stuck must ignore reversible shuffles (same idea as + # CautiousDefaultHint / demo loop avoidance), or dead deals never get + # marked stuck while Autopilot still stops. + if hint_level_is_stuck(self.level): + return AbstractHint._cautiousShallMovePile( + self, from_stack, to_stack, pile, rpile) + return AbstractHint._defaultShallMovePile( + self, from_stack, to_stack, pile, rpile) + def computeHints(self): game = self.game + stuck = hint_level_is_stuck(self.level) # 1) check Tableau piles self.step010(game.sg.dropstacks, game.s.rows) # 2) try if we can move part of a pile within the RowStacks # so that we can drop a card afterwards - if not self.hints and self.level >= 1: + # Stuck mirrors demo/debug gating (only when nothing found yet). + # Forcing these steps invented pointless moves and hid real stuck. + if not self.hints and (stuck or self.level >= HINT_LEVEL_DEBUG): self.step020(game.s.rows, game.s.foundations) # 3) try if we should move a card from a Foundation to a RowStack - if not self.hints and self.level >= 1: + if (not stuck and not self.hints and + self.level >= HINT_LEVEL_DEBUG): self.step030(game.s.foundations, game.s.rows, game.sg.dropstacks) - # 4) try if we can move a card from a RowStack to a ReserveStack - if not self.hints or self.level == 0: + # 4/5) reserves: player always; stuck/demo only if still empty + if stuck: + if not self.hints: + self.step040(game.s.rows, game.sg.reservestacks) + self.step050(game.sg.reservestacks, game.s.rows) + elif not self.hints or self.level == HINT_LEVEL_PLAYER: self.step040(game.s.rows, game.sg.reservestacks) - - # 5) try if we should move a card from a ReserveStack to a RowStack - if not self.hints or self.level == 0: self.step050(game.sg.reservestacks, game.s.rows) # Don't be too clever and give up ;-) @@ -495,7 +539,7 @@ def step010(self, dropstacks, rows): score, color = self._getDropCardScore( score, color, r, t, ncards) self.addHint(score, ncards, r, t, color) - if score >= 90000 and self.level >= 1: + if score >= 90000 and self.level >= HINT_LEVEL_DEBUG: break # 1b) try if we can move cards to one of the RowStacks for pile in self.step010b_getPiles(r): @@ -504,7 +548,8 @@ def step010(self, dropstacks, rows): def step010b_getPiles(self, stack): # return all moveable piles for this stack, longest one first - return (stack.getPile(), ) + p = stack.getPile() + return (p, ) if p else () def _shouldSkipWholePileToEmptyRow(self, r, t, lp, lr): # Some games may need to allow moving piles between empty rows. @@ -641,11 +686,12 @@ def step040(self, rows, reservestacks): for t in reservestacks: if t is r or not t.acceptsCards(r, pile): continue - if rr.acceptsCards(t, pile): - # the pile we are going to move from r to t - # could be moved back from t ro r - this is - # dangerous as we can create loops... - continue + if hint_level_avoids_move_loops(self.level): + if rr.acceptsCards(t, pile): + # the pile we are going to move from r to t + # could be moved back from t ro r - this is + # dangerous as we can create loops... + continue score = 10000 score, color = self._getMovePileScore( score, None, r, t, pile, rpile) @@ -657,7 +703,23 @@ def step040(self, rows, reservestacks): def step050(self, reservestacks, rows): if not reservestacks: return - # FIXME + for r in reservestacks: + card = r.getCard() + if not card or not r.canMoveCards([card]): + continue + pile = [card] + for t in rows: + if t is r or not t.acceptsCards(r, pile): + continue + if hint_level_avoids_move_loops(self.level): + tt = self.ClonedStack(t, stackcards=t.cards[:]) + if tt.acceptsCards(r, pile): + continue + score = 10000 + score, color = self._getMovePileScore( + score, None, r, t, pile, []) + self.addHint(score, len(pile), r, t, color) + break # ************************************************************************ @@ -665,8 +727,8 @@ def step050(self, reservestacks, rows): # ************************************************************************ class CautiousDefaultHint(DefaultHint): - # shallMovePile = DefaultHint._cautiousShallMovePile - shallMovePile = DefaultHint._cautiousDemoShallMovePile + shallMovePile = DefaultHint._cautiousShallMovePile + # shallMovePile = DefaultHint._cautiousDemoShallMovePile def _preferHighRankMoves(self): return 1 From 75bfb6fb783b77c072171177ef6c3de35aef2e8d Mon Sep 17 00:00:00 2001 From: Joe R Date: Tue, 14 Jul 2026 21:28:25 -0400 Subject: [PATCH 17/21] Slightly enlarge cardset info window due to text wrap cutoff --- pysollib/tile/selectcardset.py | 2 +- pysollib/tk/selectcardset.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pysollib/tile/selectcardset.py b/pysollib/tile/selectcardset.py index f6147e5b90..d93d19daf8 100644 --- a/pysollib/tile/selectcardset.py +++ b/pysollib/tile/selectcardset.py @@ -791,7 +791,7 @@ def __init__(self, parent, title, cardset, images, **kw): # bg = top_frame["bg"] bg = 'white' text_w = PysolText(frame, bd=1, relief="sunken", wrap="word", - padx=4, width=64, height=8, bg=bg) + padx=4, width=66, height=8, bg=bg) text_w.grid(row=row, column=0, sticky='nsew') sb = ttk.Scrollbar(frame) sb.grid(row=row, column=1, sticky='ns') diff --git a/pysollib/tk/selectcardset.py b/pysollib/tk/selectcardset.py index 7200f12f65..916057118e 100644 --- a/pysollib/tk/selectcardset.py +++ b/pysollib/tk/selectcardset.py @@ -476,7 +476,7 @@ def __init__(self, parent, title, cardset, images, **kw): # bg = top_frame["bg"] bg = 'white' text_w = tkinter.Text(frame, bd=1, relief="sunken", wrap="word", - padx=4, width=64, height=16, bg=bg) + padx=4, width=66, height=16, bg=bg) text_w.grid(row=1, column=0, sticky='nsew') sb = tkinter.Scrollbar(frame) sb.grid(row=1, column=1, sticky='ns') From 2ad8c19bf24c66b89f5c817c84d4efc0e05bd420 Mon Sep 17 00:00:00 2001 From: Brandon <33914549+bwrightkc@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:01:38 -0500 Subject: [PATCH 18/21] Give cardset-size Medium its own gettext context (#579) Fixes the duplicate msgid the same way, but also answers joeraz's question about telling the two Medium strings apart: cardset size now uses pgettext("size", "Medium"), so it's a separate translatable string from the Kivy speed menu's Medium instead of sharing one. pgettext is a small manual implementation (mygettext.py) rather than the stdlib gettext.pgettext(), which only exists on 3.8+ and CI still tests 3.7. Same context-separator trick Django's pgettext() uses internally. xgettext needs --keyword=pgettext:1c,2 to pick these up, added to the Makefile. Added the new msgctxt entry by hand to pysol.pot and all six language po files rather than running make pot, since a full regeneration produces a large unrelated diff on my machine (different xgettext version reformats every location comment). Left it untranslated for now. --- Makefile | 2 +- po/de_pysol.po | 1 + po/fr_pysol.po | 1 + po/it_pysol.po | 1 + po/pl_pysol.po | 1 + po/pt_BR_pysol.po | 1 + po/pysol.pot | 1 + po/ru_pysol.po | 1 + pysollib/mygettext.py | 6 ++++++ pysollib/resource.py | 4 ++-- 10 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index fb99baf160..fc55a2b3de 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ rules: pot: ./scripts/all_games.py gettext > po/games.pot - xgettext --keyword=n_ --add-comments=TRANSLATORS: -o po/pysol.pot \ + xgettext --keyword=n_ --keyword=pgettext:1c,2 --add-comments=TRANSLATORS: -o po/pysol.pot \ pysollib/*.py pysollib/*/*.py pysollib/*/*/*.py data/pysolfc.glade set -e; \ for lng in de fr pl it ru pt_BR; do \ diff --git a/po/de_pysol.po b/po/de_pysol.po index c387f4a68a..22b077b366 100644 --- a/po/de_pysol.po +++ b/po/de_pysol.po @@ -4036,6 +4036,7 @@ msgstr "" msgid "Small" msgstr "" +msgctxt "size" msgid "Medium" msgstr "" diff --git a/po/fr_pysol.po b/po/fr_pysol.po index f3585052c0..15393121dc 100644 --- a/po/fr_pysol.po +++ b/po/fr_pysol.po @@ -4081,6 +4081,7 @@ msgstr "" msgid "Small" msgstr "" +msgctxt "size" msgid "Medium" msgstr "" diff --git a/po/it_pysol.po b/po/it_pysol.po index 9b9fb45025..9caa0167b0 100644 --- a/po/it_pysol.po +++ b/po/it_pysol.po @@ -4139,6 +4139,7 @@ msgstr "" msgid "Small" msgstr "" +msgctxt "size" msgid "Medium" msgstr "" diff --git a/po/pl_pysol.po b/po/pl_pysol.po index 43e45f38b7..72fea03962 100644 --- a/po/pl_pysol.po +++ b/po/pl_pysol.po @@ -4107,6 +4107,7 @@ msgstr "" msgid "Small" msgstr "" +msgctxt "size" msgid "Medium" msgstr "" diff --git a/po/pt_BR_pysol.po b/po/pt_BR_pysol.po index 2c4e561848..69bdf79860 100644 --- a/po/pt_BR_pysol.po +++ b/po/pt_BR_pysol.po @@ -4111,6 +4111,7 @@ msgstr "" msgid "Small" msgstr "" +msgctxt "size" msgid "Medium" msgstr "" diff --git a/po/pysol.pot b/po/pysol.pot index 0d56017c01..b2958ad3ff 100644 --- a/po/pysol.pot +++ b/po/pysol.pot @@ -4442,6 +4442,7 @@ msgstr "" msgid "Small" msgstr "" +msgctxt "size" msgid "Medium" msgstr "" diff --git a/po/ru_pysol.po b/po/ru_pysol.po index e89da134d8..0167e796f9 100644 --- a/po/ru_pysol.po +++ b/po/ru_pysol.po @@ -4149,6 +4149,7 @@ msgstr "" msgid "Small" msgstr "" +msgctxt "size" msgid "Medium" msgstr "" diff --git a/pysollib/mygettext.py b/pysollib/mygettext.py index a009a4fb53..391bd07a2e 100644 --- a/pysollib/mygettext.py +++ b/pysollib/mygettext.py @@ -63,3 +63,9 @@ def fix_gettext(): _ = gettext.ugettext ungettext = gettext.ungettext + + +def pgettext(context, message): + key = "%s\x04%s" % (context, message) + translated = _(key) + return message if translated == key else translated diff --git a/pysollib/resource.py b/pysollib/resource.py index ac05615c8a..7ff6a97e0e 100644 --- a/pysollib/resource.py +++ b/pysollib/resource.py @@ -27,7 +27,7 @@ import traceback from pysollib.mfxutil import Image, KwStruct, Struct, USE_PIL -from pysollib.mygettext import _ +from pysollib.mygettext import _, pgettext from pysollib.settings import DEBUG # ************************************************************************ @@ -392,7 +392,7 @@ class CSI: SIZE_NAME = { 1: _("Tiny"), 2: _("Small"), - 3: _("Medium"), + 3: pgettext("size", "Medium"), 4: _("Large"), 5: _("Extra Large"), 6: _("Hi-Res"), From bb269e9bb6e1f529a6312561e6eb53741e351946 Mon Sep 17 00:00:00 2001 From: Brandon <33914549+bwrightkc@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:07:56 -0500 Subject: [PATCH 19/21] Fix macOS package build to produce a true universal2 app (#580) CI downloaded python.org's Intel-only installer (macosx10.9.pkg) even on the arm64 runner, so Rosetta translated the whole build and shipped an x86_64-only .app that needs Rosetta 2 on Apple Silicon Macs. Switch to the macos11.pkg universal2 installer, bump the deprecated macos-14 runner to macos-15, and add a lipo check after packaging that fails the build if the app or its pygame-ce extension aren't actually universal2. --- .github/workflows/macos-package.yml | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index d7c207cbf0..5eaef84fd4 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -10,16 +10,16 @@ on: jobs: build: - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v7 - - name: Set up python + - name: "Set up python (universal2: arm64 + x86_64)" env: - PYVER: '3.9.7' - PYVER_SHORT: '3.9' + PYVER: '3.11.9' + PYVER_SHORT: '3.11' run: | - wget -O python.pkg "https://www.python.org/ftp/python/${PYVER}/python-${PYVER}-macosx10.9.pkg" + wget -O python.pkg "https://www.python.org/ftp/python/${PYVER}/python-${PYVER}-macos11.pkg" sudo installer -pkg python.pkg -target / echo "/Library/Frameworks/Python.framework/Versions/${PYVER_SHORT}/bin" >> $GITHUB_PATH - name: Install dependencies @@ -67,6 +67,21 @@ jobs: PYTHONPATH="." python3 setup_osx.py py2app test -d dist/PySolFC.app zip -q -r -X PySolFC-app.zip dist + - name: Verify the app is universal2 (arm64 + x86_64) + run: | + set -euo pipefail + check_universal2() { + archs="$(lipo -archs "$1" 2>/dev/null || true)" + case "$archs" in + *arm64*x86_64*|*x86_64*arm64*) ;; + *) echo "ERROR: $1 is not universal2 (archs: ${archs:-none})"; exit 1 ;; + esac + } + check_universal2 "dist/PySolFC.app/Contents/MacOS/PySolFC" + pygame_ext=$(find dist/PySolFC.app -name 'base.cpython-*-darwin.so' -path '*/pygame/*' | head -1) + test -n "$pygame_ext" + check_universal2 "$pygame_ext" + echo "OK: app and pygame-ce are both universal2." - name: Make the .dmg for easy installation run: create-dmg --volname "Install PySolFC" From 4f03762e3c5cf8756dfcd255249631667d4f3eee Mon Sep 17 00:00:00 2001 From: Brandon <33914549+bwrightkc@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:18:59 -0500 Subject: [PATCH 20/21] Block card input while paused on Android/Kivy (#583) * Block card input while paused on Android/Kivy Pause froze the elapsed-time counter but never stopped touch input, so cards could still be dragged and moves counted while paused, letting a run finish with a 0.00 time. LImageItem now checks game.pause before handling touch down/up. * fixes --- pysollib/kivy/LApp.py | 14 ++++++++++++++ pysollib/kivy/tkcanvas.py | 5 ----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pysollib/kivy/LApp.py b/pysollib/kivy/LApp.py index cbd9f4a547..aa09d8bc53 100644 --- a/pysollib/kivy/LApp.py +++ b/pysollib/kivy/LApp.py @@ -763,6 +763,14 @@ def get_image_type(self): LB241111. ''' + def _is_paused(self): + if self.game is not None: + return bool(self.game.pause) + lapp = App.get_running_app() + app = getattr(lapp, 'app', None) + game = getattr(app, 'game', None) + return bool(game and game.pause) + def send_event_pressed_n(self, event, n): r = EVENT_PROPAGATE if self.group and n in self.group.bindings: @@ -788,6 +796,9 @@ def send_event_pressed(self, touch, event): def on_touch_down(self, touch): + if self._is_paused(): + return False + # print('LCardImage: size = %s' % self.size) if self.collide_point(*touch.pos): @@ -847,6 +858,9 @@ def on_touch_up(self, touch): touch.ungrab(self) return True + if self._is_paused(): + return False + if self.collide_point(*touch.pos): if (self.game): diff --git a/pysollib/kivy/tkcanvas.py b/pysollib/kivy/tkcanvas.py index 3550ab3fd7..c24a7b2730 100644 --- a/pysollib/kivy/tkcanvas.py +++ b/pysollib/kivy/tkcanvas.py @@ -921,14 +921,9 @@ def setTopImage(self, image, cw=0, ch=0): def hideAllItems(self): print('MfxCanvas: hideAllItems') - # TBD - # Wir lassen das. Das TopImage deckt alles ab. Spielen ist - # nicht möglich. def showAllItems(self): print('MfxCanvas: showAllItems') - # TBD - # Brauchts darum auch nicht. # Erweiterungen fuer Tk Canvas (prüfen was noch nötig!!). From 327ad87736c0376b2b451bf341e97a98f93d4d41 Mon Sep 17 00:00:00 2001 From: Joe R Date: Sun, 19 Jul 2026 11:37:22 -0400 Subject: [PATCH 21/21] 3.6.1 release --- NEWS.asciidoc | 5 +++++ html-src/news.html | 17 +++++++++++++++++ pysollib/settings.py | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/NEWS.asciidoc b/NEWS.asciidoc index bcdc90f885..f321ac384d 100644 --- a/NEWS.asciidoc +++ b/NEWS.asciidoc @@ -1,5 +1,10 @@ [[news]] === News +* _19 July, 2026:_ There is a new stable release +https://sourceforge.net/projects/pysolfc/files/PySolFC/PySolFC-3.6.1/[PySolFC +v3.6.1]. New in this release: +** Improved Python 3.14 support. +** Many bugfixes. * _13 June, 2026:_ There is a new stable release https://sourceforge.net/projects/pysolfc/files/PySolFC/PySolFC-3.6.0/[PySolFC v3.6.0]. New in this release: diff --git a/html-src/news.html b/html-src/news.html index 0f1168cc84..478d390a0c 100644 --- a/html-src/news.html +++ b/html-src/news.html @@ -1,6 +1,23 @@

    What's New?

    • +

      19 July, 2026: There is a new stable release +PySolFC +v3.6.1. New in this release:

      +
        +
      • +

        +Improved Python 3.14 support. +

        +
      • +
      • +

        +Many bugfixes. +

        +
      • +
      +
    • +
    • 13 June, 2026: There is a new stable release PySolFC v3.6.0. New in this release:

      diff --git a/pysollib/settings.py b/pysollib/settings.py index f7a44e2752..a5897f5937 100644 --- a/pysollib/settings.py +++ b/pysollib/settings.py @@ -32,7 +32,7 @@ def n_(x): # for gettext TITLE = 'PySol' PACKAGE_URL = 'http://pysolfc.sourceforge.io/' -VERSION_TUPLE = (3, 6, 0) +VERSION_TUPLE = (3, 6, 1) VERSION = '.'.join(map(str, VERSION_TUPLE)) # Tk windowing system (auto set up in init.py)