libtextworker 0.1.4
Cross-platform, free and open library for Python projects
manager.py
1 """
2 @package libtextworker.interface.manager
3 """
4 
5 # A cross-platform library for Python apps.
6 # Copyright (C) 2023-2024 Le Bao Nguyen and contributors.
7 # This is a part of the libtextworker project.
8 # Licensed under the GNU General Public License version 3.0 or later.
9 
10 import json
11 import os
12 import typing
13 import threading
14 
15 from .. import THEMES_DIR
16 from ..general import logger, CraftItems
17 from ..get_config import ConfigurationError, GetConfig
18 from ..interface import stock_ui_configs, colors
19 
20 try:
21  import darkdetect
22 except ImportError as e:
23  logger.exception(e.msg)
24  AUTOCOLOR = False
25 else:
26  AUTOCOLOR = bool(darkdetect.theme())
27 
28 def hextorgb(value: str):
29  value = value.lstrip("#")
30  lv = len(value)
31  return tuple(int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3))
32 
33 class UISync:
34  """
35  A class that automatically syncs your UI to match system settings.
36 
37  Why UISync:
38  * The original ColorManager.autocolor_run, which just makes a thread running ColorManager.configure, does not work.
39  * Easy to use, quick setup. No need to derive this class!
40  * I've looked for solutions from older builds of texteditor/textworker, found that v1.4
41  uses a custom class. So I made this:)
42 
43  Notes:
44  * The target function that will be used must accept at least arguments, with the first one
45  (excluding the self parameter if any) is for the widget, the second one is for the color.
46  * Nothing more (for now)
47 
48  Don't get this class wrong: This only makes a thread that uses your custom function.
49  """
50 
51  Target: object
52  Func: typing.Callable | type
53 
54  def __init__(this, Target: object, Func: typing.Callable | type):
55  # Nothing is allowed to be None
56  assert Target != None
57  assert Func != None
58 
59  # Target function must accept arguments
60  from inspect import signature
61  sig = signature(Func)
62  assert len(sig.parameters) > 0
63 
64  this.Target = Target
65  this.Func = Func
66 
67  this.thread = threading.Thread(target=darkdetect.listener,
68  args=(this.configure,), daemon=True)
69  this.thread.start()
70 
71  def configure(this, color: str):
72  color = color.lower()
73  return this.Func(this.Target, color)
74 
75 
77  """
78  A color manager for GUI widgets.
79  ColorManager can be used for multiple GUI widgets with only one call
80  """
81 
82  setcolorfn: dict[object | type, list] = {}
83  setfontfn: dict[object | type, list] = {}
84  setfcfn: dict[object | type, list] = {}
85 
86  _threads: dict[object, threading.Thread] = {}
87 
88  def __init__(self, default_configs: dict[str, typing.Any] = stock_ui_configs,
89  customfilepath: str = CraftItems(THEMES_DIR, "default.ini")):
90  """
91  Constructor of the class.
92  @param default_configs (dict[str]): Defaults to dev-premade configs
93  @param customfilepath (str): Custom file path. Disabled by default.
94  """
95  if customfilepath != "":
96  self._file_file = os.path.normpath(customfilepath)
97  else:
98  self._file_file = CraftItems(THEMES_DIR, "default.ini")
99 
100  GetConfig.__init__(self, default_configs, self._file_file)
101 
102  if os.path.exists("mergelist.json"):
103  self.movemove(json.loads(open("mergelist.json", "r").read()))
104 
105  def reset(self, restore: bool = False):
106  """
107  Reset the configuration file.
108  This is blocked as it can make conflicts with other instances of the class - unless you shutdown the app immediately..
109  """
110  raise NotImplementedError("reset() is blocked on ColorManager."
111  "Please use get_config.GetConfig class instead.")
112 
113  # Configure widgets
114  def GetFont(self) -> typing.Any | tuple[str, int, str, str, str]:
115  """
116  Call the font definitions.
117  When called, this returns the following:
118  (font) size (int), style, weight, family
119  The output will vary on different GUI toolkits:
120  * wxPython: wx.Font object
121  * Tkinter: tkinter.font.Font object
122  """
123 
124  if not self.has_section("font"):
125  return 10, "system", "system", ""
126 
127  family = self.getkeygetkey("font", "family", False, True)
128  size = self.getkeygetkey("font", "size", False, True)
129  weight = self.getkeygetkey("font", "weight", False, True)
130  style = self.getkeygetkey("font", "style", False, True)
131 
132  if family == "default":
133  family = ""
134 
135  try:
136  int(size)
137  except ValueError:
138  size_ = 10
139  else:
140  if int(size) > 0:
141  size_ = int(size)
142  else:
143  raise ValueError("Font size must be higher than 0")
144 
145  return size_, style, weight, family
146 
147  def GetColor(self, color: str | None = None) -> tuple[str, str]:
148  """
149  Get the current foreground/background defined in the settings.
150  @since 0.1.4: Made to be a non-@property item
151  @param color (str | None = None): Defaults to darkdetect's output/current setting.
152  @return tuple[str, str]: Background - Foreground colors
153  """
154  # print(darkdetect.theme().lower())
155  if not color:
156  if AUTOCOLOR: currmode = darkdetect.theme().lower()
157  else: currmode = str(self.getkeygetkey("color", "background", True, True)).lower()
158  else:
159  currmode = color
160 
161  # print(currmode)
162  if not currmode in ["dark", "light"]:
163  raise ConfigurationError(self._file_file, "Invalid value", "color", "background")
164 
165  # Prefer color for specific modes first
166  try:
167  test_back = self.getkeygetkey("color", "background-%s" % currmode, noraiseexp=True)
168  test_fore = self.getkeygetkey("color", "foreground-%s" % currmode, noraiseexp=True)
169  # print(test_back, test_fore)
170 
171  back_ = test_back if test_back else colors[currmode]
172 
173  fore_ = self.getkeygetkey("color", "foreground", make=True)
174  if fore_ == "default":
175  fore_ = colors[{"light": "dark", "dark": "light"}.get(currmode)]
176 
177  if test_fore:
178  fore_ = test_fore
179 
180  elif fore_.startswith("#"): # hex colors. TODO: rgb support?
181  pass
182 
183  elif fore_ in colors:
184  fore_ = colors[fore_]
185 
186  elif test_fore in colors:
187  fore_ = colors[test_fore]
188 
189  else:
190  raise ConfigurationError(self._file_file, "Invalid value", "color", "foreground")
191 
192  return back_, fore_
193  except KeyError or ConfigurationError:
194  pass
195 
196  def setcolorfunc(self, obj: type | object, func: typing.Callable | str, params: dict | tuple | None = None):
197  """
198  Set GUI widget background color-set function.
199  @param obj (type | object): Object (variable or type reference)
200  @param func (callable | str): Target function (no arg)
201  @param params (tuple | dict): Parameters to pass to func
202 
203  Function paramers must have %(color) in order to
204  pass color value. Use %(color-rgb) if you want RGB value.
205  """
206  if not obj in self.setcolorfn: self.setcolorfn[obj] = []
207  self.setcolorfn[obj].append({"fn": func, "params": params})
208 
209  def setfontcfunc(self, obj: type | object, func: typing.Callable, params: dict | tuple | None = None):
210  """
211  Set GUI widget font color-set function.
212  @param obj (type | object): Object (variable or type reference)
213  @param func (callable | str): Function to set the font style (no arg)
214  @param params (tuple | dict): Parameters to pass to func
215 
216  Function paramers must have %(font) in order to
217  pass color value. Use %(font-rgb) if you want RGB value.
218  """
219  if not obj in self.setfontfn: self.setfontfn[obj] = []
220  self.setfontfn[obj].append({"fn": func, "params": params})
221 
222  def setfontandcolorfunc(self, obj: type | object, func: typing.Callable | str, params: dict | tuple | None = None):
223  """
224  Add a function that sets both the background and font color.
225  @param obj (type | object): Object (variable or type reference)
226  @param func (typing.Callable | str): Function to use (Reference)
227  @param params (typle | dict): Function parameters
228  @since 0.1.4: First appearance
229  """
230  if not obj in self.setfcfn: self.setfcfn[obj] = []
231  self.setfcfn[obj].append({"fn": func, "params": params})
232 
233  def configure(self, widget: object, color: str | None = None):
234  """
235  Style a widget (only fore+back) with pre-defined settings.
236  This is usable for (almost) all GUI toolkits.
237 
238  @param widget : Widget to configure
239  @param color: Color to use (optional)
240  @see setcolorfunc
241  @see setfontcfunc
242  """
243 
244  if not widget:
245  logger.debug(f"Widget {widget} died, skip configuring.")
246  self._threads.pop(widget, None)
247  return
248 
249  color, fontcolor = self.GetColorGetColor(color)
250 
251  def runfn(func: typing.Callable, args: dict|tuple):
252  extra_aliases = {
253  "%(color)": color,
254  "%(font)": fontcolor
255  }
256 
257  def replacetext(target: str):
258  for key in extra_aliases:
259  target = target.replace(key, extra_aliases[key])
260  return target
261 
262  if isinstance(args, dict):
263  for key in args:
264  if isinstance(args[key], str):
265  args[key] = replacetext(args[key])
266  return func(**args)
267 
268  elif isinstance(args, tuple):
269  # BUG: Not working? (tested on wx)
270  temp = list(args)
271  for arg in temp:
272  if isinstance(arg, str):
273  arg = replacetext(arg)
274  args = tuple(temp)
275  return func(*args)
276 
277  def runloop(attr: typing.Literal["color", "font", "fc"]):
278 
279  for item in getattr(self, f"set{attr}fn"):
280  if isinstance(item, type):
281  if not isinstance(widget, item): continue
282  elif item != widget: continue
283 
284  dictionary = getattr(self, f"set{attr}fn")[item]
285 
286  for i in range(len(dictionary)):
287  fn = getattr(self, f"set{attr}fn")[item][i]["fn"]
288  if isinstance(fn, str): fn = getattr(widget, fn)
289 
290  runfn(fn, dictionary[i]["params"])
291 
292  runloop("color")
293  runloop("font")
294  runloop("fc")
295 
296  def autocolor_run(self, widget: typing.Any):
297  autocolor = self.getkeygetkey("color", "auto")
298  if (not AUTOCOLOR) or (autocolor in self.no_values):
299  logger.warning("ColorManager.autocolor_run() called when auto-color system is not usable."
300  "Detailed: auto coloring has been turned of or doesn't have required dependency (darkdetect).")
301  return
302 
303  self._threads[widget] = UISync(widget, self.configureconfigure)
typing.Any|None getkey(this, str section, str option, bool needed=False, bool make=False, bool noraiseexp=False, bool raw=False)
Definition: get_config.py:187
def move(this, dict[str, dict[str, str]] list_)
Definition: get_config.py:254
def configure(self, object widget, str|None color=None)
Definition: manager.py:233
def setfontcfunc(self, type|object obj, typing.Callable func, dict|tuple|None params=None)
Definition: manager.py:209
def setcolorfunc(self, type|object obj, typing.Callable|str func, dict|tuple|None params=None)
Definition: manager.py:196
tuple[str, str] GetColor(self, str|None color=None)
Definition: manager.py:147
def setfontandcolorfunc(self, type|object obj, typing.Callable|str func, dict|tuple|None params=None)
Definition: manager.py:222
def __init__(self, dict[str, typing.Any] default_configs=stock_ui_configs, str customfilepath=CraftItems(THEMES_DIR, "default.ini"))
Definition: manager.py:89
def reset(self, bool restore=False)
Definition: manager.py:105
typing.Any|tuple[str, int, str, str, str] GetFont(self)
Definition: manager.py:114