euporie 2.6.1__py3-none-any.whl → 2.7.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.
Files changed (67) hide show
  1. euporie/console/tabs/console.py +51 -43
  2. euporie/core/__init__.py +5 -2
  3. euporie/core/app.py +74 -57
  4. euporie/core/comm/ipywidgets.py +7 -3
  5. euporie/core/config.py +51 -27
  6. euporie/core/convert/__init__.py +2 -0
  7. euporie/core/convert/datum.py +82 -45
  8. euporie/core/convert/formats/ansi.py +1 -2
  9. euporie/core/convert/formats/common.py +7 -11
  10. euporie/core/convert/formats/ft.py +10 -7
  11. euporie/core/convert/formats/png.py +7 -6
  12. euporie/core/convert/formats/sixel.py +1 -1
  13. euporie/core/convert/formats/svg.py +28 -0
  14. euporie/core/convert/mime.py +4 -7
  15. euporie/core/data_structures.py +24 -22
  16. euporie/core/filters.py +16 -2
  17. euporie/core/format.py +30 -4
  18. euporie/core/ft/ansi.py +2 -1
  19. euporie/core/ft/html.py +155 -42
  20. euporie/core/{widgets/graphics.py → graphics.py} +225 -227
  21. euporie/core/io.py +8 -0
  22. euporie/core/key_binding/bindings/__init__.py +8 -2
  23. euporie/core/key_binding/bindings/basic.py +9 -14
  24. euporie/core/key_binding/bindings/micro.py +0 -12
  25. euporie/core/key_binding/bindings/mouse.py +107 -80
  26. euporie/core/key_binding/bindings/page_navigation.py +129 -0
  27. euporie/core/key_binding/key_processor.py +9 -1
  28. euporie/core/layout/__init__.py +1 -0
  29. euporie/core/layout/containers.py +1011 -0
  30. euporie/core/layout/decor.py +381 -0
  31. euporie/core/layout/print.py +130 -0
  32. euporie/core/layout/screen.py +75 -0
  33. euporie/core/{widgets/page.py → layout/scroll.py} +166 -111
  34. euporie/core/log.py +1 -1
  35. euporie/core/margins.py +11 -5
  36. euporie/core/path.py +43 -176
  37. euporie/core/renderer.py +31 -8
  38. euporie/core/style.py +2 -0
  39. euporie/core/tabs/base.py +2 -1
  40. euporie/core/terminal.py +19 -21
  41. euporie/core/widgets/cell.py +2 -4
  42. euporie/core/widgets/cell_outputs.py +2 -2
  43. euporie/core/widgets/decor.py +3 -359
  44. euporie/core/widgets/dialog.py +5 -5
  45. euporie/core/widgets/display.py +32 -12
  46. euporie/core/widgets/file_browser.py +3 -4
  47. euporie/core/widgets/forms.py +36 -14
  48. euporie/core/widgets/inputs.py +171 -99
  49. euporie/core/widgets/layout.py +80 -5
  50. euporie/core/widgets/menu.py +1 -3
  51. euporie/core/widgets/pager.py +3 -3
  52. euporie/core/widgets/palette.py +3 -2
  53. euporie/core/widgets/status_bar.py +2 -6
  54. euporie/core/widgets/tree.py +3 -6
  55. euporie/notebook/app.py +8 -8
  56. euporie/notebook/tabs/notebook.py +2 -2
  57. euporie/notebook/widgets/side_bar.py +1 -1
  58. euporie/preview/tabs/notebook.py +2 -2
  59. euporie/web/tabs/web.py +6 -1
  60. euporie/web/widgets/webview.py +52 -32
  61. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/METADATA +9 -11
  62. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/RECORD +67 -60
  63. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/WHEEL +1 -1
  64. {euporie-2.6.1.data → euporie-2.7.0.data}/data/share/applications/euporie-console.desktop +0 -0
  65. {euporie-2.6.1.data → euporie-2.7.0.data}/data/share/applications/euporie-notebook.desktop +0 -0
  66. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/entry_points.txt +0 -0
  67. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/licenses/LICENSE +0 -0
euporie/core/ft/html.py CHANGED
@@ -24,6 +24,7 @@ from prompt_toolkit.filters.base import Condition
24
24
  from prompt_toolkit.filters.utils import _always as always
25
25
  from prompt_toolkit.filters.utils import _never as never
