euporie 2.8.5__py3-none-any.whl → 2.8.7__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 (74) hide show
  1. euporie/console/app.py +2 -0
  2. euporie/console/tabs/console.py +27 -17
  3. euporie/core/__init__.py +2 -2
  4. euporie/core/__main__.py +2 -2
  5. euporie/core/_settings.py +7 -2
  6. euporie/core/app/_commands.py +20 -12
  7. euporie/core/app/_settings.py +34 -4
  8. euporie/core/app/app.py +31 -18
  9. euporie/core/bars/command.py +53 -27
  10. euporie/core/bars/search.py +43 -2
  11. euporie/core/border.py +7 -2
  12. euporie/core/comm/base.py +2 -2
  13. euporie/core/comm/ipywidgets.py +3 -3
  14. euporie/core/commands.py +44 -24
  15. euporie/core/completion.py +14 -6
  16. euporie/core/convert/datum.py +7 -7
  17. euporie/core/data_structures.py +20 -1
  18. euporie/core/filters.py +40 -9
  19. euporie/core/format.py +2 -3
  20. euporie/core/ft/html.py +47 -40
  21. euporie/core/graphics.py +199 -31
  22. euporie/core/history.py +15 -5
  23. euporie/core/inspection.py +16 -9
  24. euporie/core/kernel/__init__.py +53 -1
  25. euporie/core/kernel/base.py +571 -0
  26. euporie/core/kernel/{client.py → jupyter.py} +173 -430
  27. euporie/core/kernel/{manager.py → jupyter_manager.py} +4 -3
  28. euporie/core/kernel/local.py +694 -0
  29. euporie/core/key_binding/bindings/basic.py +6 -3
  30. euporie/core/keys.py +26 -25
  31. euporie/core/layout/cache.py +31 -7
  32. euporie/core/layout/containers.py +88 -13
  33. euporie/core/layout/scroll.py +69 -170
  34. euporie/core/log.py +2 -5
  35. euporie/core/path.py +61 -13
  36. euporie/core/style.py +2 -1
  37. euporie/core/suggest.py +155 -74
  38. euporie/core/tabs/__init__.py +12 -4
  39. euporie/core/tabs/_commands.py +76 -0
  40. euporie/core/tabs/_settings.py +16 -0
  41. euporie/core/tabs/base.py +89 -9
  42. euporie/core/tabs/kernel.py +83 -38
  43. euporie/core/tabs/notebook.py +28 -76
  44. euporie/core/utils.py +2 -19
  45. euporie/core/validation.py +8 -8
  46. euporie/core/widgets/_settings.py +19 -2
  47. euporie/core/widgets/cell.py +32 -32
  48. euporie/core/widgets/cell_outputs.py +10 -1
  49. euporie/core/widgets/dialog.py +60 -76
  50. euporie/core/widgets/display.py +2 -2
  51. euporie/core/widgets/forms.py +71 -59
  52. euporie/core/widgets/inputs.py +7 -4
  53. euporie/core/widgets/layout.py +281 -93
  54. euporie/core/widgets/menu.py +56 -16
  55. euporie/core/widgets/palette.py +3 -1
  56. euporie/core/widgets/tree.py +86 -76
  57. euporie/notebook/app.py +35 -16
  58. euporie/notebook/tabs/display.py +2 -2
  59. euporie/notebook/tabs/edit.py +11 -46
  60. euporie/notebook/tabs/json.py +8 -4
  61. euporie/notebook/tabs/notebook.py +26 -8
  62. euporie/preview/tabs/notebook.py +17 -13
  63. euporie/web/__init__.py +1 -0
  64. euporie/web/tabs/__init__.py +14 -0
  65. euporie/web/tabs/web.py +30 -5
  66. euporie/web/widgets/__init__.py +1 -0
  67. euporie/web/widgets/webview.py +5 -4
  68. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/METADATA +4 -2
  69. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/RECORD +74 -68
  70. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/entry_points.txt +1 -1
  71. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/licenses/LICENSE +1 -1
  72. {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-console.desktop +0 -0
  73. {euporie-2.8.5.data → euporie-2.8.7.data}/data/share/applications/euporie-notebook.desktop +0 -0
  74. {euporie-2.8.5.dist-info → euporie-2.8.7.dist-info}/WHEEL +0 -0
@@ -19,7 +19,7 @@ from prompt_toolkit.layout.processors import BeforeInput
19
19
 
20
20
  from euporie.core.comm.base import Comm, CommView
21
21
  from euporie.core.data_structures import DiBool
22
- from euporie.core.kernel.client import MsgCallbacks
22
+ from euporie.core.kernel.jupyter import MsgCallbacks
23
23
  from euporie.core.layout.decor import FocusedStyle
24
24
  from euporie.core.widgets.forms import (
25
25
  Button,
@@ -518,7 +518,7 @@ class TextBoxIpyWidgetComm(IpyWidgetComm, metaclass=ABCMeta):
518
518
  container,
519
519
  setters={
520
520
  "value": lambda x: setattr(text.buffer, "text", str(x)),
521
- "rows": partial(setattr, text.text_area.window, "height"),
521
+ "rows": partial(setattr, text.window, "height"),
522
522
  "placeholder": partial(setattr, text, "placeholder"),
523
523
  "description_allow_html": partial(setattr, labelled_widget, "html"),
524
524
  },
@@ -1403,7 +1403,7 @@ class ColorPickerModel(TextBoxIpyWidgetComm):
1403
1403
  container,
1404
1404
  setters={
1405
1405
  "value": lambda x: setattr(text.buffer, "text", str(x)),
1406
- "rows": partial(setattr, text.text_area.window, "height"),
1406
+ "rows": partial(setattr, text.window, "height"),
1407
1407
  "placeholder": partial(setattr, text, "placeholder"),
1408
1408
  },
1409
1409
  )
euporie/core/commands.py CHANGED
@@ -41,6 +41,36 @@ if TYPE_CHECKING:
41
41
  log = logging.getLogger(__name__)
42
42
 
43
43
 
44
+ def parse_args(arg: str) -> list[Any]:
45
+ """Parse a command argument string into a list of values.
46
+
47
+ Args:
48
+ arg: The argument string to parse
49
+
50
+ Returns:
51
+ A list of parsed values, with strings for items that couldn't be evaluated
52
+ """
53
+ if not arg:
54
+ return []
55
+
56
+ import ast
57
+
58
+ result = []
59
+ for item in arg.split():
60
+ try:
61
+ # Safely evaluate string as a Python literal
62
+ new_value = ast.literal_eval(item)
63
+ except (ValueError, SyntaxError):
64
+ # Keep as string if evaluation fails
65
+ result.append(item)
66
+ else:
67
+ if type(new_value) is str:
68
+ result.append(item)
69
+ else:
70
+ result.append(new_value)
71
+ return result
72
+
73
+
44
74
  class Command:
45
75
  """Wrap a function so it can be used as a key-binding or a menu item."""
46
76
 
@@ -110,18 +140,19 @@ class Command:
110
140
 
111
141
  self.keys: list[tuple[str | Keys, ...]] = []
112
142
 
113
- def run(self, arg: str | None = None) -> None:
143
+ def run(self, arg: str = "") -> None:
114
144
  """Run the command's handler."""
115
145
  if self.filter():
116
146
  app = get_app()
117
147
  result = self.key_handler(
118
148
  KeyPressEvent(
119
149
  key_processor_ref=weakref.ref(app.key_processor),
120
- arg=arg,
150
+ arg=None,
121
151
  key_sequence=[],
122
152
  previous_key_sequence=[],
123
153
  is_repeat=False,
124
154
  ),
155
+ *parse_args(arg),
125
156
  )
126
157
  if isawaitable(result):
127
158
 
@@ -141,14 +172,19 @@ class Command:
141
172
  handler = self.handler
142
173
  sig = signature(handler)
143
174
 
144
- if sig.parameters:
145
- # The handler already accepts a `KeyPressEvent` argument
175
+ if sig.parameters and next(iter(sig.parameters.keys())) == "event":
176
+ # The handler already accepts a `KeyPressEvent` argument named "event"
177
+ # as the first parameter
146
178
  return cast("KeyHandlerCallable", handler)
147
179
 
180
+ # Otherwise we need to wrap in a function which accepts a KeyPressEvent as the
181
+ # first parameter
148
182
  if iscoroutinefunction(handler):
149
183
 
150
- async def _key_handler_async(event: KeyPressEvent) -> NotImplementedOrNone:
151
- result = cast("CommandHandlerNoArgs", handler)()
184
+ async def _key_handler_async(
185
+ event: KeyPressEvent, *args: Any
186
+ ) -> NotImplementedOrNone:
187
+ result = cast("CommandHandlerNoArgs", handler)(*args)
152
188
  assert isawaitable(result)
153
189
  return await result
154
190
 
@@ -156,8 +192,8 @@ class Command:
156
192
 
157
193
  else:
158
194
 
159
- def _key_handler(event: KeyPressEvent) -> NotImplementedOrNone:
160
- return cast("CommandHandlerNoArgs", handler)()
195
+ def _key_handler(event: KeyPressEvent, *args: Any) -> NotImplementedOrNone:
196
+ return cast("CommandHandlerNoArgs", handler)(*args)
161
197
 
162
198
  return _key_handler
163
199
 
@@ -191,22 +227,6 @@ class Command:
191
227
  return format_keys([self.keys[0]])[0]
192
228
  return ""
193
229
 
194
- @property
195
- def menu_handler(self) -> Callable[[], None]:
196
- """Return a menu handler for the command."""
197
- handler = self.handler
198
- if isawaitable(handler):
199
-
200
- def _menu_handler() -> None:
201
- task = cast("CommandHandlerNoArgs", handler)()
202
- task = cast("Coroutine[Any, Any, None]", task)
203
- if task is not None:
204
- get_app().create_background_task(task)
205
-
206
- return _menu_handler
207
- else:
208
- return cast("Callable[[], None]", handler)
209
-
210
230
  @property
211
231
  def menu(self) -> MenuItem:
212
232
  """Return a menu item for the command."""
@@ -10,26 +10,34 @@ from prompt_toolkit.completion.base import CompleteEvent, Completer, Completion
10
10
  if TYPE_CHECKING:
11
11
  from collections.abc import AsyncGenerator, Iterable
12
12
  from pathlib import Path
13
+ from typing import Callable
13
14
 
14
15
  from prompt_toolkit.document import Document
15
16
 
16
- from euporie.core.kernel.client import Kernel
17
+ from euporie.core.kernel.base import BaseKernel
17
18
  from euporie.core.lsp import LspClient
18
19
 
19
20
  log = logging.getLogger(__name__)
20
21
 
21
22
 
22
23
  class KernelCompleter(Completer):
23
- """A prompt_toolkit completer which provides completions from a Jupyter kernel."""
24
+ """A prompt_toolkit completer which provides completions from a kernel."""
24
25
 
25
- def __init__(self, kernel: Kernel) -> None:
26
+ def __init__(self, kernel: BaseKernel | Callable[[], BaseKernel]) -> None:
26
27
  """Instantiate the completer for a given notebook.
27
28
 
28
29
  Args:
29
30
  kernel: A `Notebook` instance
30
31
 
31
32
  """
32
- self.kernel = kernel
33
+ self._kernel = kernel
34
+
35
+ @property
36
+ def kernel(self) -> BaseKernel:
37
+ """Return the current kernel."""
38
+ if callable(self._kernel):
39
+ return self._kernel()
40
+ return self._kernel
33
41
 
34
42
  def get_completions(
35
43
  self, document: Document, complete_event: CompleteEvent
@@ -42,8 +50,8 @@ class KernelCompleter(Completer):
42
50
  self, document: Document, complete_event: CompleteEvent
43
51
  ) -> AsyncGenerator[Completion, None]:
44
52
  """Retrieve completions from a :class:`Kernel`."""
45
- for kwargs in await self.kernel.complete_(
46
- code=document.text,
53
+ for kwargs in await self.kernel.complete_async(
54
+ source=document.text,
47
55
  cursor_pos=document.cursor_position,
48
56
  ):
49
57
  if completion_type := kwargs.get("display_meta"):
@@ -249,13 +249,13 @@ class Datum(Generic[T], metaclass=_MetaDatum):
249
249
  self._queue[key_conv] = event = asyncio.Event()
250
250
 
251
251
  routes = _CONVERTOR_ROUTE_CACHE[(self.format, to)]
252
- log.debug(
253
- "Converting %s->'%s'@%s using routes: %s",
254
- self,
255
- to,
256
- (cols, rows),
257
- routes,
258
- )
252
+ # log.debug(
253
+ # "Converting %s->'%s'@%s using routes: %s",
254
+ # self,
255
+ # to,
256
+ # (cols, rows),
257
+ # routes,
258
+ # )
259
259
  output: T | None = None
260
260
  if routes:
261
261
  datum = self
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from functools import lru_cache
6
- from typing import NamedTuple
6
+ from typing import NamedTuple, TypeVar, overload
7
+
8
+ _T = TypeVar("_T")
7
9
 
8
10
 
9
11
  class DiBool(NamedTuple):
@@ -28,6 +30,23 @@ class DiInt(NamedTuple):
28
30
  bottom: int = 0
29
31
  left: int = 0
30
32
 
33
+ @overload
34
+ def __add__(self, other: tuple[int, ...], /) -> DiInt: ...
35
+ @overload
36
+ def __add__(self, other: tuple[_T, ...], /) -> DiInt: ...
37
+
38
+ def __add__(self, other):
39
+ """Add two DiInt instances together."""
40
+ if not isinstance(other, DiInt):
41
+ return NotImplemented
42
+
43
+ return DiInt(
44
+ top=self.top + other.top,
45
+ right=self.right + other.right,
46
+ bottom=self.bottom + other.bottom,
47
+ left=self.left + other.left,
48
+ )
49
+
31
50
  @classmethod
32
51
  def from_value(cls, value: int) -> DiInt:
33
52
  """Construct an instance from a single value."""
euporie/core/filters.py CHANGED
@@ -64,6 +64,14 @@ def cursor_in_leading_ws() -> bool:
64
64
  return (not before) or before.isspace()
65
65
 
66
66
 
67
+ @Condition
68
+ def cursor_at_end_of_line() -> bool:
69
+ """Determine if the cursor of the current buffer is in leading whitespace."""
70
+ from prompt_toolkit.application.current import get_app
71
+
72
+ return get_app().current_buffer.document.is_cursor_at_the_end_of_line
73
+
74
+
67
75
  @Condition
68
76
  def has_suggestion() -> bool:
69
77
  """Determine if the current buffer can display a suggestion."""
@@ -133,6 +141,38 @@ def tab_has_focus() -> bool:
133
141
  return get_app().tab is not None
134
142
 
135
143
 
144
+ @Condition
145
+ def kernel_tab_has_focus() -> bool:
146
+ """Determine if there is a focused kernel tab."""
147
+ from euporie.core.app.current import get_app
148
+ from euporie.core.tabs.kernel import KernelTab
149
+
150
+ return isinstance(get_app().tab, KernelTab)
151
+
152
+
153
+ @cache
154
+ def tab_type_has_focus(tab_class_path: str) -> Condition:
155
+ """Determine if the focused tab is of a particular type."""
156
+ from pkgutil import resolve_name
157
+
158
+ from euporie.core.app.current import get_app
159
+
160
+ tab_class = cache(resolve_name)
161
+
162
+ return Condition(lambda: isinstance(get_app().tab, tab_class(tab_class_path)))
163
+
164
+
165
+ @Condition
166
+ def tab_can_save() -> bool:
167
+ """Determine if the current tab can save it's contents."""
168
+ from euporie.core.app.current import get_app
169
+ from euporie.core.tabs.base import Tab
170
+
171
+ return (
172
+ tab := get_app().tab
173
+ ) is not None and tab.__class__.write_file != Tab.write_file
174
+
175
+
136
176
  @Condition
137
177
  def pager_has_focus() -> bool:
138
178
  """Determine if there is a currently focused notebook."""
@@ -321,15 +361,6 @@ def multiple_cells_selected() -> bool:
321
361
  return False
322
362
 
323
363
 
324
- @Condition
325
- def kernel_tab_has_focus() -> bool:
326
- """Determine if there is a focused kernel tab."""
327
- from euporie.core.app.current import get_app
328
- from euporie.core.tabs.kernel import KernelTab
329
-
330
- return isinstance(get_app().tab, KernelTab)
331
-
332
-
333
364
  def scrollable(window: Window) -> Filter:
334
365
  """Return a filter which indicates if a window is scrollable."""
335
366
  return Condition(
euporie/core/format.py CHANGED
@@ -110,11 +110,10 @@ class LspFormatter(Formatter):
110
110
  range_ = change.get("range", {})
111
111
  start = range_.get("start", {})
112
112
  start_line = start.get("line", 0)
113
- start_char = start.get("char", 0)
113
+ start_char = start.get("character", 0)
114
114
  end = range_.get("end", {})
115
115
  end_line = end.get("line", 0)
116
- end_char = end.get("char", 0)
117
-
116
+ end_char = end.get("character", 0)
118
117
  segment = range_to_slice(
119
118
  start_line, start_char, end_line, end_char, text
120
119
  )
euporie/core/ft/html.py CHANGED
@@ -39,6 +39,7 @@ from euporie.core.border import (
39
39
  LowerLeftHalfDottedLine,
40
40
  LowerLeftHalfLine,
41
41
  NoLine,
42
+ RoundedLine,
42
43
  ThickDoubleDashedLine,
43
44
  ThickLine,
44
45
  ThickQuadrupleDashedLine,
@@ -558,7 +559,7 @@ def css_dimension(
558
559
  digits = ""
559
560
  i = 0
560
561
  try:
561
- while (c := value[i]) in "0123456789.":
562
+ while (c := value[i]) in "-0123456789.":
562
563
  digits += c
563
564
  i += 1
564
565
  except IndexError:
@@ -924,19 +925,15 @@ class Theme(Mapping):
924
925
  available_height: int,
925
926
  ) -> None:
926
927
  """Set the space available to the element for rendering."""
927
- if self.theme["position"] == "fixed":
928
+ if self.theme["position"] in {"fixed"}:
928
929
  # Space is given by position
929
- position = self.position
930
930
  dom = self.element.dom
931
931
  assert dom.width is not None
932
932
  assert dom.height is not None
933
+ position = self.position
933
934
  self.available_width = (dom.width - position.right) - position.left
934
935
  self.available_height = (dom.height - position.bottom) - position.top
935
936
 
936
- # elif parent_theme := self.parent_theme:
937
- # self.available_width = parent_theme.content_width
938
- # self.available_height = parent_theme.content_height
939
-
940
937
  else:
941
938
  self.available_width = available_width
942
939
  self.available_height = available_height
@@ -1527,6 +1524,7 @@ class Theme(Mapping):
1527
1524
  # Replace the margin on the parent
1528
1525
  if (
1529
1526
  (first_child := element.first_child_element)
1527
+ and first_child.theme.in_flow
1530
1528
  and first_child.prev_node_in_flow is None
1531
1529
  and not self.border_visibility.top
1532
1530
  and not self.padding.top
@@ -1541,6 +1539,7 @@ class Theme(Mapping):
1541
1539
  values["top"] = max(child_theme.base_margin.top, values["top"])
1542
1540
  if (
1543
1541
  (last_child := element.last_child_element)
1542
+ and last_child.theme.in_flow
1544
1543
  and last_child.next_node_in_flow is None
1545
1544
  and not self.padding.bottom
1546
1545
  and not self.border_visibility.bottom
@@ -1671,6 +1670,10 @@ class Theme(Mapping):
1671
1670
  NoLine,
1672
1671
  )
1673
1672
 
1673
+ # TODO - parse border_radius properly and check for corner radii
1674
+ if output[direction] == ThinLine and self.theme.get("border_radius"):
1675
+ output[direction] = RoundedLine
1676
+
1674
1677
  return DiLineStyle(**output)
1675
1678
 
1676
1679
  @cached_property
@@ -1857,7 +1860,11 @@ class Theme(Mapping):
1857
1860
  """The position of an element with a relative, absolute or fixed position."""
1858
1861
  # TODO - calculate position based on top, left, bottom,right, width, height
1859
1862
  soup_theme = self.element.dom.soup.theme
1860
- return DiInt(
1863
+ position = DiInt(0, 0, 0, 0)
1864
+ # if self.parent_theme is not None:
1865
+ # position += self.parent_theme.position
1866
+ position += self.base_margin
1867
+ position += DiInt(
1861
1868
  top=round(
1862
1869
  css_dimension(
1863
1870
  self.theme["top"],
@@ -1891,6 +1898,7 @@ class Theme(Mapping):
1891
1898
  or 0
1892
1899
  ),
1893
1900
  )
1901
+ return position
1894
1902
 
1895
1903
  @cached_property
1896
1904
  def anchors(self) -> DiBool:
@@ -1912,10 +1920,7 @@ class Theme(Mapping):
1912
1920
  and not self.preformatted
1913
1921
  and not element.text
1914
1922
  )
1915
- or (
1916
- self.theme["position"] == "absolute"
1917
- and try_eval(self.theme["opacity"]) == 0
1918
- )
1923
+ or (self.theme["position"] == "absolute" and self.hidden)
1919
1924
  )
1920
1925
 
1921
1926
  @cached_property
@@ -3746,19 +3751,20 @@ class HTML:
3746
3751
  ) -> StyleAndTextTuples:
3747
3752
  """Render a Node."""
3748
3753
  # Update the element theme with the available space
3749
- element.theme.update_space(available_width, available_height)
3754
+ theme = element.theme
3755
+ theme.update_space(available_width, available_height)
3750
3756
 
3751
3757
  # Render the contents
3752
- if element.theme.d_table:
3758
+ if theme.d_table:
3753
3759
  render_func = self.render_table_content
3754
3760
 
3755
- elif element.theme.d_list_item:
3761
+ elif theme.d_list_item:
3756
3762
  render_func = self.render_list_item_content
3757
3763
 
3758
- elif element.theme.d_grid:
3764
+ elif theme.d_grid:
3759
3765
  render_func = self.render_grid_content
3760
3766
 
3761
- elif element.theme.latex:
3767
+ elif theme.latex:
3762
3768
  render_func = self.render_latex_content
3763
3769
 
3764
3770
  else:
@@ -4471,8 +4477,6 @@ class HTML:
4471
4477
  float_lines_right: list[StyleAndTextTuples] = []
4472
4478
  float_width_right = 0
4473
4479
 
4474
- content_width = parent_theme.content_width
4475
-
4476
4480
  new_line: StyleAndTextTuples = []
4477
4481
 
4478
4482
  def flush() -> None:
@@ -4607,7 +4611,7 @@ class HTML:
4607
4611
  # from each active float
4608
4612
  if (
4609
4613
  new_line
4610
- and (content_width - float_width_left - float_width_right)
4614
+ and (available_width - float_width_left - float_width_right)
4611
4615
  - left
4612
4616
  - token_width
4613
4617
  < 0
@@ -4633,7 +4637,7 @@ class HTML:
4633
4637
  fillvalue=empty,
4634
4638
  ):
4635
4639
  line_width = (
4636
- content_width
4640
+ available_width
4637
4641
  - fragment_list_width(ft_left)
4638
4642
  - fragment_list_width(ft_right)
4639
4643
  )
@@ -4724,7 +4728,7 @@ class HTML:
4724
4728
  fragment_list_width(float_lines_left[0]) if float_lines_left else 0
4725
4729
  )
4726
4730
  line_width = (
4727
- content_width
4731
+ available_width
4728
4732
  - fragment_list_width(ft_left)
4729
4733
  - fragment_list_width(ft_right)
4730
4734
  )
@@ -4844,7 +4848,7 @@ class HTML:
4844
4848
  placeholder="",
4845
4849
  )
4846
4850
 
4847
- # # Fill space around block elements so they fill the content width
4851
+ # Fill space around block elements so they fill the content width
4848
4852
  if ft and ((fill and d_blocky and not theme.d_table) or d_inline_block):
4849
4853
  pad_width = None
4850
4854
  if d_blocky:
@@ -4929,7 +4933,6 @@ class HTML:
4929
4933
  parent_style = parent_theme.style if parent_theme else ""
4930
4934
 
4931
4935
  # Render the margin
4932
- # if d_blocky and (alignment := theme.block_align) != FormattedTextAlign.LEFT:
4933
4936
  if (alignment := theme.block_align) != FormattedTextAlign.LEFT:
4934
4937
  # Center block contents if margin_left and margin_right are "auto"
4935
4938
  ft = align(
@@ -4949,22 +4952,26 @@ class HTML:
4949
4952
  padding_style=parent_style,
4950
4953
  )
4951
4954
 
4952
- # Apply mouse handler to links
4953
- if (
4954
- (parent := element.parent)
4955
- and parent.name == "a"
4956
- and callable(handler := self.mouse_handler)
4957
- and (href := parent.attrs.get("href"))
4958
- ):
4959
- element.attrs["_link_path"] = self.base.joinuri(href)
4960
- element.attrs["title"] = parent.attrs.get("title")
4961
- ft = cast(
4962
- "StyleAndTextTuples",
4963
- [
4964
- (style, text, *(rest or [partial(handler, element)]))
4965
- for style, text, *rest in ft
4966
- ],
4967
- )
4955
+ # Apply mouse handler to elements with href, title, alt
4956
+ if callable(handler := self.mouse_handler):
4957
+ attrs = element.attrs
4958
+ # Inline elements inherit from parents
4959
+ if d_inline and (parent := element.parent):
4960
+ p_attrs = parent.attrs
4961
+ attrs.setdefault("href", p_attrs.get("href"))
4962
+ attrs.setdefault("title", p_attrs.get("title"))
4963
+ attrs.setdefault("alt", p_attrs.get("alt"))
4964
+ # Resolve link paths
4965
+ if href := attrs.get("href"):
4966
+ attrs["_link_path"] = self.base.joinuri(href)
4967
+ if {"title", "alt", "href"} & set(attrs):
4968
+ ft = cast(
4969
+ "StyleAndTextTuples",
4970
+ [
4971
+ (style, text, *(rest or [partial(handler, element)]))
4972
+ for style, text, *rest in ft
4973
+ ],
4974
+ )
4968
4975
 
4969
4976
  return ft
4970
4977