1+ import functools
12import pathlib
3+ import re
24from collections import Counter
35from itertools import count
6+ import warnings
47
58import os
69import sys
710import typing
811from typing import Tuple
912
10- from AnyQt .QtCore import QFileInfo , Qt
13+ from AnyQt .QtCore import QFileInfo , Qt , QSettings
1114from AnyQt .QtGui import QBrush
1215from AnyQt .QtWidgets import \
1316 QMessageBox , QFileDialog , QFileIconProvider , QComboBox
@@ -230,6 +233,14 @@ def __eq__(self, other):
230233 self .prefix == other .prefix and
231234 self .relpath == other .relpath ))
232235
236+ def to_list (self ):
237+ return [self .abspath , self .prefix , self .relpath ,
238+ self .title , self .sheet , self .file_format ]
239+
240+ @classmethod
241+ def from_list (cls , lst ):
242+ return cls (* lst )
243+
233244 @staticmethod
234245 def create (path , searchpaths , ** kwargs ):
235246 """
@@ -330,7 +341,16 @@ def __repr__(self):
330341 __str__ = __repr__
331342
332343
333- class RecentPathsWidgetMixin :
344+ def _check_init (f ):
345+ @functools .wraps (f )
346+ def wrapper (self , * args , ** kwargs ):
347+ if not self ._init_called :
348+ raise RuntimeError (f"{ type (self ).__name__ } .__init__ was not called" )
349+ return f (self , * args , ** kwargs )
350+ return wrapper
351+
352+
353+ class _RecentPathsWidgetMixinBase :
334354 """
335355 Provide a setting with recent paths and relocation capabilities
336356
@@ -345,8 +365,8 @@ class RecentPathsWidgetMixin:
345365 and overload the method `select_file`, for instance like this
346366
347367 def select_file(self, n):
348- super().select_file(n)
349- self.open_file()
368+ recent = super().select_file(n)
369+ self.open_file(recent )
350370
351371 The mixin works by adding a `recent_path` setting storing a list of
352372 instances of :obj:`RecentPath` (not pure strings). The widget can also
@@ -362,67 +382,144 @@ def select_file(self, n):
362382
363383 #: list with search paths; overload to add, say, documentation datasets dir
364384 SEARCH_PATHS = []
385+ MAX_RECENT_PATHS = 15
365386
366- #: List[RecentPath]
367- recent_paths = Setting ([])
368-
387+ # This is just a declaration. The definition is provided by subclasses.
388+ recent_paths : list [RecentPath ]
369389 _init_called = False
370390
371391 def __init__ (self ):
372- super ().__init__ ()
373392 self ._init_called = True
374- self ._relocate_recent_files ()
375-
376- def _check_init (self ):
377- if not self ._init_called :
378- raise RuntimeError ("RecentPathsWidgetMixin.__init__ was not called" )
393+ super ().__init__ ()
379394
380395 def _search_paths (self ):
381396 basedir = self .workflowEnv ().get ("basedir" , None )
382397 if basedir is None :
383398 return self .SEARCH_PATHS
384399 return self .SEARCH_PATHS + [("basedir" , basedir )]
385400
386- def _relocate_recent_files ( self ):
387- self . _check_init ()
401+ @ _check_init
402+ def _relocated_recent_files ( self , paths ):
388403 search_paths = self ._search_paths ()
389404 rec = []
390- for recent in self . recent_paths :
405+ for recent in paths :
391406 kwargs = dict (title = recent .title , sheet = recent .sheet , file_format = recent .file_format )
392407 resolved = recent .resolve (search_paths )
393408 if resolved is not None :
394409 rec .append (
395410 RecentPath .create (resolved .abspath , search_paths , ** kwargs ))
396- elif recent .search (search_paths ) is not None :
397- rec .append (
398- RecentPath .create (recent .search (search_paths ), search_paths , ** kwargs )
411+ elif (path := recent .search (search_paths )) is not None :
412+ rec .append (RecentPath .create (path , search_paths , ** kwargs )
399413 )
400414 else :
401415 rec .append (recent )
402- # change the list in-place for the case the widgets wraps this list
403- # in some model (untested!)
404- self .recent_paths [:] = rec
416+ return rec
405417
418+ @_check_init
406419 def add_path (self , filename ):
407420 """Add (or move) a file name to the top of recent paths"""
408- self ._check_init ()
409421 recent = RecentPath .create (filename , self ._search_paths ())
410422 if recent in self .recent_paths :
411423 self .recent_paths .remove (recent )
412424 self .recent_paths .insert (0 , recent )
425+ del self .recent_paths [self .MAX_RECENT_PATHS :]
426+ return recent
413427
428+ @_check_init
414429 def select_file (self , n ):
415430 """Move the n-th file to the top of the list"""
416431 recent = self .recent_paths [n ]
417432 del self .recent_paths [n ]
418433 self .recent_paths .insert (0 , recent )
434+ return recent
435+
436+
437+ class RecentPathsWidgetMixin (_RecentPathsWidgetMixinBase ):
438+ recent_paths : list [RecentPath ] = Setting ([])
439+
440+ def __init__ (self ):
441+ super ().__init__ ()
442+ warnings .warn (
443+ "RecentPathsWidgetMixin is deprecated because it exposes user's "
444+ "paths; use LocalRecentPathsWidgetMixin instead." ,
445+ DeprecationWarning , stacklevel = 2 )
446+ self ._relocate_recent_files ()
447+
448+ def _relocate_recent_files (self ):
449+ # change the list in-place for the case the widgets wraps this list
450+ # in some model (untested!)
451+ self .recent_paths [:] = self ._relocated_recent_files (self .recent_paths )
419452
420453 def last_path (self ):
421- """Return the most recent absolute path or `None` if there is none"""
422454 return self .recent_paths [0 ].abspath if self .recent_paths else None
423455
424456
425- class RecentPathsWComboMixin (RecentPathsWidgetMixin ):
457+ class LocalRecentPathsWidgetMixin (_RecentPathsWidgetMixinBase ):
458+ active_path : typing .Optional [RecentPath ]
459+ DefaultRecentPaths : list [RecentPath ] = []
460+
461+ def __init__ (self ):
462+ super ().__init__ ()
463+ assert hasattr (self , "active_path" ), (
464+ "LocalRecentPathsWidgetMixin must be mixed into a widget that defines "
465+ "active_path setting; use LocalRecentPathsWComboMixin if you want a "
466+ "combo box with active path handling" )
467+ self ._ensure_recent_paths ()
468+ self ._relocate_recent_files ()
469+ self .update_active_path ()
470+
471+ @classmethod
472+ def __setting_name (cls ):
473+ name = cls .__qualname__
474+ name = re .sub ("^OW" , "" , name )
475+ name = re .sub ("[^A-Za-z_.]" , "" , name )
476+ name = re .sub ("([a-z])([A-Z])" , lambda m : f"{ m .group (1 )} -{ m .group (2 )} " , name )
477+ name = name .lower ()
478+ name = f"recent-paths/{ name } "
479+ return name
480+
481+ @classmethod
482+ def _ensure_recent_paths (cls ):
483+ if "recent_paths" in cls .__dict__ :
484+ return
485+
486+ paths = QSettings ().value (cls .__setting_name (), [], type = list )
487+ paths = [RecentPath .from_list (setting ) for setting in paths ]
488+ if not paths :
489+ # Use a copy to avoid sharing the mutable DefaultRecentPaths list
490+ paths = cls .DefaultRecentPaths [:]
491+ setattr (cls , "recent_paths" , paths )
492+
493+ @classmethod
494+ def store_recent_paths (cls ):
495+ QSettings ().setValue (
496+ cls .__setting_name (),
497+ [path .to_list () for path in getattr (cls , "recent_paths" )]
498+ )
499+
500+ def _relocate_recent_files (self ):
501+ cls = type (self )
502+ paths = getattr (cls , "recent_paths" , [])
503+ paths = self ._relocated_recent_files (paths )
504+ setattr (cls , "recent_paths" , paths )
505+ cls .store_recent_paths ()
506+
507+ def add_path (self , filename ):
508+ recent = super ().add_path (filename )
509+ self .store_recent_paths ()
510+ return recent
511+
512+ def select_file (self , n ):
513+ recent = super ().select_file (n )
514+ self .store_recent_paths ()
515+ return recent
516+
517+ def update_active_path (self ):
518+ if self .active_path :
519+ self .active_path = self ._relocated_recent_files ([self .active_path ])[0 ]
520+
521+
522+ class _RecentPathsWComboMixinBase :
426523 """
427524 Adds file combo handling to :obj:`RecentPathsWidgetMixin`.
428525
@@ -438,26 +535,28 @@ def __init__(self):
438535
439536 def add_path (self , filename ):
440537 """Add (or move) a file name to the top of recent paths"""
441- super ().add_path (filename )
538+ recent = super ().add_path (filename )
442539 self .set_file_list ()
540+ return recent
443541
444542 def select_file (self , n ):
445543 """Move the n-th file to the top of the list"""
446- super ().select_file (n )
544+ recent = super ().select_file (n )
447545 self .set_file_list ()
546+ return recent
448547
449- def set_file_list (self ):
548+ @_check_init
549+ def _set_file_list (self , n ):
450550 """
451551 Sets the items in the file list combo
452552 """
453- self ._check_init ()
454553 self .file_combo .clear ()
455554 if not self .recent_paths :
456555 self .file_combo .addItem ("(none)" )
457556 self .file_combo .model ().item (0 ).setEnabled (False )
458557 self .file_combo .setToolTip ("" )
459558 else :
460- self . file_combo . setToolTip (self .recent_paths [ 0 ]. abspath )
559+ assert 0 <= n < len (self .recent_paths )
461560 paths = unambiguous_paths (
462561 [recent .abspath for recent in self .recent_paths ], minlevel = 2 )
463562 for i , recent , path in zip (count (), self .recent_paths , paths ):
@@ -466,8 +565,62 @@ def set_file_list(self):
466565 if not os .path .exists (recent .abspath ):
467566 self .file_combo .setItemData (i , QBrush (Qt .red ),
468567 Qt .ForegroundRole )
568+ self .file_combo .setToolTip (self .recent_paths [n ].abspath )
569+ self .file_combo .setCurrentIndex (n )
469570
470571 def update_file_list (self , key , value , oldvalue ):
471572 if key == "basedir" :
472573 self ._relocate_recent_files ()
473574 self .set_file_list ()
575+
576+
577+ class RecentPathsWComboMixin (RecentPathsWidgetMixin ,
578+ _RecentPathsWComboMixinBase ):
579+ def set_file_list (self ):
580+ self ._set_file_list (0 )
581+
582+
583+ class LocalRecentPathsWComboMixin (LocalRecentPathsWidgetMixin ,
584+ _RecentPathsWComboMixinBase ):
585+ active_path : typing .Optional [RecentPath ] = Setting (None )
586+
587+ def __init__ (self ):
588+ super ().__init__ ()
589+ if self .active_path is None :
590+ if self .recent_paths :
591+ self .active_path = self .recent_paths [0 ]
592+ else :
593+ if self .active_path not in self .recent_paths :
594+ self .recent_paths .insert (0 , self .active_path )
595+ self .store_recent_paths ()
596+
597+ @_check_init
598+ def set_file_list (self ):
599+ if self .active_path in self .recent_paths :
600+ n = self .recent_paths .index (self .active_path )
601+ else :
602+ n = 0
603+ self ._set_file_list (n )
604+
605+ def add_path (self , filename ):
606+ recent = super ().add_path (filename )
607+ self .active_path = recent
608+ return recent
609+
610+ def select_file (self , n ):
611+ recent = super ().select_file (n )
612+ self .active_path = recent
613+ return recent
614+
615+ @classmethod
616+ def merge_paths (cls , old_paths : list [RecentPath ]):
617+ cls ._ensure_recent_paths ()
618+ for old_path in old_paths :
619+ if old_path in cls .recent_paths :
620+ cls .recent_paths .remove (old_path )
621+ cls .recent_paths [:0 ] = old_paths
622+ del cls .recent_paths [cls .MAX_RECENT_PATHS :]
623+ cls .store_recent_paths ()
624+
625+ def last_path (self ):
626+ return self .active_path and self .active_path .abspath
0 commit comments