omdev 0.0.0.dev440__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.

Files changed (132) hide show
  1. omdev/.omlish-manifests.json +17 -29
  2. omdev/__about__.py +9 -7
  3. omdev/amalg/gen/gen.py +49 -6
  4. omdev/amalg/gen/imports.py +1 -1
  5. omdev/amalg/gen/manifests.py +1 -1
  6. omdev/amalg/gen/resources.py +1 -1
  7. omdev/amalg/gen/srcfiles.py +13 -3
  8. omdev/amalg/gen/strip.py +1 -1
  9. omdev/amalg/gen/types.py +1 -1
  10. omdev/amalg/gen/typing.py +1 -1
  11. omdev/amalg/info.py +32 -0
  12. omdev/cache/data/actions.py +1 -1
  13. omdev/cache/data/specs.py +1 -1
  14. omdev/cexts/_boilerplate.cc +2 -3
  15. omdev/cexts/cmake.py +4 -1
  16. omdev/ci/cli.py +1 -2
  17. omdev/cmdlog/cli.py +1 -2
  18. omdev/dataclasses/_dumping.py +1960 -0
  19. omdev/dataclasses/_template.py +22 -0
  20. omdev/dataclasses/cli.py +6 -1
  21. omdev/dataclasses/codegen.py +340 -60
  22. omdev/dataclasses/dumping.py +200 -0
  23. omdev/interp/uv/provider.py +1 -0
  24. omdev/interp/venvs.py +1 -0
  25. omdev/irc/messages/base.py +50 -0
  26. omdev/irc/messages/formats.py +92 -0
  27. omdev/irc/messages/messages.py +775 -0
  28. omdev/irc/messages/parsing.py +99 -0
  29. omdev/irc/numerics/__init__.py +0 -0
  30. omdev/irc/numerics/formats.py +97 -0
  31. omdev/irc/numerics/numerics.py +865 -0
  32. omdev/irc/numerics/types.py +59 -0
  33. omdev/irc/protocol/LICENSE +11 -0
  34. omdev/irc/protocol/__init__.py +61 -0
  35. omdev/irc/protocol/consts.py +6 -0
  36. omdev/irc/protocol/errors.py +30 -0
  37. omdev/irc/protocol/message.py +21 -0
  38. omdev/irc/protocol/nuh.py +55 -0
  39. omdev/irc/protocol/parsing.py +158 -0
  40. omdev/irc/protocol/rendering.py +153 -0
  41. omdev/irc/protocol/tags.py +102 -0
  42. omdev/irc/protocol/utils.py +30 -0
  43. omdev/manifests/_dumping.py +125 -25
  44. omdev/markdown/__init__.py +0 -0
  45. omdev/markdown/incparse.py +116 -0
  46. omdev/markdown/tokens.py +51 -0
  47. omdev/packaging/marshal.py +8 -8
  48. omdev/packaging/requires.py +6 -6
  49. omdev/packaging/specifiers.py +2 -1
  50. omdev/packaging/versions.py +4 -4
  51. omdev/packaging/wheelfile.py +2 -0
  52. omdev/precheck/blanklines.py +66 -0
  53. omdev/precheck/caches.py +1 -1
  54. omdev/precheck/imports.py +14 -1
  55. omdev/precheck/main.py +4 -3
  56. omdev/precheck/unicode.py +39 -15
  57. omdev/py/asts/__init__.py +0 -0
  58. omdev/py/asts/parents.py +28 -0
  59. omdev/py/asts/toplevel.py +123 -0
  60. omdev/py/asts/visitors.py +18 -0
  61. omdev/py/attrdocs.py +1 -1
  62. omdev/py/bracepy.py +12 -4
  63. omdev/py/reprs.py +32 -0
  64. omdev/py/srcheaders.py +1 -1
  65. omdev/py/tokens/__init__.py +0 -0
  66. omdev/py/tools/mkrelimp.py +1 -1
  67. omdev/py/tools/pipdepup.py +629 -0
  68. omdev/pyproject/pkg.py +190 -45
  69. omdev/pyproject/reqs.py +31 -9
  70. omdev/pyproject/tools/__init__.py +0 -0
  71. omdev/pyproject/tools/aboutdeps.py +55 -0
  72. omdev/pyproject/venvs.py +8 -1
  73. omdev/rs/__init__.py +0 -0
  74. omdev/scripts/ci.py +398 -80
  75. omdev/scripts/interp.py +193 -35
  76. omdev/scripts/lib/inject.py +74 -27
  77. omdev/scripts/lib/logs.py +75 -27
  78. omdev/scripts/lib/marshal.py +67 -25
  79. omdev/scripts/pyproject.py +941 -90
  80. omdev/tools/git/cli.py +10 -0
  81. omdev/tools/json/processing.py +5 -2
  82. omdev/tools/jsonview/cli.py +31 -5
  83. omdev/tools/pawk/pawk.py +2 -2
  84. omdev/tools/pip.py +8 -0
  85. omdev/tui/__init__.py +0 -0
  86. omdev/tui/apps/__init__.py +0 -0
  87. omdev/tui/apps/edit/__init__.py +0 -0
  88. omdev/tui/apps/edit/main.py +163 -0
  89. omdev/tui/apps/irc/__init__.py +0 -0
  90. omdev/tui/apps/irc/__main__.py +4 -0
  91. omdev/tui/apps/irc/app.py +278 -0
  92. omdev/tui/apps/irc/client.py +187 -0
  93. omdev/tui/apps/irc/commands.py +175 -0
  94. omdev/tui/apps/irc/main.py +26 -0
  95. omdev/tui/apps/markdown/__init__.py +0 -0
  96. omdev/tui/apps/markdown/__main__.py +11 -0
  97. omdev/{ptk → tui/apps}/markdown/cli.py +5 -7
  98. omdev/tui/rich/__init__.py +34 -0
  99. omdev/tui/rich/console2.py +20 -0
  100. omdev/tui/rich/markdown2.py +186 -0
  101. omdev/tui/textual/__init__.py +226 -0
  102. omdev/tui/textual/app2.py +11 -0
  103. omdev/tui/textual/autocomplete/LICENSE +21 -0
  104. omdev/tui/textual/autocomplete/__init__.py +33 -0
  105. omdev/tui/textual/autocomplete/matching.py +226 -0
  106. omdev/tui/textual/autocomplete/paths.py +202 -0
  107. omdev/tui/textual/autocomplete/widget.py +612 -0
  108. omdev/tui/textual/drivers2.py +55 -0
  109. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev486.dist-info}/METADATA +11 -9
  110. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev486.dist-info}/RECORD +119 -73
  111. omdev/ptk/__init__.py +0 -103
  112. omdev/ptk/apps/ncdu.py +0 -167
  113. omdev/ptk/confirm.py +0 -60
  114. omdev/ptk/markdown/LICENSE +0 -22
  115. omdev/ptk/markdown/__init__.py +0 -10
  116. omdev/ptk/markdown/__main__.py +0 -11
  117. omdev/ptk/markdown/border.py +0 -94
  118. omdev/ptk/markdown/markdown.py +0 -390
  119. omdev/ptk/markdown/parser.py +0 -42
  120. omdev/ptk/markdown/styles.py +0 -29
  121. omdev/ptk/markdown/tags.py +0 -299
  122. omdev/ptk/markdown/utils.py +0 -366
  123. omdev/pyproject/cexts.py +0 -110
  124. /omdev/{ptk/apps → irc}/__init__.py +0 -0
  125. /omdev/{tokens → irc/messages}/__init__.py +0 -0
  126. /omdev/{tokens → py/tokens}/all.py +0 -0
  127. /omdev/{tokens → py/tokens}/tokenizert.py +0 -0
  128. /omdev/{tokens → py/tokens}/utils.py +0 -0
  129. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev486.dist-info}/WHEEL +0 -0
  130. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev486.dist-info}/entry_points.txt +0 -0
  131. {omdev-0.0.0.dev440.dist-info → omdev-0.0.0.dev486.dist-info}/licenses/LICENSE +0 -0
  132. {omdev-0.0.0.dev440.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
@@ -452,6 +453,7 @@ class Cli(ap.Cli):
452
453
  BUILTIN_COMMIT_MESSAGES: ta.Mapping[str, str] = {
453
454
  'tableflip': '(╯°□°)╯︵ ┻━┻',
454
455
  'tableunflip': '┬─┬ノ(º _ ºノ)',
456
+ 'shrug': r'¯\_(ツ)_/¯',
455
457
  }
456
458
 
457
459
  @ap.cmd(
@@ -501,10 +503,18 @@ class Cli(ap.Cli):
501
503
  repo_dir = os.path.join(tmp_dir, repo_dir_name)
502
504
  check.state(os.path.isdir(repo_dir))
503
505
 
506
+ #
507
+
504
508
  git_dir = os.path.join(repo_dir, '.git')
505
509
  check.state(os.path.isdir(git_dir))
506
510
  shutil.rmtree(git_dir)
507
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
+
508
518
  shutil.move(repo_dir, cwd)
509
519
 
510
520
  out_dir = repo_dir_name
@@ -53,8 +53,11 @@ class Processor:
53
53
 
54
54
  def _marshal(self, v: ta.Any) -> ta.Any:
55
55
  return msh.MarshalContext(
56
- config_registry=msh.global_config_registry(),
57
- factory=self._marshaler_factory(),
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]:
@@ -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(filepath: str, port: int) -> None:
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 filepath.endswith('.jsonl'):
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 filepath.endswith('.json5'):
102
+ elif mode == 'json5':
87
103
  from omlish.formats import json5
88
104
  json_content = json5.loads(raw_content)
89
- else:
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
- view_json(args.filepath, args.port)
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
- sys.exit(1)
387
+ raise SystemExit(1) from None
388
388
  except KeyboardInterrupt:
389
- sys.exit(1)
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,4 @@
1
+ if __name__ == '__main__':
2
+ from .main import _main
3
+
4
+ _main()
@@ -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()