xulbux 1.7.2__py3-none-any.whl → 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of xulbux might be problematic. Click here for more details.

@@ -2,22 +2,23 @@
2
2
  Functions for logging and other small actions within the console.\n
3
3
  ----------------------------------------------------------------------------------------------------------
4
4
  You can also use special formatting codes directly inside the log message to change their appearance.
5
- For more detailed information about formatting codes, see the the `xx_format_codes` module documentation.
5
+ For more detailed information about formatting codes, see the the `format_codes` module documentation.
6
6
  """
7
7
 
8
- from ._consts_ import COLOR, CHARS
9
- from .xx_format_codes import FormatCodes, _COMPILED
10
- from .xx_string import String
11
- from .xx_color import Color, Rgba, Hexa
12
-
13
- from prompt_toolkit.key_binding.key_bindings import KeyBindings
14
- from typing import Optional, Literal, Mapping, Any, cast
15
- import prompt_toolkit as _prompt_toolkit
16
- import pyperclip as _pyperclip
8
+ from .base.consts import COLOR, CHARS
9
+ from .format_codes import FormatCodes, _COMPILED
10
+ from .string import String
11
+ from .color import Color, Rgba, Hexa
12
+
13
+ from typing import Callable, Optional, Literal, Mapping, Any, cast
14
+ from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings
15
+ from prompt_toolkit.validation import ValidationError, Validator
16
+ from prompt_toolkit.styles import Style
17
+ from prompt_toolkit.keys import Keys
18
+ import prompt_toolkit as _pt
17
19
  import keyboard as _keyboard
18
20
  import getpass as _getpass
19
21
  import shutil as _shutil
20
- import mouse as _mouse
21
22
  import sys as _sys
22
23
  import os as _os
23
24
 
@@ -63,7 +64,7 @@ class ArgResult:
63
64
  --------------------------------------------------------------------------------------------------------
64
65
  When the `ArgResult` instance is accessed as a boolean it will correspond to the `exists` attribute."""
65
66
 
66
- def __init__(self, exists: bool, value: Any):
67
+ def __init__(self, exists: bool, value: Any | list[Any]):
67
68
  self.exists: bool = exists
68
69
  self.value: Any = value
69
70
 
@@ -76,12 +77,11 @@ class Args:
76
77
  For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
77
78
  Each such attribute (e.g. `args.foo`) is an instance of `ArgResult`."""
78
79
 
79
- def __init__(self, **kwargs: dict[str, Any]):
80
+ def __init__(self, **kwargs: dict[str, Any | list[Any]]):
80
81
  for alias_name, data_dict in kwargs.items():
81
82
  if not alias_name.isidentifier():
82
83
  raise TypeError(f"Argument alias '{alias_name}' is invalid. It must be a valid Python variable name.")
83
- arg_result_instance = ArgResult(exists=data_dict["exists"], value=data_dict["value"])
84
- setattr(self, alias_name, arg_result_instance)
84
+ setattr(self, alias_name, ArgResult(exists=cast(bool, data_dict["exists"]), value=data_dict["value"]))
85
85
 
86
86
  def __len__(self):
87
87
  return len(vars(self))
@@ -132,7 +132,8 @@ class Console:
132
132
 
133
133
  @staticmethod
134
134
  def get_args(
135
- find_args: Mapping[str, list[str] | tuple[str, ...] | dict[str, list[str] | tuple[str, ...] | Any]],
135
+ find_args: Mapping[str, list[str] | tuple[str, ...] | dict[str, list[str] | tuple[str, ...] | Any]
136
+ | Literal["before", "after"]],
136
137
  allow_spaces: bool = False
137
138
  ) -> Args:
138
139
  """Will search for the specified arguments in the command line
@@ -150,24 +151,31 @@ class Console:
150
151
  "default": "some_value" # Optional
151
152
  }
152
153
  ```
154
+ 3. Positional argument collection (string value):
155
+ ```python
156
+ "alias_name": "before" # Collects non-flagged args before first flag
157
+ "alias_name": "after" # Collects non-flagged args after last flag
158
+ ```
153
159
  Example `find_args`:
154
160
  ```python