26
26
  from prompt_toolkit.formatted_text.utils import split_lines
27
+ from prompt_toolkit.layout.containers import WindowAlign
27
28
  from prompt_toolkit.layout.dimension import Dimension
28
29
  from prompt_toolkit.utils import Event
29
30
  from upath import UPath
@@ -56,6 +57,7 @@ from euporie.core.data_structures import DiBool, DiInt, DiStr
56
57
  from euporie.core.ft.table import Cell, Table, compute_padding
57
58
  from euporie.core.ft.utils import (
58
59
  FormattedTextAlign,
60
+ FormattedTextVerticalAlign,
59
61
  add_border,
60
62
  align,
61
63
  apply_reverse_overwrites,
@@ -70,6 +72,7 @@ from euporie.core.ft.utils import (
70
72
  paste,
71
73
  strip,
72
74
  truncate,
75
+ valign,
73
76
  )
74
77
 
75
78
 
@@ -94,6 +97,7 @@ if TYPE_CHECKING:
94
97
  from pathlib import Path
95
98
  from typing import Any, Callable, Generator, Iterator
96
99
 
100
+ from fsspec.spec import AbstractFileSystem
97
101
  from prompt_toolkit.filters.base import Filter
98
102
  from prompt_toolkit.formatted_text.base import StyleAndTextTuples
99
103
  from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
@@ -874,6 +878,13 @@ class Theme(Mapping):
874
878
  self.available_width = available_width
875
879
  self.available_height = available_height
876
880
 
881
+ def reset(self) -> None:
882
+ """Reset all cached properties."""
883
+ # Iterate ove a copy of keys as dict changes size during loop
884
+ for attr in list(self.__dict__.keys()):
885
+ if isinstance(Theme.__dict__.get(attr), cached_property):
886
+ delattr(self, attr)
887
+
877
888
  @cached_property
878
889
  def theme(self) -> dict[str, str]:
879
890
  """Return the combined computed theme."""
@@ -1741,6 +1752,12 @@ class Theme(Mapping):
1741
1752
  if "line-through" in theme["text_decoration"]:
1742
1753
  style = f"{style} strike"
1743
1754
 
1755
+ if "blink" in theme["text_decoration"]:
1756
+ style = f"{style} blink"
1757
+
1758
+ if self.hidden:
1759
+ style = f"{style} hidden nounderline"
1760
+
1744
1761
  return style
1745
1762
 
1746
1763
  async def text_transform(self, value: str) -> str:
@@ -1928,17 +1945,19 @@ class Theme(Mapping):
1928
1945
  )
1929
1946
 
1930
1947
  @cached_property
1931
- def grid_template(self) -> Iterator[list[str]]:
1948
+ def grid_template(self) -> list[list[str]]:
1932
1949
  """Calculate the size of the grid tracks."""
1933
1950
 
1934
1951
  def _multiply_repeats(m: re.Match) -> str:
1935
1952
  result = m.groupdict()
1936
1953
  return " ".join(int(result["count"]) * [result["value"]])
1937
1954
 
1955
+ template = []
1938
1956
  for css_item in ("grid_template_columns", "grid_template_rows"):
1939
1957
  value = self.theme.get(css_item, "")
1940
1958
  value = _GRID_TEMPLATE_REPEAT_RE.sub(_multiply_repeats, value)
1941
- yield [m.group() for m in _GRID_TEMPLATE_RE.finditer(value)]
1959
+ template.append([m.group() for m in _GRID_TEMPLATE_RE.finditer(value)])
1960
+ return template
1942
1961
 
1943
1962
  @cached_property
1944
1963
  def grid_column_start(self) -> int | None:
@@ -1995,6 +2014,11 @@ class Theme(Mapping):
1995
2014
  (order > 0, order),
1996
2015
  )
1997
2016
 
2017
+ @cached_property
2018
+ def latex(self) -> bool:
2019
+ """If the element should be rendered as LaTeX."""
2020
+ return self.theme.get("_format") == "latex"
2021
+
1998
2022
  # Mapping methods - these are passed on to the underlying theme dictionary
1999
2023
 
2000
2024
  def __getitem__(self, key: str) -> Any:
@@ -2075,6 +2099,10 @@ _BROWSER_CSS: CssSelectors = {
2075
2099
  "display": "inline",
2076
2100
  "font_weight": "bold",
2077
2101
  },
