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
euporie/console/app.py CHANGED
@@ -32,6 +32,7 @@ from euporie.core.filters import has_dialog
32
32
  from euporie.core.layout.mouse import DisableMouseOnScroll
33
33
  from euporie.core.widgets.dialog import (
34
34
  AboutDialog,
35
+ ConfirmDialog,
35
36
  NoKernelsDialog,
36
37
  SaveAsDialog,
37
38
  SelectKernelDialog,
@@ -112,6 +113,7 @@ class ConsoleApp(BaseApp):
112
113
  self.dialogs["no-kernels"] = NoKernelsDialog(self)
113
114
  self.dialogs["change-kernel"] = SelectKernelDialog(self)
114
115
  self.dialogs["shortcuts"] = ShortcutsDialog(self)
116
+ self.dialogs["confirm"] = ConfirmDialog(self)
115
117
 
116
118
  return FloatContainer(
117
119
  DisableMouseOnScroll(
@@ -40,7 +40,7 @@ from euporie.core.filters import (
40
40
  )
41
41
  from euporie.core.format import LspFormatter
42
42
  from euporie.core.io import edit_in_editor
43
- from euporie.core.kernel.client import MsgCallbacks
43
+ from euporie.core.kernel.base import MsgCallbacks
44
44
  from euporie.core.key_binding.registry import (
45
45
  load_registered_bindings,
46
46
  register_bindings,
@@ -137,10 +137,7 @@ class Console(KernelTab):
137
137
 
138
138
  self.container = self.load_container()
139
139
 
140
- self.kernel.start(cb=self.kernel_started, wait=False)
141
-
142
140
  self.app.before_render += self.render_outputs
143
-
144
141
  self.on_advance = Event(self)
145
142
 
146
143
  async def load_lsps(self) -> None:
@@ -162,9 +159,26 @@ class Console(KernelTab):
162
159
 
163
160
  lsp.on_exit += lsp_unload
164
161
 
162
+ def post_init_kernel(self) -> None:
163
+ """Start the kernel after if has been loaded."""
164
+ # Load container
165
+ super().post_init_kernel()
166
+
167
+ # Start kernel
168
+ if self.kernel._status == "stopped":
169
+ self.kernel.start(cb=self.kernel_started, wait=False)
170
+
165
171
  def kernel_died(self) -> None:
166
- """Call when the kernel dies."""
172
+ """Call if the kernel dies."""
167
173
  log.error("The kernel has died")
174
+ if confirm := self.app.dialogs.get("confirm"):
175
+ confirm.show(
176
+ title="Kernel connection lost",
177
+ message="The kernel appears to have died\n"
178
+ "as it can no longer be reached.\n\n"
179
+ "Do you want to restart the kernel?",
180
+ cb=self.kernel.restart,
181
+ )
168
182
 
169
183
  async def load_history(self) -> None:
170
184
  """Load kernel history."""
@@ -191,9 +205,7 @@ class Console(KernelTab):
191
205
  def validate_input(self, code: str) -> bool:
192
206
  """Determine if the entered code is ready to run."""
193
207
  assert self.kernel is not None
194
- completeness_status = self.kernel.is_complete(code, wait=True).get(
195
- "status", "unknown"
196
- )
208
+ completeness_status = self.kernel.is_complete(code).get("status", "unknown")
197
209
  return not (
198
210
  not code.strip()
199
211
  or completeness_status == "incomplete"
@@ -404,12 +416,7 @@ class Console(KernelTab):
404
416
  if ((json_cells and cell.id != json_cells[0].id) or i > 0) and (
405
417
  (height_known and rows_above_layout > 0) or not height_known
406
418
  ):
407
- children.append(
408
- Window(
409
- height=1,
410
- dont_extend_height=True,
411
- )
412
- )
419
+ children.append(Window(height=1, dont_extend_height=True))
413
420
 
414
421
  # Cell input
415
422
  children.append(
@@ -441,9 +448,12 @@ class Console(KernelTab):
441
448
  if outputs := cell.outputs:
442
449
  # Add space before an output if last rendered cell did not have outputs
443
450
  # or we are rendering a new output
444
- if self.last_rendered is not None and (
445
- not self.last_rendered.outputs
446
- or cell.execution_count != self.last_rendered.execution_count
451
+ if self.last_rendered is None or (
452
+ self.last_rendered is not None
453
+ and (
454
+ not self.last_rendered.outputs
455
+ or cell.execution_count != self.last_rendered.execution_count
456
+ )
447
457
  ):
448
458
  children.append(
449
459
  Window(
euporie/core/__init__.py CHANGED
@@ -1,10 +1,10 @@
1
1
  """This package defines the euporie application and its components."""
2
2
 
3
3
  __app_name__ = "euporie"
4
- __version__ = "2.8.5"
4
+ __version__ = "2.8.7"
5
5
  __logo__ = "⚈"
6
6
  __strapline__ = "Jupyter in the terminal"
7
7
  __author__ = "Josiah Outram Halstead"
8
8
  __email__ = "josiah@halstead.email"
9
- __copyright__ = f"© 2024, {__author__}"
9
+ __copyright__ = f"© 2025, {__author__}"
10
10
  __license__ = "MIT"
euporie/core/__main__.py CHANGED
@@ -7,13 +7,13 @@ from importlib.metadata import entry_points
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  if TYPE_CHECKING:
10
- from importlib.metadata import EntryPoint, EntryPoints, SelectableGroups
10
+ from importlib.metadata import EntryPoint, EntryPoints
11
11
 
12
12
 
13
13
  @cache
14
14
  def available_apps() -> dict[str, EntryPoint]:
15
15
  """Return a list of loadable euporie apps."""
16
- eps: dict | SelectableGroups | EntryPoints
16
+ eps: dict | EntryPoints
17
17
  try:
18
18
  eps = entry_points(group="euporie.apps")
19
19
  except TypeError:
euporie/core/_settings.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Defines core settings."""
2
2
 
3
+ import json
4
+
3
5
  from euporie.core import __version__
4
6
  from euporie.core.config import add_setting
5
7
 
@@ -73,8 +75,11 @@ add_setting(
73
75
  name="log_config",
74
76
  group="euporie.core.log",
75
77
  flags=["--log-config"],
76
- type_=str,
77
- default="{}",
78
+ type_=json.loads,
79
+ default={},
80
+ schema={
81
+ "type": "object",
82
+ },
78
83
  title="additional logging configuration",
79
84
  help_="Additional logging configuration",
80
85
  description="""
@@ -8,7 +8,7 @@ from prompt_toolkit.filters import buffer_has_focus
8
8
 
9
9
  from euporie.core.app.current import get_app
10
10
  from euporie.core.commands import add_cmd
11
- from euporie.core.filters import tab_has_focus
11
+ from euporie.core.filters import tab_has_focus, tab_type_has_focus
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from prompt_toolkit.key_binding.key_processor import KeyPressEvent
@@ -20,18 +20,12 @@ def _quit() -> None:
20
20
  get_app().exit()
21
21
 
22
22
 
23
- @add_cmd(aliases=["wq", "x"])
24
- def _save_and_quit(event: KeyPressEvent) -> None:
25
- """Save the current tab then quits euporie."""
26
- from upath import UPath
23
+ @add_cmd(aliases=["q!"])
24
+ def _force_quit() -> None:
25
+ """Quit euporie without saving any changes."""
26
+ from prompt_toolkit.application.application import Application
27
27
 
28
- app = get_app()
29
- if (tab := get_app().tab) is not None:
30
- try:
31
- tab._save(UPath(event._arg) if event._arg else None)
32
- except NotImplementedError:
33
- pass
34
- app.exit()
28
+ Application.exit(get_app())
35
29
 
36
30
 
37
31
  @add_cmd(aliases=["bc"], filter=tab_has_focus, menu_title="Close File")
@@ -68,3 +62,17 @@ def _focus_previous() -> None:
68
62
  def _clear_screen() -> None:
69
63
  """Clear the screen."""
70
64
  get_app().renderer.clear()
65
+
66
+
67
+ @add_cmd(hidden=True, aliases=[""])
68
+ def _go_to(event: KeyPressEvent, index: int = 0) -> None:
69
+ """Go to a line or cell by number."""
70
+ index = max(0, index - 1)
71
+ if buffer_has_focus():
72
+ buffer = get_app().current_buffer
73
+ buffer.cursor_position = len("".join(buffer.text.splitlines(True)[:index]))
74
+ elif tab_type_has_focus("euporie.notebook.tabs.notebook:Notebook")():
75
+ from euporie.notebook.tabs.notebook import Notebook
76
+
77
+ if isinstance(nb := get_app().tab, Notebook):
78
+ nb.select(index)
@@ -140,6 +140,8 @@ add_setting(
140
140
 
141
141
  e.g.
142
142
 
143
+ .. code-block:: json
144
+
143
145
  [
144
146
  {"command": ["ruff", "format", "-"], "languages": ["python"]},
145
147
  {"command": ["black", "-"], "languages": ["python"]},
@@ -281,6 +283,30 @@ add_setting(
281
283
  """,
282
284
  )
283
285
 
286
+ add_setting(
287
+ name="custom_styles",
288
+ group="euporie.core.style",
289
+ flags=["--custom-styles"],
290
+ type_=json.loads,
291
+ default={},
292
+ schema={
293
+ "type": "object",
294
+ },
295
+ help_="Additional style settings",
296
+ description="""
297
+ A JSON object mapping style names to prompt-toolkit style values.
298
+
299
+ The style keys used in euporie can be found in :py:func:`euporie.core.style.build_style`.
300
+
301
+ e.g.:
302
+
303
+ .. code-block:: json
304
+
305
+ { "cell input prompt":"fg:purple", "cell output prompt": "fg:green" }
306
+
307
+ """,
308
+ )
309
+
284
310
  add_setting(
285
311
  name="key_bindings",
286
312
  group="euporie.core.app.app",
@@ -288,19 +314,19 @@ add_setting(
288
314
  type_=json.loads,
289
315
  help_="Additional key binding definitions",
290
316
  default={},
291
- description="""
292
- A mapping of component names to mappings of command name to key-binding lists.
293
- """,
294
317
  schema={
295
318
  "type": "object",
296
319
  },
320
+ description="""
321
+ A mapping of component names to mappings of command name to key-binding lists.
322
+ """,
297
323
  )
298
324
 
299
325
  add_setting(
300
326
  name="graphics",
301
327
  group="euporie.core.app.app",
302
328
  flags=["--graphics"],
303
- choices=["none", "sixel", "kitty", "iterm"],
329
+ choices=["none", "sixel", "kitty", "kitty-unicode", "iterm"],
304
330
  type_=str,
305
331
  default=None,
306
332
  help_="The preferred graphics protocol",
@@ -375,6 +401,8 @@ add_setting(
375
401
  description="""
376
402
  Additional language servers can be defined here, e.g.:
377
403
 
404
+ .. code-block:: json
405
+
378
406
  {
379
407
  "ruff": {"command": ["ruff-lsp"], "languages": ["python"]},
380
408
  "pylsp": {"command": ["pylsp"], "languages": ["python"]},
@@ -392,6 +420,8 @@ add_setting(
392
420
  empty dictionary. For example, the following would disable the awk language
393
421
  server:
394
422
 
423
+ .. code-block:: json
424
+
395
425
  {
396
426
  "awk-language-server": {},
397
427
  }
euporie/core/app/app.py CHANGED
@@ -520,14 +520,17 @@ class BaseApp(ConfigurableApp, Application, ABC):
520
520
  @classmethod
521
521
  def launch(cls) -> None:
522
522
  """Launch the app."""
523
+ from prompt_toolkit.utils import in_main_thread
524
+
523
525
  super().launch()
524
526
  # Run the application
525
527
  with create_app_session(input=cls.load_input(), output=cls.load_output()):
526
528
  # Create an instance of the app and run it
527
529
  app = cls()
528
- # Handle SIGTERM while the app is running
529
- original_sigterm = signal.getsignal(signal.SIGTERM)
530
- signal.signal(signal.SIGTERM, app.cleanup)
530
+ if in_main_thread():
531
+ # Handle SIGTERM while the app is running
532
+ original_sigterm = signal.getsignal(signal.SIGTERM)
533
+ signal.signal(signal.SIGTERM, app.cleanup)
531
534
  # Set and run the app
532
535
  with set_app(app):
533
536
  try:
@@ -535,7 +538,8 @@ class BaseApp(ConfigurableApp, Application, ABC):
535
538
  except (EOFError, KeyboardInterrupt):
536
539
  result = None
537
540
  finally:
538
- signal.signal(signal.SIGTERM, original_sigterm)
541
+ if in_main_thread():
542
+ signal.signal(signal.SIGTERM, original_sigterm)
539
543
  # Shut down any remaining LSP clients at exit
540
544
  app.shutdown_lsps()
541
545
  return result
@@ -582,14 +586,16 @@ class BaseApp(ConfigurableApp, Application, ABC):
582
586
  path_mime = get_mime(path) or "text/plain"
583
587
  log.debug("File %s has mime type: %s", path, path_mime)
584
588
 
585
- tab_options: list[TabRegistryEntry] = []
589
+ # Use a set to automatically handle duplicates
590
+ tab_options: set[TabRegistryEntry] = set()
586
591
  for entry in self.tab_registry:
587
592
  for mime_type in entry.mime_types:
588
593
  if PurePath(path_mime).match(mime_type):
589
- tab_options.append(entry)
594
+ tab_options.add(entry)
590
595
  if path.suffix in entry.file_extensions:
591
- tab_options.append(entry)
596
+ tab_options.add(entry)
592
597
 
598
+ # Sort by weight (TabRegistryEntry.__lt__ handles this)
593
599
  return sorted(tab_options, reverse=True)
594
600
 
595
601
  def get_file_tab(self, path: Path) -> type[Tab] | None:
@@ -791,6 +797,13 @@ class BaseApp(ConfigurableApp, Application, ABC):
791
797
  syntax_theme = "tango" if self.color_palette.bg.is_light else "euporie"
792
798
  return syntax_theme
793
799
 
800
+ base_styles = (
801
+ Style(MIME_STYLE),
802
+ Style(HTML_STYLE),
803
+ Style(LOG_STYLE),
804
+ Style(IPYWIDGET_STYLE),
805
+ )
806
+
794
807
  def create_merged_style(self) -> BaseStyle:
795
808
  """Generate a new merged style for the application.
796
809
 
@@ -801,6 +814,11 @@ class BaseApp(ConfigurableApp, Application, ABC):
801
814
  Return a combined style to use for the application
802
815
 
803
816
  """
817
+ styles: list[BaseStyle] = [
818
+ style_from_pygments_cls(get_style_by_name(self.syntax_theme)),
819
+ *self.base_styles,
820
+ ]
821
+
804
822
  # Get foreground and background colors based on the configured colour scheme
805
823
  theme_colors: dict[str, dict[str, str]] = {
806
824
  "default": {},
@@ -844,7 +862,7 @@ class BaseApp(ConfigurableApp, Application, ABC):
844
862
  )
845
863
 
846
864
  # Build app style
847
- app_style = build_style(cp)
865
+ styles.append(build_style(cp))
848
866
 
849
867
  # Apply style transformations based on the configured color scheme
850
868
  self.style_transformation = merge_style_transformations(
@@ -862,16 +880,11 @@ class BaseApp(ConfigurableApp, Application, ABC):
862
880
  ]
863
881
  )
864
882
 
865
- return merge_styles(
866
- [
867
- style_from_pygments_cls(get_style_by_name(self.syntax_theme)),
868
- Style(MIME_STYLE),
869
- Style(HTML_STYLE),
870
- Style(LOG_STYLE),
871
- Style(IPYWIDGET_STYLE),
872
- app_style,
873
- ]
874
- )
883
+ # Add user style customizations
884
+ if custom_style_dict := self.config.custom_styles:
885
+ styles.append(Style.from_dict(custom_style_dict))
886
+
887
+ return merge_styles(styles)
875
888
 
876
889
  def update_style(self, query: Setting | None = None) -> None:
877
890
  """Update the application's style when the syntax theme is changed."""
@@ -3,14 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import re
7
+ from functools import lru_cache
6
8
  from typing import TYPE_CHECKING
7
9
 
8
10
  from prompt_toolkit.buffer import Buffer
9
11
  from prompt_toolkit.completion.base import Completer, Completion
10
- from prompt_toolkit.filters import (
11
- buffer_has_focus,
12
- has_focus,
13
- )
12
+ from prompt_toolkit.filters import buffer_has_focus, has_focus, vi_navigation_mode
13
+ from prompt_toolkit.key_binding.vi_state import InputMode
14
14
  from prompt_toolkit.layout.containers import ConditionalContainer, Container, Window
15
15
  from prompt_toolkit.layout.controls import (
16
16
  BufferControl,
@@ -29,6 +29,7 @@ from euporie.core.key_binding.registry import (
29
29
 
30
30
  if TYPE_CHECKING:
31
31
  from collections.abc import Iterable
32
+ from typing import Unpack
32
33
 
33
34
  from prompt_toolkit.completion.base import CompleteEvent
34
35
  from prompt_toolkit.document import Document
@@ -39,6 +40,23 @@ if TYPE_CHECKING:
39
40
  log = logging.getLogger(__name__)
40
41
 
41
42
 
43
+ @lru_cache
44
+ def _parse_cmd(text: str) -> tuple[Command | None, str]:
45
+ """Parse a command line to command and arguments.
46
+
47
+ Command names cannot start with digits, so lines staring with digits have an empty
48
+ command string (this is used for the go-to-cell/go-to-line shortcuts).
49
+ """
50
+ if match := re.fullmatch(r"^(?P<cmd>[^\d][^\s]*|)\s*(?P<args>.*)$", text):
51
+ cmd, args = match.groups()
52
+ else:
53
+ cmd, args = "", ""
54
+ try:
55
+ return get_cmd(cmd), args
56
+ except KeyError:
57
+ return None, args
58
+
59
+
42
60
  class CommandCompleter(Completer):
43
61
  """Completer of commands."""
44
62
 
@@ -49,7 +67,11 @@ class CommandCompleter(Completer):
49
67
  prefix = document.text
50
68
  found_so_far: set[Command] = set()
51
69
  for alias, command in commands.items():
52
- if alias.startswith(prefix) and command not in found_so_far:
70
+ if (
71
+ alias.startswith(prefix)
72
+ and command not in found_so_far
73
+ and not command.hidden()
74
+ ):
53
75
  yield Completion(
54
76
  command.name,
55
77
  start_position=-len(prefix),
@@ -105,21 +127,18 @@ class CommandBar:
105
127
 
106
128
  def _validate(self, text: str) -> bool:
107
129
  """Verify that a valid command has been entered."""
108
- cmd, _, args = text.partition(" ")
109
- try:
110
- get_cmd(cmd)
111
- except KeyError:
112
- return False
113
- else:
114
- return True
130
+ cmd, _args = _parse_cmd(text)
131
+ return bool(cmd)
115
132
 
116
133
  def _accept(self, buffer: Buffer) -> bool:
117
134
  """Return value determines if the text is kept."""
118
- # TODO - lookup and run command with args
119
- get_app().layout.focus_last()
135
+ app = get_app()
136
+ app.vi_state.input_mode = InputMode.NAVIGATION
137
+ app.layout.focus_last()
120
138
  text = buffer.text.strip()
121
- cmd, _, args = text.partition(" ")
122
- get_cmd(cmd).run(args)
139
+ cmd, args = _parse_cmd(text)
140
+ if cmd:
141
+ cmd.run(args)
123
142
  return False
124
143
 
125
144
  def __pt_container__(self) -> Container:
@@ -135,25 +154,28 @@ class CommandBar:
135
154
  "activate-command-bar-shell-alt": "A-!",
136
155
  },
137
156
  "euporie.core.bars.command:CommandBar": {
138
- "deactivate-command-bar": "escape",
157
+ "deactivate-command-bar": ["escape", "c-c"],
139
158
  },
140
159
  }
141
160
  )
142
161
 
143
162
  @staticmethod
144
163
  @add_cmd(name="activate-command-bar-alt", hidden=True)
145
- @add_cmd(filter=~buffer_has_focus)
164
+ @add_cmd(filter=~buffer_has_focus | vi_navigation_mode)
146
165
  def _activate_command_bar(event: KeyPressEvent) -> None:
147
166
  """Enter command mode."""
148
167
  event.app.layout.focus(COMMAND_BAR_BUFFER)
168
+ event.app.vi_state.input_mode = InputMode.INSERT
149
169
 
150
170
  @staticmethod
151
171
  @add_cmd(filter=~buffer_has_focus)
152
172
  @add_cmd(name="activate-command-bar-shell-alt", hidden=True)
153
173
  def _activate_command_bar_shell(event: KeyPressEvent) -> None:
154
174
  """Enter command mode."""
155
- layout = event.app.layout
175
+ app = event.app
176
+ layout = app.layout
156
177
  layout.focus(COMMAND_BAR_BUFFER)
178
+ app.vi_state.input_mode = InputMode.INSERT
157
179
  if isinstance(control := layout.current_control, BufferControl):
158
180
  buffer = control.buffer
159
181
  buffer.text = "shell "
@@ -163,20 +185,24 @@ class CommandBar:
163
185
  @add_cmd(hidden=True)
164
186
  def _deactivate_command_bar(event: KeyPressEvent) -> None:
165
187
  """Exit command mode."""
166
- layout = event.app.layout
188
+ app = event.app
189
+ layout = app.layout
167
190
  layout.focus(COMMAND_BAR_BUFFER)
168
191
  if isinstance(control := layout.current_control, BufferControl):
192
+ app.vi_state.input_mode = InputMode.NAVIGATION
169
193
  buffer = control.buffer
170
194
  buffer.reset()
171
- event.app.layout.focus_previous()
195
+ app.layout.focus_previous()
172
196
 
173
197
  @staticmethod
174
198
  @add_cmd(aliases=["shell"])
175
- async def _run_shell_command(event: KeyPressEvent) -> None:
199
+ async def _run_shell_command(
200
+ event: KeyPressEvent, *cmd_arg: Unpack[tuple[str]]
201
+ ) -> None:
176
202
  """Run system command."""
177
- app = event.app
178
- if event._arg:
179
- await app.run_system_command(
180
- event._arg,
181
- display_before_text=[("bold", "$ "), ("", f"{event._arg}\n")],
203
+ command = " ".join(str(x) for x in cmd_arg)
204
+ if command:
205
+ await event.app.run_system_command(
206
+ command,
207
+ display_before_text=[("bold", "$ "), ("", f"{command}\n")],
182
208
  )
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import re
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from prompt_toolkit.buffer import Buffer
@@ -27,6 +28,7 @@ from euporie.core.key_binding.registry import (
27
28
  if TYPE_CHECKING:
28
29
  from prompt_toolkit.filters import FilterOrBool
29
30
  from prompt_toolkit.formatted_text.base import AnyFormattedText
31
+ from prompt_toolkit.layout.controls import UIControl
30
32
 
31
33
  log = logging.getLogger(__name__)
32
34
 
@@ -119,9 +121,17 @@ def find_searchable_controls(
119
121
  search_buffer_control: SearchBufferControl, current_control: BufferControl | None
120
122
  ) -> list[BufferControl]:
121
123
  """Find list of searchable controls and the index of the next control."""
122
- searchable_controls: list[BufferControl] = []
124
+ # If a tab provides a list of buffers to search, use that. Otherwise, trawl the
125
+ # layout for buffer controls with this as its search control
126
+ long_list: list[UIControl]
127
+ if tab := get_app().tab:
128
+ try:
129
+ long_list = list(tab.__pt_searchables__())
130
+ except NotImplementedError:
131
+ long_list = list(get_app().layout.find_all_controls())
123
132
  next_control_index = 0
124
- for control in get_app().layout.find_all_controls():
133
+ searchable_controls: list[BufferControl] = []
134
+ for control in long_list:
125
135
  # Find the index of the next searchable control so we can link the search
126
136
  # control to it if the currently focused control is not searchable. This is so
127
137
  # that the next searchable control can be focused when search is completed.
@@ -134,6 +144,7 @@ def find_searchable_controls(
134
144
  ):
135
145
  # Add it to our list
136
146
  searchable_controls.append(control)
147
+ # Cut list based on current control index
137
148
  searchable_controls = (
138
149
  searchable_controls[next_control_index:]
139
150
  + searchable_controls[:next_control_index]
@@ -332,3 +343,33 @@ def accept_search() -> None:
332
343
  search_buffer_control.buffer.append_to_history()
333
344
  # Stop the search
334
345
  stop_search()
346
+
347
+
348
+ @add_cmd()
349
+ def _replace_all(find_str: str, replace_str: str) -> None:
350
+ """Find and replace text in all searchable buffers.
351
+
352
+ Args:
353
+ find_str: String pattern to find (will be converted to regex)
354
+ replace_str: Replacement string
355
+ """
356
+ # Convert find string to regex pattern
357
+ pattern = re.compile(find_str)
358
+
359
+ # Get searchable controls
360
+ search_buffer_control, current_control = find_search_control()
361
+ if search_buffer_control is None:
362
+ return
363
+ searchable_controls = find_searchable_controls(
364
+ search_buffer_control, current_control
365
+ )
366
+
367
+ # Apply replacements to each buffer
368
+ for control in searchable_controls:
369
+ if isinstance(control, BufferControl):
370
+ buffer = control.buffer
371
+ text = buffer.text
372
+ new_text = pattern.sub(replace_str, text)
373
+ if new_text != text:
374
+ buffer.text = new_text
375
+ buffer.on_text_changed()
euporie/core/border.py CHANGED
@@ -512,6 +512,11 @@ _GRID_CHARS = {
512
512
  GridChar(LowerLeftQuarterLine, NoLine, NoLine, UpperRightEighthLine): " ",
513
513
  GridChar(UpperRightQuarterLine, UpperRightEighthLine, NoLine, NoLine): " ",
514
514
 
515
+ GridChar(NoLine, NoLine, UpperRightQuarterLine, UpperRightEighthLine): "▁",
516
+ GridChar(UpperRightQuarterLine, NoLine, NoLine, UpperRightEighthLine): "▔",
517
+ GridChar(LowerLeftQuarterLine, UpperRightEighthLine, NoLine, NoLine): "▔",
518
+ GridChar(NoLine, LowerLeftEighthLine, LowerLeftQuarterLine, NoLine): "▁",
519
+
515
520
  # LowerLeftQuarterLine
516
521
  GridChar(LowerLeftQuarterLine, NoLine, LowerLeftQuarterLine, NoLine): "▎",
517
522
  GridChar(NoLine, LowerLeftQuarterLine, NoLine, LowerLeftQuarterLine): "▂",
@@ -875,9 +880,9 @@ InsetGrid = (
875
880
 
876
881
  OutsetGrid = (
877
882
  LowerLeftEighthLine.top_edge
878
- + UpperRightEighthLine.right_edge
883
+ + UpperRightQuarterLine.right_edge
879
884
  + UpperRightEighthLine.bottom_edge
880
- + LowerLeftEighthLine.left_edge
885
+ + LowerLeftQuarterLine.left_edge
881
886
  + ThinLine.inner
882
887
  )
883
888
 
euporie/core/comm/base.py CHANGED
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
15
15
 
16
16
  from prompt_toolkit.layout.containers import AnyContainer
17
17
 
18
- from euporie.core.kernel.client import Kernel
18
+ from euporie.core.kernel.jupyter import JupyterKernel
19
19
  from euporie.core.tabs.kernel import KernelTab
20
20
  from euporie.core.widgets.cell_outputs import OutputParent
21
21
 
@@ -40,7 +40,7 @@ class CommView:
40
40
  """
41
41
  self.container = container
42
42
  self.setters: dict[str, Callable[..., None]] = dict(setters or {})
43
- self.kernel: Kernel | None = None
43
+ self.kernel: JupyterKernel | None = None
44
44
 
45
45
  def update(self, changes: dict[str, Any]) -> None:
46
46
  """Update the view to reflect changes in the Comm.