euporie 2.8.5__py3-none-any.whl → 2.8.6__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.
- euporie/core/__init__.py +1 -1
- euporie/core/__main__.py +2 -2
- euporie/core/_settings.py +7 -2
- euporie/core/app/_commands.py +26 -1
- euporie/core/app/_settings.py +34 -4
- euporie/core/app/app.py +18 -11
- euporie/core/bars/command.py +44 -21
- euporie/core/commands.py +0 -16
- euporie/core/filters.py +32 -9
- euporie/core/format.py +2 -3
- euporie/core/graphics.py +191 -31
- euporie/core/layout/scroll.py +35 -33
- euporie/core/log.py +1 -4
- euporie/core/path.py +61 -13
- euporie/core/tabs/__init__.py +2 -4
- euporie/core/tabs/base.py +73 -7
- euporie/core/tabs/kernel.py +2 -3
- euporie/core/tabs/notebook.py +14 -54
- euporie/core/utils.py +1 -18
- euporie/core/widgets/cell.py +1 -1
- euporie/core/widgets/dialog.py +32 -3
- euporie/core/widgets/display.py +2 -2
- euporie/core/widgets/menu.py +1 -1
- euporie/notebook/tabs/display.py +2 -2
- euporie/notebook/tabs/edit.py +8 -43
- euporie/notebook/tabs/json.py +2 -2
- euporie/web/__init__.py +1 -0
- euporie/web/tabs/__init__.py +14 -0
- euporie/web/tabs/web.py +10 -4
- euporie/web/widgets/__init__.py +1 -0
- euporie/web/widgets/webview.py +2 -4
- {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/METADATA +4 -2
- {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/RECORD +38 -35
- {euporie-2.8.5.data → euporie-2.8.6.data}/data/share/applications/euporie-console.desktop +0 -0
- {euporie-2.8.5.data → euporie-2.8.6.data}/data/share/applications/euporie-notebook.desktop +0 -0
- {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/WHEEL +0 -0
- {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/entry_points.txt +0 -0
- {euporie-2.8.5.dist-info → euporie-2.8.6.dist-info}/licenses/LICENSE +0 -0
euporie/core/graphics.py
CHANGED
@@ -376,8 +376,8 @@ class ItermGraphicControl(GraphicControl):
|
|
376
376
|
return self._format_cache.get(key, render_lines)
|
377
377
|
|
378
378
|
|
379
|
-
class
|
380
|
-
"""
|
379
|
+
class BaseKittyGraphicControl(GraphicControl):
|
380
|
+
"""Base graphic control with common methods for both styles of kitty display."""
|
381
381
|
|
382
382
|
_kitty_image_count: ClassVar[int] = 1
|
383
383
|
|
@@ -451,7 +451,7 @@ class KittyGraphicControl(GraphicControl):
|
|
451
451
|
BoundedWritePosition(0, 0, width=cols, height=rows, bbox=bbox)
|
452
452
|
)
|
453
453
|
self.kitty_image_id = self._kitty_image_count
|
454
|
-
|
454
|
+
self.__class__._kitty_image_count += 1
|
455
455
|
|
456
456
|
while data:
|
457
457
|
chunk, data = data[:4096], data[4096:]
|
@@ -471,24 +471,6 @@ class KittyGraphicControl(GraphicControl):
|
|
471
471
|
self.app.output.flush()
|
472
472
|
self.loaded = True
|
473
473
|
|
474
|
-
def hide_cmd(self) -> str:
|
475
|
-
"""Generate a command to hide the graphic."""
|
476
|
-
return passthrough(
|
477
|
-
self._kitty_cmd(
|
478
|
-
a="d",
|
479
|
-
d="i",
|
480
|
-
i=self.kitty_image_id,
|
481
|
-
q=1,
|
482
|
-
),
|
483
|
-
self.app.config,
|
484
|
-
)
|
485
|
-
|
486
|
-
def hide(self) -> None:
|
487
|
-
"""Hide the graphic from show without deleting it."""
|
488
|
-
if self.kitty_image_id > 0:
|
489
|
-
self.app.output.write_raw(self.hide_cmd())
|
490
|
-
self.app.output.flush()
|
491
|
-
|
492
474
|
def delete(self) -> None:
|
493
475
|
"""Delete the graphic from the terminal."""
|
494
476
|
if self.kitty_image_id > 0:
|
@@ -506,6 +488,22 @@ class KittyGraphicControl(GraphicControl):
|
|
506
488
|
self.app.output.flush()
|
507
489
|
self.loaded = False
|
508
490
|
|
491
|
+
def reset(self) -> None:
|
492
|
+
"""Hide and delete the kitty graphic from the terminal."""
|
493
|
+
self.hide()
|
494
|
+
self.delete()
|
495
|
+
super().reset()
|
496
|
+
|
497
|
+
def close(self) -> None:
|
498
|
+
"""Remove the displayed object entirely."""
|
499
|
+
super().close()
|
500
|
+
if not self.app.leave_graphics():
|
501
|
+
self.delete()
|
502
|
+
|
503
|
+
|
504
|
+
class KittyGraphicControl(BaseKittyGraphicControl):
|
505
|
+
"""A graphic control which displays images using Kitty's graphics protocol."""
|
506
|
+
|
509
507
|
def get_rendered_lines(
|
510
508
|
self, visible_width: int, visible_height: int, wrap_lines: bool = False
|
511
509
|
) -> list[StyleAndTextTuples]:
|
@@ -616,17 +614,173 @@ class KittyGraphicControl(GraphicControl):
|
|
616
614
|
)
|
617
615
|
return self._format_cache.get(key, render_lines)
|
618
616
|
|
619
|
-
def
|
620
|
-
"""
|
621
|
-
|
622
|
-
|
623
|
-
|
617
|
+
def hide_cmd(self) -> str:
|
618
|
+
"""Generate a command to hide the graphic."""
|
619
|
+
return passthrough(
|
620
|
+
self._kitty_cmd(
|
621
|
+
a="d",
|
622
|
+
d="i",
|
623
|
+
i=self.kitty_image_id,
|
624
|
+
q=1,
|
625
|
+
),
|
626
|
+
self.app.config,
|
627
|
+
)
|
628
|
+
|
629
|
+
def hide(self) -> None:
|
630
|
+
"""Hide the graphic from show without deleting it."""
|
631
|
+
if self.kitty_image_id > 0:
|
632
|
+
self.app.output.write_raw(self.hide_cmd())
|
633
|
+
self.app.output.flush()
|
624
634
|
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
635
|
+
|
636
|
+
class KittyUnicodeGraphicControl(BaseKittyGraphicControl):
|
637
|
+
"""A graphic control which displays images using Kitty's Unicode placeholder mechanism."""
|
638
|
+
|
639
|
+
PLACEHOLDER = "\U0010eeee" # U+10EEEE placeholder character
|
640
|
+
# fmt: off
|
641
|
+
DIACRITICS = ( # Diacritics for encoding row/column numbers (0-9)
|
642
|
+
"\u0305", "\u030d", "\u030e", "\u0310", "\u0312", "\u033d", "\u033e", "\u033f",
|
643
|
+
"\u0346", "\u034a", "\u034b", "\u034c", "\u0350", "\u0351", "\u0352", "\u0357",
|
644
|
+
"\u035b", "\u0363", "\u0364", "\u0365", "\u0366", "\u0367", "\u0368", "\u0369",
|
645
|
+
"\u036a", "\u036b", "\u036c", "\u036d", "\u036e", "\u036f", "\u0483", "\u0484",
|
646
|
+
"\u0485", "\u0486", "\u0487", "\u0592", "\u0593", "\u0594", "\u0595", "\u0597",
|
647
|
+
"\u0598", "\u0599", "\u059c", "\u059d", "\u059e", "\u059f", "\u05a0", "\u05a1",
|
648
|
+
"\u05a8", "\u05a9", "\u05ab", "\u05ac", "\u05af", "\u05c4", "\u0610", "\u0611",
|
649
|
+
"\u0612", "\u0613", "\u0614", "\u0615", "\u0616", "\u0617", "\u0657", "\u0658",
|
650
|
+
"\u0659", "\u065a", "\u065b", "\u065d", "\u065e", "\u06d6", "\u06d7", "\u06d8",
|
651
|
+
"\u06d9", "\u06da", "\u06db", "\u06dc", "\u06df", "\u06e0", "\u06e1", "\u06e2",
|
652
|
+
"\u06e4", "\u06e7", "\u06e8", "\u06eb", "\u06ec", "\u0730", "\u0732", "\u0733",
|
653
|
+
"\u0735", "\u0736", "\u073a", "\u073d", "\u073f", "\u0740", "\u0741", "\u0743",
|
654
|
+
"\u0745", "\u0747", "\u0749", "\u074a", "\u07eb", "\u07ec", "\u07ed", "\u07ee",
|
655
|
+
"\u07ef", "\u07f0", "\u07f1", "\u07f3", "\u0816", "\u0817", "\u0818", "\u0819",
|
656
|
+
"\u081b", "\u081c", "\u081d", "\u081e", "\u081f", "\u0820", "\u0821", "\u0822",
|
657
|
+
"\u0823", "\u0825", "\u0826", "\u0827", "\u0829", "\u082a", "\u082b", "\u082c",
|
658
|
+
"\u082d", "\u0951", "\u0953", "\u0954", "\u0f82", "\u0f83", "\u0f86", "\u0f87",
|
659
|
+
"\u135d", "\u135e", "\u135f", "\u17dd", "\u193a", "\u1a17", "\u1a75", "\u1a76",
|
660
|
+
"\u1a77", "\u1a78", "\u1a79", "\u1a7a", "\u1a7b", "\u1a7c", "\u1b6b", "\u1b6d",
|
661
|
+
"\u1b6e", "\u1b6f", "\u1b70", "\u1b71", "\u1b72", "\u1b73", "\u1cd0", "\u1cd1",
|
662
|
+
"\u1cd2", "\u1cda", "\u1cdb", "\u1ce0", "\u1dc0", "\u1dc1", "\u1dc3", "\u1dc4",
|
663
|
+
"\u1dc5", "\u1dc6", "\u1dc7", "\u1dc8", "\u1dc9", "\u1dcb", "\u1dcc", "\u1dd1",
|
664
|
+
"\u1dd2", "\u1dd3", "\u1dd4", "\u1dd5", "\u1dd6", "\u1dd7", "\u1dd8", "\u1dd9",
|
665
|
+
"\u1dda", "\u1ddb", "\u1ddc", "\u1ddd", "\u1dde", "\u1ddf", "\u1de0", "\u1de1",
|
666
|
+
"\u1de2", "\u1de3", "\u1de4", "\u1de5", "\u1de6", "\u1dfe", "\u20d0", "\u20d1",
|
667
|
+
"\u20d4", "\u20d5", "\u20d6", "\u20d7", "\u20db", "\u20dc", "\u20e1", "\u20e7",
|
668
|
+
"\u20e9", "\u20f0", "\u2cef", "\u2cf0", "\u2cf1", "\u2de0", "\u2de1", "\u2de2",
|
669
|
+
"\u2de3", "\u2de4", "\u2de5", "\u2de6", "\u2de7", "\u2de8", "\u2de9", "\u2dea",
|
670
|
+
"\u2deb", "\u2dec", "\u2ded", "\u2dee", "\u2def", "\u2df0", "\u2df1", "\u2df2",
|
671
|
+
"\u2df3", "\u2df4", "\u2df5", "\u2df6", "\u2df7", "\u2df8", "\u2df9", "\u2dfa",
|
672
|
+
"\u2dfb", "\u2dfc", "\u2dfd", "\u2dfe", "\u2dff", "\ua66f", "\ua67c", "\ua67d",
|
673
|
+
"\ua6f0", "\ua6f1", "\ua8e0", "\ua8e1", "\ua8e2", "\ua8e3", "\ua8e4", "\ua8e5",
|
674
|
+
"\ua8e6", "\ua8e7", "\ua8e8", "\ua8e9", "\ua8ea", "\ua8eb", "\ua8ec", "\ua8ed",
|
675
|
+
"\ua8ee", "\ua8ef", "\ua8f0", "\ua8f1", "\uaab0", "\uaab2", "\uaab3", "\uaab7",
|
676
|
+
"\uaab8", "\uaabe", "\uaabf", "\uaac1", "\ufe20", "\ufe21", "\ufe22", "\ufe23",
|
677
|
+
"\ufe24", "\ufe25", "\ufe26",
|
678
|
+
"\U00010a0f", "\U00010a38", "\U0001d185", "\U0001d186", "\U0001d187",
|
679
|
+
"\U0001d188", "\U0001d189", "\U0001d1aa", "\U0001d1ab", "\U0001d1ac",
|
680
|
+
"\U0001d1ad", "\U0001d242", "\U0001d243", "\U0001d244",
|
681
|
+
)
|
682
|
+
# fmt: on
|
683
|
+
|
684
|
+
def __init__(
|
685
|
+
self,
|
686
|
+
datum: Datum,
|
687
|
+
scale: float = 0,
|
688
|
+
bbox: DiInt | None = None,
|
689
|
+
) -> None:
|
690
|
+
"""Create a new kitty graphic instance."""
|
691
|
+
super().__init__(datum, scale, bbox)
|
692
|
+
self.placements: set[tuple[int, int]] = set()
|
693
|
+
|
694
|
+
def get_rendered_lines(
|
695
|
+
self, visible_width: int, visible_height: int, wrap_lines: bool = False
|
696
|
+
) -> list[StyleAndTextTuples]:
|
697
|
+
"""Get rendered lines from the cache, or generate them."""
|
698
|
+
bbox = self.bbox
|
699
|
+
|
700
|
+
cell_size_px = self.app.cell_size_px
|
701
|
+
datum = self._datum_pad_cache[(self.datum, *cell_size_px)]
|
702
|
+
px, py = datum.pixel_size()
|
703
|
+
# Fall back to a default pixel size
|
704
|
+
px = px or 100
|
705
|
+
py = py or 100
|
706
|
+
|
707
|
+
d_cols, d_aspect = datum.cell_size()
|
708
|
+
d_rows = d_cols * d_aspect
|
709
|
+
|
710
|
+
total_available_width = visible_width + bbox.left + bbox.right
|
711
|
+
total_available_height = visible_height + bbox.top + bbox.bottom
|
712
|
+
|
713
|
+
# Scale down the graphic to fit in the available space
|
714
|
+
if d_rows > total_available_height or d_cols > total_available_width:
|
715
|
+
if d_rows / total_available_height > d_cols / total_available_width:
|
716
|
+
ratio = min(1, total_available_height / d_rows)
|
717
|
+
else:
|
718
|
+
ratio = min(1, total_available_width / d_cols)
|
719
|
+
else:
|
720
|
+
ratio = 1
|
721
|
+
|
722
|
+
# Calculate the size and cropping bbox at which we want to display the graphic
|
723
|
+
cols = floor(d_cols * ratio)
|
724
|
+
rows = ceil(cols * d_aspect)
|
725
|
+
if not self.loaded:
|
726
|
+
self.load(cols=cols, rows=rows, bbox=DiInt(0, 0, 0, 0))
|
727
|
+
|
728
|
+
# Add virtual placement for this size if required
|
729
|
+
if (cols, rows) not in self.placements:
|
730
|
+
cmd = self._kitty_cmd(
|
731
|
+
a="p", # Display a previously transmitted image
|
732
|
+
i=self.kitty_image_id,
|
733
|
+
p=1, # Placement ID
|
734
|
+
U=1, # Create a virtual placement
|
735
|
+
c=cols,
|
736
|
+
r=rows,
|
737
|
+
q=2,
|
738
|
+
)
|
739
|
+
self.app.output.write_raw(passthrough(cmd, self.app.config))
|
740
|
+
self.app.output.flush()
|
741
|
+
self.placements.add((cols, rows))
|
742
|
+
|
743
|
+
def render_lines() -> list[StyleAndTextTuples]:
|
744
|
+
"""Render the lines to display in the control."""
|
745
|
+
ft: StyleAndTextTuples = []
|
746
|
+
|
747
|
+
# Generate placeholder grid
|
748
|
+
col_start = bbox.left
|
749
|
+
col_stop = cols - bbox.right
|
750
|
+
placeholder = self.PLACEHOLDER
|
751
|
+
diacritics = self.DIACRITICS
|
752
|
+
for row in range(bbox.top, rows - bbox.bottom):
|
753
|
+
for col in range(col_start, col_stop):
|
754
|
+
ft.extend(
|
755
|
+
[
|
756
|
+
# We set the ptk-color for the last column so the renderer
|
757
|
+
# knows to change the color back after this gets rendered.
|
758
|
+
("fg:#888" if col == col_stop - 1 else "", " "),
|
759
|
+
(
|
760
|
+
"[ZeroWidthEscape]",
|
761
|
+
# We move the cursor back a cell before writing the
|
762
|
+
# kitty unicode char using a ZWE
|
763
|
+
"\b"
|
764
|
+
# Set the kitty graphic and placement we want to render
|
765
|
+
# by manually setting an 8-bit foregroun color.
|
766
|
+
# The placement ID is set to 1 using underline color.
|
767
|
+
f"\x1b[38;5;{self.kitty_image_id}m\x1b[58;1m"
|
768
|
+
# Writing the unicode char moves the cursor forward
|
769
|
+
# again to where the renderer expects it to be
|
770
|
+
f"{placeholder}{diacritics[row]}{diacritics[col]}",
|
771
|
+
),
|
772
|
+
]
|
773
|
+
)
|
774
|
+
ft.append(("", "\n"))
|
775
|
+
return list(split_lines(ft))
|
776
|
+
|
777
|
+
key = (
|
778
|
+
visible_width,
|
779
|
+
self.app.color_palette,
|
780
|
+
self.app.cell_size_px,
|
781
|
+
bbox,
|
782
|
+
)
|
783
|
+
return self._format_cache.get(key, render_lines)
|
630
784
|
|
631
785
|
|
632
786
|
class NotVisible(Exception):
|
@@ -767,11 +921,17 @@ def select_graphic_control(format_: str) -> type[GraphicControl] | None:
|
|
767
921
|
and (not _in_mplex or (_in_mplex and force_graphics))
|
768
922
|
):
|
769
923
|
useable_graphics_controls.append(KittyGraphicControl)
|
924
|
+
useable_graphics_controls.append(KittyUnicodeGraphicControl)
|
770
925
|
if (
|
771
926
|
preferred_graphics_protocol == "kitty"
|
772
927
|
and KittyGraphicControl in useable_graphics_controls
|
773
928
|
):
|
774
929
|
SelectedGraphicControl = KittyGraphicControl
|
930
|
+
elif (
|
931
|
+
preferred_graphics_protocol == "kitty-unicode"
|
932
|
+
and KittyUnicodeGraphicControl in useable_graphics_controls
|
933
|
+
):
|
934
|
+
SelectedGraphicControl = KittyUnicodeGraphicControl
|
775
935
|
# Tmux now supports sixels (>=3.4)
|
776
936
|
elif (app.term_graphics_sixel or force_graphics) and find_route(
|
777
937
|
format_, "sixel"
|
euporie/core/layout/scroll.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1
|
-
"""Contains containers which display children at full height
|
1
|
+
"""Contains containers which display children at full height vertically stacked."""
|
2
2
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
|
+
import asyncio
|
5
6
|
import logging
|
6
7
|
from typing import TYPE_CHECKING, cast
|
7
8
|
|
@@ -20,7 +21,6 @@ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseModifie
|
|
20
21
|
|
21
22
|
from euporie.core.layout.cache import CachedContainer
|
22
23
|
from euporie.core.layout.screen import BoundedWritePosition
|
23
|
-
from euporie.core.utils import run_in_thread_with_context
|
24
24
|
|
25
25
|
if TYPE_CHECKING:
|
26
26
|
from collections.abc import Sequence
|
@@ -68,7 +68,7 @@ class ScrollingContainer(Container):
|
|
68
68
|
self._child_cache: dict[int, CachedContainer] = {}
|
69
69
|
self._children: list[CachedContainer] = []
|
70
70
|
self.refresh_children = True
|
71
|
-
self.pre_rendered =
|
71
|
+
self.pre_rendered: float | None = None
|
72
72
|
|
73
73
|
self._selected_slice = slice(
|
74
74
|
0, 1
|
@@ -92,23 +92,33 @@ class ScrollingContainer(Container):
|
|
92
92
|
|
93
93
|
def pre_render_children(self, width: int, height: int) -> None:
|
94
94
|
"""Render all unrendered children in a background thread."""
|
95
|
+
self.pre_rendered = 0.0
|
96
|
+
children = self.all_children()
|
97
|
+
incr = 1 / len(children)
|
98
|
+
app = get_app()
|
99
|
+
|
100
|
+
def _cb(task: asyncio.Task) -> None:
|
101
|
+
"""Task callback to update pre-rendering percentage."""
|
102
|
+
assert isinstance(self.pre_rendered, float)
|
103
|
+
self.pre_rendered += incr
|
104
|
+
app.invalidate()
|
95
105
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
app.invalidate()
|
106
|
+
tasks = set()
|
107
|
+
for child in children:
|
108
|
+
if isinstance(child, CachedContainer):
|
109
|
+
task = app.create_background_task(
|
110
|
+
asyncio.to_thread(child.render, width, height)
|
111
|
+
)
|
112
|
+
task.add_done_callback(_cb)
|
113
|
+
tasks.add(task)
|
114
|
+
|
115
|
+
async def _finish() -> None:
|
116
|
+
await asyncio.gather(*tasks)
|
108
117
|
self.pre_rendered = 1.0
|
109
118
|
app.invalidate()
|
119
|
+
# app.exit()
|
110
120
|
|
111
|
-
|
121
|
+
app.create_background_task(_finish())
|
112
122
|
|
113
123
|
def reset(self) -> None:
|
114
124
|
"""Reset the state of this container and all the children."""
|
@@ -237,7 +247,6 @@ class ScrollingContainer(Container):
|
|
237
247
|
:py:const:`None`
|
238
248
|
|
239
249
|
"""
|
240
|
-
# self.refresh_children = True
|
241
250
|
if n > 0:
|
242
251
|
if (
|
243
252
|
min(self.visible_indices) == 0
|
@@ -255,8 +264,8 @@ class ScrollingContainer(Container):
|
|
255
264
|
if bottom_pos is not None:
|
256
265
|
n = max(
|
257
266
|
n,
|
258
|
-
self.
|
259
|
-
-
|
267
|
+
(bottom_pos + bottom_child.height + self.scrolling)
|
268
|
+
- self.last_write_position.height,
|
260
269
|
)
|
261
270
|
if (
|
262
271
|
bottom_pos + bottom_child.height + self.scrolling + n
|
@@ -365,9 +374,6 @@ class ScrollingContainer(Container):
|
|
365
374
|
# Record children which are currently visible
|
366
375
|
visible_indices = set()
|
367
376
|
|
368
|
-
# Ensure we have the right children
|
369
|
-
all_children = self.all_children()
|
370
|
-
|
371
377
|
# Force the selected children to refresh
|
372
378
|
selected_indices = self.selected_indices
|
373
379
|
self._selected_children: list[CachedContainer] = []
|
@@ -549,19 +555,10 @@ class ScrollingContainer(Container):
|
|
549
555
|
# are partially obscured
|
550
556
|
self.last_write_position = write_position
|
551
557
|
|
552
|
-
# Calculate scrollbar info
|
553
|
-
sizes = self.known_sizes
|
554
|
-
avg_size = sum(sizes.values()) / len(sizes) if sizes else 0
|
555
|
-
n_children = len(all_children)
|
556
|
-
for i in range(n_children):
|
557
|
-
if i not in sizes:
|
558
|
-
sizes[i] = int(avg_size)
|
559
|
-
content_height = max(sum(sizes.values()), 1)
|
560
|
-
|
561
558
|
# Mock up a WindowRenderInfo so we can draw a scrollbar margin
|
562
559
|
self.render_info = WindowRenderInfo(
|
563
560
|
window=cast("Window", self),
|
564
|
-
ui_content=UIContent(line_count=
|
561
|
+
ui_content=UIContent(line_count=max(sum(self.known_sizes.values()), 1)),
|
565
562
|
horizontal_scroll=0,
|
566
563
|
vertical_scroll=self.vertical_scroll,
|
567
564
|
window_width=available_width,
|
@@ -577,7 +574,7 @@ class ScrollingContainer(Container):
|
|
577
574
|
self.scrolling = 0
|
578
575
|
|
579
576
|
# Trigger pre-rendering of children
|
580
|
-
if
|
577
|
+
if self.pre_rendered is None:
|
581
578
|
self.pre_render_children(available_width, available_height)
|
582
579
|
|
583
580
|
@property
|
@@ -730,9 +727,14 @@ class ScrollingContainer(Container):
|
|
730
727
|
def known_sizes(self) -> dict[int, int]:
|
731
728
|
"""A dictionary mapping child indices to height values."""
|
732
729
|
sizes = {}
|
730
|
+
missing = set()
|
733
731
|
for i, child in enumerate(self._children):
|
734
732
|
if isinstance(child, CachedContainer) and child.height:
|
735
733
|
sizes[i] = child.height
|
734
|
+
else:
|
735
|
+
missing.add(i)
|
736
|
+
avg = int(sum(sizes.values()) / len(sizes))
|
737
|
+
sizes.update(dict.fromkeys(missing, avg))
|
736
738
|
return sizes
|
737
739
|
|
738
740
|
def _scroll_up(self) -> None:
|
euporie/core/log.py
CHANGED
@@ -506,10 +506,7 @@ def setup_logs(config: Config | None = None) -> None:
|
|
506
506
|
|
507
507
|
# Update log_config based on additional config dict provided
|
508
508
|
if config.log_config:
|
509
|
-
|
510
|
-
|
511
|
-
extra_config = json.loads(config.log_config)
|
512
|
-
dict_merge(log_config, extra_config)
|
509
|
+
dict_merge(log_config, config.log_config)
|
513
510
|
|
514
511
|
# Configure the logger
|
515
512
|
# Pytype used TypedDicts to validate the dictionary structure, but I cannot get
|
euporie/core/path.py
CHANGED
@@ -9,36 +9,72 @@ from typing import TYPE_CHECKING
|
|
9
9
|
import upath
|
10
10
|
from aiohttp.client_reqrep import ClientResponse
|
11
11
|
from fsspec.implementations.http import HTTPFileSystem as FsHTTPFileSystem
|
12
|
+
from fsspec.implementations.http import get_client
|
12
13
|
from fsspec.registry import register_implementation as fs_register_implementation
|
13
14
|
from upath import UPath
|
14
15
|
|
15
16
|
if TYPE_CHECKING:
|
17
|
+
import asyncio
|
16
18
|
from collections.abc import Mapping
|
17
19
|
from os import PathLike
|
18
|
-
from typing import Any
|
20
|
+
from typing import Any, Callable
|
19
21
|
|
20
|
-
|
21
|
-
log = logging.getLogger(__name__)
|
22
|
-
|
23
|
-
|
24
|
-
# Monkey-patch `aiohttp` to not raise exceptions on non-200 responses
|
22
|
+
import aiohttp
|
25
23
|
|
26
24
|
|
27
|
-
|
28
|
-
"""Monkey-patch :py:class:`aiohttp.ClientResponse` not to raise for any status."""
|
29
|
-
|
25
|
+
log = logging.getLogger(__name__)
|
30
26
|
|
31
|
-
setattr(ClientResponse, "raise_for_status", _raise_for_status) # noqa B010
|
32
27
|
|
28
|
+
class NoRaiseClientResponse(ClientResponse):
|
29
|
+
"""An ``aiohttp`` client response which does not raise on >=400 status responses."""
|
33
30
|
|
34
|
-
|
31
|
+
@property
|
32
|
+
def ok(self) -> bool:
|
33
|
+
"""Returns ``True`` if ``status`` is probably renderable."""
|
34
|
+
return self.status not in {405}
|
35
35
|
|
36
36
|
|
37
37
|
class HTTPFileSystem(FsHTTPFileSystem):
|
38
|
-
"""A
|
38
|
+
"""A HTTP filesystem implementation which does not raise on errors."""
|
39
|
+
|
40
|
+
def __init__(
|
41
|
+
self,
|
42
|
+
simple_links: bool = True,
|
43
|
+
block_size: int | None = None,
|
44
|
+
same_scheme: bool = True,
|
45
|
+
size_policy: None = None,
|
46
|
+
cache_type: str = "bytes",
|
47
|
+
cache_options: dict[str, Any] | None = None,
|
48
|
+
asynchronous: bool = False,
|
49
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
50
|
+
client_kwargs: dict[str, Any] | None = None,
|
51
|
+
get_client: Callable[..., aiohttp.ClientSession] = get_client,
|
52
|
+
encoded: bool = False,
|
53
|
+
**storage_options: Any,
|
54
|
+
) -> None:
|
55
|
+
"""Defaults to using :py:mod:`NoRaiseClientResponse` for responses."""
|
56
|
+
client_kwargs = {
|
57
|
+
"response_class": NoRaiseClientResponse,
|
58
|
+
**(client_kwargs or {}),
|
59
|
+
}
|
60
|
+
super().__init__(
|
61
|
+
simple_links=simple_links,
|
62
|
+
block_size=block_size,
|
63
|
+
same_scheme=same_scheme,
|
64
|
+
size_policy=size_policy,
|
65
|
+
cache_type=cache_type,
|
66
|
+
cache_options=cache_options,
|
67
|
+
asynchronous=asynchronous,
|
68
|
+
loop=loop,
|
69
|
+
client_kwargs=client_kwargs,
|
70
|
+
get_client=get_client,
|
71
|
+
encoded=encoded,
|
72
|
+
**storage_options,
|
73
|
+
)
|
39
74
|
|
40
75
|
def _raise_not_found_for_status(self, response: ClientResponse, url: str) -> None:
|
41
76
|
"""Do not raise an exception for 404 errors."""
|
77
|
+
response.raise_for_status()
|
42
78
|
|
43
79
|
|
44
80
|
fs_register_implementation("http", HTTPFileSystem, clobber=True)
|
@@ -63,8 +99,15 @@ class UntitledPath(upath.core.UPath):
|
|
63
99
|
return False
|
64
100
|
|
65
101
|
|
66
|
-
def parse_path(path: str | PathLike, resolve: bool =
|
102
|
+
def parse_path(path: str | PathLike, resolve: bool | None = None) -> Path:
|
67
103
|
"""Parse and resolve a path."""
|
104
|
+
if resolve is None:
|
105
|
+
from upath.implementations.http import HTTPPath
|
106
|
+
|
107
|
+
if isinstance(path, HTTPPath):
|
108
|
+
resolve = True
|
109
|
+
else:
|
110
|
+
resolve = False
|
68
111
|
if not isinstance(path, Path):
|
69
112
|
path = UPath(path)
|
70
113
|
try:
|
@@ -76,4 +119,9 @@ def parse_path(path: str | PathLike, resolve: bool = True) -> Path:
|
|
76
119
|
path = path.resolve()
|
77
120
|
except (AttributeError, NotImplementedError, Exception):
|
78
121
|
log.info("Path %s not resolvable", path)
|
122
|
+
else:
|
123
|
+
try:
|
124
|
+
path = path.absolute()
|
125
|
+
except NotImplementedError:
|
126
|
+
pass
|
79
127
|
return path
|
euporie/core/tabs/__init__.py
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
from dataclasses import dataclass, field
|
6
|
-
from
|
6
|
+
from pkgutil import resolve_name
|
7
7
|
from typing import TYPE_CHECKING
|
8
8
|
|
9
9
|
if TYPE_CHECKING:
|
@@ -23,9 +23,7 @@ class TabRegistryEntry:
|
|
23
23
|
@property
|
24
24
|
def tab_class(self) -> type[Tab]:
|
25
25
|
"""Import and return the tab class."""
|
26
|
-
|
27
|
-
module = import_module(module_path)
|
28
|
-
return getattr(module, attribute)
|
26
|
+
return resolve_name(self.path)
|
29
27
|
|
30
28
|
def __lt__(self, other: TabRegistryEntry) -> bool:
|
31
29
|
"""Sort by weight."""
|
euporie/core/tabs/base.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
|
+
import asyncio
|
5
6
|
import logging
|
6
7
|
from abc import ABCMeta
|
7
8
|
from typing import TYPE_CHECKING, ClassVar
|
@@ -13,13 +14,12 @@ from upath import UPath
|
|
13
14
|
|
14
15
|
from euporie.core.app.current import get_app
|
15
16
|
from euporie.core.commands import add_cmd
|
16
|
-
from euporie.core.filters import tab_has_focus
|
17
|
+
from euporie.core.filters import tab_can_save, tab_has_focus
|
17
18
|
from euporie.core.key_binding.registry import (
|
18
19
|
register_bindings,
|
19
20
|
)
|
20
21
|
from euporie.core.layout.containers import Window
|
21
|
-
from euporie.core.path import parse_path
|
22
|
-
from euporie.core.utils import run_in_thread_with_context
|
22
|
+
from euporie.core.path import UntitledPath, parse_path
|
23
23
|
|
24
24
|
if TYPE_CHECKING:
|
25
25
|
from pathlib import Path
|
@@ -107,11 +107,77 @@ class Tab(metaclass=ABCMeta):
|
|
107
107
|
cb()
|
108
108
|
self.after_save.fire()
|
109
109
|
|
110
|
-
|
110
|
+
self.app.create_background_task(asyncio.to_thread(self.save, path, _wrapped_cb))
|
111
111
|
|
112
112
|
def save(self, path: Path | None = None, cb: Callable | None = None) -> None:
|
113
|
-
"""Save the current
|
114
|
-
|
113
|
+
"""Save the current file."""
|
114
|
+
if path is not None:
|
115
|
+
self.path = path
|
116
|
+
|
117
|
+
if (self.path is None or isinstance(self.path, UntitledPath)) and (
|
118
|
+
dialog := self.app.dialogs.get("save-as")
|
119
|
+
):
|
120
|
+
dialog.show(tab=self, cb=cb)
|
121
|
+
return
|
122
|
+
|
123
|
+
path = self.path
|
124
|
+
try:
|
125
|
+
# Ensure parent path exists
|
126
|
+
parent = path.parent
|
127
|
+
parent.mkdir(exist_ok=True, parents=True)
|
128
|
+
|
129
|
+
# Create backup if original file exists
|
130
|
+
backup_path: Path | None = None
|
131
|
+
if path.exists():
|
132
|
+
name = f"{path.name}.bak"
|
133
|
+
if not name.startswith("."):
|
134
|
+
name = f".{name}"
|
135
|
+
backup_path = parent / name
|
136
|
+
try:
|
137
|
+
import shutil
|
138
|
+
|
139
|
+
shutil.copy2(path, backup_path)
|
140
|
+
except Exception as e:
|
141
|
+
log.error("Failed to create backup: %s", e)
|
142
|
+
raise
|
143
|
+
|
144
|
+
# Write new content directly to original file
|
145
|
+
try:
|
146
|
+
self.write_file(path)
|
147
|
+
except Exception as e:
|
148
|
+
log.error("Failed to write file: %s", e)
|
149
|
+
# Restore from backup if it exists
|
150
|
+
if backup_path is not None:
|
151
|
+
log.info("Restoring backup")
|
152
|
+
backup_path.replace(path)
|
153
|
+
raise
|
154
|
+
|
155
|
+
self.dirty = False
|
156
|
+
self.saving = False
|
157
|
+
self.app.invalidate()
|
158
|
+
log.debug("File saved successfully")
|
159
|
+
|
160
|
+
# Run the callback
|
161
|
+
if callable(cb):
|
162
|
+
cb()
|
163
|
+
|
164
|
+
except Exception:
|
165
|
+
log.exception("An error occurred while saving the file")
|
166
|
+
if dialog := self.app.dialogs.get("save-as"):
|
167
|
+
dialog.show(tab=self, cb=cb)
|
168
|
+
|
169
|
+
def write_file(self, path: Path) -> None:
|
170
|
+
"""Write the tab's data to a path.
|
171
|
+
|
172
|
+
Not implement in the base tab.
|
173
|
+
|
174
|
+
Args:
|
175
|
+
path: An path at which to save the file
|
176
|
+
|
177
|
+
"""
|
178
|
+
raise NotImplementedError(
|
179
|
+
f"File saving not implement for `{self.__class__.__name__}` tab"
|
180
|
+
)
|
115
181
|
|
116
182
|
def __pt_status__(self) -> StatusBarFields | None:
|
117
183
|
"""Return a list of statusbar field values shown then this tab is active."""
|
@@ -141,7 +207,7 @@ class Tab(metaclass=ABCMeta):
|
|
141
207
|
Tab._refresh_tab()
|
142
208
|
|
143
209
|
@staticmethod
|
144
|
-
@add_cmd(filter=
|
210
|
+
@add_cmd(filter=tab_can_save, aliases=["w"])
|
145
211
|
def _save_file(event: KeyPressEvent) -> None:
|
146
212
|
"""Save the current file."""
|
147
213
|
if (tab := get_app().tab) is not None:
|