omdev 0.0.0.dev416__py3-none-any.whl → 0.0.0.dev500__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 (211) hide show
  1. omdev/{.manifests.json → .omlish-manifests.json} +23 -47
  2. omdev/README.md +51 -0
  3. omdev/__about__.py +12 -8
  4. omdev/amalg/cli/main.py +1 -2
  5. omdev/amalg/gen/gen.py +49 -6
  6. omdev/amalg/gen/imports.py +1 -1
  7. omdev/amalg/gen/manifests.py +1 -1
  8. omdev/amalg/gen/resources.py +1 -1
  9. omdev/amalg/gen/srcfiles.py +26 -3
  10. omdev/amalg/gen/strip.py +1 -1
  11. omdev/amalg/gen/types.py +1 -1
  12. omdev/amalg/gen/typing.py +1 -1
  13. omdev/amalg/info.py +32 -0
  14. omdev/cache/compute/storage.py +3 -1
  15. omdev/cache/data/actions.py +1 -1
  16. omdev/cache/data/cache.py +2 -2
  17. omdev/cache/data/specs.py +1 -1
  18. omdev/cexts/_boilerplate.cc +2 -3
  19. omdev/cexts/_distutils/build_ext.py +5 -2
  20. omdev/cexts/_distutils/compilers/ccompiler.py +5 -2
  21. omdev/cexts/_distutils/compilers/options.py +3 -0
  22. omdev/cexts/_distutils/compilers/unixccompiler.py +6 -2
  23. omdev/cexts/_distutils/dir_util.py +6 -2
  24. omdev/cexts/_distutils/errors.py +3 -0
  25. omdev/cexts/_distutils/extension.py +3 -0
  26. omdev/cexts/_distutils/file_util.py +6 -2
  27. omdev/cexts/_distutils/modified.py +3 -0
  28. omdev/cexts/_distutils/spawn.py +6 -2
  29. omdev/cexts/_distutils/sysconfig.py +3 -0
  30. omdev/cexts/_distutils/util.py +6 -2
  31. omdev/cexts/_distutils/version.py +3 -0
  32. omdev/cexts/cmake.py +5 -3
  33. omdev/cexts/scan.py +1 -2
  34. omdev/ci/cache.py +7 -3
  35. omdev/ci/cli.py +6 -4
  36. omdev/ci/docker/buildcaching.py +3 -1
  37. omdev/ci/docker/cache.py +2 -1
  38. omdev/ci/docker/cacheserved/cache.py +4 -1
  39. omdev/ci/docker/cacheserved/manifests.py +2 -2
  40. omdev/ci/docker/dataserver.py +2 -2
  41. omdev/ci/docker/imagepulling.py +2 -1
  42. omdev/ci/docker/packing.py +1 -1
  43. omdev/ci/docker/repositories.py +2 -1
  44. omdev/ci/github/api/clients.py +8 -4
  45. omdev/ci/github/api/v1/client.py +4 -1
  46. omdev/ci/github/api/v2/api.py +2 -0
  47. omdev/ci/github/api/v2/azure.py +4 -1
  48. omdev/ci/github/api/v2/client.py +4 -1
  49. omdev/cli/clicli.py +37 -7
  50. omdev/clipboard/clipboard.py +1 -1
  51. omdev/cmake.py +2 -1
  52. omdev/cmdlog/cli.py +1 -2
  53. omdev/dataclasses/_dumping.py +1960 -0
  54. omdev/dataclasses/_template.py +22 -0
  55. omdev/dataclasses/cli.py +7 -2
  56. omdev/dataclasses/codegen.py +342 -62
  57. omdev/dataclasses/dumping.py +200 -0
  58. omdev/dataserver/handlers.py +3 -2
  59. omdev/dataserver/targets.py +2 -2
  60. omdev/imgur.py +2 -2
  61. omdev/interp/cli.py +1 -1
  62. omdev/interp/inspect.py +2 -1
  63. omdev/interp/providers/base.py +3 -2
  64. omdev/interp/providers/standalone.py +4 -1
  65. omdev/interp/providers/system.py +2 -2
  66. omdev/interp/pyenv/install.py +2 -1
  67. omdev/interp/pyenv/provider.py +2 -2
  68. omdev/interp/types.py +3 -2
  69. omdev/interp/uv/provider.py +40 -2
  70. omdev/interp/uv/uv.py +2 -2
  71. omdev/interp/venvs.py +3 -2
  72. omdev/irc/messages/base.py +50 -0
  73. omdev/irc/messages/formats.py +92 -0
  74. omdev/irc/messages/messages.py +775 -0
  75. omdev/irc/messages/parsing.py +99 -0
  76. omdev/irc/numerics/formats.py +97 -0
  77. omdev/irc/numerics/numerics.py +865 -0
  78. omdev/irc/numerics/types.py +59 -0
  79. omdev/irc/protocol/LICENSE +11 -0
  80. omdev/irc/protocol/__init__.py +61 -0
  81. omdev/irc/protocol/consts.py +6 -0
  82. omdev/irc/protocol/errors.py +30 -0
  83. omdev/irc/protocol/message.py +21 -0
  84. omdev/irc/protocol/nuh.py +55 -0
  85. omdev/irc/protocol/parsing.py +158 -0
  86. omdev/irc/protocol/rendering.py +153 -0
  87. omdev/irc/protocol/tags.py +102 -0
  88. omdev/irc/protocol/utils.py +30 -0
  89. omdev/manifests/_dumping.py +529 -136
  90. omdev/manifests/building.py +6 -3
  91. omdev/manifests/main.py +1 -1
  92. omdev/markdown/__init__.py +0 -0
  93. omdev/markdown/incparse.py +116 -0
  94. omdev/markdown/tokens.py +51 -0
  95. omdev/oci/data.py +2 -2
  96. omdev/oci/datarefs.py +2 -2
  97. omdev/oci/media.py +2 -2
  98. omdev/oci/repositories.py +3 -2
  99. omdev/packaging/marshal.py +9 -9
  100. omdev/packaging/requires.py +6 -6
  101. omdev/packaging/revisions.py +5 -2
  102. omdev/packaging/specifiers.py +41 -42
  103. omdev/packaging/versions.py +10 -10
  104. omdev/packaging/wheelfile.py +4 -2
  105. omdev/precheck/blanklines.py +66 -0
  106. omdev/precheck/caches.py +1 -1
  107. omdev/precheck/imports.py +14 -1
  108. omdev/precheck/lite.py +2 -2
  109. omdev/precheck/main.py +5 -5
  110. omdev/precheck/unicode.py +39 -15
  111. omdev/py/asts/__init__.py +0 -0
  112. omdev/py/asts/parents.py +28 -0
  113. omdev/py/asts/toplevel.py +123 -0
  114. omdev/py/asts/visitors.py +18 -0
  115. omdev/py/attrdocs.py +6 -7
  116. omdev/py/bracepy.py +12 -4
  117. omdev/py/docstrings/numpydoc.py +4 -4
  118. omdev/py/reprs.py +32 -0
  119. omdev/py/scripts/execstat.py +31 -26
  120. omdev/py/srcheaders.py +1 -1
  121. omdev/py/tokens/__init__.py +0 -0
  122. omdev/{tokens → py/tokens}/utils.py +2 -1
  123. omdev/py/tools/importscan.py +2 -2
  124. omdev/py/tools/mkrelimp.py +3 -4
  125. omdev/py/tools/pipdepup.py +686 -0
  126. omdev/pyproject/cli.py +1 -1
  127. omdev/pyproject/pkg.py +197 -48
  128. omdev/pyproject/reqs.py +36 -10
  129. omdev/pyproject/tools/__init__.py +0 -0
  130. omdev/pyproject/tools/aboutdeps.py +60 -0
  131. omdev/pyproject/venvs.py +12 -2
  132. omdev/rs/__init__.py +0 -0
  133. omdev/scripts/ci.py +9551 -6982
  134. omdev/scripts/interp.py +1323 -892
  135. omdev/scripts/lib/__init__.py +0 -0
  136. omdev/scripts/lib/inject.py +2086 -0
  137. omdev/scripts/lib/logs.py +2175 -0
  138. omdev/scripts/lib/marshal.py +1731 -0
  139. omdev/scripts/pyproject.py +4979 -1874
  140. omdev/tools/docker.py +19 -7
  141. omdev/tools/git/cli.py +56 -16
  142. omdev/tools/git/messages.py +2 -2
  143. omdev/tools/json/cli.py +6 -6
  144. omdev/tools/json/formats.py +2 -0
  145. omdev/tools/json/parsing.py +5 -5
  146. omdev/tools/json/processing.py +6 -3
  147. omdev/tools/json/rendering.py +2 -2
  148. omdev/tools/jsonview/cli.py +49 -65
  149. omdev/tools/jsonview/resources/jsonview.html.j2 +43 -0
  150. omdev/tools/pawk/README.md +195 -0
  151. omdev/tools/pawk/pawk.py +2 -2
  152. omdev/tools/pip.py +8 -0
  153. omdev/tui/__init__.py +0 -0
  154. omdev/tui/apps/__init__.py +0 -0
  155. omdev/tui/apps/edit/__init__.py +0 -0
  156. omdev/tui/apps/edit/main.py +167 -0
  157. omdev/tui/apps/irc/__init__.py +0 -0
  158. omdev/tui/apps/irc/__main__.py +4 -0
  159. omdev/tui/apps/irc/app.py +286 -0
  160. omdev/tui/apps/irc/client.py +187 -0
  161. omdev/tui/apps/irc/commands.py +175 -0
  162. omdev/tui/apps/irc/main.py +26 -0
  163. omdev/tui/apps/markdown/__init__.py +0 -0
  164. omdev/tui/apps/markdown/__main__.py +11 -0
  165. omdev/{ptk → tui/apps}/markdown/cli.py +5 -7
  166. omdev/tui/rich/__init__.py +46 -0
  167. omdev/tui/rich/console2.py +20 -0
  168. omdev/tui/rich/markdown2.py +186 -0
  169. omdev/tui/textual/__init__.py +265 -0
  170. omdev/tui/textual/app2.py +16 -0
  171. omdev/tui/textual/autocomplete/LICENSE +21 -0
  172. omdev/tui/textual/autocomplete/__init__.py +33 -0
  173. omdev/tui/textual/autocomplete/matching.py +226 -0
  174. omdev/tui/textual/autocomplete/paths.py +202 -0
  175. omdev/tui/textual/autocomplete/widget.py +612 -0
  176. omdev/tui/textual/debug/__init__.py +10 -0
  177. omdev/tui/textual/debug/dominfo.py +151 -0
  178. omdev/tui/textual/debug/screen.py +24 -0
  179. omdev/tui/textual/devtools.py +187 -0
  180. omdev/tui/textual/drivers2.py +55 -0
  181. omdev/tui/textual/logging2.py +20 -0
  182. omdev/tui/textual/types.py +45 -0
  183. {omdev-0.0.0.dev416.dist-info → omdev-0.0.0.dev500.dist-info}/METADATA +18 -12
  184. omdev-0.0.0.dev500.dist-info/RECORD +386 -0
  185. omdev/ptk/__init__.py +0 -103
  186. omdev/ptk/apps/ncdu.py +0 -167
  187. omdev/ptk/confirm.py +0 -60
  188. omdev/ptk/markdown/LICENSE +0 -22
  189. omdev/ptk/markdown/__init__.py +0 -10
  190. omdev/ptk/markdown/__main__.py +0 -11
  191. omdev/ptk/markdown/border.py +0 -94
  192. omdev/ptk/markdown/markdown.py +0 -390
  193. omdev/ptk/markdown/parser.py +0 -42
  194. omdev/ptk/markdown/styles.py +0 -29
  195. omdev/ptk/markdown/tags.py +0 -299
  196. omdev/ptk/markdown/utils.py +0 -366
  197. omdev/pyproject/cexts.py +0 -110
  198. omdev/tools/antlr/__main__.py +0 -11
  199. omdev/tools/antlr/cli.py +0 -62
  200. omdev/tools/antlr/consts.py +0 -7
  201. omdev/tools/antlr/gen.py +0 -188
  202. omdev-0.0.0.dev416.dist-info/RECORD +0 -332
  203. /omdev/{ptk/apps → irc}/__init__.py +0 -0
  204. /omdev/{tokens → irc/messages}/__init__.py +0 -0
  205. /omdev/{tools/antlr → irc/numerics}/__init__.py +0 -0
  206. /omdev/{tokens → py/tokens}/all.py +0 -0
  207. /omdev/{tokens → py/tokens}/tokenizert.py +0 -0
  208. {omdev-0.0.0.dev416.dist-info → omdev-0.0.0.dev500.dist-info}/WHEEL +0 -0
  209. {omdev-0.0.0.dev416.dist-info → omdev-0.0.0.dev500.dist-info}/entry_points.txt +0 -0
  210. {omdev-0.0.0.dev416.dist-info → omdev-0.0.0.dev500.dist-info}/licenses/LICENSE +0 -0
  211. {omdev-0.0.0.dev416.dist-info → omdev-0.0.0.dev500.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,195 @@
1
+ [From alecthomas/pawk](https://github.com/alecthomas/pawk/blob/d60f78399e8a01857ebd73415a00e7eb424043ab/pawk.py)
2
+
3
+ ---
4
+
5
+ PAWK - A Python line processor (like AWK)
6
+
7
+ PAWK aims to bring the full power of Python to AWK-like line-processing.
8
+
9
+ Here are some quick examples to show some of the advantages of pawk over AWK.
10
+
11
+ The first example transforms `/etc/hosts` into a JSON map of host to IP:
12
+
13
+ cat /etc/hosts | pawk -B 'd={}' -E 'json.dumps(d)' '!/^#/ d[f[1]] = f[0]'
14
+
15
+ Breaking this down:
16
+
17
+ 1. `-B 'd={}'` is a begin statement initializing a dictionary, executed once before processing begins.
18
+ 2. `-E 'json.dumps(d)'` is an end statement expression, producing the JSON representation of the dictionary `d`.
19
+ 3. `!/^#/` tells pawk to match any line *not* beginning with `#`.
20
+ 4. `d[f[1]] = f[0]` adds a dictionary entry where the key is the second field in the line (the first hostname) and the
21
+ value is the first field (the IP address).
22
+
23
+ And another example showing how to bzip2-compress + base64-encode a file:
24
+
25
+ cat pawk.py | pawk -E 'base64.encodestring(bz2.compress(t))'
26
+
27
+ ### AWK example translations
28
+
29
+ Most basic AWK constructs are available. You can find more idiomatic examples below in the example section, but here are
30
+ a bunch of awk commands and their equivalent pawk commands to get started with:
31
+
32
+ Print lines matching a pattern:
33
+
34
+ ls -l / | awk '/etc/'
35
+ ls -l / | pawk '/etc/'
36
+
37
+ Print lines *not* matching a pattern:
38
+
39
+ ls -l / | awk '!/etc/'
40
+ ls -l / | pawk '!/etc/'
41
+
42
+ Field slicing and dicing (here pawk wins because of Python's array slicing):
43
+
44
+ ls -l / | awk '/etc/ {print $5, $6, $7, $8, $9}'
45
+ ls -l / | pawk '/etc/ f[4:]'
46
+
47
+ Begin and end actions (in this case, summing the sizes of all files):
48
+
49
+ ls -l | awk 'BEGIN {c = 0} {c += $5} END {print c}'
50
+ ls -l | pawk -B 'c = 0' -E 'c' 'c += int(f[4])'
51
+
52
+ Print files where a field matches a numeric expression (in this case where files are > 1024 bytes):
53
+
54
+ ls -l | awk '$5 > 1024'
55
+ ls -l | pawk 'int(f[4]) > 1024'
56
+
57
+ Matching a single field (any filename with "t" in it):
58
+
59
+ ls -l | awk '$NF ~/t/'
60
+ ls -l | pawk '"t" in f[-1]'
61
+
62
+ ## Expression evaluation
63
+
64
+ PAWK evaluates a Python expression or statement against each line in stdin. The following variables are available in
65
+ local context:
66
+
67
+ - `line` - Current line text, including newline.
68
+ - `l` - Current line text, excluding newline.
69
+ - `n` - The current 1-based line number.
70
+ - `f` - Fields of the line (split by the field separator `-F`).
71
+ - `nf` - Number of fields in this line.
72
+ - `m` - Tuple of match regular expression capture groups, if any.
73
+
74
+
75
+ In the context of the `-E` block:
76
+
77
+ - `t` - The entire input text up to the current cursor position.
78
+
79
+ If the flag `-H, --header` is provided, each field in the first row of the input will be treated as field variable names
80
+ in subsequent rows. The header is not output. For example, given the input:
81
+
82
+ ```
83
+ count name
84
+ 12 bob
85
+ 34 fred
86
+ ```
87
+
88
+ We could do:
89
+
90
+ ```
91
+ $ pawk -H '"%s is %s" % (name, count)' < input.txt
92
+ bob is 12
93
+ fred is 34
94
+ ```
95
+
96
+ To output a header as well, use `-B`:
97
+
98
+ ```
99
+ $ pawk -H -B '"name is count"' '"%s is %s" % (name, count)' < input.txt
100
+ name is count
101
+ bob is 12
102
+ fred is 34
103
+ ```
104
+
105
+ Module references will be automatically imported if possible. Additionally, the `--import <module>[,<module>,...]` flag
106
+ can be used to import symbols from a set of modules into the evaluation context.
107
+
108
+ eg. `--import os.path` will import all symbols from `os.path`, such as `os.path.isfile()`, into the context.
109
+
110
+ ## Output
111
+
112
+ ### Line actions
113
+
114
+ The type of the evaluated expression determines how output is displayed:
115
+
116
+ - `tuple` or `list`: the elements are converted to strings and joined with the output delimiter (`-O`).
117
+ - `None` or `False`: nothing is output for that line.
118
+ - `True`: the original line is output.
119
+ - Any other value is converted to a string.
120
+
121
+ ### Start/end blocks
122
+
123
+ The rules are the same as for line actions with one difference. Because there is no "line" that corresponds to them, an
124
+ expression returning True is ignored.
125
+
126
+ $ echo -ne 'foo\nbar' | pawk -E t
127
+ foo
128
+ bar
129
+
130
+
131
+ ## Command-line usage
132
+
133
+ ```
134
+ Usage: cat input | pawk [<options>] <expr>
135
+
136
+ A Python line-processor (like awk).
137
+
138
+ See https://github.com/alecthomas/pawk for details. Based on
139
+ http://code.activestate.com/recipes/437932/.
140
+
141
+ Options:
142
+ -h, --help show this help message and exit
143
+ -I <filename>, --in_place=<filename>
144
+ modify given input file in-place
145
+ -i <modules>, --import=<modules>
146
+ comma-separated list of modules to "from x import *"
147
+ from
148
+ -F <delim> input delimiter
149
+ -O <delim> output delimiter
150
+ -L <delim> output line separator
151
+ -B <statement>, --begin=<statement>
152
+ begin statement
153
+ -E <statement>, --end=<statement>
154
+ end statement
155
+ -s, --statement DEPRECATED. retained for backward compatibility
156
+ -H, --header use first row as field variable names in subsequent
157
+ rows
158
+ --strict abort on exceptions
159
+ ```
160
+
161
+ ## Examples
162
+
163
+ ### Line processing
164
+
165
+ Print the name and size of every file from stdin:
166
+
167
+ find . -type f | pawk 'f[0], os.stat(f[0]).st_size'
168
+
169
+ > **Note:** this example also shows how pawk automatically imports referenced modules, in this case `os`.
170
+
171
+ Print the sum size of all files from stdin:
172
+
173
+ find . -type f | \
174
+ pawk \
175
+ --begin 'c=0' \
176
+ --end c \
177
+ 'c += os.stat(f[0]).st_size'
178
+
179
+ Short-flag version:
180
+
181
+ find . -type f | pawk -B c=0 -E c 'c += os.stat(f[0]).st_size'
182
+
183
+
184
+ ### Whole-file processing
185
+
186
+ If you do not provide a line expression, but do provide an end statement, pawk will accumulate each line, and the entire
187
+ file's text will be available in the end statement as `t`. This is useful for operations on entire files, like the
188
+ following example of converting a file from markdown to HTML:
189
+
190
+ cat README.md | \
191
+ pawk --end 'markdown.markdown(t)'
192
+
193
+ Short-flag version:
194
+
195
+ cat README.md | pawk -E 'markdown.markdown(t)'
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,167 @@
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
+
23
+ border: thick $background 80%;
24
+
25
+ padding: 1 2;
26
+
27
+ background: $surface;
28
+ }
29
+
30
+ #question {
31
+ height: 3;
32
+
33
+ content-align: center middle;
34
+ }
35
+
36
+ Button {
37
+ width: 1fr;
38
+ }
39
+ """
40
+
41
+ def compose(self) -> tx.ComposeResult:
42
+ yield tx.Vertical(
43
+ tx.Label('You have unsaved changes. Do you want to save before quitting?', id='question'),
44
+ tx.Button('Save and Quit', variant='success', id='save'),
45
+ tx.Button('Quit Without Saving', variant='warning', id='quit'),
46
+ tx.Button('Cancel', variant='primary', id='cancel'),
47
+ id='dialog',
48
+ )
49
+
50
+ def on_button_pressed(self, event: tx.Button.Pressed) -> None:
51
+ if event.button.id == 'save':
52
+ self.dismiss(True)
53
+ elif event.button.id == 'quit':
54
+ self.dismiss(False)
55
+ else:
56
+ self.dismiss(None)
57
+
58
+
59
+ class TextEditor(tx.App):
60
+ """A simple text editor using Textual."""
61
+
62
+ CSS = """
63
+ TextArea {
64
+ height: 1fr;
65
+ }
66
+
67
+ Container {
68
+ height: 100%;
69
+ }
70
+ """
71
+
72
+ BINDINGS: ta.ClassVar[ta.Sequence[tx.Binding]] = [
73
+ tx.Binding('ctrl+s', 'save', 'Save', show=True),
74
+ tx.Binding('ctrl+q', 'quit', 'Quit', show=True),
75
+ ]
76
+
77
+ def __init__(self, filepath: pathlib.Path):
78
+ super().__init__()
79
+
80
+ self.filepath = filepath
81
+ self.text_area: tx.TextArea | None = None
82
+ self.modified = False
83
+ self.original_content = ''
84
+
85
+ def compose(self) -> tx.ComposeResult:
86
+ """Create child widgets for the app."""
87
+
88
+ yield tx.Header()
89
+ yield tx.Container(tx.TextArea(id='editor'))
90
+ yield tx.Footer()
91
+
92
+ def on_mount(self) -> None:
93
+ """Load the file content when the app starts."""
94
+
95
+ self.text_area = self.query_one('#editor', tx.TextArea)
96
+
97
+ # Load existing file or create new
98
+ if self.filepath.exists():
99
+ content = self.filepath.read_text()
100
+ self.text_area.load_text(content)
101
+ self.original_content = content
102
+ else:
103
+ self.original_content = ''
104
+
105
+ self.title = f'Text Editor - {self.filepath.name}'
106
+ self.sub_title = str(self.filepath)
107
+
108
+ def on_text_area_changed(self, event: tx.TextArea.Changed) -> None:
109
+ """Track if the document has been modified."""
110
+
111
+ if self.text_area:
112
+ current_content = self.text_area.text
113
+ self.modified = current_content != self.original_content
114
+
115
+ # Update title to show modified status
116
+ status = ' *' if self.modified else ''
117
+ self.title = f'Text Editor - {self.filepath.name}{status}'
118
+
119
+ def action_save(self) -> None:
120
+ """Save the current content to file."""
121
+
122
+ if self.text_area:
123
+ content = self.text_area.text
124
+ self.filepath.write_text(content)
125
+ self.original_content = content
126
+ self.modified = False
127
+ self.title = f'Text Editor - {self.filepath.name}'
128
+ self.notify(f'Saved to {self.filepath.name}')
129
+
130
+ async def action_quit(self) -> None:
131
+ """Quit the editor, prompting to save if modified."""
132
+
133
+ if self.modified:
134
+ def check_quit(should_save: bool | None) -> None:
135
+ if should_save is None:
136
+ # User cancelled
137
+ return
138
+
139
+ if should_save:
140
+ self.action_save()
141
+
142
+ self.exit()
143
+
144
+ await self.push_screen(QuitConfirmScreen(), callback=check_quit)
145
+
146
+ else:
147
+ self.exit()
148
+
149
+
150
+ def main() -> None:
151
+ """Parse arguments and run the editor."""
152
+
153
+ parser = argparse.ArgumentParser(description='Simple text editor')
154
+ parser.add_argument('filename', help='File to edit')
155
+ args = parser.parse_args()
156
+
157
+ filepath = pathlib.Path(args.filename).resolve()
158
+
159
+ # Create parent directories if needed
160
+ filepath.parent.mkdir(parents=True, exist_ok=True)
161
+
162
+ app = TextEditor(filepath)
163
+ app.run()
164
+
165
+
166
+ if __name__ == '__main__':
167
+ main()
File without changes
@@ -0,0 +1,4 @@
1
+ if __name__ == '__main__':
2
+ from .main import _main
3
+
4
+ _main()
@@ -0,0 +1,286 @@
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
+
49
+ border: none;
50
+
51
+ padding: 0;
52
+
53
+ overflow-y: auto;
54
+ }
55
+
56
+ #status {
57
+ height: 1;
58
+
59
+ border: none;
60
+
61
+ padding: 0;
62
+
63
+ color: $text;
64
+ background: $primary;
65
+ }
66
+
67
+ #input {
68
+ border: none;
69
+
70
+ padding: 0;
71
+
72
+ dock: bottom;
73
+ }
74
+ """
75
+
76
+ BINDINGS: ta.ClassVar[ta.Sequence[tx.Binding]] = [
77
+ tx.Binding('ctrl+n', 'next_window', 'Next Window', show=False),
78
+ tx.Binding('ctrl+p', 'prev_window', 'Previous Window', show=False),
79
+ ]
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ startup_commands: ta.Sequence[str] | None = None,
85
+ ) -> None:
86
+ super().__init__()
87
+
88
+ self._client: IrcClient | None = None
89
+ self._windows: dict[str, IrcWindow] = {'system': IrcWindow('system')}
90
+ self._window_order: list[str] = ['system']
91
+ self._current_window_idx: int = 0
92
+ self._current_channel: str | None = None
93
+ self._startup_commands: ta.Sequence[str] = startup_commands or []
94
+
95
+ @property
96
+ def client(self) -> IrcClient | None:
97
+ return self._client
98
+
99
+ @property
100
+ def current_channel(self) -> str | None:
101
+ return self._current_channel
102
+
103
+ #
104
+
105
+ def compose(self) -> tx.ComposeResult:
106
+ text_area = tx.TextArea(id='messages', read_only=True, show_line_numbers=False)
107
+ text_area.cursor_blink = False
108
+ yield text_area
109
+ yield tx.Static('', id='status')
110
+ yield tx.Input(placeholder='Enter command or message', id='input', select_on_focus=False)
111
+
112
+ async def on_mount(self) -> None:
113
+ """Initialize on mount."""
114
+
115
+ self._client = IrcClient(self.on_irc_message)
116
+ self.update_display()
117
+ self.query_one('#input').focus()
118
+
119
+ # Show connection prompt
120
+ await self.add_message('system', 'IRC Client - Use /connect <server> <port> <nickname>')
121
+ await self.add_message('system', 'Example: /connect irc.libera.chat 6667 mynick')
122
+
123
+ # Execute startup commands
124
+ for cmd in self._startup_commands:
125
+ # Add leading slash if not present
126
+ if not cmd.startswith('/'):
127
+ cmd = '/' + cmd
128
+ await self.add_message('system', f'Executing: {cmd}')
129
+ await self.handle_command(cmd)
130
+
131
+ async def on_key(self, event: tx.Key) -> None:
132
+ """Handle key events - redirect typing to input when messages area is focused."""
133
+
134
+ focused = self.focused
135
+ if focused and focused.id == 'messages':
136
+ # If a printable character or common input key is pressed, focus the input and forward event
137
+ if event.is_printable or event.key in ('space', 'backspace', 'delete'):
138
+ input_widget = self.query_one('#input', tx.Input)
139
+ input_widget.focus()
140
+ # Post the key event to the input widget so it handles it naturally
141
+ input_widget.post_message(tx.Key(event.key, event.character))
142
+ # Stop the event from being processed by the messages widget
143
+ event.stop()
144
+
145
+ async def on_input_submitted(self, event: tx.Input.Submitted) -> None:
146
+ """Handle user input."""
147
+
148
+ text = event.value.strip()
149
+ event.input.value = ''
150
+
151
+ if not text:
152
+ return
153
+
154
+ # Handle commands
155
+ if text.startswith('/'):
156
+ await self.handle_command(text)
157
+ else:
158
+ # Send message to current channel
159
+ if self._current_channel and self._client and self._client.connected:
160
+ await self._client.privmsg(self._current_channel, text)
161
+ await self.add_message(self._current_channel, f'<{self._client.nickname}> {text}')
162
+ else:
163
+ await self.add_message('system', 'Not in a channel or not connected')
164
+
165
+ async def handle_command(self, text: str) -> None:
166
+ """Handle IRC commands."""
167
+
168
+ try:
169
+ parts = shlex.split(text)
170
+ except ValueError as e:
171
+ await self.add_message('system', f'Invalid command syntax: {e}')
172
+ return
173
+
174
+ if not parts:
175
+ return
176
+
177
+ cmd = parts[0].lstrip('/').lower()
178
+ argv = parts[1:]
179
+
180
+ command = self._commands.get(cmd)
181
+ if command:
182
+ await command.run(self, argv)
183
+ else:
184
+ await self.add_message('system', f'Unknown command: /{cmd}')
185
+
186
+ def action_next_window(self) -> None:
187
+ """Switch to next window."""
188
+
189
+ if len(self._window_order) > 1:
190
+ self._current_window_idx = (self._current_window_idx + 1) % len(self._window_order)
191
+ self.update_display()
192
+
193
+ def action_prev_window(self) -> None:
194
+ """Switch to previous window."""
195
+
196
+ if len(self._window_order) > 1:
197
+ self._current_window_idx = (self._current_window_idx - 1) % len(self._window_order)
198
+ self.update_display()
199
+
200
+ def get_or_create_window(self, name: str) -> IrcWindow:
201
+ """Get or create a window."""
202
+
203
+ if name not in self._windows:
204
+ self._windows[name] = IrcWindow(name)
205
+ self._window_order.append(name)
206
+ return self._windows[name]
207
+
208
+ def switch_to_window(self, name: str) -> None:
209
+ """Switch to a specific window."""
210
+
211
+ if name in self._window_order:
212
+ self._current_window_idx = self._window_order.index(name)
213
+ self.update_display()
214
+
215
+ async def add_message(self, window_name: str, message: str) -> None:
216
+ """Add a message to a window."""
217
+
218
+ window = self.get_or_create_window(window_name)
219
+ timestamp = lang.utcnow().strftime('%H:%M')
220
+ window.add_line(f'[{timestamp}] {message}')
221
+ self.update_display()
222
+
223
+ async def on_irc_message(self, window_name: str, message: str) -> None:
224
+ """Callback for IRC messages."""
225
+
226
+ await self.add_message(window_name, message)
227
+
228
+ _last_window: str | None = None
229
+
230
+ def update_display(self) -> None:
231
+ """Update the display."""
232
+
233
+ current_window_name = self._window_order[self._current_window_idx]
234
+ current_window = self._windows[current_window_name]
235
+
236
+ # Update current channel for sending messages
237
+ self._current_channel = current_window_name if current_window_name.startswith('#') else None
238
+
239
+ # Mark as read
240
+ current_window.unread = 0
241
+
242
+ # Update messages display
243
+ messages_widget = self.query_one('#messages', tx.TextArea)
244
+
245
+ # Check if we switched windows or need full reload
246
+ window_changed = self._last_window != current_window_name
247
+ self._last_window = current_window_name
248
+
249
+ lines_to_show = current_window.lines[-100:] # Last 100 lines
250
+
251
+ if window_changed:
252
+ # Full reload when switching windows
253
+ messages_widget.load_text('\n'.join(lines_to_show))
254
+ current_window.displayed_line_count = len(lines_to_show)
255
+
256
+ else:
257
+ # Append only new lines
258
+ new_line_count = len(lines_to_show) - current_window.displayed_line_count
259
+ if new_line_count > 0:
260
+ new_lines = lines_to_show[-new_line_count:]
261
+ # Get the end position
262
+ doc = messages_widget.document
263
+ end_line = doc.line_count - 1
264
+ end_col = len(doc.get_line(end_line))
265
+ # Append new lines using insert
266
+ prefix = '\n' if len(doc.text) > 0 else ''
267
+ messages_widget.insert(prefix + '\n'.join(new_lines), location=(end_line, end_col))
268
+ current_window.displayed_line_count = len(lines_to_show)
269
+
270
+ # Update status line
271
+ window_list = []
272
+ for i, name in enumerate(self._window_order):
273
+ win = self._windows[name]
274
+ indicator = f'[{i + 1}:{name}]'
275
+ if i == self._current_window_idx:
276
+ indicator = f'[{i + 1}:{name}*]'
277
+ elif win.unread > 0:
278
+ indicator = f'[{i + 1}:{name}({win.unread})]'
279
+ window_list.append(indicator)
280
+
281
+ status_text = ' '.join(window_list)
282
+ self.query_one('#status', tx.Static).update(status_text)
283
+
284
+ async def on_unmount(self) -> None:
285
+ if (cl := self._client) is not None:
286
+ await cl.shutdown()