euporie 2.8.13__py3-none-any.whl → 2.8.15__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 (36) hide show
  1. euporie/console/tabs/console.py +4 -0
  2. euporie/core/__init__.py +1 -1
  3. euporie/core/cache.py +36 -0
  4. euporie/core/clipboard.py +16 -1
  5. euporie/core/config.py +1 -1
  6. euporie/core/convert/datum.py +25 -9
  7. euporie/core/convert/formats/ansi.py +25 -21
  8. euporie/core/convert/formats/base64.py +3 -1
  9. euporie/core/convert/formats/common.py +4 -4
  10. euporie/core/convert/formats/ft.py +6 -3
  11. euporie/core/convert/formats/html.py +22 -24
  12. euporie/core/convert/formats/markdown.py +4 -2
  13. euporie/core/convert/formats/pil.py +3 -1
  14. euporie/core/convert/formats/png.py +6 -4
  15. euporie/core/convert/formats/rich.py +3 -1
  16. euporie/core/convert/formats/sixel.py +3 -3
  17. euporie/core/convert/formats/svg.py +3 -1
  18. euporie/core/ft/html.py +192 -125
  19. euporie/core/ft/table.py +1 -1
  20. euporie/core/kernel/__init__.py +7 -3
  21. euporie/core/kernel/base.py +13 -0
  22. euporie/core/kernel/local.py +8 -3
  23. euporie/core/layout/containers.py +5 -0
  24. euporie/core/tabs/kernel.py +6 -1
  25. euporie/core/widgets/cell_outputs.py +39 -8
  26. euporie/core/widgets/display.py +11 -4
  27. euporie/core/widgets/forms.py +11 -5
  28. euporie/core/widgets/menu.py +9 -8
  29. euporie/preview/tabs/notebook.py +15 -4
  30. {euporie-2.8.13.dist-info → euporie-2.8.15.dist-info}/METADATA +2 -1
  31. {euporie-2.8.13.dist-info → euporie-2.8.15.dist-info}/RECORD +36 -35
  32. {euporie-2.8.13.data → euporie-2.8.15.data}/data/share/applications/euporie-console.desktop +0 -0
  33. {euporie-2.8.13.data → euporie-2.8.15.data}/data/share/applications/euporie-notebook.desktop +0 -0
  34. {euporie-2.8.13.dist-info → euporie-2.8.15.dist-info}/WHEEL +0 -0
  35. {euporie-2.8.13.dist-info → euporie-2.8.15.dist-info}/entry_points.txt +0 -0
  36. {euporie-2.8.13.dist-info → euporie-2.8.15.dist-info}/licenses/LICENSE +0 -0
@@ -8,6 +8,8 @@ from euporie.core.convert.registry import register
8
8
  from euporie.core.filters import have_modules
9
9
 
10
10
  if TYPE_CHECKING:
11
+ from typing import Any
12
+
11
13
  from euporie.core.convert.datum import Datum
12
14
 
13
15
 
@@ -18,7 +20,7 @@ async def latex_to_svg_py_ziamath(
18
20
  rows: int | None = None,
19
21
  fg: str | None = None,
20
22
  bg: str | None = None,
21
- extend: bool = True,
23
+ **kwargs: Any,
22
24
  ) -> str:
23
25
  """Convert LaTeX to SVG using :py:mod:`ziamath`."""
24
26
  import ziamath as zm
euporie/core/ft/html.py CHANGED
@@ -21,6 +21,7 @@ from prompt_toolkit.data_structures import Size
21
21
  from prompt_toolkit.filters.base import Condition
22
22
  from prompt_toolkit.filters.utils import _always as always
23
23
  from prompt_toolkit.filters.utils import _never as never
24
+ from prompt_toolkit.filters.utils import to_filter
24
25
  from prompt_toolkit.formatted_text.utils import split_lines
25
26
  from prompt_toolkit.layout.containers import WindowAlign
26
27
  from prompt_toolkit.layout.dimension import Dimension
@@ -108,7 +109,7 @@ if TYPE_CHECKING:
108
109
  from typing import Any, Callable
109
110
 
