euporie 2.3.2__py3-none-any.whl → 2.4.1__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 (92) hide show
  1. euporie/console/__main__.py +3 -1
  2. euporie/console/app.py +6 -4
  3. euporie/console/tabs/console.py +34 -9
  4. euporie/core/__init__.py +6 -1
  5. euporie/core/__main__.py +1 -1
  6. euporie/core/app.py +79 -109
  7. euporie/core/border.py +44 -14
  8. euporie/core/comm/base.py +5 -4
  9. euporie/core/comm/ipywidgets.py +11 -11
  10. euporie/core/comm/registry.py +12 -6
  11. euporie/core/commands.py +30 -23
  12. euporie/core/completion.py +1 -4
  13. euporie/core/config.py +15 -5
  14. euporie/core/convert/{base.py → core.py} +117 -53
  15. euporie/core/convert/formats/ansi.py +46 -25
  16. euporie/core/convert/formats/base64.py +3 -3
  17. euporie/core/convert/formats/common.py +38 -13
  18. euporie/core/convert/formats/formatted_text.py +54 -12
  19. euporie/core/convert/formats/html.py +5 -5
  20. euporie/core/convert/formats/jpeg.py +1 -1
  21. euporie/core/convert/formats/markdown.py +4 -4
  22. euporie/core/convert/formats/pdf.py +1 -1
  23. euporie/core/convert/formats/pil.py +5 -3
  24. euporie/core/convert/formats/png.py +7 -6
  25. euporie/core/convert/formats/rich.py +4 -3
  26. euporie/core/convert/formats/sixel.py +5 -5
  27. euporie/core/convert/utils.py +1 -1
  28. euporie/core/current.py +11 -5
  29. euporie/core/formatted_text/ansi.py +4 -8
  30. euporie/core/formatted_text/html.py +1630 -856
  31. euporie/core/formatted_text/markdown.py +177 -166
  32. euporie/core/formatted_text/table.py +20 -14
  33. euporie/core/formatted_text/utils.py +21 -10
  34. euporie/core/io.py +14 -14
  35. euporie/core/kernel.py +48 -37
  36. euporie/core/key_binding/bindings/micro.py +5 -1
  37. euporie/core/key_binding/bindings/mouse.py +2 -2
  38. euporie/core/keys.py +3 -0
  39. euporie/core/launch.py +5 -2
  40. euporie/core/lexers.py +13 -2
  41. euporie/core/log.py +135 -139
  42. euporie/core/margins.py +32 -14
  43. euporie/core/path.py +273 -0
  44. euporie/core/processors.py +35 -0
  45. euporie/core/renderer.py +21 -5
  46. euporie/core/style.py +34 -19
  47. euporie/core/tabs/base.py +101 -17
  48. euporie/core/tabs/notebook.py +72 -30
  49. euporie/core/terminal.py +56 -48
  50. euporie/core/utils.py +12 -16
  51. euporie/core/widgets/cell.py +6 -5
  52. euporie/core/widgets/cell_outputs.py +2 -2
  53. euporie/core/widgets/decor.py +74 -82
  54. euporie/core/widgets/dialog.py +132 -28
  55. euporie/core/widgets/display.py +76 -24
  56. euporie/core/widgets/file_browser.py +87 -31
  57. euporie/core/widgets/formatted_text_area.py +1 -3
  58. euporie/core/widgets/forms.py +79 -40
  59. euporie/core/widgets/inputs.py +23 -13
  60. euporie/core/widgets/layout.py +4 -3
  61. euporie/core/widgets/menu.py +368 -216
  62. euporie/core/widgets/page.py +99 -58
  63. euporie/core/widgets/pager.py +1 -1
  64. euporie/core/widgets/palette.py +30 -27
  65. euporie/core/widgets/search_bar.py +38 -25
  66. euporie/core/widgets/status_bar.py +103 -5
  67. euporie/data/desktop/euporie-console.desktop +7 -0
  68. euporie/data/desktop/euporie-notebook.desktop +7 -0
  69. euporie/hub/__main__.py +3 -1
  70. euporie/hub/app.py +9 -7
  71. euporie/notebook/__main__.py +3 -1
  72. euporie/notebook/app.py +7 -30
  73. euporie/notebook/tabs/__init__.py +7 -3
  74. euporie/notebook/tabs/display.py +18 -9
  75. euporie/notebook/tabs/edit.py +106 -23
  76. euporie/notebook/tabs/json.py +73 -0
  77. euporie/notebook/tabs/log.py +18 -8
  78. euporie/notebook/tabs/notebook.py +60 -41
  79. euporie/preview/__main__.py +3 -1
  80. euporie/preview/app.py +2 -1
  81. euporie/preview/tabs/notebook.py +23 -10
  82. euporie/web/tabs/web.py +149 -0
  83. euporie/web/widgets/webview.py +563 -0
  84. euporie-2.4.1.data/data/share/applications/euporie-console.desktop +7 -0
  85. euporie-2.4.1.data/data/share/applications/euporie-notebook.desktop +7 -0
  86. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/METADATA +6 -5
  87. euporie-2.4.1.dist-info/RECORD +129 -0
  88. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/WHEEL +1 -1
  89. euporie/core/url.py +0 -64
  90. euporie-2.3.2.dist-info/RECORD +0 -122
  91. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/entry_points.txt +0 -0
  92. {euporie-2.3.2.dist-info → euporie-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,14 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
- import contextvars
7
5
  import logging
8
6
  import weakref
9
7
  from typing import TYPE_CHECKING, cast
10
8
 
11
9
  from prompt_toolkit.application.current import get_app
12
10
  from prompt_toolkit.data_structures import Point
11
+ from prompt_toolkit.eventloop.utils import run_in_executor_with_context
13
12
  from prompt_toolkit.filters import is_searching
14
13
  from prompt_toolkit.layout.containers import (
15
14
  Container,
@@ -20,6 +19,7 @@ from prompt_toolkit.layout.containers import (
20
19
  )
21
20
  from prompt_toolkit.layout.controls import UIContent
22
21
  from prompt_toolkit.layout.dimension import Dimension, to_dimension
22
+ from prompt_toolkit.layout.layout import walk
23
23
  from prompt_toolkit.layout.mouse_handlers import MouseHandlers
24
24
  from prompt_toolkit.layout.screen import Char, Screen, WritePosition
25
25
  from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseModifier
@@ -27,7 +27,7 @@ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseModifie
27
27
  from euporie.core.data_structures import DiInt
28
28
 
29
29
  if TYPE_CHECKING:
30
- from typing import Callable, Sequence
30
+ from typing import Callable, Iterable, Sequence
31
31
 
32
32
  from prompt_toolkit.key_binding.key_bindings import (
33
33
  KeyBindingsBase,
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
35
35
  )
36
36
  from prompt_toolkit.layout.containers import AnyContainer
37
37
  from prompt_toolkit.layout.dimension import AnyDimension
38
+ from prompt_toolkit.utils import Event
38
39
 
39
40
  MouseHandler = Callable[[MouseEvent], object]
40
41
 
@@ -84,10 +85,24 @@ class ChildRenderInfo:
84
85
  self.screen = Screen(default_char=Char(char=" "))
85
86
  self.mouse_handlers = MouseHandlers()
86
87
 
88
+ self.render_counter = -1
89
+ self._invalid = True
90
+ self._invalidate_events: list[Event[object]] = []
91
+ self._layout_hash = 0
87
92
  self.height = 0
88
93
  self.width = 0
89
94
 
90
- self.refresh = True
95
+ def invalidate(self) -> None:
96
+ """Flag the child's rendering as out-of-date."""
97
+ self._invalid = True
98
+
99
+ def _invalidate_handler(self, sender: object) -> None:
100
+ self.invalidate()
101
+
102
+ @property
103
+ def layout_hash(self) -> int:
104
+ """Return a hash of the child's current layout."""
105
+ return sum(hash(container) for container in walk(self.container))
91
106
 
92
107
  def render(
93
108
  self,
@@ -103,9 +118,24 @@ class ChildRenderInfo:
103
118
  style: The parent style to apply when rendering
104
119
 
105
120
  """
106
- if self.refresh:
107
- self.refresh = False
121
+ # Check if refresh is needed
122
+ refresh = False
123
+ new_layout_hash = self.layout_hash
124
+ if self.render_counter != (new_render_counter := get_app().render_counter):
125
+ if (
126
+ self._invalid
127
+ or self.width != available_width
128
+ or self._layout_hash != (new_layout_hash := self.layout_hash)
129
+ ):
130
+ self.render_counter = new_render_counter
131
+ refresh = True
132
+
133
+ # Refresh if needed
134
+ if refresh:
135
+ self._invalid = False
136
+ self._layout_hash = new_layout_hash
108
137
  # log.debug("Re-rendering cell %s", self.child.index)
138
+ self.width = available_width
109
139
  self.height = self.container.preferred_height(
110
140
  available_width, available_height
111
141
  ).preferred
@@ -128,6 +158,20 @@ class ChildRenderInfo:
128
158
  )
129
159
  self.screen.draw_all_floats()
130
160
 
161
+ # Collect invalidation events
162
+ def gather_events() -> Iterable[Event[object]]:
163
+ for container in walk(self.container):
164
+ if isinstance(container, Window):
165
+ for event in container.content.get_invalidate_events():
166
+ event += self._invalidate_handler
167
+ yield event
168
+
169
+ # Remove all the original event handlers
170
+ for event in self._invalidate_events:
171
+ event -= self._invalidate_handler
172
+ # Update the list of handlers
173
+ self._invalidate_events = list(gather_events())
174
+
131
175
  def blit(
132
176
  self,
133
177
  screen: Screen,
@@ -238,7 +282,7 @@ class ChildRenderInfo:
238
282
 
239
283
  # Refresh the child if there was a response
240
284
  if response is None:
241
- self.refresh = True
285
+ self.invalidate()
242
286
  return response
243
287
 
244
288
  # This would work if windows returned NotImplemented when scrolled
@@ -260,7 +304,7 @@ class ChildRenderInfo:
260
304
  else:
261
305
  self.parent.select(index, extend=False)
262
306
  response = None
263
- self.refresh = True
307
+ self.invalidate()
264
308
 
265
309
  # Attempt to focus the container
266
310
  layout.focus(self.child)
@@ -316,6 +360,8 @@ class ChildRenderInfo:
316
360
  class ScrollingContainer(Container):
317
361
  """A scrollable container which renders only the currently visible children."""
318
362
 
363
+ render_info: WindowRenderInfo | None
364
+
319
365
  def __init__(
320
366
  self,
321
367
  children: Callable[[], Sequence[AnyContainer]] | Sequence[AnyContainer],
@@ -361,41 +407,27 @@ class ScrollingContainer(Container):
361
407
 
362
408
  def pre_render_children(self, width: int, height: int) -> None:
363
409
  """Render all unrendered children in a background thread."""
364
- # Copy the current context so ``get_app()`` works in the thread
365
- ctx = contextvars.copy_context()
410
+ # Prevent multiple calls
411
+ self.pre_rendered = 0.00001
366
412
 
367
413
  def render_in_thread() -> None:
368
- """Create a new event loop in the thread."""
369
- loop = asyncio.new_event_loop()
370
- asyncio.set_event_loop(loop)
371
-
372
- async def render_run_in_loop() -> None:
373
- """Render all children sequentially."""
374
- n_children = len(self.children)
375
- for i in range(n_children):
376
- if i < len(self.children):
377
- self.get_child_render_info(i).render(width, height)
378
- self.pre_rendered = i / n_children
379
- get_app().invalidate()
380
- self.pre_rendered = 1.0
414
+ """Render children in thread."""
415
+ n_children = len(self.children)
416
+ for i in range(n_children):
417
+ if i < len(self.children):
418
+ self.get_child_render_info(i).render(width, height)
419
+ self.pre_rendered = i / n_children
381
420
  get_app().invalidate()
421
+ self.pre_rendered = 1.0
422
+ get_app().invalidate()
382
423
 
383
- loop.run_until_complete(render_run_in_loop())
384
-
385
- async def trigger_render() -> None:
386
- """Use an executor thread from the current event loop."""
387
- await asyncio.get_event_loop().run_in_executor(
388
- None, ctx.run, render_in_thread
389
- )
390
-
391
- get_app().create_background_task(trigger_render())
424
+ run_in_executor_with_context(render_in_thread)
392
425
 
393
426
  def reset(self) -> None:
394
427
  """Reet the state of this container and all the children."""
395
- meta_data = list(self.child_render_infos.values())
396
- for meta in meta_data:
428
+ for meta in self.child_render_infos.values():
397
429
  meta.container.reset()
398
- meta.refresh = True
430
+ meta.invalidate()
399
431
 
400
432
  def preferred_width(self, max_available_width: int) -> Dimension:
401
433
  """Do not provide a preferred width - grow to fill the available space."""
@@ -446,15 +478,15 @@ class ScrollingContainer(Container):
446
478
  render_info: ChildRenderInfo | None
447
479
  for render_info in self._selected_child_render_infos:
448
480
  if render_info:
449
- render_info.refresh = True
481
+ render_info.invalidate()
450
482
  # If a child currently has focus, request to refresh it
451
- for child in self.children:
452
- if (
453
- render_info := self.child_render_infos.get(hash(child))
454
- ) is not None:
455
- if app.layout.has_focus(render_info.child):
456
- render_info.refresh = True
457
- break
483
+ # for child in self.children:
484
+ # if (
485
+ # render_info := self.child_render_infos.get(hash(child))
486
+ # ) is not None:
487
+ # if app.layout.has_focus(render_info.child):
488
+ # render_info.invalidate()
489
+ # break
458
490
  # Get the first selected child and focus it
459
491
  child = self.children[new_slice.start]
460
492
  if not app.layout.has_focus(child):
@@ -565,7 +597,11 @@ class ScrollingContainer(Container):
565
597
  """
566
598
  # self.refresh_children = True
567
599
  if n > 0:
568
- if min(self.visible_indicies) == 0 and self.index_positions[0] is not None:
600
+ if (
601
+ min(self.visible_indicies) == 0
602
+ and self.index_positions
603
+ and self.index_positions[0] is not None
604
+ ):
569
605
  n = min(n, 0 - self.index_positions[0] - self.scrolling)
570
606
  if self.index_positions[0] + self.scrolling + n > 0:
571
607
  return NotImplemented
@@ -636,10 +672,6 @@ class ScrollingContainer(Container):
636
672
  available_width = write_position.width
637
673
  available_height = write_position.height
638
674
 
639
- # Trigger pre-rendering of children
640
- if not self.pre_rendered:
641
- self.pre_render_children(available_width, available_height)
642
-
643
675
  # Update screen height
644
676
  screen.height = max(screen.height, ypos + write_position.height)
645
677
 
@@ -651,23 +683,19 @@ class ScrollingContainer(Container):
651
683
  self._selected_child_render_infos = []
652
684
  for index in selected_indices:
653
685
  render_info = self.get_child_render_info(index)
654
- # Do not bother to re-render children if we are scrolling
686
+ # Do not bother to re-render selected children if we are scrolling
655
687
  if not self.scrolling:
656
- render_info.refresh = True
688
+ render_info.invalidate()
657
689
  self._selected_child_render_infos.append(render_info)
658
690
  self.index_positions[index] = None
659
691
 
660
- # Refresh all children if the width has changed
661
- if self.last_write_position.width != write_position.width:
662
- for child_render_info in self.child_render_infos.values():
663
- child_render_info.refresh = True
664
-
665
692
  # Refresh visible children if searching
666
693
  if is_searching():
667
694
  for index in self.visible_indicies:
668
- self.get_child_render_info(index).refresh = True
695
+ self.get_child_render_info(index).invalidate()
669
696
 
670
697
  # Scroll to make the cursor visible
698
+ layout = get_app().layout
671
699
  if self.scroll_to_cursor:
672
700
  selected_child_render_info = self._selected_child_render_infos[0]
673
701
  selected_child_render_info.render(
@@ -675,7 +703,7 @@ class ScrollingContainer(Container):
675
703
  available_height=available_height,
676
704
  style=f"{parent_style} {self.style}",
677
705
  )
678
- current_window = get_app().layout.current_window
706
+ current_window = layout.current_window
679
707
  cursor_position = selected_child_render_info.screen.cursor_positions.get(
680
708
  current_window
681
709
  )
@@ -805,13 +833,22 @@ class ScrollingContainer(Container):
805
833
 
806
834
  # Update which children will appear in the layout
807
835
  self.visible_indicies = visible_indicies
836
+
837
+ # Update parent relations in layout
838
+ def _walk(e: Container) -> None:
839
+ for c in e.get_children():
840
+ layout._child_to_parent[c] = e
841
+ _walk(c)
842
+
843
+ _walk(self)
844
+
808
845
  # Record where the contain was last drawn so we can determine if cell outputs
809
846
  # are partially obscured
810
847
  self.last_write_position = write_position
811
848
 
812
849
  # Calculate scrollbar info
813
850
  sizes = self.known_sizes
814
- avg_size = sum(sizes.values()) / len(sizes)
851
+ avg_size = sum(sizes.values()) / len(sizes) if sizes else 0
815
852
  n_children = len(self.children)
816
853
  for i in range(n_children):
817
854
  if i not in sizes:
@@ -836,6 +873,10 @@ class ScrollingContainer(Container):
836
873
  # Signal that we are no longer scrolling
837
874
  self.scrolling = 0
838
875
 
876
+ # Trigger pre-rendering of children
877
+ if not self.pre_rendered:
878
+ self.pre_render_children(available_width, available_height)
879
+
839
880
  @property
840
881
  def vertical_scroll(self) -> int:
841
882
  """The best guess at the absolute vertical scroll position."""
@@ -15,7 +15,7 @@ from prompt_toolkit.layout.containers import (
15
15
  from prompt_toolkit.widgets import Box
16
16
 
17
17
  from euporie.core.commands import add_cmd
18
- from euporie.core.convert.base import BASE64_FORMATS, MIME_FORMATS, find_route
18
+ from euporie.core.convert.core import BASE64_FORMATS, MIME_FORMATS, find_route
19
19
  from euporie.core.current import get_app
20
20
  from euporie.core.filters import pager_has_focus
21
21
  from euporie.core.key_binding.registry import (
@@ -17,10 +17,11 @@ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
17
17
  from euporie.core.commands import Command, add_cmd, commands
18
18
  from euporie.core.current import get_app
19
19
  from euporie.core.key_binding.registry import register_bindings
20
- from euporie.core.margins import ScrollbarMargin
20
+ from euporie.core.margins import MarginContainer, ScrollbarMargin
21
21
  from euporie.core.widgets.decor import FocusedStyle
22
22
  from euporie.core.widgets.dialog import Dialog
23
23
  from euporie.core.widgets.forms import Text
24
+ from euporie.core.widgets.status_bar import StatusContainer
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  from prompt_toolkit.buffer import Buffer
@@ -29,7 +30,8 @@ if TYPE_CHECKING:
29
30
  from prompt_toolkit.key_binding.key_processor import KeyPressEvent
30
31
  from prompt_toolkit.layout.controls import GetLinePrefixCallable
31
32
 
32
- from euporie.core.app import BaseApp, StatusBarFields
33
+ from euporie.core.app import BaseApp
34
+ from euporie.core.widgets.status_bar import StatusBarFields
33
35
 
34
36
  log = logging.getLogger(__name__)
35
37
 
@@ -192,39 +194,34 @@ class CommandPalette(Dialog):
192
194
  placeholder=" Type to search…",
193
195
  )
194
196
  self.text_area.buffer.on_text_changed += self.text_changed
195
- scroll_bar_margin = ScrollbarMargin(display_arrows=False)
196
-
197
- self.body = HSplit(
198
- [
199
- VSplit(
200
- [FocusedStyle(self.text_area)],
201
- padding=1,
202
- ),
203
- Window(
204
- CommandMenuControl(self),
205
- scroll_offsets=ScrollOffsets(bottom=1),
206
- right_margins=[scroll_bar_margin],
207
- ),
208
- ],
197
+
198
+ self.body = StatusContainer(
199
+ body=HSplit(
200
+ [
201
+ VSplit(
202
+ [FocusedStyle(self.text_area)],
203
+ padding=1,
204
+ ),
205
+ VSplit(
206
+ [
207
+ window := Window(
208
+ CommandMenuControl(self),
209
+ scroll_offsets=ScrollOffsets(bottom=1),
210
+ ),
211
+ MarginContainer(ScrollbarMargin(), target=window),
212
+ ]
213
+ ),
214
+ ],
215
+ ),
216
+ status=self.__pt_status__,
209
217
  )
210
218
  self.buttons = {}
211
219
 
212
- get_app().container_statuses[self.container] = self.statusbar_fields
213
-
214
220
  def load(self) -> None:
215
221
  """Reset the dialog ready for display."""
216
222
  self.text_area.buffer.text = ""
217
223
  self.to_focus = self.text_area
218
224
 
219
- def statusbar_fields(
220
- self,
221
- ) -> StatusBarFields:
222
- """Return a list of statusbar field values shown then this tab is active."""
223
- if self.matches:
224
- return ([self.matches[self.index].command.description], [])
225
- else:
226
- return ([], [])
227
-
228
225
  def select(self, n: int, event: KeyPressEvent | None = None) -> None:
229
226
  """Change the index of the selected command.
230
227
 
@@ -267,6 +264,12 @@ class CommandPalette(Dialog):
267
264
  else:
268
265
  return False
269
266
 
267
+ def __pt_status__(self) -> StatusBarFields | None:
268
+ """Return a list of statusbar field values shown then this tab is active."""
269
+ if self.matches:
270
+ return ([self.matches[self.index].command.description], [])
271
+ return None
272
+
270
273
  # ################################### Commands ####################################
271
274
 
272
275
  @staticmethod
@@ -9,6 +9,7 @@ from prompt_toolkit.filters.app import is_searching
9
9
  from prompt_toolkit.key_binding.vi_state import InputMode
10
10
  from prompt_toolkit.layout.controls import BufferControl, SearchBufferControl
11
11
  from prompt_toolkit.search import SearchDirection
12
+ from prompt_toolkit.selection import SelectionState
12
13
  from prompt_toolkit.widgets import SearchToolbar as PtkSearchToolbar
13
14
 
14
15
  from euporie.core.commands import add_cmd
@@ -57,6 +58,20 @@ class SearchBar(PtkSearchToolbar):
57
58
  "euporie.core.widgets.search_bar.SearchBar"
58
59
  )
59
60
 
61
+ register_bindings(
62
+ {
63
+ "euporie.core.app.BaseApp": {
64
+ "find": ["c-f", "f3", "f7"],
65
+ "find-next": "c-g",
66
+ "find-previous": "c-p",
67
+ },
68
+ "euporie.core.widgets.search_bar.SearchBar": {
69
+ "accept-search": "enter",
70
+ "stop-search": "escape",
71
+ },
72
+ }
73
+ )
74
+
60
75
 
61
76
  def start_global_search(
62
77
  buffer_control: BufferControl | None = None,
@@ -94,6 +109,7 @@ def start_global_search(
94
109
  control.search_state.direction = direction
95
110
  # Add it to our list
96
111
  searchable_controls.append(control)
112
+
97
113
  # Stop the search if we did not find any searchable controls
98
114
  if not searchable_controls:
99
115
  return
@@ -113,10 +129,7 @@ def start_global_search(
113
129
  app.vi_state.input_mode = InputMode.INSERT
114
130
 
115
131
 
116
- @add_cmd(
117
- menu_title="Find",
118
- # filter=control_is_searchable,
119
- )
132
+ @add_cmd(menu_title="Find")
120
133
  def find() -> None:
121
134
  """Enter search mode."""
122
135
  start_global_search(direction=SearchDirection.FORWARD)
@@ -142,10 +155,13 @@ def find_prev_next(direction: SearchDirection) -> None:
142
155
  search_state = control.search_state
143
156
  search_state.direction = direction
144
157
  # Apply search to buffer
145
- control.buffer.apply_search(
146
- search_state, include_current_position=False, count=1
158
+ buffer = control.buffer
159
+ buffer.apply_search(search_state, include_current_position=False, count=1)
160
+ # Set selection
161
+ buffer.selection_state = SelectionState(
162
+ buffer.cursor_position + len(search_state.text)
147
163
  )
148
- # break
164
+ buffer.selection_state.enter_shift_mode()
149
165
 
150
166
 
151
167
  @add_cmd()
@@ -170,8 +186,8 @@ def stop_search() -> None:
170
186
  if buffer_control is None:
171
187
  return
172
188
  search_buffer_control = buffer_control.search_buffer_control
173
- # Focus the original buffer again.
174
- layout.focus(buffer_control)
189
+ # Focus the previous control
190
+ layout.focus(layout.previous_control)
175
191
  # Close the search toolbar
176
192
  if search_buffer_control is not None:
177
193
  del layout.search_links[search_buffer_control]
@@ -202,23 +218,20 @@ def accept_search() -> None:
202
218
  if search_buffer_control.buffer.text:
203
219
  search_state.text = search_buffer_control.buffer.text
204
220
  # Apply search.
205
- control.buffer.apply_search(search_state, include_current_position=True)
221
+ control.buffer.apply_search(
222
+ search_state, include_current_position=True, count=1
223
+ )
224
+
225
+ # Set selection on target control
226
+ buffer_control = layout.search_target_buffer_control
227
+ if buffer_control and control.is_focusable():
228
+ buffer = buffer_control.buffer
229
+ buffer.selection_state = SelectionState(
230
+ buffer.cursor_position + len(search_state.text)
231
+ )
232
+ buffer.selection_state.enter_shift_mode()
233
+
206
234
  # Add query to history of search line.
207
235
  search_buffer_control.buffer.append_to_history()
208
236
  # Stop the search
209
237
  stop_search()
210
-
211
-
212
- register_bindings(
213
- {
214
- "euporie.core.app.BaseApp": {
215
- "find": ["c-f", "f3", "f7"],
216
- "find-next": "c-g",
217
- "find-previous": "c-p",
218
- },
219
- "euporie.core.widgets.search_bar.SearchBar": {
220
- "accept-search": "enter",
221
- "stop-search": "escape",
222
- },
223
- }
224
- )
@@ -2,15 +2,23 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  from typing import TYPE_CHECKING
7
+ from weakref import WeakKeyDictionary
6
8
 
9
+ from prompt_toolkit.cache import FastDictCache
7
10
  from prompt_toolkit.filters.utils import to_filter
11
+ from prompt_toolkit.formatted_text import to_formatted_text
12
+ from prompt_toolkit.layout import containers
8
13
  from prompt_toolkit.layout.containers import (
9
14
  ConditionalContainer,
10
15
  VSplit,
11
16
  Window,
12
17
  WindowAlign,
13
18
  )
19
+ from prompt_toolkit.layout.containers import (
20
+ to_container as ptk_to_container,
21
+ )
14
22
  from prompt_toolkit.layout.controls import FormattedTextControl
15
23
 
16
24
  from euporie.core.config import add_setting
@@ -18,28 +26,76 @@ from euporie.core.current import get_app
18
26
  from euporie.core.filters import is_searching
19
27
 
20
28
  if TYPE_CHECKING:
29
+ from typing import Callable, Sequence
30
+
21
31
  from prompt_toolkit.filters.base import FilterOrBool
22
- from prompt_toolkit.layout.containers import AnyContainer
32
+ from prompt_toolkit.formatted_text import StyleAndTextTuples
33
+ from prompt_toolkit.formatted_text.base import AnyFormattedText
34
+ from prompt_toolkit.layout.containers import AnyContainer, Container
35
+
36
+ StatusBarFields = tuple[Sequence[AnyFormattedText], Sequence[AnyFormattedText]]
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+
41
+ _CONTAINER_STATUSES: WeakKeyDictionary[
42
+ Container, Callable[[], StatusBarFields | None]
43
+ ] = WeakKeyDictionary()
44
+
45
+
46
+ def to_container(container: AnyContainer) -> Container:
47
+ """Monkey-patch `to_container` to collect container status functions."""
48
+ result = ptk_to_container(container)
49
+ if hasattr(container, "__pt_status__"):
50
+ _CONTAINER_STATUSES[result] = container.__pt_status__
51
+ return result
52
+
53
+
54
+ containers.to_container = to_container
55
+
56
+
57
+ class StatusContainer:
58
+ """A container which allows attaching a status function."""
59
+
60
+ def __init__(
61
+ self, body: AnyContainer, status: Callable[[], StatusBarFields | None]
62
+ ) -> None:
63
+ """Initiate a new instance with a body and status function."""
64
+ self.body = body
65
+ self.status = status
66
+
67
+ def __pt_status__(self) -> StatusBarFields | None:
68
+ """Return the status fields."""
69
+ return self.status()
70
+
71
+ def __pt_container__(self) -> AnyContainer:
72
+ """Return the body container."""
73
+ return self.body
23
74
 
24
75
 
25
76
  class StatusBar:
26
77
  """A status bar which shows the status of the current tab."""
27
78
 
28
- def __init__(self, extra_filter: FilterOrBool = True) -> None:
79
+ def __init__(
80
+ self, extra_filter: FilterOrBool = True, default: StatusBarFields | None = None
81
+ ) -> None:
29
82
  """Create a new status bar instance."""
30
- self.app = get_app()
83
+ self.default: StatusBarFields = default or ([], [])
84
+ self._status_cache: FastDictCache[
85
+ tuple[int], list[StyleAndTextTuples]
86
+ ] = FastDictCache(self._status, size=1)
31
87
  self.container = ConditionalContainer(
32
88
  content=VSplit(
33
89
  [
34
90
  Window(
35
91
  FormattedTextControl(
36
- lambda: self.app.format_status(part="left")
92
+ lambda: self._status_cache[get_app().render_counter,][0]
37
93
  ),
38
94
  style="class:status",
39
95
  ),
40
96
  Window(
41
97
  FormattedTextControl(
42
- lambda: self.app.format_status(part="right")
98
+ lambda: self._status_cache[get_app().render_counter,][1]
43
99
  ),
44
100
  style="class:status.right",
45
101
  align=WindowAlign.RIGHT,
@@ -52,6 +108,48 @@ class StatusBar:
52
108
  & to_filter(extra_filter),
53
109
  )
54
110
 
111
+ def _status(self, render_counter: int = 0) -> list[StyleAndTextTuples]:
112
+ """Load and format the current status bar entries."""
113
+ layout = get_app().layout
114
+ current: Container = layout.current_window
115
+
116
+ entries = self.default
117
+ while True:
118
+ if callable(
119
+ func := (
120
+ _CONTAINER_STATUSES.get(current)
121
+ or getattr(current, "__pt_status__", None)
122
+ )
123
+ ):
124
+ result = func()
125
+ if result is not None:
126
+ entries = result
127
+ break
128
+ elif current in layout._child_to_parent:
129
+ current = layout._child_to_parent[current]
130
+ continue
131
+ break
132
+
133
+ output: list[StyleAndTextTuples] = []
134
+ # Show the tab's status fields
135
+ for entry in entries:
136
+ output.append([])
137
+ for field in entry:
138
+ if field:
139
+ if isinstance(field, tuple):
140
+ ft = [field]
141
+ else:
142
+ ft = to_formatted_text(field, style="class:status.field")
143
+ output[-1] += [
144
+ ("class:status.field", " "),
145
+ *ft,
146
+ ("class:status.field", " "),
147
+ ("class:status", " "),
148
+ ]
149
+ if output[-1]:
150
+ output[-1].pop()
151
+ return output
152
+
55
153
  def __pt_container__(self) -> AnyContainer:
56
154
  """Return the widget's container."""
57
155
  return self.container