155
161
  find_args={
156
- "arg1": { # With default
162
+ "text": "before", # Positional args before flags
163
+ "arg1": { # With default
157
164
  "flags": ["-a1", "--arg1"],
158
165
  "default": "default_val"
159
166
  },
160
- "arg2": ("-a2", "--arg2"), # Without default (original format)
161
- "arg3": ["-a3"], # Without default (list format)
162
- "arg4": { # Flag with default True
167
+ "arg2": ("-a2", "--arg2"), # Without default (original format)
168
+ "arg3": ["-a3"], # Without default (list format)
169
+ "arg4": { # Flag with default True
163
170
  "flags": ["-f"],
164
171
  "default": True
165
172
  }
166
173
  }
167
174
  ```
168
175
  If the script is called via the command line:\n
169
- `python script.py -a1 "value1" --arg2 -f`\n
176
+ `python script.py Hello World -a1 "value1" --arg2 -f`\n
170
177
  ...it would return an `Args` object where:
178
+ - `args.text.exists` is `True`, `args.text.value` is `["Hello", "World"]`
171
179
  - `args.arg1.exists` is `True`, `args.arg1.value` is `"value1"`
172
180
  - `args.arg2.exists` is `True`, `args.arg2.value` is `True` (flag present without value)
173
181
  - `args.arg3.exists` is `False`, `args.arg3.value` is `None` (not present, no default)
@@ -176,19 +184,46 @@ class Console:
176
184
  - `exists` will be `False`
177
185
  - `value` will be the specified `default` value, or `None` if no default was specified.\n
178
186
  ----------------------------------------------------------------
187
+ For positional arguments:
188
+ - `"before"`: Collects all non-flagged arguments that appear before the first flag
189
+ - `"after"`: Collects all non-flagged arguments that appear after the last flag's value
190
+ ----------------------------------------------------------------
179
191
  Normally if `allow_spaces` is false, it will take a space as
180
192
  the end of an args value. If it is true, it will take spaces as
181
- part of the value until the next arg is found.
193
+ part of the value up until the next arg-flag is found.
182
194
  (Multiple spaces will become one space in the value.)"""
183
195
  args = _sys.argv[1:]
184
196
  args_len = len(args)
185
197
  arg_lookup = {}
186
198
  results = {}
199
+ positional_configs = {}
200
+ before_count = 0
201
+ after_count = 0
202
+
203
+ # PARSE "find_args" CONFIGURATION
187
204
  for alias, config in find_args.items():
188
205
  flags = None
189
206
  default_value = None
190
- if isinstance(config, (list, tuple)):
207
+
208
+ if isinstance(config, str):
209
+ # HANDLE POSITIONAL ARGUMENT COLLECTION
210
+ if config not in ("before", "after"):
211
+ raise ValueError(
212
+ f"Invalid positional argument type '{config}' for alias '{alias}'. Must be 'before' or 'after'."
213
+ )
214
+ if config == "before":
215
+ before_count += 1
216
+ if before_count > 1:
217
+ raise ValueError("Only one alias can have the value 'before' for positional argument collection.")
218
+ elif config == "after":
219
+ after_count += 1
220
+ if after_count > 1:
221
+ raise ValueError("Only one alias can have the value 'after' for positional argument collection.")
222
+ positional_configs[alias] = config
223
+ results[alias] = {"exists": False, "value": []}
224
+ elif isinstance(config, (list, tuple)):
191
225
  flags = config
226
+ results[alias] = {"exists": False, "value": default_value}
192
227
  elif isinstance(config, dict):
193
228
  if "flags" not in config:
194
229
  raise ValueError(f"Invalid configuration for alias '{alias}'. Dictionary must contain a 'flags' key.")
@@ -196,15 +231,54 @@ class Console:
196
231
  default_value = config.get("default")
197
232
  if not isinstance(flags, (list, tuple)):
198
233
  raise ValueError(f"Invalid 'flags' for alias '{alias}'. Must be a list or tuple.")
234
+ results[alias] = {"exists": False, "value": default_value}
199
235
  else:
200
- raise TypeError(f"Invalid configuration type for alias '{alias}'. Must be a list, tuple, or dict.")
201
- results[alias] = {"exists": False, "value": default_value}
202
- for flag in flags:
203
- if flag in arg_lookup:
204
- raise ValueError(
205
- f"Duplicate flag '{flag}' found. It's assigned to both '{arg_lookup[flag]}' and '{alias}'."
206
- )
207
- arg_lookup[flag] = alias
236
+ raise TypeError(
237
+ f"Invalid configuration type for alias '{alias}'. Must be a list, tuple, dict or literal 'before' / 'after'."
238
+ )
239
+
240
+ # BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGUMENTS
241
+ if flags is not None:
242
+ for flag in flags:
243
+ if flag in arg_lookup:
244
+ raise ValueError(
245
+ f"Duplicate flag '{flag}' found. It's assigned to both '{arg_lookup[flag]}' and '{alias}'."
246
+ )
247
+ arg_lookup[flag] = alias
248
+
249
+ # FIND POSITIONS OF FIRST AND LAST FLAGS FOR POSITIONAL ARGUMENT COLLECTION
250
+ first_flag_pos = None
251
+ last_flag_with_value_pos = None
252
+
253
+ for i, arg in enumerate(args):
254
+ if arg in arg_lookup:
255
+ if first_flag_pos is None:
256
+ first_flag_pos = i
257
+ # CHECK IF THIS FLAG HAS A VALUE FOLLOWING IT
258
+ flag_has_value = (i + 1 < args_len and not args[i + 1].startswith("-") and args[i + 1] not in arg_lookup)
259
+ if flag_has_value:
260
+ if not allow_spaces:
261
+ last_flag_with_value_pos = i + 1
262
+ else:
263
+ # FIND THE END OF THE MULTI-WORD VALUE
264
+ j = i + 1
265
+ while j < args_len and not args[j].startswith("-") and args[j] not in arg_lookup:
266
+ j += 1
267
+ last_flag_with_value_pos = j - 1
268
+
269
+ # COLLECT "before" POSITIONAL ARGUMENTS
270
+ for alias, pos_type in positional_configs.items():
271
+ if pos_type == "before":
272
+ before_args = []
273
+ end_pos = first_flag_pos if first_flag_pos is not None else args_len
274
+ for i in range(end_pos):
275
+ if not args[i].startswith("-"):
276
+ before_args.append(String.to_type(args[i]))
277
+ if before_args:
278
+ results[alias]["value"] = before_args
279
+ results[alias]["exists"] = len(before_args) > 0
280
+
281
+ # PROCESS FLAGGED ARGUMENTS
208
282
  i = 0
209
283
  while i < args_len:
210
284
  arg = args[i]
@@ -230,19 +304,43 @@ class Console:
230
304
  if not value_found_after_flag:
231
305
  results[alias]["value"] = True
232
306
  i += 1
307
+
308
+ # COLLECT "after" POSITIONAL ARGUMENTS
309
+ for alias, pos_type in positional_configs.items():
310
+ if pos_type == "after":
311
+ after_args = []
312
+ start_pos = (last_flag_with_value_pos + 1) if last_flag_with_value_pos is not None else 0
313
+ # IF NO FLAGS WERE FOUND WITH VALUES, START AFTER THE LAST FLAG
314
+ if last_flag_with_value_pos is None and first_flag_pos is not None:
315
+ # FIND THE LAST FLAG POSITION
316
+ last_flag_pos = None
317
+ for i, arg in enumerate(args):
318
+ if arg in arg_lookup:
319
+ last_flag_pos = i
320
+ if last_flag_pos is not None:
321
+ start_pos = last_flag_pos + 1
322
+
323
+ for i in range(start_pos, args_len):
324
+ if not args[i].startswith("-") and args[i] not in arg_lookup:
325
+ after_args.append(String.to_type(args[i]))
326
+
327
+ if after_args:
328
+ results[alias]["value"] = after_args
329
+ results[alias]["exists"] = len(after_args) > 0
330
+
233
331
  return Args(**results)
234
332
 
235
333
  @staticmethod
236
334
  def pause_exit(
237
- pause: bool = False,
335
+ pause: bool = True,
238
336
  exit: bool = False,
239
337
  prompt: object = "",
240
338
  exit_code: int = 0,
241
339
  reset_ansi: bool = False,
242
340
  ) -> None:
243
- """Will print the `prompt` and then pause the program if `pause` is set
244
- to `True` and after the pause, exit the program if `exit` is set to `True`."""
245
- print(prompt, end="", flush=True)
341
+ """Will print the `prompt` and then pause the program if `pause` is
342
+ true and after the pause, exit the program if `exit` is set true."""
343
+ FormatCodes.print(prompt, end="", flush=True)
246
344
  if reset_ansi:
247
345
  FormatCodes.print("[_]", end="")
248
346
  if pause:
@@ -281,7 +379,7 @@ class Console:
281
379
  - `_console_tabsize` -⠀the tab size of the console (default is 8)\n
282
380
  -----------------------------------------------------------------------------------
283
381
  The log message can be formatted with special formatting codes. For more detailed
284
- information about formatting codes, see `xx_format_codes` module documentation."""
382
+ information about formatting codes, see `format_codes` module documentation."""
285
383
  title = "" if title is None else title.strip().upper()
286
384
  title_len, tab_len = len(title) + 4, _console_tabsize - ((len(title) + 4) % _console_tabsize)
287
385
  if title_bg_color is not None and Color.is_valid(title_bg_color):
@@ -342,7 +440,7 @@ class Console:
342
440
  i = find_string_part(pos)
343
441
  adjusted_pos = (pos - cumulative_pos[i]) + offset_adjusts[i]
344
442
  parts = [result[i][:adjusted_pos], removal, result[i][adjusted_pos:]]
345
- result[i] = ''.join(parts)
443
+ result[i] = "".join(parts)
346
444
  offset_adjusts[i] += len(removal)
347
445
  return result
348
446
 
@@ -353,8 +451,7 @@ class Console:
353
451
  format_linebreaks: bool = True,
354
452
  start: str = "",
355
453
  end: str = "\n",
356
- title_bg_color: Optional[Rgba | Hexa] = COLOR.yellow,
357
- default_color: Optional[Rgba | Hexa] = COLOR.text,
454
+ default_color: Optional[Rgba | Hexa] = None,
358
455
  pause: bool = False,
359
456
  exit: bool = False,
360
457
  ) -> None:
@@ -362,7 +459,7 @@ class Console:
362
459
  at the message and exit the program after the message was printed.
363
460
  If `active` is false, no debug message will be printed."""
364
461
  if active:
365
- Console.log("DEBUG", prompt, format_linebreaks, start, end, title_bg_color, default_color)
462
+ Console.log("DEBUG", prompt, format_linebreaks, start, end, COLOR.YELLOW, default_color)
366
463
  Console.pause_exit(pause, exit)
367
464
 
368
465
  @staticmethod
@@ -371,14 +468,13 @@ class Console:
371
468
  format_linebreaks: bool = True,
372
469
  start: str = "",
373
470
  end: str = "\n",
374
- title_bg_color: Optional[Rgba | Hexa] = COLOR.blue,
375
- default_color: Optional[Rgba | Hexa] = COLOR.text,
471
+ default_color: Optional[Rgba | Hexa] = None,
376
472
  pause: bool = False,
377
473
  exit: bool = False,
378
474
  ) -> None:
379
475
  """A preset for `log()`: `INFO` log message with the options to pause
380
476
  at the message and exit the program after the message was printed."""
381
- Console.log("INFO", prompt, format_linebreaks, start, end, title_bg_color, default_color)
477
+ Console.log("INFO", prompt, format_linebreaks, start, end, COLOR.BLUE, default_color)
382
478
  Console.pause_exit(pause, exit)
383
479
 
384
480
  @staticmethod
@@ -387,14 +483,13 @@ class Console:
387
483
  format_linebreaks: bool = True,
388
484
  start: str = "",
389
485
  end: str = "\n",
390
- title_bg_color: Optional[Rgba | Hexa] = COLOR.teal,
391
- default_color: Optional[Rgba | Hexa] = COLOR.text,
486
+ default_color: Optional[Rgba | Hexa] = None,
392
487
  pause: bool = False,
393
488
  exit: bool = False,
394
489
  ) -> None:
395
490
  """A preset for `log()`: `DONE` log message with the options to pause
396
491
  at the message and exit the program after the message was printed."""
397
- Console.log("DONE", prompt, format_linebreaks, start, end, title_bg_color, default_color)
492
+ Console.log("DONE", prompt, format_linebreaks, start, end, COLOR.TEAL, default_color)
398
493
  Console.pause_exit(pause, exit)
399
494
 
400
495
  @staticmethod
@@ -403,14 +498,13 @@ class Console:
403
498
  format_linebreaks: bool = True,
404
499
  start: str = "",
405
500
  end: str = "\n",
406
- title_bg_color: Optional[Rgba | Hexa] = COLOR.orange,
407
- default_color: Optional[Rgba | Hexa] = COLOR.text,
501
+ default_color: Optional[Rgba | Hexa] = None,
408
502
  pause: bool = False,
409
503
  exit: bool = False,
410
504
  ) -> None:
411
505
  """A preset for `log()`: `WARN` log message with the options to pause
412
506
  at the message and exit the program after the message was printed."""
413
- Console.log("WARN", prompt, format_linebreaks, start, end, title_bg_color, default_color)
507
+ Console.log("WARN", prompt, format_linebreaks, start, end, COLOR.ORANGE, default_color)
414
508
  Console.pause_exit(pause, exit)
415
509
 
416
510
  @staticmethod
@@ -419,15 +513,14 @@ class Console:
419
513
  format_linebreaks: bool = True,
420
514
  start: str = "",
421
515
  end: str = "\n",
422
- title_bg_color: Optional[Rgba | Hexa] = COLOR.red,
423
- default_color: Optional[Rgba | Hexa] = COLOR.text,
516
+ default_color: Optional[Rgba | Hexa] = None,
424
517
  pause: bool = False,
425
518
  exit: bool = True,
426
519
  reset_ansi: bool = True,
427
520
  ) -> None:
428
521
  """A preset for `log()`: `FAIL` log message with the options to pause
429
522
  at the message and exit the program after the message was printed."""
430
- Console.log("FAIL", prompt, format_linebreaks, start, end, title_bg_color, default_color)
523
+ Console.log("FAIL", prompt, format_linebreaks, start, end, COLOR.RED, default_color)
431
524
  Console.pause_exit(pause, exit, reset_ansi=reset_ansi)
432
525
 
433
526
  @staticmethod
@@ -436,15 +529,14 @@ class Console:
436
529
  format_linebreaks: bool = True,
437
530
  start: str = "",
438
531
  end: str = "\n",
439
- title_bg_color: Optional[Rgba | Hexa] = COLOR.magenta,
440
- default_color: Optional[Rgba | Hexa] = COLOR.text,
532
+ default_color: Optional[Rgba | Hexa] = None,
441
533
  pause: bool = False,
442
534
  exit: bool = True,
443
535
  reset_ansi: bool = True,
444
536
  ) -> None:
445
537
  """A preset for `log()`: `EXIT` log message with the options to pause
446
538
  at the message and exit the program after the message was printed."""
447
- Console.log("EXIT", prompt, format_linebreaks, start, end, title_bg_color, default_color)
539
+ Console.log("EXIT", prompt, format_linebreaks, start, end, COLOR.MAGENTA, default_color)
448
540
  Console.pause_exit(pause, exit, reset_ansi=reset_ansi)
449
541
 
450
542
  @staticmethod
@@ -456,6 +548,7 @@ class Console:
456
548
  default_color: Optional[Rgba | Hexa] = None,
457
549
  w_padding: int = 2,
458
550
  w_full: bool = False,
551
+ indent: int = 0,
459
552
  ) -> None:
460
553
  """Will print a box with a colored background, containing a formatted log message:
461
554
  - `*values` -⠀the box content (each value is on a new line)
@@ -464,23 +557,25 @@ class Console:
464
557
  - `box_bg_color` -⠀the background color of the box
465
558
  - `default_color` -⠀the default text color of the `*values`
466
559
  - `w_padding` -⠀the horizontal padding (in chars) to the box content
467
- - `w_full` -⠀whether to make the box be the full console width or not\n
560
+ - `w_full` -⠀whether to make the box be the full console width or not
561
+ - `indent` -⠀the indentation of the box (in chars)\n
468
562
  -----------------------------------------------------------------------------------
469
563
  The box content can be formatted with special formatting codes. For more detailed
470
- information about formatting codes, see `xx_format_codes` module documentation."""
564
+ information about formatting codes, see `format_codes` module documentation."""
471
565
  lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color)
472
566
  pad_w_full = (Console.w - (max_line_len + (2 * w_padding))) if w_full else 0
473
567
  if box_bg_color is not None and Color.is_valid(box_bg_color):
474
568
  box_bg_color = Color.to_hexa(box_bg_color)
569
+ spaces_l = " " * indent
475
570
  lines = [
476
- f"[bg:{box_bg_color}]{' ' * w_padding}{line}" + " " *
477
- ((w_padding + max_line_len - len(unfmt)) + pad_w_full) + "[*]" for line, unfmt in zip(lines, unfmt_lines)
571
+ f"{spaces_l}[bg:{box_bg_color}]{' ' * w_padding}"
572
+ + _COMPILED["formatting"].sub(lambda m: f"{m.group(0)}[bg:{box_bg_color}]", line) +
573
+ (" " * ((w_padding + max_line_len - len(unfmt)) + pad_w_full)) + "[*]" for line, unfmt in zip(lines, unfmt_lines)
478
574
  ]
479
575
  pady = " " * (Console.w if w_full else max_line_len + (2 * w_padding))
480
576
  FormatCodes.print(
481
- f"{start}[bg:{box_bg_color}]{pady}[*]\n"
482
- + _COMPILED["formatting"].sub(lambda m: f"{m.group(0)}[bg:{box_bg_color}]", "\n".join(lines))
483
- + f"\n[bg:{box_bg_color}]{pady}[_]",
577
+ f"{start}{spaces_l}[bg:{box_bg_color}]{pady}[*]\n" + "\n".join(lines)
578
+ + f"\n{spaces_l}[bg:{box_bg_color}]{pady}[_]",
484
579
  default_color=default_color or "#000",
485
580
  sep="\n",
486
581
  end=end,
@@ -492,10 +587,11 @@ class Console:
492
587
  start: str = "",
493
588
  end: str = "\n",
494
589
  border_type: Literal["standard", "rounded", "strong", "double"] = "rounded",
495
- border_style: str | Rgba | Hexa = f"dim|{COLOR.gray}",
590
+ border_style: str | Rgba | Hexa = f"dim|{COLOR.GRAY}",
496
591
  default_color: Optional[Rgba | Hexa] = None,
497
592
  w_padding: int = 1,
498
593
  w_full: bool = False,
594
+ indent: int = 0,
499
595
  _border_chars: Optional[tuple[str, str, str, str, str, str, str, str]] = None,
500
596
  ) -> None:
501
597
  """Will print a bordered box, containing a formatted log message:
@@ -507,10 +603,11 @@ class Console:
507
603
  - `default_color` -⠀the default text color of the `*values`
508
604
  - `w_padding` -⠀the horizontal padding (in chars) to the box content
509
605
  - `w_full` -⠀whether to make the box be the full console width or not
606
+ - `indent` -⠀the indentation of the box (in chars)
510
607
  - `_border_chars` -⠀define your own border characters set (overwrites `border_type`)\n
511
608
  ---------------------------------------------------------------------------------------
512
609
  The box content can be formatted with special formatting codes. For more detailed
513
- information about formatting codes, see `xx_format_codes` module documentation.\n
610
+ information about formatting codes, see `format_codes` module documentation.\n
514
611
  ---------------------------------------------------------------------------------------
515
612
  The `border_type` can be one of the following:
516
613
  - `"standard" = ('┌', '─', '┐', '│', '┘', '─', '└', '│')`
@@ -534,18 +631,18 @@ class Console:
534
631
  }
535
632
  border_chars = borders.get(border_type, borders["standard"]) if _border_chars is None else _border_chars
536
633
  lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color)
537
- print(unfmt_lines)
538
634
  pad_w_full = (Console.w - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0
539
635
  if border_style is not None and Color.is_valid(border_style):
540
636
  border_style = Color.to_hexa(border_style)
637
+ spaces_l = " " * indent
541
638
  border_l = f"[{border_style}]{border_chars[7]}[*]"
542
639
  border_r = f"[{border_style}]{border_chars[3]}[_]"
543
640
  lines = [
544
- f"{border_l}{' ' * w_padding}{line}[_]" + " " * ((w_padding + max_line_len - len(unfmt)) + pad_w_full) + border_r
545
- for line, unfmt in zip(lines, unfmt_lines)
641
+ f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]" + " " *
642
+ ((w_padding + max_line_len - len(unfmt)) + pad_w_full) + border_r for line, unfmt in zip(lines, unfmt_lines)
546
643
  ]
547
- border_t = f"[{border_style}]{border_chars[0]}{border_chars[1] * (Console.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]"
548
- border_b = f"[{border_style}]{border_chars[6]}{border_chars[5] * (Console.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]"
644
+ border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (Console.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]"
645
+ border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (Console.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]"
549
646
  FormatCodes.print(
550
647
  f"{start}{border_t}[_]\n" + "\n".join(lines) + f"\n{border_b}[_]",
551
648
  default_color=default_color,
@@ -568,22 +665,28 @@ class Console:
568
665
  def confirm(
569
666
  prompt: object = "Do you want to continue?",
570
667
  start="",
571
- end="\n",
572
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
668
+ end="",
669
+ default_color: Optional[Rgba | Hexa] = None,
573
670
  default_is_yes: bool = True,
574
671
  ) -> bool:
575
672
  """Ask a yes/no question.\n
576
673
  ---------------------------------------------------------------------------------------
674
+ - `prompt` -⠀the input prompt
675
+ - `start` -⠀something to print before the input
676
+ - `end` -⠀something to print after the input (e.g. `\\n`)
677
+ - `default_color` -⠀the default text color of the `prompt`
678
+ - `default_is_yes` -⠀the default answer if the user just presses enter
679
+ ---------------------------------------------------------------------------------------
577
680
  The prompt can be formatted with special formatting codes. For more detailed
578
- information about formatting codes, see the `xx_format_codes` module documentation."""
681
+ information about formatting codes, see the `format_codes` module documentation."""
579
682
  confirmed = input(
580
683
  FormatCodes.to_ansi(
581
- f'{start} {str(prompt)} [_|dim](({"Y" if default_is_yes else "y"}/{"n" if default_is_yes else "N"}): )',
684
+ f'{start}{str(prompt)} [_|dim](({"Y" if default_is_yes else "y"}/{"n" if default_is_yes else "N"}): )',
582
685
  default_color=default_color,
583
686
  )
584
687
  ).strip().lower() in (("", "y", "yes") if default_is_yes else ("y", "yes"))
585
688
  if end:
586
- Console.log("", end, end="")
689
+ FormatCodes.print(end, end="")
587
690
  return confirmed
588
691
 
589
692
  @staticmethod
@@ -591,13 +694,13 @@ class Console:
591
694
  prompt: object = "",
592
695
  start="",
593
696
  end="\n",
594
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
697
+ default_color: Optional[Rgba | Hexa] = None,
595
698
  show_keybindings=True,
596
699
  input_prefix=" ⮡ ",
597
700
  reset_ansi=True,
598
701
  ) -> str:
599
- """An input where users can input (and paste) text over multiple lines.\n
600
- -----------------------------------------------------------------------------------
702
+ """An input where users can write (and paste) text over multiple lines.\n
703
+ ---------------------------------------------------------------------------------------
601
704
  - `prompt` -⠀the input prompt
602
705
  - `start` -⠀something to print before the input
603
706
  - `end` -⠀something to print after the input (e.g. `\\n`)
@@ -605,9 +708,9 @@ class Console:
605
708
  - `show_keybindings` -⠀whether to show the special keybindings or not
606
709
  - `input_prefix` -⠀the prefix of the input line
607
710
  - `reset_ansi` -⠀whether to reset the ANSI codes after the input or not
608
- -----------------------------------------------------------------------------------
711
+ ---------------------------------------------------------------------------------------
609
712
  The input prompt can be formatted with special formatting codes. For more detailed
610
- information about formatting codes, see `xx_format_codes` module documentation."""
713
+ information about formatting codes, see the `format_codes` module documentation."""
611
714
  kb = KeyBindings()
612
715
 
613
716
  @kb.add("c-d", eager=True) # CTRL+D
@@ -617,135 +720,184 @@ class Console:
617
720
  FormatCodes.print(start + str(prompt), default_color=default_color)
618
721
  if show_keybindings:
619
722
  FormatCodes.print("[dim][[b](CTRL+D)[dim] : end of input][_dim]")
620
- input_string = _prompt_toolkit.prompt(input_prefix, multiline=True, wrap_lines=True, key_bindings=kb)
723
+ input_string = _pt.prompt(input_prefix, multiline=True, wrap_lines=True, key_bindings=kb)
621
724
  FormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end)
622
725
  return input_string
623
726
 
624
727
  @staticmethod
625
- def restricted_input(
728
+ def input(
626
729
  prompt: object = "",
627
730
  start="",
628
- end="\n",
629
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
630
- allowed_chars: str = CHARS.all, # type: ignore[assignment]
731
+ end="",
732
+ default_color: Optional[Rgba | Hexa] = None,
733
+ placeholder: Optional[str] = None,
734
+ mask_char: Optional[str] = None,
631
735
  min_len: Optional[int] = None,
632
736
  max_len: Optional[int] = None,
633
- mask_char: Optional[str] = None,
634
- reset_ansi: bool = True,
635
- ) -> Optional[str]:
636
- """Acts like a standard Python `input()` with the advantage, that you can specify:
637
- - what text characters the user is allowed to type and
638
- - the minimum and/or maximum length of the users input
639
- - optional mask character (hide user input, e.g. for passwords)
640
- - reset the ANSI formatting codes after the user continues\n
641
- ---------------------------------------------------------------------------------------
642
- The input can be formatted with special formatting codes. For more detailed
643
- information about formatting codes, see the `xx_format_codes` module documentation."""
644
- FormatCodes.print(start + str(prompt), default_color=default_color, end="")
645
- result = ""
646
- select_all = False
647
- last_line_count = 1
648
- last_console_width = 0
649
-
650
- def update_display(console_width: int) -> None:
651
- nonlocal last_line_count, last_console_width
652
- lines = String.split_count(str(prompt) + (mask_char * len(result) if mask_char else result), console_width)
653
- line_count = len(lines)
654
- if (line_count > 1 or line_count < last_line_count) and not last_line_count == 1:
655
- if last_console_width > console_width:
656
- line_count *= 2
657
- for _ in range(line_count if line_count < last_line_count and not line_count > last_line_count else (
658
- line_count - 2 if line_count > last_line_count else line_count - 1)):
659
- _sys.stdout.write("\033[2K\r\033[A")
660
- prompt_len = len(str(prompt)) if prompt else 0
661
- prompt_str = lines[0][:prompt_len]
662
- input_str = (
663
- lines[0][prompt_len:] if len(lines) == 1 else "\n".join([lines[0][prompt_len:]] + lines[1:])
664
- ) # SEPARATE THE PROMPT AND THE INPUT
665
- _sys.stdout.write(
666
- "\033[2K\r" + FormatCodes.to_ansi(prompt_str) + ("\033[7m" if select_all else "") + input_str + "\033[27m"
667
- )
668
- last_line_count, last_console_width = line_count, console_width
669
-
670
- def handle_enter():
671
- if min_len is not None and len(result) < min_len:
672
- return False
673
- FormatCodes.print(f"[_]{end}" if reset_ansi else end, default_color=default_color)
674
- return True
675
-
676
- def handle_backspace_delete():
677
- nonlocal result, select_all
678
- if select_all:
679
- result, select_all = "", False
680
- elif result and event.name == "backspace":
681
- result = result[:-1]
682
- update_display(Console.w)
683
-
684
- def handle_paste():
685
- nonlocal result, select_all
686
- if select_all:
687
- result, select_all = "", False
688
- filtered_text = "".join(char for char in _pyperclip.paste() if allowed_chars == CHARS.all or char in allowed_chars)
689
- if max_len is None or len(result) + len(filtered_text) <= max_len:
690
- result += filtered_text
691
- update_display(Console.w)
692
-
693
- def handle_select_all():
694
- nonlocal select_all
695
- select_all = True
696
- update_display(Console.w)
697
-
698
- def handle_character_input():
699
- nonlocal result
700
- if event.name is not None and ((allowed_chars == CHARS.all or event.name in allowed_chars) and
701
- (max_len is None or len(result) < max_len)):
702
- result += event.name
703
- update_display(Console.w)
704
-
705
- while True:
706
- event = _keyboard.read_event()
707
- if event.event_type == "down":
708
- if event.name == "enter" and handle_enter():
709
- return result.rstrip("\n")
710
- elif event.name in ("backspace", "delete", "entf"):
711
- handle_backspace_delete()
712
- elif (event.name == "v" and _keyboard.is_pressed("ctrl")) or _mouse.is_pressed("right"):
713
- handle_paste()
714
- elif event.name == "a" and _keyboard.is_pressed("ctrl"):
715
- handle_select_all()
716
- elif event.name == "c" and _keyboard.is_pressed("ctrl"):
717
- raise KeyboardInterrupt
718
- elif event.name == "esc":
719
- return None
720
- elif event.name == "space":
721
- handle_character_input()
722
- elif event.name is not None and len(event.name) == 1:
723
- handle_character_input()
737
+ allowed_chars: str = CHARS.ALL, #type: ignore[assignment]
738
+ allow_paste: bool = True,
739
+ validator: Optional[Callable[[str], Optional[str]]] = None,
740
+ ) -> str:
741
+ """Acts like a standard Python `input()` a bunch of cool extra features.\n
742
+ ------------------------------------------------------------------------------------
743
+ - `prompt` -⠀the input prompt
744
+ - `start` -⠀something to print before the input
745
+ - `end` -⠀something to print after the input (e.g. `\\n`)
746
+ - `default_color` -⠀the default text color of the `prompt`
747
+ - `placeholder` -⠀a placeholder text that is shown when the input is empty
748
+ - `mask_char` -⠀if set, the input will be masked with this character
749
+ - `min_len` -⠀the minimum length of the input (required to submit)
750
+ - `max_len` -⠀the maximum length of the input (can't write further if reached)
751
+ - `allowed_chars` -⠀a string of characters that are allowed to be inputted
752
+ (default allows all characters)
753
+ - `allow_paste` -⠀whether to allow pasting text into the input or not
754
+ - `validator` -⠀a function that takes the input string and returns a string error
755
+ message if invalid, or nothing if valid
756
+ ------------------------------------------------------------------------------------
757
+ The input prompt can be formatted with special formatting codes. For more detailed
758
+ information about formatting codes, see the `format_codes` module documentation."""
759
+ result_text = ""
760
+ tried_pasting = False
761
+ filtered_chars = set()
762
+
763
+ class InputValidator(Validator):
764
+
765
+ def validate(self, document) -> None:
766
+ text_to_validate = result_text if mask_char else document.text
767
+ if min_len and len(text_to_validate) < min_len:
768
+ raise ValidationError(message="", cursor_position=len(document.text))
769
+ if validator and validator(text_to_validate) not in ("", None):
770
+ raise ValidationError(message="", cursor_position=len(document.text))
771
+
772
+ def bottom_toolbar() -> _pt.formatted_text.ANSI:
773
+ nonlocal tried_pasting
774
+ try:
775
+ if mask_char:
776
+ text_to_check = result_text
724
777
  else:
725
- select_all = False
726
- update_display(Console.w)
778
+ app = _pt.application.get_app()
779
+ text_to_check = app.current_buffer.text
780
+ toolbar_msgs = []
781
+ if max_len and len(text_to_check) > max_len:
782
+ toolbar_msgs.append("[b|#FFF|bg:red]( Text too long! )")
783
+ if validator and text_to_check and (validation_error_msg := validator(text_to_check)) not in ("", None):
784
+ toolbar_msgs.append(f"[b|#000|bg:br:red] {validation_error_msg} [_bg]")
785
+ if filtered_chars:
786
+ plural = "" if len(char_list := "".join(sorted(filtered_chars))) == 1 else "s"
787
+ toolbar_msgs.append(f"[b|#000|bg:yellow]( Char{plural} '{char_list}' not allowed )")
788
+ filtered_chars.clear()
789
+ if min_len and len(text_to_check) < min_len:
790
+ toolbar_msgs.append(f"[b|#000|bg:yellow]( Need {min_len - len(text_to_check)} more chars )")
791
+ if tried_pasting:
792
+ toolbar_msgs.append("[b|#000|bg:br:yellow]( Pasting disabled )")
793
+ tried_pasting = False
794
+ if max_len and len(text_to_check) == max_len:
795
+ toolbar_msgs.append("[b|#000|bg:br:yellow]( Maximum length reached )")
796
+ return _pt.formatted_text.ANSI(FormatCodes.to_ansi(" ".join(toolbar_msgs)))
797
+ except Exception:
798
+ return _pt.formatted_text.ANSI("")
799
+
800
+ def process_insert_text(text: str) -> tuple[str, set[str]]:
801
+ removed_chars = set()
802
+ if not text: return "", removed_chars
803
+ processed_text = "".join(c for c in text if ord(c) >= 32)
804
+ if allowed_chars != CHARS.ALL:
805
+ filtered_text = ""
806
+ for char in processed_text:
807
+ if char in allowed_chars:
808
+ filtered_text += char
809
+ else:
810
+ removed_chars.add(char)
811
+ processed_text = filtered_text
812
+ if max_len:
813
+ if (remaining_space := max_len - len(result_text)) > 0:
814
+ if len(processed_text) > remaining_space:
815
+ processed_text = processed_text[:remaining_space]
816
+ else:
817
+ processed_text = ""
818
+ return processed_text, removed_chars
819
+
820
+ def insert_text_event(event: KeyPressEvent) -> None:
821
+ nonlocal result_text, filtered_chars
822
+ try:
823
+ insert_text = event.data
824
+ if not insert_text: return
825
+ buffer = event.app.current_buffer
826
+ cursor_pos = buffer.cursor_position
827
+ insert_text, filtered_chars = process_insert_text(insert_text)
828
+ if insert_text:
829
+ result_text = result_text[:cursor_pos] + insert_text + result_text[cursor_pos:]
830
+ if mask_char:
831
+ buffer.insert_text(mask_char[0] * len(insert_text))
832
+ else:
833
+ buffer.insert_text(insert_text)
834
+ except Exception:
835
+ pass
836
+
837
+ def remove_text_event(event: KeyPressEvent, is_backspace: bool = False) -> None:
838
+ nonlocal result_text
839
+ try:
840
+ buffer = event.app.current_buffer
841
+ cursor_pos = buffer.cursor_position
842
+ has_selection = buffer.selection_state is not None
843
+ if has_selection:
844
+ start, end = buffer.document.selection_range()
845
+ result_text = result_text[:start] + result_text[end:]
846
+ buffer.cursor_position = start
847
+ buffer.delete(end - start)
848
+ else:
849
+ if is_backspace:
850
+ if cursor_pos > 0:
851
+ result_text = result_text[:cursor_pos - 1] + result_text[cursor_pos:]
852
+ buffer.delete_before_cursor(1)
853
+ else:
854
+ if cursor_pos < len(result_text):
855
+ result_text = result_text[:cursor_pos] + result_text[cursor_pos + 1:]
856
+ buffer.delete(1)
857
+ except Exception:
858
+ pass
727
859
 
728
- @staticmethod
729
- def pwd_input(
730
- prompt: object = "Password: ",
731
- start="",
732
- end="\n",
733
- default_color: Optional[Rgba | Hexa] = COLOR.cyan,
734
- allowed_chars: str = CHARS.standard_ascii,
735
- min_len: Optional[int] = None,
736
- max_len: Optional[int] = None,
737
- reset_ansi: bool = True,
738
- ) -> Optional[str]:
739
- """Password input (preset for `Console.restricted_input()`)
740
- that always masks the entered characters with asterisks."""
741
- return Console.restricted_input(
742
- prompt=prompt,
743
- start=start,
744
- end=end,
745
- default_color=default_color,
746
- allowed_chars=allowed_chars,
747
- min_len=min_len,
748
- max_len=max_len,
749
- mask_char="*",
750
- reset_ansi=reset_ansi,
860
+ kb = KeyBindings()
861
+
862
+ @kb.add(Keys.Delete)
863
+ def _(event: KeyPressEvent) -> None:
864
+ remove_text_event(event)
865
+
866
+ @kb.add(Keys.Backspace)
867
+ def _(event: KeyPressEvent) -> None:
868
+ remove_text_event(event, is_backspace=True)
869
+
870
+ @kb.add(Keys.ControlA)
871
+ def _(event: KeyPressEvent) -> None:
872
+ buffer = event.app.current_buffer
873
+ buffer.cursor_position = 0
874
+ buffer.start_selection()
875
+ buffer.cursor_position = len(buffer.text)
876
+
877
+ @kb.add(Keys.BracketedPaste)
878
+ def _(event: KeyPressEvent) -> None:
879
+ if allow_paste:
880
+ insert_text_event(event)
881
+ else:
882
+ nonlocal tried_pasting
883
+ tried_pasting = True
884
+
885
+ @kb.add(Keys.Any)
886
+ def _(event: KeyPressEvent) -> None:
887
+ insert_text_event(event)
888
+
889
+ custom_style = Style.from_dict({'bottom-toolbar': 'noreverse'})
890
+ session = _pt.PromptSession(
891
+ message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)),
892
+ validator=InputValidator(),
893
+ validate_while_typing=True,
894
+ key_bindings=kb,
895
+ bottom_toolbar=bottom_toolbar,
896
+ placeholder=_pt.formatted_text.ANSI(FormatCodes.to_ansi(f"[i|br:black]{placeholder}[_i|_c]"))
897
+ if placeholder else "",
898
+ style=custom_style,
751
899
  )
900
+ FormatCodes.print(start, end="")
901
+ session.prompt()
902
+ FormatCodes.print(end, end="")
903
+ return result_text