110
111
  from fsspec.spec import AbstractFileSystem
111
- from prompt_toolkit.filters.base import Filter
112
+ from prompt_toolkit.filters.base import Filter, FilterOrBool
112
113
  from prompt_toolkit.formatted_text.base import StyleAndTextTuples
113
114
  from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
114
115
  from prompt_toolkit.mouse_events import MouseEvent
@@ -191,6 +192,23 @@ _GRID_TEMPLATE_REPEAT_RE = re.compile(
191
192
  r"repeat\(\s*(?P<count>\d+)\s*,\s*(?P<value>[^)]+?)\s*\)"
192
193
  )
193
194
 
195
+ _MATHJAX_TAG_RE = re.compile(
196
+ r"""
197
+ ^(?P<beginning>.*?)
198
+ (?P<middle>
199
+ \\\[(?P<display_bracket>.*?)\\\]
200
+ |
201
+ \\\((?P<inline_bracket>.*?)\\\)
202
+ |
203
+ \$\$(?P<display_dollar>.*?)\$\$
204
+ |
205
+ \$(?P<inline_dollar>.*?)(?: \$(?=\$\$) | (?<!\$)\$(?!\$) )
206
+ )
207
+ (?P<end>.*)$
208
+ """,
209
+ re.MULTILINE | re.VERBOSE,
210
+ )
211
+
194
212
  # List of elements which might not have a close tag
195
213
  _VOID_ELEMENTS = (
196
214
  "area",
@@ -1038,7 +1056,17 @@ class Theme(Mapping):
1038
1056
  """Calculate the theme defined in CSS."""
1039
1057
  specificity_rules = []
1040
1058
  element = self.element
1059
+ # Pre-compute element attributes once
1060
+ element_name = element.name
1061
+ element_is_first = element.is_first_child_element
1062
+ element_is_last = element.is_last_child_element
1063
+ element_sibling_idx = element.sibling_element_index
1064
+ element_attrs = element.attrs
1065
+ element_parent = element.parent
1066
+ element_parents_rev = [x for x in element.parents[::-1] if x]
1067
+
1041
1068
  for condition, css_block in css.items():
1069
+ # TODO - cache CSS within condition blocks
1042
1070
  if condition():
1043
1071
  for selectors, rule in css_block.items():
1044
1072
  for selector_parts in selectors:
@@ -1048,42 +1076,42 @@ class Theme(Mapping):
1048
1076
  selector.item or "",
1049
1077
  selector.attr or "",
1050
1078
  selector.pseudo or "",
1051
- element.name,
1052
- element.is_first_child_element,
1053
- element.is_last_child_element,
1054
- element.sibling_element_index,
1055
- **element.attrs,
1079
+ element_name,
1080
+ element_is_first,
1081
+ element_is_last,
1082
+ element_sibling_idx,
1083
+ **element_attrs,
1056
1084
  ):
1057
1085
  continue
1058
1086
 
1059
1087
  # All of the parent selectors should match a separate parent in order
1060
1088
  # TODO - combinators
1061
1089
  # https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors#combinators
