omdev 0.0.0.dev439__py3-none-any.whl → 0.0.0.dev486__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.
Potentially problematic release.
This version of omdev might be problematic. Click here for more details.
- omdev/.omlish-manifests.json +18 -30
- omdev/__about__.py +9 -7
- omdev/amalg/gen/gen.py +49 -6
- omdev/amalg/gen/imports.py +1 -1
- omdev/amalg/gen/manifests.py +1 -1
- omdev/amalg/gen/resources.py +1 -1
- omdev/amalg/gen/srcfiles.py +13 -3
- omdev/amalg/gen/strip.py +1 -1
- omdev/amalg/gen/types.py +1 -1
- omdev/amalg/gen/typing.py +1 -1
- omdev/amalg/info.py +32 -0
- omdev/cache/data/actions.py +1 -1
- omdev/cache/data/specs.py +1 -1
- omdev/cexts/_boilerplate.cc +2 -3
- omdev/cexts/cmake.py +4 -1
- omdev/ci/cli.py +1 -2
- omdev/ci/github/api/v2/api.py +2 -0
- omdev/cmdlog/cli.py +1 -2
- omdev/dataclasses/_dumping.py +1960 -0
- omdev/dataclasses/_template.py +22 -0
- omdev/dataclasses/cli.py +6 -1
- omdev/dataclasses/codegen.py +340 -60
- omdev/dataclasses/dumping.py +200 -0
- omdev/interp/uv/provider.py +1 -0
- omdev/interp/venvs.py +1 -0
- omdev/irc/messages/base.py +50 -0
- omdev/irc/messages/formats.py +92 -0
- omdev/irc/messages/messages.py +775 -0
- omdev/irc/messages/parsing.py +99 -0
- omdev/irc/numerics/__init__.py +0 -0
- omdev/irc/numerics/formats.py +97 -0
- omdev/irc/numerics/numerics.py +865 -0
- omdev/irc/numerics/types.py +59 -0
- omdev/irc/protocol/LICENSE +11 -0
- omdev/irc/protocol/__init__.py +61 -0
- omdev/irc/protocol/consts.py +6 -0
- omdev/irc/protocol/errors.py +30 -0
- omdev/irc/protocol/message.py +21 -0
- omdev/irc/protocol/nuh.py +55 -0
- omdev/irc/protocol/parsing.py +158 -0
- omdev/irc/protocol/rendering.py +153 -0
- omdev/irc/protocol/tags.py +102 -0
- omdev/irc/protocol/utils.py +30 -0
- omdev/manifests/_dumping.py +125 -25
- omdev/markdown/__init__.py +0 -0
- omdev/markdown/incparse.py +116 -0
- omdev/markdown/tokens.py +51 -0
- omdev/packaging/marshal.py +8 -8
- omdev/packaging/requires.py +6 -6
- omdev/packaging/specifiers.py +2 -1
- omdev/packaging/versions.py +4 -4
- omdev/packaging/wheelfile.py +2 -0
- omdev/precheck/blanklines.py +66 -0
- omdev/precheck/caches.py +1 -1
- omdev/precheck/imports.py +14 -1
- omdev/precheck/main.py +4 -3
- omdev/precheck/unicode.py +39 -15
- omdev/py/asts/__init__.py +0 -0
- omdev/py/asts/parents.py +28 -0
- omdev/py/asts/toplevel.py +123 -0
- omdev/py/asts/visitors.py +18 -0
- omdev/py/attrdocs.py +6 -7
- omdev/py/bracepy.py +12 -4
- omdev/py/reprs.py +32 -0
- omdev/py/srcheaders.py +1 -1
- omdev/py/tokens/__init__.py +0 -0
- omdev/py/tools/mkrelimp.py +1 -1
- omdev/py/tools/pipdepup.py +629 -0
- omdev/pyproject/pkg.py +190 -45
- omdev/pyproject/reqs.py +31 -9
- omdev/pyproject/tools/__init__.py +0 -0
- omdev/pyproject/tools/aboutdeps.py +55 -0
- omdev/pyproject/venvs.py +8 -1
- omdev/rs/__init__.py +0 -0
- omdev/scripts/ci.py +400 -80
- omdev/scripts/interp.py +193 -35
- omdev/scripts/lib/__init__.py +0 -0
- omdev/scripts/{inject.py → lib/inject.py} +75 -28
- omdev/scripts/lib/logs.py +2079 -0
- omdev/scripts/{marshal.py → lib/marshal.py} +68 -26
- omdev/scripts/pyproject.py +941 -90
- omdev/tools/git/cli.py +12 -1
- omdev/tools/json/processing.py +5 -2
- omdev/tools/jsonview/cli.py +31 -5
- omdev/tools/pawk/pawk.py +2 -2
- omdev/tools/pip.py +8 -0
- omdev/tui/__init__.py +0 -0
- omdev/tui/apps/__init__.py +0 -0
- omdev/tui/apps/edit/__init__.py +0 -0
- omdev/tui/apps/edit/main.py +163 -0
- omdev/tui/apps/irc/__init__.py +0 -0
- omdev/tui/apps/irc/__main__.py +4 -0
- omdev/tui/apps/irc/app.py +278 -0
- omdev/tui/apps/irc/client.py +187 -0
- omdev/tui/apps/irc/commands.py +175 -0
- omdev/tui/apps/irc/main.py +26 -0
- omdev/tui/apps/markdown/__init__.py +0 -0
- omdev/tui/apps/markdown/__main__.py +11 -0
- omdev/{ptk → tui/apps}/markdown/cli.py +5 -7
- omdev/tui/rich/__init__.py +34 -0
- omdev/tui/rich/console2.py +20 -0
- omdev/tui/rich/markdown2.py +186 -0
- omdev/tui/textual/__init__.py +226 -0
- omdev/tui/textual/app2.py +11 -0
- omdev/tui/textual/autocomplete/LICENSE +21 -0
- omdev/tui/textual/autocomplete/__init__.py +33 -0
- omdev/tui/textual/autocomplete/matching.py +226 -0
- omdev/tui/textual/autocomplete/paths.py +202 -0
- omdev/tui/textual/autocomplete/widget.py +612 -0
- omdev/tui/textual/drivers2.py +55 -0
- {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/METADATA +11 -9
- {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/RECORD +121 -73
- omdev/ptk/__init__.py +0 -103
- omdev/ptk/apps/ncdu.py +0 -167
- omdev/ptk/confirm.py +0 -60
- omdev/ptk/markdown/LICENSE +0 -22
- omdev/ptk/markdown/__init__.py +0 -10
- omdev/ptk/markdown/__main__.py +0 -11
- omdev/ptk/markdown/border.py +0 -94
- omdev/ptk/markdown/markdown.py +0 -390
- omdev/ptk/markdown/parser.py +0 -42
- omdev/ptk/markdown/styles.py +0 -29
- omdev/ptk/markdown/tags.py +0 -299
- omdev/ptk/markdown/utils.py +0 -366
- omdev/pyproject/cexts.py +0 -110
- /omdev/{ptk/apps → irc}/__init__.py +0 -0
- /omdev/{tokens → irc/messages}/__init__.py +0 -0
- /omdev/{tokens → py/tokens}/all.py +0 -0
- /omdev/{tokens → py/tokens}/tokenizert.py +0 -0
- /omdev/{tokens → py/tokens}/utils.py +0 -0
- {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/WHEEL +0 -0
- {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/entry_points.txt +0 -0
- {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/licenses/LICENSE +0 -0
- {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/top_level.txt +0 -0
omdev/tools/git/cli.py
CHANGED
|
@@ -21,6 +21,7 @@ TODO:
|
|
|
21
21
|
fatal: Need to specify how to reconcile divergent branches.
|
|
22
22
|
"""
|
|
23
23
|
import dataclasses as dc
|
|
24
|
+
import glob
|
|
24
25
|
import os
|
|
25
26
|
import shutil
|
|
26
27
|
import tempfile
|
|
@@ -315,6 +316,7 @@ class Cli(ap.Cli):
|
|
|
315
316
|
ap.arg('-g', '--message-generator', nargs='?'),
|
|
316
317
|
ap.arg('--dry-run', action='store_true'),
|
|
317
318
|
ap.arg('-y', '--no-confirmation', action='store_true'),
|
|
319
|
+
ap.arg('-r', '--repository'),
|
|
318
320
|
ap.arg('dir', nargs='*'),
|
|
319
321
|
aliases=['acp'],
|
|
320
322
|
)
|
|
@@ -354,7 +356,7 @@ class Cli(ap.Cli):
|
|
|
354
356
|
|
|
355
357
|
check_call('git', 'commit', '-m', msg)
|
|
356
358
|
|
|
357
|
-
check_call('git', 'push')
|
|
359
|
+
check_call('git', 'push', *([self.args.repository] if self.args.repository is not None else []))
|
|
358
360
|
|
|
359
361
|
if not self.args.dir:
|
|
360
362
|
run(None)
|
|
@@ -451,6 +453,7 @@ class Cli(ap.Cli):
|
|
|
451
453
|
BUILTIN_COMMIT_MESSAGES: ta.Mapping[str, str] = {
|
|
452
454
|
'tableflip': '(╯°□°)╯︵ ┻━┻',
|
|
453
455
|
'tableunflip': '┬─┬ノ(º _ ºノ)',
|
|
456
|
+
'shrug': r'¯\_(ツ)_/¯',
|
|
454
457
|
}
|
|
455
458
|
|
|
456
459
|
@ap.cmd(
|
|
@@ -500,10 +503,18 @@ class Cli(ap.Cli):
|
|
|
500
503
|
repo_dir = os.path.join(tmp_dir, repo_dir_name)
|
|
501
504
|
check.state(os.path.isdir(repo_dir))
|
|
502
505
|
|
|
506
|
+
#
|
|
507
|
+
|
|
503
508
|
git_dir = os.path.join(repo_dir, '.git')
|
|
504
509
|
check.state(os.path.isdir(git_dir))
|
|
505
510
|
shutil.rmtree(git_dir)
|
|
506
511
|
|
|
512
|
+
for f in glob.glob(os.path.join(repo_dir, '**/.gitattributes'), recursive=True):
|
|
513
|
+
if os.path.isfile(f):
|
|
514
|
+
os.unlink(f)
|
|
515
|
+
|
|
516
|
+
#
|
|
517
|
+
|
|
507
518
|
shutil.move(repo_dir, cwd)
|
|
508
519
|
|
|
509
520
|
out_dir = repo_dir_name
|
omdev/tools/json/processing.py
CHANGED
|
@@ -53,8 +53,11 @@ class Processor:
|
|
|
53
53
|
|
|
54
54
|
def _marshal(self, v: ta.Any) -> ta.Any:
|
|
55
55
|
return msh.MarshalContext(
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
configs=msh.global_config_registry(),
|
|
57
|
+
marshal_factory_context=msh.MarshalFactoryContext(
|
|
58
|
+
configs=msh.global_config_registry(),
|
|
59
|
+
marshaler_factory=self._marshaler_factory(),
|
|
60
|
+
),
|
|
58
61
|
).marshal(v)
|
|
59
62
|
|
|
60
63
|
def process(self, v: ta.Any) -> ta.Iterator[ta.Any]:
|
omdev/tools/jsonview/cli.py
CHANGED
|
@@ -14,8 +14,10 @@ import os
|
|
|
14
14
|
import socketserver
|
|
15
15
|
import sys
|
|
16
16
|
import threading
|
|
17
|
+
import typing as ta
|
|
17
18
|
import webbrowser
|
|
18
19
|
|
|
20
|
+
from omlish import check
|
|
19
21
|
from omlish import lang
|
|
20
22
|
|
|
21
23
|
|
|
@@ -69,7 +71,15 @@ HTML_TEMPLATE = """
|
|
|
69
71
|
"""
|
|
70
72
|
|
|
71
73
|
|
|
72
|
-
def view_json(
|
|
74
|
+
def view_json(
|
|
75
|
+
filepath: str,
|
|
76
|
+
port: int,
|
|
77
|
+
*,
|
|
78
|
+
mode: ta.Literal['jsonl', 'json5', 'json', None] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
if filepath == '-':
|
|
81
|
+
filepath = '/dev/stdin'
|
|
82
|
+
|
|
73
83
|
if not os.path.exists(filepath):
|
|
74
84
|
print(f"Error: File not found at '{filepath}'", file=sys.stderr)
|
|
75
85
|
return
|
|
@@ -81,13 +91,21 @@ def view_json(filepath: str, port: int) -> None:
|
|
|
81
91
|
print(f'Error: Invalid JSON file. {e}', file=sys.stderr)
|
|
82
92
|
return
|
|
83
93
|
|
|
84
|
-
if
|
|
94
|
+
if mode is None:
|
|
95
|
+
if filepath.endswith('.jsonl'):
|
|
96
|
+
mode = 'json'
|
|
97
|
+
elif filepath.endswith('.json5'):
|
|
98
|
+
mode = 'json5'
|
|
99
|
+
|
|
100
|
+
if mode == 'jsonl':
|
|
85
101
|
json_content = [json.loads(sl) for l in raw_content.splitlines() if (sl := l.strip())]
|
|
86
|
-
elif
|
|
102
|
+
elif mode == 'json5':
|
|
87
103
|
from omlish.formats import json5
|
|
88
104
|
json_content = json5.loads(raw_content)
|
|
89
|
-
|
|
105
|
+
elif mode in ('json', None):
|
|
90
106
|
json_content = json.loads(raw_content)
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(mode)
|
|
91
109
|
|
|
92
110
|
# Use compact dumps for embedding in JS, it's more efficient
|
|
93
111
|
json_string = json.dumps(json_content)
|
|
@@ -142,9 +160,17 @@ def _main() -> None:
|
|
|
142
160
|
default=(default_port := 8999),
|
|
143
161
|
help=f'The port to run the web server on. Defaults to {default_port}.',
|
|
144
162
|
)
|
|
163
|
+
parser.add_argument('-l', '--lines', action='store_true')
|
|
164
|
+
parser.add_argument('-5', '--five', action='store_true')
|
|
145
165
|
args = parser.parse_args()
|
|
146
166
|
|
|
147
|
-
|
|
167
|
+
check.state(not (args.lines and args.five))
|
|
168
|
+
|
|
169
|
+
view_json(
|
|
170
|
+
args.filepath,
|
|
171
|
+
args.port,
|
|
172
|
+
mode='jsonl' if args.lines else 'json5' if args.five else None,
|
|
173
|
+
)
|
|
148
174
|
|
|
149
175
|
|
|
150
176
|
if __name__ == '__main__':
|
omdev/tools/pawk/pawk.py
CHANGED
|
@@ -384,9 +384,9 @@ def main() -> None:
|
|
|
384
384
|
# Workaround for close failed in file object destructor: sys.excepthook is missing lost sys.stderr
|
|
385
385
|
# http://stackoverflow.com/questions/7955138/addressing-sys-excepthook-error-in-bash-script
|
|
386
386
|
sys.stderr.write(str(e) + '\n')
|
|
387
|
-
|
|
387
|
+
raise SystemExit(1) from None
|
|
388
388
|
except KeyboardInterrupt:
|
|
389
|
-
|
|
389
|
+
raise SystemExit(1) from None
|
|
390
390
|
|
|
391
391
|
|
|
392
392
|
# @omlish-manifest
|
omdev/tools/pip.py
CHANGED
|
@@ -22,6 +22,11 @@ from ..pip import lookup_latest_package_version
|
|
|
22
22
|
##
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
DEV_DEP_NAMES: ta.AbstractSet[str] = frozenset([
|
|
26
|
+
'pydevd-pycharm',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
|
|
25
30
|
class Cli(ap.Cli):
|
|
26
31
|
@ap.cmd(
|
|
27
32
|
ap.arg('package'),
|
|
@@ -49,6 +54,9 @@ class Cli(ap.Cli):
|
|
|
49
54
|
for l in src.splitlines(keepends=True):
|
|
50
55
|
if l.startswith('-e'):
|
|
51
56
|
continue
|
|
57
|
+
pr = parse_requirement(l)
|
|
58
|
+
if pr.name in DEV_DEP_NAMES:
|
|
59
|
+
continue
|
|
52
60
|
out.append(l)
|
|
53
61
|
|
|
54
62
|
new_src = ''.join(out)
|
omdev/tui/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import pathlib
|
|
3
|
+
import typing as ta
|
|
4
|
+
|
|
5
|
+
from ... import textual as tx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
##
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QuitConfirmScreen(tx.ModalScreen[bool]):
|
|
12
|
+
"""Screen with a dialog to confirm quit without saving."""
|
|
13
|
+
|
|
14
|
+
CSS = """
|
|
15
|
+
QuitConfirmScreen {
|
|
16
|
+
align: center middle;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#dialog {
|
|
20
|
+
width: 60;
|
|
21
|
+
height: 11;
|
|
22
|
+
border: thick $background 80%;
|
|
23
|
+
background: $surface;
|
|
24
|
+
padding: 1 2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#question {
|
|
28
|
+
height: 3;
|
|
29
|
+
content-align: center middle;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Button {
|
|
33
|
+
width: 1fr;
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def compose(self) -> tx.ComposeResult:
|
|
38
|
+
yield tx.Vertical(
|
|
39
|
+
tx.Label('You have unsaved changes. Do you want to save before quitting?', id='question'),
|
|
40
|
+
tx.Button('Save and Quit', variant='success', id='save'),
|
|
41
|
+
tx.Button('Quit Without Saving', variant='warning', id='quit'),
|
|
42
|
+
tx.Button('Cancel', variant='primary', id='cancel'),
|
|
43
|
+
id='dialog',
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def on_button_pressed(self, event: tx.Button.Pressed) -> None:
|
|
47
|
+
if event.button.id == 'save':
|
|
48
|
+
self.dismiss(True)
|
|
49
|
+
elif event.button.id == 'quit':
|
|
50
|
+
self.dismiss(False)
|
|
51
|
+
else:
|
|
52
|
+
self.dismiss(None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TextEditor(tx.App):
|
|
56
|
+
"""A simple text editor using Textual."""
|
|
57
|
+
|
|
58
|
+
CSS = """
|
|
59
|
+
TextArea {
|
|
60
|
+
height: 1fr;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Container {
|
|
64
|
+
height: 100%;
|
|
65
|
+
}
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
BINDINGS: ta.ClassVar[ta.Sequence[tx.Binding]] = [
|
|
69
|
+
tx.Binding('ctrl+s', 'save', 'Save', show=True),
|
|
70
|
+
tx.Binding('ctrl+q', 'quit', 'Quit', show=True),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def __init__(self, filepath: pathlib.Path):
|
|
74
|
+
super().__init__()
|
|
75
|
+
|
|
76
|
+
self.filepath = filepath
|
|
77
|
+
self.text_area: tx.TextArea | None = None
|
|
78
|
+
self.modified = False
|
|
79
|
+
self.original_content = ''
|
|
80
|
+
|
|
81
|
+
def compose(self) -> tx.ComposeResult:
|
|
82
|
+
"""Create child widgets for the app."""
|
|
83
|
+
|
|
84
|
+
yield tx.Header()
|
|
85
|
+
yield tx.Container(tx.TextArea(id='editor'))
|
|
86
|
+
yield tx.Footer()
|
|
87
|
+
|
|
88
|
+
def on_mount(self) -> None:
|
|
89
|
+
"""Load the file content when the app starts."""
|
|
90
|
+
|
|
91
|
+
self.text_area = self.query_one('#editor', tx.TextArea)
|
|
92
|
+
|
|
93
|
+
# Load existing file or create new
|
|
94
|
+
if self.filepath.exists():
|
|
95
|
+
content = self.filepath.read_text()
|
|
96
|
+
self.text_area.load_text(content)
|
|
97
|
+
self.original_content = content
|
|
98
|
+
else:
|
|
99
|
+
self.original_content = ''
|
|
100
|
+
|
|
101
|
+
self.title = f'Text Editor - {self.filepath.name}'
|
|
102
|
+
self.sub_title = str(self.filepath)
|
|
103
|
+
|
|
104
|
+
def on_text_area_changed(self, event: tx.TextArea.Changed) -> None:
|
|
105
|
+
"""Track if the document has been modified."""
|
|
106
|
+
|
|
107
|
+
if self.text_area:
|
|
108
|
+
current_content = self.text_area.text
|
|
109
|
+
self.modified = current_content != self.original_content
|
|
110
|
+
|
|
111
|
+
# Update title to show modified status
|
|
112
|
+
status = ' *' if self.modified else ''
|
|
113
|
+
self.title = f'Text Editor - {self.filepath.name}{status}'
|
|
114
|
+
|
|
115
|
+
def action_save(self) -> None:
|
|
116
|
+
"""Save the current content to file."""
|
|
117
|
+
|
|
118
|
+
if self.text_area:
|
|
119
|
+
content = self.text_area.text
|
|
120
|
+
self.filepath.write_text(content)
|
|
121
|
+
self.original_content = content
|
|
122
|
+
self.modified = False
|
|
123
|
+
self.title = f'Text Editor - {self.filepath.name}'
|
|
124
|
+
self.notify(f'Saved to {self.filepath.name}')
|
|
125
|
+
|
|
126
|
+
async def action_quit(self) -> None:
|
|
127
|
+
"""Quit the editor, prompting to save if modified."""
|
|
128
|
+
|
|
129
|
+
if self.modified:
|
|
130
|
+
def check_quit(should_save: bool | None) -> None:
|
|
131
|
+
if should_save is None:
|
|
132
|
+
# User cancelled
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if should_save:
|
|
136
|
+
self.action_save()
|
|
137
|
+
|
|
138
|
+
self.exit()
|
|
139
|
+
|
|
140
|
+
await self.push_screen(QuitConfirmScreen(), callback=check_quit)
|
|
141
|
+
|
|
142
|
+
else:
|
|
143
|
+
self.exit()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main() -> None:
|
|
147
|
+
"""Parse arguments and run the editor."""
|
|
148
|
+
|
|
149
|
+
parser = argparse.ArgumentParser(description='Simple text editor')
|
|
150
|
+
parser.add_argument('filename', help='File to edit')
|
|
151
|
+
args = parser.parse_args()
|
|
152
|
+
|
|
153
|
+
filepath = pathlib.Path(args.filename).resolve()
|
|
154
|
+
|
|
155
|
+
# Create parent directories if needed
|
|
156
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
|
|
158
|
+
app = TextEditor(filepath)
|
|
159
|
+
app.run()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == '__main__':
|
|
163
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TODO:
|
|
3
|
+
- use omdev.irc obv
|
|
4
|
+
- readliney input
|
|
5
|
+
- command suggest / autocomplete
|
|
6
|
+
- disable command palette
|
|
7
|
+
- grow input box for multiline
|
|
8
|
+
- styled text
|
|
9
|
+
"""
|
|
10
|
+
import shlex
|
|
11
|
+
import typing as ta
|
|
12
|
+
|
|
13
|
+
from omlish import lang
|
|
14
|
+
|
|
15
|
+
from ... import textual as tx
|
|
16
|
+
from .client import IrcClient
|
|
17
|
+
from .commands import ALL_COMMANDS
|
|
18
|
+
from .commands import IrcCommand
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IrcWindow:
|
|
25
|
+
"""Represents a chat window."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
|
|
30
|
+
self.name: str = name
|
|
31
|
+
self.lines: list[str] = []
|
|
32
|
+
self.unread: int = 0
|
|
33
|
+
self.displayed_line_count: int = 0 # Track how many lines are currently displayed
|
|
34
|
+
|
|
35
|
+
def add_line(self, line: str) -> None:
|
|
36
|
+
self.lines.append(line)
|
|
37
|
+
self.unread += 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class IrcApp(tx.App):
|
|
41
|
+
"""IRC client application."""
|
|
42
|
+
|
|
43
|
+
_commands: ta.ClassVar[ta.Mapping[str, IrcCommand]] = ALL_COMMANDS
|
|
44
|
+
|
|
45
|
+
CSS = """
|
|
46
|
+
#messages {
|
|
47
|
+
height: 1fr;
|
|
48
|
+
overflow-y: auto;
|
|
49
|
+
border: none;
|
|
50
|
+
padding: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#status {
|
|
54
|
+
height: 1;
|
|
55
|
+
background: $primary;
|
|
56
|
+
color: $text;
|
|
57
|
+
border: none;
|
|
58
|
+
padding: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#input {
|
|
62
|
+
dock: bottom;
|
|
63
|
+
border: none;
|
|
64
|
+
padding: 0;
|
|
65
|
+
}
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
BINDINGS: ta.ClassVar[ta.Sequence[tx.Binding]] = [
|
|
69
|
+
tx.Binding('ctrl+n', 'next_window', 'Next Window', show=False),
|
|
70
|
+
tx.Binding('ctrl+p', 'prev_window', 'Previous Window', show=False),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
startup_commands: ta.Sequence[str] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__()
|
|
79
|
+
|
|
80
|
+
self._client: IrcClient | None = None
|
|
81
|
+
self._windows: dict[str, IrcWindow] = {'system': IrcWindow('system')}
|
|
82
|
+
self._window_order: list[str] = ['system']
|
|
83
|
+
self._current_window_idx: int = 0
|
|
84
|
+
self._current_channel: str | None = None
|
|
85
|
+
self._startup_commands: ta.Sequence[str] = startup_commands or []
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def client(self) -> IrcClient | None:
|
|
89
|
+
return self._client
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def current_channel(self) -> str | None:
|
|
93
|
+
return self._current_channel
|
|
94
|
+
|
|
95
|
+
#
|
|
96
|
+
|
|
97
|
+
def compose(self) -> tx.ComposeResult:
|
|
98
|
+
text_area = tx.TextArea(id='messages', read_only=True, show_line_numbers=False)
|
|
99
|
+
text_area.cursor_blink = False
|
|
100
|
+
yield text_area
|
|
101
|
+
yield tx.Static('', id='status')
|
|
102
|
+
yield tx.Input(placeholder='Enter command or message', id='input', select_on_focus=False)
|
|
103
|
+
|
|
104
|
+
async def on_mount(self) -> None:
|
|
105
|
+
"""Initialize on mount."""
|
|
106
|
+
|
|
107
|
+
self._client = IrcClient(self.on_irc_message)
|
|
108
|
+
self.update_display()
|
|
109
|
+
self.query_one('#input').focus()
|
|
110
|
+
|
|
111
|
+
# Show connection prompt
|
|
112
|
+
await self.add_message('system', 'IRC Client - Use /connect <server> <port> <nickname>')
|
|
113
|
+
await self.add_message('system', 'Example: /connect irc.libera.chat 6667 mynick')
|
|
114
|
+
|
|
115
|
+
# Execute startup commands
|
|
116
|
+
for cmd in self._startup_commands:
|
|
117
|
+
# Add leading slash if not present
|
|
118
|
+
if not cmd.startswith('/'):
|
|
119
|
+
cmd = '/' + cmd
|
|
120
|
+
await self.add_message('system', f'Executing: {cmd}')
|
|
121
|
+
await self.handle_command(cmd)
|
|
122
|
+
|
|
123
|
+
async def on_key(self, event: tx.Key) -> None:
|
|
124
|
+
"""Handle key events - redirect typing to input when messages area is focused."""
|
|
125
|
+
|
|
126
|
+
focused = self.focused
|
|
127
|
+
if focused and focused.id == 'messages':
|
|
128
|
+
# If a printable character or common input key is pressed, focus the input and forward event
|
|
129
|
+
if event.is_printable or event.key in ('space', 'backspace', 'delete'):
|
|
130
|
+
input_widget = self.query_one('#input', tx.Input)
|
|
131
|
+
input_widget.focus()
|
|
132
|
+
# Post the key event to the input widget so it handles it naturally
|
|
133
|
+
input_widget.post_message(tx.Key(event.key, event.character))
|
|
134
|
+
# Stop the event from being processed by the messages widget
|
|
135
|
+
event.stop()
|
|
136
|
+
|
|
137
|
+
async def on_input_submitted(self, event: tx.Input.Submitted) -> None:
|
|
138
|
+
"""Handle user input."""
|
|
139
|
+
|
|
140
|
+
text = event.value.strip()
|
|
141
|
+
event.input.value = ''
|
|
142
|
+
|
|
143
|
+
if not text:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Handle commands
|
|
147
|
+
if text.startswith('/'):
|
|
148
|
+
await self.handle_command(text)
|
|
149
|
+
else:
|
|
150
|
+
# Send message to current channel
|
|
151
|
+
if self._current_channel and self._client and self._client.connected:
|
|
152
|
+
await self._client.privmsg(self._current_channel, text)
|
|
153
|
+
await self.add_message(self._current_channel, f'<{self._client.nickname}> {text}')
|
|
154
|
+
else:
|
|
155
|
+
await self.add_message('system', 'Not in a channel or not connected')
|
|
156
|
+
|
|
157
|
+
async def handle_command(self, text: str) -> None:
|
|
158
|
+
"""Handle IRC commands."""
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
parts = shlex.split(text)
|
|
162
|
+
except ValueError as e:
|
|
163
|
+
await self.add_message('system', f'Invalid command syntax: {e}')
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if not parts:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
cmd = parts[0].lstrip('/').lower()
|
|
170
|
+
argv = parts[1:]
|
|
171
|
+
|
|
172
|
+
command = self._commands.get(cmd)
|
|
173
|
+
if command:
|
|
174
|
+
await command.run(self, argv)
|
|
175
|
+
else:
|
|
176
|
+
await self.add_message('system', f'Unknown command: /{cmd}')
|
|
177
|
+
|
|
178
|
+
def action_next_window(self) -> None:
|
|
179
|
+
"""Switch to next window."""
|
|
180
|
+
|
|
181
|
+
if len(self._window_order) > 1:
|
|
182
|
+
self._current_window_idx = (self._current_window_idx + 1) % len(self._window_order)
|
|
183
|
+
self.update_display()
|
|
184
|
+
|
|
185
|
+
def action_prev_window(self) -> None:
|
|
186
|
+
"""Switch to previous window."""
|
|
187
|
+
|
|
188
|
+
if len(self._window_order) > 1:
|
|
189
|
+
self._current_window_idx = (self._current_window_idx - 1) % len(self._window_order)
|
|
190
|
+
self.update_display()
|
|
191
|
+
|
|
192
|
+
def get_or_create_window(self, name: str) -> IrcWindow:
|
|
193
|
+
"""Get or create a window."""
|
|
194
|
+
|
|
195
|
+
if name not in self._windows:
|
|
196
|
+
self._windows[name] = IrcWindow(name)
|
|
197
|
+
self._window_order.append(name)
|
|
198
|
+
return self._windows[name]
|
|
199
|
+
|
|
200
|
+
def switch_to_window(self, name: str) -> None:
|
|
201
|
+
"""Switch to a specific window."""
|
|
202
|
+
|
|
203
|
+
if name in self._window_order:
|
|
204
|
+
self._current_window_idx = self._window_order.index(name)
|
|
205
|
+
self.update_display()
|
|
206
|
+
|
|
207
|
+
async def add_message(self, window_name: str, message: str) -> None:
|
|
208
|
+
"""Add a message to a window."""
|
|
209
|
+
|
|
210
|
+
window = self.get_or_create_window(window_name)
|
|
211
|
+
timestamp = lang.utcnow().strftime('%H:%M')
|
|
212
|
+
window.add_line(f'[{timestamp}] {message}')
|
|
213
|
+
self.update_display()
|
|
214
|
+
|
|
215
|
+
async def on_irc_message(self, window_name: str, message: str) -> None:
|
|
216
|
+
"""Callback for IRC messages."""
|
|
217
|
+
|
|
218
|
+
await self.add_message(window_name, message)
|
|
219
|
+
|
|
220
|
+
_last_window: str | None = None
|
|
221
|
+
|
|
222
|
+
def update_display(self) -> None:
|
|
223
|
+
"""Update the display."""
|
|
224
|
+
|
|
225
|
+
current_window_name = self._window_order[self._current_window_idx]
|
|
226
|
+
current_window = self._windows[current_window_name]
|
|
227
|
+
|
|
228
|
+
# Update current channel for sending messages
|
|
229
|
+
self._current_channel = current_window_name if current_window_name.startswith('#') else None
|
|
230
|
+
|
|
231
|
+
# Mark as read
|
|
232
|
+
current_window.unread = 0
|
|
233
|
+
|
|
234
|
+
# Update messages display
|
|
235
|
+
messages_widget = self.query_one('#messages', tx.TextArea)
|
|
236
|
+
|
|
237
|
+
# Check if we switched windows or need full reload
|
|
238
|
+
window_changed = self._last_window != current_window_name
|
|
239
|
+
self._last_window = current_window_name
|
|
240
|
+
|
|
241
|
+
lines_to_show = current_window.lines[-100:] # Last 100 lines
|
|
242
|
+
|
|
243
|
+
if window_changed:
|
|
244
|
+
# Full reload when switching windows
|
|
245
|
+
messages_widget.load_text('\n'.join(lines_to_show))
|
|
246
|
+
current_window.displayed_line_count = len(lines_to_show)
|
|
247
|
+
|
|
248
|
+
else:
|
|
249
|
+
# Append only new lines
|
|
250
|
+
new_line_count = len(lines_to_show) - current_window.displayed_line_count
|
|
251
|
+
if new_line_count > 0:
|
|
252
|
+
new_lines = lines_to_show[-new_line_count:]
|
|
253
|
+
# Get the end position
|
|
254
|
+
doc = messages_widget.document
|
|
255
|
+
end_line = doc.line_count - 1
|
|
256
|
+
end_col = len(doc.get_line(end_line))
|
|
257
|
+
# Append new lines using insert
|
|
258
|
+
prefix = '\n' if len(doc.text) > 0 else ''
|
|
259
|
+
messages_widget.insert(prefix + '\n'.join(new_lines), location=(end_line, end_col))
|
|
260
|
+
current_window.displayed_line_count = len(lines_to_show)
|
|
261
|
+
|
|
262
|
+
# Update status line
|
|
263
|
+
window_list = []
|
|
264
|
+
for i, name in enumerate(self._window_order):
|
|
265
|
+
win = self._windows[name]
|
|
266
|
+
indicator = f'[{i + 1}:{name}]'
|
|
267
|
+
if i == self._current_window_idx:
|
|
268
|
+
indicator = f'[{i + 1}:{name}*]'
|
|
269
|
+
elif win.unread > 0:
|
|
270
|
+
indicator = f'[{i + 1}:{name}({win.unread})]'
|
|
271
|
+
window_list.append(indicator)
|
|
272
|
+
|
|
273
|
+
status_text = ' '.join(window_list)
|
|
274
|
+
self.query_one('#status', tx.Static).update(status_text)
|
|
275
|
+
|
|
276
|
+
async def on_unmount(self) -> None:
|
|
277
|
+
if (cl := self._client) is not None:
|
|
278
|
+
await cl.shutdown()
|