2102
+ ((CssSelector(item="blink"),),): {
2103
+ "display": "inline",
2104
+ "text_decoration": "blink",
2105
+ },
2078
2106
  (
2079
2107
  (CssSelector(item="cite"),),
2080
2108
  (CssSelector(item="dfn"),),
@@ -2739,7 +2767,13 @@ _BROWSER_CSS: CssSelectors = {
2739
2767
  CssSelector(item="::root", attr="[_initial_format=markdown]"),
2740
2768
  CssSelector(item=".math"),
2741
2769
  ),
2742
- ): {"text_transform": "latex"},
2770
+ ): {
2771
+ "_format": "latex",
2772
+ "text_transform": "latex",
2773
+ "display": "inline-block",
2774
+ "vertical_align": "top",
2775
+ # "margin_right": "1em",
2776
+ },
2743
2777
  (
2744
2778
  (
2745
2779
  CssSelector(item="::root", attr="[_initial_format=markdown]"),
@@ -2747,6 +2781,7 @@ _BROWSER_CSS: CssSelectors = {
2747
2781
  ),
2748
2782
  ): {
2749
2783
  "text_align": "center",
2784
+ "display": "block",
2750
2785
  },
2751
2786
  (
2752
2787
  (
@@ -2823,6 +2858,16 @@ class Node:
2823
2858
 
2824
2859
  self.theme = Theme(self, parent_theme=parent.theme if parent else None)
2825
2860
 
2861
+ def reset(self) -> None:
2862
+ """Reset the node and all its children."""
2863
+ for attr in list(self.__dict__.keys()):
2864
+ if isinstance(Node.__dict__.get(attr), cached_property):
2865
+ delattr(self, attr)
2866
+ self.theme.reset()
2867
+ for child in self.contents:
2868
+ child.reset()
2869
+ self.marker = None
2870
+
2826
2871
  def _outer_html(self, d: int = 0, attrs: bool = True) -> str:
2827
2872
  dd = " " * d
2828
2873
  s = ""
@@ -3425,7 +3470,9 @@ class HTML:
3425
3470
  browser_css: CssSelectors | None = None,
3426
3471
  mouse_handler: Callable[[Node, MouseEvent], NotImplementedOrNone] | None = None,
3427
3472
  paste_fixed: bool = True,
3473
+ defer_assets: bool = False,
3428
3474
  on_update: Callable[[HTML], None] | None = None,
3475
+ on_change: Callable[[HTML], None] | None = None,
3429
3476
  _initial_format: str = "",
3430
3477
  ) -> None:
3431
3478
  """Initialize the markdown formatter.
@@ -3444,7 +3491,9 @@ class HTML:
3444
3491
  browser_css: The browser CSS to use
3445
3492
  mouse_handler: A mouse handler function to use when links are clicked
3446
3493
  paste_fixed: Whether fixed elements should be pasted over the output
3494
+ defer_assets: Whether to render the page before remote assets are loaded
3447
3495
  on_update: An optional callback triggered when the DOM updates
3496
+ on_change: An optional callback triggered when the DOM changes
3448
3497
  _initial_format: The initial format of the data being displayed
3449
3498
 
3450
3499
  """
@@ -3454,6 +3503,7 @@ class HTML:
3454
3503
 
3455
3504
  self.browser_css = browser_css or _BROWSER_CSS
3456
3505
  self.css: CssSelectors = css or {}
3506
+ self.defer_assets = defer_assets
3457
3507
 
3458
3508
  self.render_count = 0
3459
3509
  self.width = width
@@ -3473,8 +3523,12 @@ class HTML:
3473
3523
  self.fixed_mask: StyleAndTextTuples = []
3474
3524
  # self.anchors = []
3475
3525
  self.on_update = Event(self, on_update)
3526
+ self.on_change = Event(self, on_change)
3476
3527
 
3477
- self.assets_loaded = False
3528
+ self._dom_processed = False
3529
+ self._assets_loaded = False
3530
+ self._url_cbs: dict[str, Callable[[Any], None]] = {}
3531
+ self._url_fs_map: dict[str, AbstractFileSystem] = {}
3478
3532
 
3479
3533
  # Lazily load attributes
3480
3534
 
@@ -3488,12 +3542,11 @@ class HTML:
3488
3542
  """Parse the markup."""
3489
3543
  return self.parser.parse(self.markup)
3490
3544
 
3491
- async def load_assets(self) -> None:
3545
+ def process_dom(self) -> None:
3492
3546
  """Load CSS styles and image resources.
3493
3547
 
3494
3548
  Do not touch element's themes!
3495
3549
  """
3496
- url_cbs = {}
3497
3550
 
3498
3551
  def _process_css(data: bytes) -> None:
3499
3552
  try:
@@ -3529,7 +3582,9 @@ class HTML:
3529
3582
  and (href := attrs.get("href", ""))
3530
3583
  ):
3531
3584
  url = urljoin(str(self.base), href)
3532
- url_cbs[url] = _process_css
3585
+ fs, url = url_to_fs(url)
3586
+ self._url_fs_map[url] = fs
3587
+ self._url_cbs[url] = _process_css
3533
3588
 
3534
3589
  # In case of a <style> tab, load first child's text
3535
3590
  elif child.name == "style":
@@ -3542,22 +3597,40 @@ class HTML:
3542
3597
  elif child.name == "img" and (src := child.attrs.get("src")):
3543
3598
  child.attrs["_missing"] = "true"
3544
3599
  url = urljoin(str(self.base), src)
3545
- url_cbs[url] = partial(_process_img, child)
3600
+ fs, url = url_to_fs(url)
3601
+ self._url_fs_map[url] = fs
3602
+ self._url_cbs[url] = partial(_process_img, child)
3603
+
3604
+ self._dom_processed = True
3605
+
3606
+ async def load_assets(self) -> None:
3607
+ """Load remote assets asynchronously."""
3608
+ self._assets_loaded = True
3546
3609
 
3547
3610
  # Load all remote assets for each protocol using fsspec (where they are loaded
3548
3611
  # asynchronously in their own thread) and trigger callbacks if the file is
3549
3612
  # loaded successfully
3550
- m = [url_to_fs(url) for url in url_cbs]
3551
- fs_to_urls = {fs: [url for f, url in m if f == fs] for fs in dict(m)}
3552
- for fs, urls in fs_to_urls.items():
3553
- for url, result in fs.cat(urls).items():
3554
- if not isinstance(result, Exception):
3555
- log.debug("File %s loaded", url)
3556
- url_cbs[url](result)
3557
- else:
3558
- log.error("Error loading %s", url)
3613
+ fs_url_map = {
3614
+ fs: [url for url, f in self._url_fs_map.items() if f == fs]
3615
+ for fs in set(self._url_fs_map.values())
3616
+ }
3617
+ for fs, urls in fs_url_map.items():
3618
+ try:
3619
+ results = fs.cat(urls, recursive=False, on_error="return")
3620
+ except Exception:
3621
+ log.warning("Error connecting to %s", fs)
3622
+ else:
3623
+ # for url, result in zip(urls, results.values()):
3624
+ for url, result in results.items():
3625
+ if not isinstance(result, Exception):
3626
+ log.debug("File %s loaded", url)
3627
+ self._url_cbs[url](result)
3628
+ else:
3629
+ log.warning("Error loading %s", url)
3559
3630
 
3560
- self.assets_loaded = True
3631
+ # Reset all nodes so they will update with the new CSS from assets
3632
+ if self.defer_assets:
3633
+ self.soup.reset()
3561
3634
 
3562
3635
  def render(self, width: int | None, height: int | None) -> StyleAndTextTuples:
3563
3636
  """Render the current markup at a given size."""
@@ -3585,7 +3658,10 @@ class HTML:
3585
3658
  assert self.width is not None
3586
3659
  assert self.height is not None
3587
3660
 
3588
- if not self.assets_loaded:
3661
+ # The soup gets parsed when we load assets, and asset data gets attached to it
3662
+ if not self._dom_processed:
3663
+ self.process_dom()
3664
+ if not self.defer_assets and not self._assets_loaded:
3589
3665
  await self.load_assets()
3590
3666
 
3591
3667
  ft = await self.render_element(
@@ -3651,6 +3727,12 @@ class HTML:
3651
3727
  self.render_count += 1
3652
3728
  self.formatted_text = ft
3653
3729
 
3730
+ # Load assets after initial render and requuest a re-render when loaded
3731
+ if self.defer_assets and not self._assets_loaded:
3732
+ loop = asyncio.get_event_loop()
3733
+ task = loop.create_task(self.load_assets())
3734
+ task.add_done_callback(lambda fut: self.on_change.fire())
3735
+
3654
3736
  return ft
3655
3737
 
3656
3738
  async def render_element(
@@ -3676,6 +3758,9 @@ class HTML:
3676
3758
  elif element.theme.d_grid:
3677
3759
  render_func = self.render_grid_content
3678
3760
 
3761
+ elif element.theme.latex:
3762
+ render_func = self.render_latex_content
3763
+
3679
3764
  else:
3680
3765
  name = element.name.lstrip(":")
3681
3766
  render_func = getattr(
@@ -3711,14 +3796,15 @@ class HTML:
3711
3796
 
3712
3797
  """
3713
3798
  ft: StyleAndTextTuples = []
3714
- if text := element.text:
3715
- if transformed := (await element.theme.text_transform(text)):
3716
- text = transformed
3717
- if parent_theme := element.theme.parent_theme:
3718
- style = parent_theme.style
3719
- else:
3720
- style = ""
3721
- ft = [(style, text)]
3799
+
3800
+ text = await element.theme.text_transform(element.text)
3801
+
3802
+ if parent_theme := element.theme.parent_theme:
3803
+ style = parent_theme.style
3804
+ else:
3805
+ style = ""
3806
+
3807
+ ft.append((style, text))
3722
3808
  return ft
3723
3809
 
3724
3810
  async def render_details_content(
@@ -4178,6 +4264,47 @@ class HTML:
4178
4264
 
4179
4265
  return table.render()
4180
4266
 
4267
+ async def render_latex_content(
4268
+ self,
4269
+ element: Node,
4270
+ left: int = 0,
4271
+ fill: bool = True,
4272
+ align_content: bool = True,
4273
+ ) -> StyleAndTextTuples:
4274
+ """Render LaTeX math content."""
4275
+ theme = element.theme
4276
+
4277
+ # Render text representation
4278
+ ft: StyleAndTextTuples = await self.render_node_content(
4279
+ element, left, fill, align_content
4280
+ )
4281
+
4282
+ # Render graphic representation
4283
+ latex = element.text + "".join(
4284
+ child.text for child in element.renderable_descendents
4285
+ )
4286
+ latex = f"${latex}$"
4287
+ if element.theme.d_blocky:
4288
+ latex = f"${latex}$"
4289
+ datum = Datum(
4290
+ latex,
4291
+ "latex",
4292
+ fg=theme.color,
4293
+ bg=theme.background_color,
4294
+ align=WindowAlign.CENTER,
4295
+ )
4296
+ self.graphic_data.add(datum)
4297
+
4298
+ # Calculate size and pad text representation
4299
+ cols, aspect = await datum.cell_size_async()
4300
+ rows = max(len(list(split_lines(ft))), ceil(cols * aspect))
4301
+ cols = max(cols, max_line_width(ft))
4302
+
4303
+ key = datum.add_size(Size(rows, cols))
4304
+ ft = [(f"[Graphic_{key}]", ""), *ft]
4305
+ ft = valign(ft, height=rows, how=FormattedTextVerticalAlign.TOP)
4306
+ return ft
4307
+
4181
4308
  @overload
4182
4309
  async def _render_image(
4183
4310
  self, data: bytes, format_: str, theme: Theme, path: Path | None = None
@@ -4249,7 +4376,7 @@ class HTML:
4249
4376
  content_width = theme.content_width
4250
4377
  # content_height = theme.content_height
4251
4378
  src = str(element.attrs.get("src", ""))
4252
- path = self.base / src
4379
+ path = UPath(urljoin(str(self.base), src))
4253
4380
 
4254
4381
  if not element.attrs.get("_missing") and (data := element.attrs.get("_data")):
4255
4382
  # Display it graphically
@@ -4825,20 +4952,6 @@ class HTML:
4825
4952
  padding_style=parent_style,
4826
4953
  )
4827
4954
 
4828
- # Ensure hidden content is blank and styled like the parent
4829
- if theme.hidden:
4830
- ft = cast(
4831
- "StyleAndTextTuples",
4832
- [
4833
- (
4834
- parent_style,
4835
- "\n".join([" " * len(x) for x in text.split("\n")]),
4836
- *rest,
4837
- )
4838
- for style, text, *rest in ft
4839
- ],
4840
- )
4841
-
4842
4955
  # Apply mouse handler to links
4843
4956
  if (
4844
4957
  (parent := element.parent)