1062
- unmatched_parents: list[Node] = [
1063
- x for x in element.parents[::-1] if x
1064
- ]
1065
-
1066
- _unmatched_parents: list[Node]
1067
- parent = element.parent
1068
- if parent and (
1069
- (selector.comb == ">" and parent)
1090
+ unmatched_parents: list[Node]
1091
+ if element_parent and (
1092
+ (selector.comb == ">" and element_parent)
1070
1093
  # Pseudo-element selectors only match direct ancestors
1071
1094
  or ((item := selector.item) and item.startswith("::"))
1072
1095
  ):
1073
- _unmatched_parents = [parent]
1096
+ unmatched_parents = [element_parent]
1074
1097
  else:
1075
- _unmatched_parents = unmatched_parents
1098
+ unmatched_parents = element_parents_rev[:]
1076
1099
 
1077
1100
  # TODO investigate caching element / selector chains so we don't have to
1078
1101
  # iterate through every parent every time
1079
1102
 
1080
1103
  # Iterate through selector items in reverse, skipping the last
1081
1104
  for selector in selector_parts[-2::-1]:
1082
- for i, parent in enumerate(_unmatched_parents):
1105
+ # Pre-compute selector attributes
1106
+ item = selector.item or ""
1107
+ attrs = selector.attr or ""
1108
+ pseudo = selector.pseudo or ""
1109
+ parent: Node | None
1110
+ for i, parent in enumerate(unmatched_parents):
1083
1111
  if parent and match_css_selector(
1084
- selector.item or "",
1085
- selector.attr or "",
1086
- selector.pseudo or "",
1112
+ item,
1113
+ attrs,
1114
+ pseudo,
1087
1115
  parent.name,
1088
1116
  parent.is_first_child_element,
1089
1117
  parent.is_last_child_element,
@@ -1093,9 +1121,9 @@ class Theme(Mapping):
1093
1121
  if selector.comb == ">" and (
1094
1122
  parent := parent.parent
1095
1123
  ):
1096
- _unmatched_parents = [parent]
1124
+ unmatched_parents = [parent]
1097
1125
  else:
1098
- _unmatched_parents = unmatched_parents[i + 1 :]
1126
+ unmatched_parents = element_parents_rev[i + 1 :]
1099
1127
  break
1100
1128
  else:
1101
1129
  break
@@ -1109,6 +1137,10 @@ class Theme(Mapping):
1109
1137
  # the rest of the selectors for this rule
1110
1138
  break
1111
1139
 
1140
+ # Shortcut in case of no rules
1141
+ if not specificity_rules:
1142
+ return {}
1143
+
1112
1144
  # Move !important rules to the end
1113
1145
  rules = [
1114
1146
  (k, v)
@@ -1300,10 +1332,7 @@ class Theme(Mapping):
1300
1332
  return max([len(x) for x in self.element.text.split()] or [0])
1301
1333
  else:
1302
1334
  return max(
1303
- [
1304
- child.theme.min_content_width
1305
- for child in self.element.renderable_contents
1306
- ]
1335
+ [child.theme.min_content_width for child in self.element.contents]
1307
1336
  or [0]
1308
1337
  )
1309
1338
 
@@ -1314,10 +1343,7 @@ class Theme(Mapping):
1314
1343
  return max([len(x) for x in self.element.text.split("\n")] or [0])
1315
1344
  else:
1316
1345
  return sum(
1317
- [
1318
- child.theme.max_content_width
1319
- for child in self.element.renderable_contents
1320
- ]
1346
+ [child.theme.max_content_width for child in self.element.contents]
1321
1347
  or [0]
1322
1348
  )
1323
1349
 
@@ -1375,7 +1401,7 @@ class Theme(Mapping):
1375
1401
 
1376
1402
  @property
1377
1403
  def height(self) -> int | None:
1378
- """The perscribed height."""
1404
+ """The prescribed height."""
1379
1405
  # TODO - process min-/max-height
1380
1406
  if value := self.get("height"):
1381
1407
  theme_height = css_dimension(
@@ -1914,12 +1940,12 @@ class Theme(Mapping):
1914
1940
  def skip(self) -> bool:
1915
1941
  """Determine if the element should not be displayed."""
1916
1942
  return (
1917
- "none" in self.theme["display"]
1918
- or (
1919
- (element := self.element).name == "::text"
1920
- and not self.preformatted
1921
- and not element.text
1943
+ (
1944
+ (element := self.element).name in ("::before", "::after")
1945
+ and self.theme.get("content") in (None, "normal")
1922
1946
  )
1947
+ or "none" in self.theme["display"]
1948
+ or (element.name == "::text" and not self.preformatted and not element.text)
1923
1949
  or (self.theme["position"] == "absolute" and self.hidden)
1924
1950
  )
1925
1951
 
@@ -2169,13 +2195,13 @@ _BROWSER_CSS: CssSelectors = {
2169
2195
  CssSelector(item="q"),
2170
2196
  CssSelector(item="::before"),
2171
2197
  ),
2172
- ): {"content": "“"},
2198
+ ): {"content": "''"},
2173
2199
  (
2174
2200
  (
2175
2201
  CssSelector(item="q"),
2176
2202
  CssSelector(item="::after"),
2177
2203
  ),
2178
- ): {"content": "”"},
2204
+ ): {"content": "''"},
2179
2205
  # Images
2180
2206
  (
2181
2207
  (CssSelector(item="img", attr="[_missing]"),),
@@ -2778,6 +2804,7 @@ _BROWSER_CSS: CssSelectors = {
2778
2804
  "_pt_class": "markdown,code",
2779
2805
  },
2780
2806
  (
2807
+ (CssSelector(item="::latex"),),
2781
2808
  (
2782
2809
  CssSelector(item="::root", attr="[_initial_format=markdown]"),
2783
2810
  CssSelector(item=".math"),
@@ -2824,7 +2851,7 @@ _BROWSER_CSS: CssSelectors = {
2824
2851
  CssSelector(item="*"),
2825
2852
  ),
2826
2853
  ): {
2827
- "pt_class": "markdown,code,block",
2854
+ "_pt_class": "markdown,code,block",
2828
2855
  },
2829
2856
  (
2830
2857
  (
@@ -2841,7 +2868,12 @@ _BROWSER_CSS: CssSelectors = {
2841
2868
  "overflow_y": "hidden",
2842
2869
  "vertical_align": "middle",
2843
2870
  },
2844
- }
2871
+ },
2872
+ get_app().config.filters.wrap_cell_outputs: {
2873
+ ((CssSelector(item="table.dataframe"),),): {
2874
+ "max_width": "100%",
2875
+ },
2876
+ },
2845
2877
  }
2846
2878
 
2847
2879
 
@@ -2868,9 +2900,6 @@ class Node:
2868
2900
  self.contents: list[Node] = contents or []
2869
2901
  self.closed = False
2870
2902
  self.marker: Node | None = None
2871
- self.before: Node | None = None
2872
- self.after: Node | None = None
2873
-
2874
2903
  self.theme = Theme(self, parent_theme=parent.theme if parent else None)
2875
2904
 
2876
2905
  def reset(self) -> None:
@@ -2911,22 +2940,49 @@ class Node:
2911
2940
  @cached_property
2912
2941
  def preceding_text(self) -> str:
2913
2942
  """Return the text preceding this element."""
2914
- s = ""
2915
2943
  parent = self.parent
2916
- while parent and not parent.theme.d_blocky:
2944
+ # Move up to the current block element
2945
+ while parent and not (parent.theme.d_blocky or parent.theme.d_inline_block):
2917
2946
  parent = parent.parent
2918
- if parent:
2919
- for node in parent.renderable_descendents:
2920
- if node is self:
2921
- break
2922
- s += node.text
2923
- return s
2947
+ if not parent:
2948
+ return ""
2949
+
2950
+ def _text_descendents(node: Node) -> Generator[Node]:
2951
+ for child in node.contents:
2952
+ if child.name == "::text":
2953
+ yield child
2954
+ elif (
2955
+ child.contents
2956
+ and (child.theme.d_inline or child.theme.d_inline_block)
2957
+ and child.theme.in_flow
2958
+ ):
2959
+ yield from _text_descendents(child)
2960
+
2961
+ last_valid_text = ""
2962
+ for node in _text_descendents(parent):
2963
+ if node is self:
2964
+ return last_valid_text
2965
+ elif text := node.text:
2966
+ last_valid_text = text
2967
+ return ""
2924
2968
 
2925
2969
  @cached_property
2926
2970
  def text(self) -> str:
2927
2971
  """Get the element's computed text."""
2928
- if text := self._text:
2929
- # if False and not (preformatted := self.theme.preformatted):
2972
+ text = self._text
2973
+
2974
+ # Allow replacing context of pseudo-elements
2975
+ if (
2976
+ (parent := self.parent)
2977
+ and parent.name.startswith("::")
2978
+ and (_text := parent.theme.theme.get("content", "").strip())
2979
+ ):
2980
+ if _text.startswith('"') and _text.endswith('"'):
2981
+ text = _text.strip('"')
2982
+ elif _text.startswith("'") and _text.endswith("'"):
2983
+ text = _text.strip("'")
2984
+
2985
+ if text:
2930
2986
  if not (preformatted := self.theme.preformatted):
2931
2987
  # 1. All spaces and tabs immediately before and after a line break are ignored
2932
2988
  text = re.sub(r"(\s+(?=\n)|(?<=\n)\s+)", "", text, flags=re.MULTILINE)
@@ -2997,44 +3053,6 @@ class Node:
2997
3053
  if element.name == tag:
2998
3054
  yield element
2999
3055
 
3000
- @cached_property
3001
- def renderable_contents(self) -> list[Node]:
3002
- """List the node's contents including '::before' and '::after' elements."""
3003
- # Do not add '::before' and '::after' elements to themselves
3004
- if self.name.startswith("::"):
3005
- return self.contents
3006
-
3007
- contents = []
3008
-
3009
- # Add ::before node
3010
- before_node = Node(dom=self.dom, name="::before", parent=self)
3011
- if (text := before_node.theme.theme.get("content", "").strip()) and (
3012
- (text.startswith('"') and text.endswith('"'))
3013
- or (text.startswith("'") and text.endswith("'"))
3014
- ):
3015
- text = text.strip('"').strip("'")
3016
- before_node.contents.append(
3017
- Node(dom=self.dom, name="::text", parent=before_node, text=text)
3018
- )
3019
- contents.append(before_node)
3020
-
3021
- contents.extend(self.contents)
3022
-
3023
- # Add ::after node
3024
- after_node = Node(dom=self.dom, name="::after", parent=self)
3025
-
3026
- if (text := after_node.theme.theme.get("content", "")) and (
3027
- (text.startswith('"') and text.endswith('"'))
3028
- or (text.startswith("'") and text.endswith("'"))
3029
- ):
3030
- text = text.strip('"').strip("'")
3031
- after_node.contents.append(
3032
- Node(dom=self.dom, name="::text", parent=after_node, text=text)
3033
- )
3034
- contents.append(after_node)
3035
-
3036
- return contents
3037
-
3038
3056
  @property
3039
3057
  def descendents(self) -> Generator[Node]:
3040
3058
  """Yield all descendent elements."""
@@ -3044,18 +3062,10 @@ class Node:
3044
3062
 
3045
3063
  @property
3046
3064
  def renderable_descendents(self) -> Generator[Node]:
3047
- """Yield descendents, including pseudo and skipping inline elements."""
3048
- for child in self.renderable_contents:
3049
- if (
3050
- child.theme.d_inline
3051
- and child.renderable_contents
3052
- and child.name != "::text"
3053
- ):
3065
+ """Yield descendent elements with actual text content."""
3066
+ for child in self.contents:
3067
+ if child.theme.d_inline and child.contents and child.name != "::text":
3054
3068
  yield from child.renderable_descendents
3055
- # elif (
3056
- # child.name == "::text" and self.name != "::block" and self.theme.d_blocky
3057
- # ):
3058
- # yield Node(dom=self.dom, name="::block", parent=self, contents=[child])
3059
3069
  else:
3060
3070
  yield child
3061
3071
 
@@ -3473,6 +3483,7 @@ class HTML:
3473
3483
  fill: bool = True,
3474
3484
  css: CssSelectors | None = None,
3475
3485
  browser_css: CssSelectors | None = None,
3486
+ mathjax: FilterOrBool = True,
3476
3487
  mouse_handler: Callable[[Node, MouseEvent], NotImplementedOrNone] | None = None,
3477
3488
  paste_fixed: bool = True,
3478
3489
  defer_assets: bool = False,
@@ -3494,6 +3505,7 @@ class HTML:
3494
3505
  fill: Whether remaining space in block elements should be filled
3495
3506
  css: Base CSS to apply when rendering the HTML
3496
3507
  browser_css: The browser CSS to use
3508
+ mathjax: Whether to search for LaTeX in MathJax tags
3497
3509
  mouse_handler: A mouse handler function to use when links are clicked
3498
3510
  paste_fixed: Whether fixed elements should be pasted over the output
3499
3511
  defer_assets: Whether to render the page before remote assets are loaded
@@ -3508,6 +3520,7 @@ class HTML:
3508
3520
 
3509
3521
  self.browser_css = browser_css or _BROWSER_CSS
3510
3522
  self.css: CssSelectors = css or {}
3523
+ self.mathjax = to_filter(mathjax)
3511
3524
  self.defer_assets = defer_assets
3512
3525
 
3513
3526
  self.render_count = 0
@@ -3552,6 +3565,7 @@ class HTML:
3552
3565
 
3553
3566
  Do not touch element's themes!
3554
3567
  """
3568
+ mathjax = self.mathjax()
3555
3569
 
3556
3570
  def _process_css(data: bytes) -> None:
3557
3571
  try:
@@ -3606,6 +3620,68 @@ class HTML:
3606
3620
  self._url_fs_map[url] = fs
3607
3621
  self._url_cbs[url] = partial(_process_img, child)
3608
3622
 
3623
+ # Convert non-HTML MathJax tags in texts to <::latex> special tags
3624
+ elif mathjax and child.name == "::text":
3625
+ text = child._text
3626
+ nodes = []
3627
+ while text:
3628
+ # Split out MathJax parts
3629
+ if (match := re.match(_MATHJAX_TAG_RE, text)) is not None:
3630
+ groups = match.groupdict()
3631
+ before = groups["beginning"]
3632
+ latex = groups["display_bracket"] or groups["display_dollar"]
3633
+ b_attrs: list[tuple[str, str | None]] | None
3634
+ if latex:
3635
+ b_attrs = [("style", "display: block")]
3636
+ else:
3637
+ latex = groups["inline_bracket"] or groups["inline_dollar"]
3638
+ b_attrs = []
3639
+ text = groups["end"]
3640
+ else:
3641
+ break
3642
+ # Add the text before the MathJax tag
3643
+ if before:
3644
+ nodes.append(
3645
+ Node(
3646
+ dom=self,
3647
+ name="::text",
3648
+ parent=child.parent,
3649
+ text=before,
3650
+ )
3651
+ )
3652
+ # Add the LaTeX node
3653
+ if latex:
3654
+ latex_node = Node(
3655
+ dom=self, name="::latex", parent=child.parent, attrs=b_attrs
3656
+ )
3657
+ latex_node.contents = [
3658
+ Node(dom=self, name="::text", parent=latex_node, text=latex)
3659
+ ]
3660
+ nodes.append(latex_node)
3661
+ if nodes:
3662
+ parent = child.parent
3663
+ # Add a text node for any remaining text
3664
+ if text:
3665
+ nodes.append(
3666
+ Node(dom=self, name="::text", parent=parent, text=text)
3667
+ )
3668
+ # Replace the original text node with the new nodes
3669
+ if parent is not None:
3670
+ index = parent.contents.index(child)
3671
+ parent.contents[index : index + 1] = nodes
3672
+
3673
+ # Add pseudo-elements just before rendering
3674
+ if child.name not in _VOID_ELEMENTS and not child.name.startswith("::"):
3675
+ before_node = Node(dom=self, name="::before", parent=child)
3676
+ before_node.contents.append(
3677
+ Node(dom=self, name="::text", parent=before_node)
3678
+ )
3679
+ after_node = Node(dom=self, name="::after", parent=child)
3680
+ after_node.contents.append(
3681
+ Node(dom=self, name="::text", parent=after_node)
3682
+ )
3683
+ child.contents = [before_node, *child.contents, after_node]
3684
+
3609
3685
  self._dom_processed = True
3610
3686
 
3611
3687
  async def load_assets(self) -> None:
@@ -3647,7 +3723,7 @@ class HTML:
3647
3723
  self, width: int | None, height: int | None
3648
3724
  ) -> StyleAndTextTuples:
3649
3725
  """Render the current markup at a given size, asynchronously."""
3650
- # log.debug("Rendering at (%d, %d)", width, height)
3726
+ # log.debug("Rendering at (%r, %r)", width, height)
3651
3727
  no_w = width is None and self.width is None
3652
3728
  no_h = height is None and self.height is None
3653
3729
  if no_w or no_h:
@@ -3732,7 +3808,7 @@ class HTML:
3732
3808
  self.render_count += 1
3733
3809
  self.formatted_text = ft
3734
3810
 
3735
- # Load assets after initial render and requuest a re-render when loaded
3811
+ # Load assets after initial render and request a re-render when loaded
3736
3812
  if self.defer_assets and not self._assets_loaded:
3737
3813
  loop = asyncio.get_event_loop()
3738
3814
  task = loop.create_task(self.load_assets())
@@ -3934,7 +4010,7 @@ class HTML:
3934
4010
  table_x_dim = Dimension(
3935
4011
  min=table_theme.min_width,
3936
4012
  preferred=table_theme.content_width if "width" in table_theme else None,
3937
- max=table_theme.max_width or table_theme.content_width,
4013
+ max=table_theme.max_width,
3938
4014
  )
3939
4015
 
3940
4016
  table = Table(
@@ -4044,7 +4120,7 @@ class HTML:
4044
4120
  fill=True,
4045
4121
  )
4046
4122
  if ft_caption:
4047
- ft.extend(ft_caption)
4123
+ ft.extend([*ft_caption, ("", "\n")])
4048
4124
 
4049
4125
  ft.extend(ft_table)
4050
4126
 
@@ -4414,7 +4490,7 @@ class HTML:
4414
4490
  ) -> StyleAndTextTuples:
4415
4491
  """Display images rendered as ANSI art."""
4416
4492
  theme = element.theme
4417
- # HTMLParser clobber the case of element attributes
4493
+ # HTMLParser clobbers the case of element attributes
4418
4494
  element.attrs["xmlns"] = "http://www.w3.org/2000/svg"
4419
4495
  element.attrs["xmlns:xlink"] = "http://www.w3.org/1999/xlink"
4420
4496
  # We fix the SVG viewBox here
@@ -4517,7 +4593,7 @@ class HTML:
4517
4593
 
4518
4594
  # Render each child node
4519
4595
  for child, rendering in zip(coros, renderings):
4520
- # Start a new line if we encounter a <br> element
4596
+ # Start a new line if we just passed a <br> element
4521
4597
  if child.name == "br":
4522
4598
  flush()
4523
4599
  continue
@@ -4577,10 +4653,7 @@ class HTML:
4577
4653
  # current output. This might involve re-aligning the last line in the
4578
4654
  # output, which could have been an inline-block
4579
4655
 
4580
- elif d_inline and (
4581
- # parent_theme.d_inline or parent_theme.d_inline_block or preformatted
4582
- parent_theme.d_inline or preformatted
4583
- ):
4656
+ elif d_inline and (parent_theme.d_inline or preformatted):
4584
4657
  new_line.extend(rendering)
4585
4658
 
4586
4659
  elif d_inline or d_inline_block:
@@ -4798,14 +4871,6 @@ class HTML:
4798
4871
  ignore_whitespace=True,
4799
4872
  style=theme.style,
4800
4873
  )
4801
- else:
4802
- ft = truncate(
4803
- ft,
4804
- content_width,
4805
- placeholder="",
4806
- ignore_whitespace=True,
4807
- style=theme.style,
4808
- )
4809
4874
 
4810
4875
  # Truncate or expand the height
4811
4876
  overflow_y = theme.get("overflow_y") in {"hidden", "auto"}
@@ -4986,6 +5051,7 @@ if __name__ == "__main__":
4986
5051
  import sys
4987
5052
 
4988
5053
  from prompt_toolkit.application.current import create_app_session, set_app
5054
+ from prompt_toolkit.formatted_text.utils import to_formatted_text
4989
5055
  from prompt_toolkit.shortcuts.utils import print_formatted_text
4990
5056
  from prompt_toolkit.styles.style import Style
4991
5057
 
@@ -5000,11 +5066,12 @@ if __name__ == "__main__":
5000
5066
  set_app(DummyApp()),
5001
5067
  ):
5002
5068
  print_formatted_text(
5003
- HTML(
5004
- path.read_text(),
5005
- base=path,
5006
- collapse_root_margin=False,
5007
- fill=True,
5069
+ to_formatted_text(
5070
+ asyncio.run(
5071
+ HTML(
5072
+ path.read_text(), base=path, collapse_root_margin=False
5073
+ )._render(None, None)
5074
+ )
5008
5075
  ),
5009
5076
  style=Style(HTML_STYLE),
5010
5077
  )
euporie/core/ft/table.py CHANGED
@@ -232,7 +232,7 @@ class SpacerCell(Cell):
232
232
  width=None,
233
233
  align=align,
234
234
  style=expands.style,
235
- padding=0,
235
+ padding=expands.padding,
236
236
  border_line=expands.border_line,
237
237
  border_style=expands.border_style,
238
238
  border_visibility=expands.border_visibility,
@@ -12,11 +12,15 @@ if TYPE_CHECKING:
12
12
  from euporie.core.kernel.base import BaseKernel, KernelInfo, MsgCallbacks
13
13
  from euporie.core.tabs.kernel import KernelTab
14
14
 
15
- KERNEL_REGISTRY = {
16
- "local": "euporie.core.kernel.local:LocalPythonKernel",
17
- }
15
+ KERNEL_REGISTRY = {}
18
16
  if find_spec("jupyter_client"):
19
17
  KERNEL_REGISTRY["jupyter"] = "euporie.core.kernel.jupyter:JupyterKernel"
18
+ KERNEL_REGISTRY.update(
19
+ {
20
+ "local": "euporie.core.kernel.local:LocalPythonKernel",
21
+ "none": "euporie.core.kernel.base:NoKernel",
22
+ }
23
+ )
20
24
 
21
25
 
22
26
  def list_kernels() -> list[KernelInfo]:
@@ -499,6 +499,19 @@ class BaseKernel(ABC):
499
499
  class NoKernel(BaseKernel):
500
500
  """A `None` kernel."""
501
501
 
502
+ @classmethod
503
+ def variants(cls) -> list[KernelInfo]:
504
+ """Return available kernel specifications."""
505
+ return [
506
+ KernelInfo(
507
+ name="none",
508
+ display_name="No Kernel",
509
+ factory=cls,
510
+ kind="new",
511
+ type=cls,
512
+ )
513
+ ]
514
+
502
515
  @property
503
516
  def spec(self) -> dict[str, str]:
504
517
  """The kernelspec metadata for the current kernel instance."""
@@ -24,7 +24,7 @@ from euporie.core.app.current import get_app
24
24
  from euporie.core.kernel.base import BaseKernel, KernelInfo, MsgCallbacks
25
25
 
26
26
  if TYPE_CHECKING:
27
- from typing import Any, Callable, Unpack
27
+ from typing import Any, Callable, TextIO, Unpack
28
28
 
29
29
  from euporie.core.tabs.kernel import KernelTab
30
30
 
@@ -175,7 +175,7 @@ class LocalPythonKernel(BaseKernel):
175
175
  virtual_env_path = Path(
176
176
  os.environ["VIRTUAL_ENV"], "lib", "python{}.{}", "site-packages"
177
177
  )
178
- p_ver = sys.version_info[:2]
178
+ p_ver = tuple(str(x) for x in sys.version_info[:2])
179
179
 
180
180
  # Predict version from py[thon]-x.x in the $VIRTUAL_ENV
181
181
  re_m = re.search(r"\bpy(?:thon)?([23])\.(\d+)\b", os.environ["VIRTUAL_ENV"])
@@ -631,7 +631,12 @@ class InputBuiltin(BaseHook):
631
631
  self._is_password = is_password
632
632
  update_wrapper(self, input)
633
633
 
634
- def __call__(self, prompt: str = "", stream: Any = None) -> str:
634
+ def __call__(
635
+ self,
636
+ prompt: str = "",
637
+ stream: TextIO | None = None,
638
+ echo_char: str | None = None,
639
+ ) -> str:
635
640
  """Get input from user via callback."""
636
641
  if (callbacks := self.callbacks) and (get_input := callbacks.get("get_input")):
637
642
  # Clear any previous input