vappman 0.1__tar.gz

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.
vappman-0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Joe D
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
vappman-0.1/PKG-INFO ADDED
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.1
2
+ Name: vappman
3
+ Version: 0.1
4
+ Summary: A visual wrapper for appman
5
+ Author-email: Joe Defen <joedef@google.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/joedefen/vappman
8
+ Project-URL: Bug Tracker, https://github.com/joedefen/vappman/issues
9
+ Keywords: app,installer,manager,appimages
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: importlib-metadata; python_version < "3.8"
17
+ Requires-Dist: psutil>=5.9
18
+
19
+ # vappman
20
+ `vappman` presents a visual (curses) interface to `appman`.
21
+
22
+ * Install `vappman` using `pipx install vappman`, or however you do so.
23
+ * Prerequisites: install [ivan-hc/AppMan: AppImage package manager to install, update (for real) and manage ALL of them locally thanks to "AM", the ever-growing AUR-inspired database listing (for now) 1900+ portable apps and programs for GNU/Linux. Manage your AppImages with the ease of APT and the power of PacMan.](https://github.com/ivan-hc/AppMan) and all of its prerequisites.
24
+
25
+ NOTE: `vappman` is in a very preliminary state,
26
+ but it implements the most needed, basic functionality:
27
+ * filtering the app list
28
+ * installing new apps
29
+ * removing installed apps
30
+
31
+ ## Usage
32
+ * Run `vappman` from the command line.
33
+ * It presents some keys available on the top line.
34
+ * Use '?' to learn the navigation keys (e.g., you can use the mouse wheel,
35
+ arrow keys, and many `vi`-like keys)
36
+ * Then `vappman` presents a list of installed apps, followed by available/uninstalled apps.
37
+ * Enter `/` to enter a "filter" for installed/uninstalled apps, if you wish.
38
+ * If you enter plain old "words", then those words must match the beginning of words
39
+ of the apps or descriptions (in order, but not contiguously).
40
+ * Or you can enter an regular expression acceptable to python (e.g., `\b` means word
41
+ boundary, etc.)
42
+ * Use `i` to install apps, and `r` to remove apps. When you install or remove an app, `appman` drops out of `curses` mode, runs the `appman` command so you can see the result, and then prompts your to hit ENTER to return to `vappman.
43
+
44
+ ## Example Screenshot
45
+ ![vappman-with-filter](images/vappman-with-filter.png).
46
+
47
+ ---
48
+
49
+ NOTES:
50
+ * the filter is `card` so it shows apps with words starting with `card`.
51
+ * the current position is on `glabels`; thus if `i` is typed, `appman install glabels` is run.
52
+ * if the horizontal line (second line show) has no decorations, then you are looking
53
+ all the filtered apps; otherwise, the decoration suggests where you are in the
54
+ partial view of the filtered apps.
55
+
vappman-0.1/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # vappman
2
+ `vappman` presents a visual (curses) interface to `appman`.
3
+
4
+ * Install `vappman` using `pipx install vappman`, or however you do so.
5
+ * Prerequisites: install [ivan-hc/AppMan: AppImage package manager to install, update (for real) and manage ALL of them locally thanks to "AM", the ever-growing AUR-inspired database listing (for now) 1900+ portable apps and programs for GNU/Linux. Manage your AppImages with the ease of APT and the power of PacMan.](https://github.com/ivan-hc/AppMan) and all of its prerequisites.
6
+
7
+ NOTE: `vappman` is in a very preliminary state,
8
+ but it implements the most needed, basic functionality:
9
+ * filtering the app list
10
+ * installing new apps
11
+ * removing installed apps
12
+
13
+ ## Usage
14
+ * Run `vappman` from the command line.
15
+ * It presents some keys available on the top line.
16
+ * Use '?' to learn the navigation keys (e.g., you can use the mouse wheel,
17
+ arrow keys, and many `vi`-like keys)
18
+ * Then `vappman` presents a list of installed apps, followed by available/uninstalled apps.
19
+ * Enter `/` to enter a "filter" for installed/uninstalled apps, if you wish.
20
+ * If you enter plain old "words", then those words must match the beginning of words
21
+ of the apps or descriptions (in order, but not contiguously).
22
+ * Or you can enter an regular expression acceptable to python (e.g., `\b` means word
23
+ boundary, etc.)
24
+ * Use `i` to install apps, and `r` to remove apps. When you install or remove an app, `appman` drops out of `curses` mode, runs the `appman` command so you can see the result, and then prompts your to hit ENTER to return to `vappman.
25
+
26
+ ## Example Screenshot
27
+ ![vappman-with-filter](images/vappman-with-filter.png).
28
+
29
+ ---
30
+
31
+ NOTES:
32
+ * the filter is `card` so it shows apps with words starting with `card`.
33
+ * the current position is on `glabels`; thus if `i` is typed, `appman install glabels` is run.
34
+ * if the horizontal line (second line show) has no decorations, then you are looking
35
+ all the filtered apps; otherwise, the decoration suggests where you are in the
36
+ partial view of the filtered apps.
37
+
@@ -0,0 +1,97 @@
1
+ # HOW TO DEVELOP
2
+
3
+ #
4
+ # Prep Work (ensure need modules are up-to-date):
5
+ # sudo apt install python3-pip
6
+ # sudo apt install python3-pip-whl
7
+ # pip install build --break-system-packages
8
+ # sudo apt install twine
9
+ # -- antiquated way
10
+ # sudo pacman -Syu python-pip
11
+ # sudo pacman -Syu python-build
12
+ # sudo pacman -Syu python-twine
13
+ # -- antiquated way
14
+ # python3 -m pip install --upgrade pip
15
+ # python3 -m pip install --upgrade build
16
+ # python3 -m pip install --upgrade twine
17
+ #
18
+ # Optionally, `python3 -m venv venv`, and then
19
+ # - source env/bin/activate # to activate
20
+ # - deactivate # to deactivate
21
+ #
22
+ # rm -rf ./dist && python3 -m build && pip install -e . --break-system-packages
23
+ # pip-tray
24
+ #
25
+ # -OR-
26
+ # python3 -m vappman.main
27
+ #
28
+ # -OR-
29
+ # cd src/vappman && ./main.py
30
+ #
31
+ # -OR-
32
+ # src/vappman/main.py
33
+
34
+ # HOW TO PUBLISH...
35
+ # PUBLIC Build and deploy (from project directory):
36
+ # ## BUMP the version (below in [project])
37
+ # rm -rf dist; python3 -m build; ls dist/.
38
+ # python3 -m twine upload dist/* # keyring --disable # may be required
39
+ # ## Enter __token__ and the saved TOKEN (in bitwarden)
40
+ # pipx upgrade pwr-tray || pipx install pwr-tray # >= python3.11
41
+ # --OR-- sudo python3 -m pip install pwr-tray # <= python3.10
42
+ # ## VISIT https://pypi.org/project/pwr-tray and delete old versions
43
+ #
44
+ # TEST Build and test (from project directory):
45
+ # ## BUMP the version (below in [project])
46
+ # rm -r dist; python3 -m build
47
+ # python3 -m twine upload --repository testpypi dist/* # keyring --disable # may be required
48
+ # ## Enter __token__ and the saved TOKEN (in bitwarden)
49
+ # sudo python3 -m pip install --upgrade --index-url https://test.pypi.org/simple/ --no-deps --break-system-packages my-sna
50
+ # ## VISIT https://test.pypi.org/project/pwr-tray and delete old versions
51
+
52
+ [build-system]
53
+ requires = ["setuptools>=42", "wheel"]
54
+ build-backend = "setuptools.build_meta"
55
+
56
+ [project]
57
+ name = "vappman"
58
+ version = "0.1"
59
+ description = "A visual wrapper for appman"
60
+ authors = [
61
+ { name = "Joe Defen", email = "joedef@google.com" }
62
+ ]
63
+ readme = "README.md"
64
+ license = { text = "MIT" }
65
+ requires-python = ">=3.8"
66
+
67
+ keywords = ["app", "installer", "manager", "appimages", ]
68
+
69
+ classifiers = [
70
+ "Programming Language :: Python :: 3",
71
+ "License :: OSI Approved :: MIT License",
72
+ "Operating System :: POSIX :: Linux"
73
+ ]
74
+ dependencies = [
75
+ 'importlib-metadata; python_version<"3.8"',
76
+ 'psutil>=5.9',
77
+ ]
78
+
79
+ [project.urls]
80
+ "Homepage" = "https://github.com/joedefen/vappman"
81
+ "Bug Tracker" = "https://github.com/joedefen/vappman/issues"
82
+
83
+ [project.scripts]
84
+ vappman = "vappman.main:main"
85
+
86
+ [tool.setuptools]
87
+ package-dir = {"" = "src"}
88
+
89
+ [tool.setuptools.packages.find]
90
+ where = ["src"]
91
+
92
+ [tool.setuptools.package-data]
93
+
94
+ exclude = [
95
+ "__pycache__",
96
+ ]
97
+
vappman-0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,740 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Custom Wrapper for python curses.
5
+ """
6
+ # pylint: disable=too-many-instance-attributes,too-many-arguments
7
+ # pylint: disable=invalid-name,broad-except,too-many-branches
8
+
9
+ # pylint: disable=wrong-import-position,disable=wrong-import-order
10
+ # import VirtEnv
11
+ # VirtEnv.ensure_venv(__name__)
12
+
13
+ import traceback
14
+ import atexit
15
+ import signal
16
+ import time
17
+ import curses
18
+ import textwrap
19
+ from types import SimpleNamespace
20
+ from curses.textpad import rectangle, Textbox
21
+ dump_str = None
22
+
23
+ def ignore_ctrl_c():
24
+ """" Ignore SIGINT """
25
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
26
+
27
+ def restore_ctrl_c():
28
+ """" Handle SIGINT """
29
+ signal.signal(signal.SIGINT, signal.default_int_handler)
30
+
31
+ class OptionSpinner:
32
+ """Manage a bunch of options where the value is rotate thru
33
+ a fixed set of values pressing a key."""
34
+ def __init__(self):
35
+ """Give the object with the attribute to change its
36
+ value (e.g., options from argparse or "self" from
37
+ the object managing the window).
38
+
39
+ And array of specs like:
40
+ ['a - allow auto suggestions', 'allow_auto', True, False],
41
+ ['/ - filter pattern', 'filter_str', self.filter_str],
42
+ A spec can have a trailing None + more comments shown after
43
+ the value.
44
+ """
45
+ self.options, self.keys = [], []
46
+ self.margin = 4 # + actual width (1st column right pos)
47
+ self.align = self.margin # + actual width (1st column right pos)
48
+ self.default_obj = SimpleNamespace() # if not given one
49
+ self.attr_to_option = {} # given an attribute, find its option ns
50
+ self.key_to_option = {} # given key, options namespace
51
+ self.keys = set()
52
+
53
+ @staticmethod
54
+ def _make_option_ns():
55
+ return SimpleNamespace(
56
+ keys=[],
57
+ descr='',
58
+ obj=None,
59
+ attr='',
60
+ vals=None,
61
+ prompt=None,
62
+ comments=[],
63
+ )
64
+
65
+ def get_value(self, attr, coerce=False):
66
+ """Get the value of the given attribute."""
67
+ ns = self.attr_to_option.get(attr, None)
68
+ obj = ns.obj if ns else None
69
+ value = getattr(obj, attr, None) if obj else None
70
+ if value is None and obj and coerce:
71
+ if ns.vals:
72
+ if value not in ns.vals:
73
+ value = ns.vals[0]
74
+ setattr(obj, attr, value)
75
+ else:
76
+ if value is None:
77
+ value = ''
78
+ setattr(ns.obj, ns.attr, '')
79
+ return value
80
+
81
+ def _register(self, ns):
82
+ """ Create the mappings needed"""
83
+ assert ns.attr not in self.attr_to_option
84
+ self.attr_to_option[ns.attr] = ns
85
+ for key in ns.keys:
86
+ assert key not in self.key_to_option, f'key ({chr(key)}, {key}) already used'
87
+ self.key_to_option[key] = ns
88
+ self.keys.add(key)
89
+ self.options.append(ns)
90
+ self.align = max(self.align, self.margin+len(ns.descr))
91
+ self.get_value(ns.attr, coerce=True)
92
+
93
+ def add(self, obj, specs):
94
+ """ Compatibility Method."""
95
+ for spec in specs:
96
+ ns = self._make_option_ns()
97
+ ns.descr = spec[0]
98
+ ns.obj = obj
99
+ ns.attr = spec[1]
100
+ ns.vals=spec[2:]
101
+ if None in ns.vals:
102
+ idx = ns.vals.index(None)
103
+ ns.vals = ns.vals[:idx]
104
+ ns.comments = ns.vals[idx+1:]
105
+ ns.keys = [ord(ns.descr[0])]
106
+ self._register(ns)
107
+
108
+ def add_key(self, attr, descr, obj=None, vals=None, prompt=None, keys=None, comments=None):
109
+ """ Standard method"""
110
+ ns = self._make_option_ns()
111
+ if keys:
112
+ ns.keys = list(keys) if isinstance(keys, (list, tuple, set)) else [keys]
113
+ else:
114
+ ns.keys = [ord(descr[0])]
115
+ if comments is None:
116
+ ns.comments = []
117
+ else:
118
+ ns.comments = list(comments) if isinstance(keys, (list, tuple)) else [comments]
119
+ ns.descr = descr
120
+ ns.attr = attr
121
+ ns.obj = obj if obj else self.default_obj
122
+ ns.vals, ns.prompt = vals, prompt
123
+ assert bool(ns.vals) ^ bool(ns.prompt)
124
+ self._register(ns)
125
+
126
+ @staticmethod
127
+ def show_help_nav_keys(win):
128
+ """For help screens, show the navigation keys. """
129
+ for line in Window.get_nav_keys_blurb().splitlines():
130
+ if line:
131
+ win.add_header(line)
132
+
133
+ def show_help_body(self, win):
134
+ """ Write the help page section."""
135
+ win.add_body('Type keys to alter choice:', curses.A_UNDERLINE)
136
+
137
+ for ns in self.options:
138
+ # get / coerce the current value
139
+ value = self.get_value(ns.attr)
140
+ assert value is not None, f'cannot get value of {repr(ns.attr)}'
141
+ choices = ns.vals if ns.vals else [value]
142
+
143
+ win.add_body(f'{ns.descr:>{self.align}}: ')
144
+
145
+ for choice in choices:
146
+ shown = f'{choice}'
147
+ if isinstance(choice, bool):
148
+ shown = "ON" if choice else "off"
149
+ win.add_body(' ', resume=True)
150
+ win.add_body(shown, resume=True,
151
+ attr=curses.A_REVERSE if choice == value else None)
152
+
153
+ for comment in ns.comments:
154
+ win.add_body(f'{"":>{self.align}}: {comment}')
155
+
156
+ def do_key(self, key, win):
157
+ """Do the automated processing of a key."""
158
+ ns = self.key_to_option.get(key, None)
159
+ if ns is None:
160
+ return None
161
+ value = self.get_value(ns.attr)
162
+ if ns.vals:
163
+ idx = ns.vals.index(value) if value in ns.vals else -1
164
+ value = ns.vals[(idx+1) % len(ns.vals)] # choose next
165
+ else:
166
+ value = win.answer(prompt=ns.prompt, seed=str(value))
167
+ setattr(ns.obj, ns.attr, value)
168
+ return value
169
+
170
+ class Window:
171
+ """ Layer above curses to encapsulate what we need """
172
+ timeout_ms = 200
173
+ static_scr = None
174
+ nav_keys = """
175
+ Navigation: H/M/L: top/middle/end-of-page
176
+ k, UP: up one row 0, HOME: first row
177
+ j, DOWN: down one row $, END: last row
178
+ Ctrl-u: half-page up Ctrl-b, PPAGE: page up
179
+ Ctrl-d: half-page down Ctrl-f, NPAGE: page down
180
+ """
181
+ def __init__(self, head_line=True, head_rows=50, body_rows=200,
182
+ body_cols=200, keys=None, pick_mode=False, pick_size=1,
183
+ mod_pick=None):
184
+ self.scr = self._start_curses()
185
+
186
+ self.head = SimpleNamespace(
187
+ pad=curses.newpad(head_rows, body_cols),
188
+ rows=head_rows,
189
+ cols=body_cols,
190
+ row_cnt=0, # no. head rows added
191
+ texts = [],
192
+ view_cnt=0, # no. head rows viewable (NOT in body)
193
+ )
194
+ self.body = SimpleNamespace(
195
+ pad = curses.newpad(body_rows, body_cols),
196
+ rows= body_rows,
197
+ cols=body_cols,
198
+ row_cnt = 0,
199
+ texts = []
200
+ )
201
+ self.mod_pick = mod_pick # call back to modify highlighted row
202
+ self.hor_line_cnt = 1 if head_line else 0 # no. h-lines in header
203
+ self.scroll_pos = 0 # how far down into body are we?
204
+ self.max_scroll_pos = 0
205
+ self.pick_pos = 0 # in highlight mode, where are we?
206
+ self.last_pick_pos = -1 # last highlighted position
207
+ self.pick_mode = pick_mode # whether in highlight mode
208
+ self.pick_size = pick_size # whether in highlight mode
209
+ self.rows, self.cols = 0, 0
210
+ self.body_cols, self.body_rows = body_cols, body_rows
211
+ self.scroll_view_size = 0 # no. viewable lines of the body
212
+ self.handled_keys = set(keys) if isinstance(keys, (set, list)) else []
213
+ self._set_screen_dims()
214
+ self.calc()
215
+
216
+ def get_pad_width(self):
217
+ # how much space to actually draw chars?
218
+ return min(self.cols-1, self.body_cols)
219
+
220
+ @staticmethod
221
+ def get_nav_keys_blurb():
222
+ """For a help screen, describe the nav keys"""
223
+ return textwrap.dedent(Window.nav_keys)
224
+
225
+ def _set_screen_dims(self):
226
+ """Recalculate dimensions ... return True if geometry changed."""
227
+ rows, cols = self.scr.getmaxyx()
228
+ same = bool(rows == self.rows and cols == self.cols)
229
+ self.rows, self.cols = rows, cols
230
+ return same
231
+
232
+ @staticmethod
233
+ def _start_curses():
234
+ """ Curses initial setup. Note: not using curses.wrapper because we
235
+ don't wish to change the colors. """
236
+ atexit.register(Window.stop_curses)
237
+ ignore_ctrl_c()
238
+ Window.static_scr = scr = curses.initscr()
239
+ curses.noecho()
240
+ curses.cbreak()
241
+ curses.curs_set(0)
242
+ scr.keypad(1)
243
+ scr.timeout(Window.timeout_ms)
244
+ scr.clear()
245
+ return scr
246
+
247
+ def set_pick_mode(self, on=True, pick_size=1):
248
+ """Set whether in highlight mode."""
249
+ was_on, was_size = self.pick_mode, self.pick_size
250
+ self.pick_mode = bool(on)
251
+ self.pick_size = max(pick_size, 1)
252
+ if self.pick_mode and (not was_on or was_size != self.pick_size):
253
+ self.last_pick_pos = -2 # indicates need to clear them all
254
+
255
+ @staticmethod
256
+ def stop_curses():
257
+ """ Curses shutdown (registered to be called on exit). """
258
+ if Window.static_scr:
259
+ curses.nocbreak()
260
+ curses.echo()
261
+ Window.static_scr.keypad(0)
262
+ curses.endwin()
263
+ Window.static_scr = None
264
+ restore_ctrl_c()
265
+
266
+ def calc(self):
267
+ """Recalculate dimensions ... return True if geometry changed."""
268
+ same = self._set_screen_dims()
269
+ self.head.view_cnt = min(self.rows - self.hor_line_cnt, self.head.row_cnt)
270
+ self.scroll_view_size = self.rows - self.head.view_cnt - self.hor_line_cnt
271
+ self.max_scroll_pos = max(self.body.row_cnt - self.scroll_view_size, 0)
272
+ self.body_base = self.head.view_cnt + self.hor_line_cnt
273
+ return not same
274
+
275
+ def _put(self, ns, *args):
276
+ """ Add text to head/body pad using its namespace. args:
277
+ - bytes (converted to str/unicode)
278
+ - str (unicode)
279
+ - None (same as curses.A_NORMAL)
280
+ - int (curses attribute)
281
+ """
282
+ def flush(attr=None):
283
+ nonlocal self, is_body, row, text, seg, first
284
+ if (is_body and self.pick_mode) or attr is None:
285
+ attr = curses.A_NORMAL
286
+ if seg and first:
287
+ ns.pad.addstr(row, 0, seg[0:self.get_pad_width()], attr)
288
+ elif seg:
289
+ _, x = ns.pad.getyx()
290
+ cols = self.get_pad_width() - x
291
+ if cols > 0:
292
+ ns.pad.addstr(seg[0:cols], attr)
293
+ text += seg
294
+ seg, first, attr = '', False, None
295
+
296
+ is_body = bool(id(ns) == id(self.body))
297
+ if ns.row_cnt < ns.rows:
298
+ row = max(ns.row_cnt, 0)
299
+ text, seg, first = '', '', True
300
+ for arg in args:
301
+ if isinstance(arg, bytes):
302
+ arg = arg.decode('utf-8')
303
+ if isinstance(arg, str):
304
+ seg += arg # note: add w/o spacing
305
+ elif arg is None or isinstance(arg, (int)):
306
+ # assume arg is attribute ... flushes text
307
+ flush(attr=arg)
308
+ flush()
309
+ ns.texts.append(text) # text only history
310
+ ns.row_cnt += 1
311
+
312
+ def put_head(self, *args):
313
+ """ Put a line above the line."""
314
+ self._put(self.head, *args)
315
+
316
+ def put_body(self, *args):
317
+ """ Put a line below the line."""
318
+ self._put(self.body, *args)
319
+
320
+ def _add(self, ns, text, attr=None, resume=False):
321
+ """ Add text to head/body pad using its namespace"""
322
+ is_body = bool(id(ns) == id(self.body))
323
+ if ns.row_cnt < ns.rows:
324
+ row = max(ns.row_cnt - (1 if resume else 0), 0)
325
+ if (is_body and self.pick_mode) or attr is None:
326
+ attr = curses.A_NORMAL
327
+ if resume:
328
+ _, x = ns.pad.getyx()
329
+ cols = self.get_pad_width() - x
330
+ if cols > 0:
331
+ ns.pad.addstr(text[0:cols], attr)
332
+ ns.texts[row] += text
333
+ else:
334
+ ns.pad.addstr(row, 0, text[0:self.cols], attr)
335
+ ns.texts.append(text) # text only history
336
+ ns.row_cnt += 1
337
+
338
+ def add_header(self, text, attr=None, resume=False):
339
+ """Add text to header"""
340
+ self._add(self.head, text, attr, resume)
341
+
342
+ def add_body(self, text, attr=None, resume=False):
343
+ """ Add text to body (below header and header line)"""
344
+ self._add(self.body, text, attr, resume)
345
+
346
+ def draw(self, y, x, text, text_attr=None, width=None, leftpad=False, header=False):
347
+ """Draws the given text (as utf-8 or unicode) at position (row=y,col=x)
348
+ with optional text attributes and width.
349
+ This is more compatible with my older, simpler Window class.
350
+ """
351
+ ns = self.head if header else self.body
352
+ text_attr = text_attr if text_attr else curses.A_NORMAL
353
+ if y < 0 or y >= ns.rows or x < 0 or x >= ns.cols:
354
+ return # nada if out of bounds
355
+ ns.row_cnt = max(ns.row_cnt, y+1)
356
+
357
+ uni = text if isinstance(text, str) else text.decode('utf-8')
358
+
359
+ if width is not None:
360
+ width = min(width, self.get_pad_width() - x)
361
+ if width <= 0:
362
+ return
363
+ padlen = width - len(uni)
364
+ if padlen > 0:
365
+ if leftpad:
366
+ uni = padlen * ' ' + uni
367
+ else: # rightpad
368
+ uni += padlen * ' '
369
+ text = uni[:width].encode('utf-8')
370
+ else:
371
+ text = uni.encode('utf-8')
372
+
373
+ try:
374
+ while y >= len(ns.texts):
375
+ ns.texts.append('')
376
+ ns.texts[y] = ns.texts[y][:x].ljust(x) + uni + ns.texts[y][x+len(uni):]
377
+ ns.pad.addstr(y, x, text, text_attr)
378
+ except curses.error:
379
+ # this sucks, but curses returns an error if drawing the last character
380
+ # on the screen always. this can happen if resizing screen even if
381
+ # special care is taken. So, we just ignore errors. Anyhow, you cannot
382
+ # get decent error handling.
383
+ pass
384
+
385
+
386
+ def highlight_picked(self):
387
+ """Highlight the current pick and un-highlight the previous pick."""
388
+ def get_text(pos):
389
+ nonlocal self
390
+ return self.body.texts[pos][0:self.cols] if pos < len(self.body.texts) else ''
391
+
392
+ if not self.pick_mode:
393
+ return
394
+ pos0, pos1 = self.last_pick_pos, self.pick_pos
395
+ if pos0 == -2: # special flag to clear all formatting
396
+ for row in range(self.body.row_cnt):
397
+ line = get_text(row).ljust(self.get_pad_width())
398
+ self.body.pad.addstr(row, 0, get_text(row), curses.A_NORMAL)
399
+ if pos0 != pos1:
400
+ if 0 <= pos0 < self.body.row_cnt:
401
+ for i in range(self.pick_size):
402
+ line = get_text(pos0+i).ljust(self.get_pad_width())
403
+ self.body.pad.addstr(pos0+i, 0, line, curses.A_NORMAL)
404
+ if 0 <= pos1 < self.body.row_cnt:
405
+ for i in range(self.pick_size):
406
+ line = get_text(pos1+i)
407
+ if self.mod_pick:
408
+ line = self.mod_pick(line)
409
+ line = line.ljust(self.get_pad_width())
410
+ self.body.pad.addstr(pos1+i, 0, line, curses.A_REVERSE)
411
+ self.last_pick_pos = pos1
412
+
413
+ def _scroll_indicator_row(self):
414
+ """ Compute the absolute scroll indicator row:
415
+ - We want the top to be only when scroll_pos==0
416
+ - We want the bottom to be only when scroll_pos=max_scroll_pos-1
417
+ """
418
+ if self.max_scroll_pos <= 1:
419
+ return self.body_base
420
+ y2, y1 = self.scroll_view_size-1, 1
421
+ x2, x1 = self.max_scroll_pos, 1
422
+ x = self.scroll_pos
423
+ pos = y1 + (y2-y1)*(x-x1)/(x2-x1)
424
+ return min(self.body_base + int(max(pos, 0)), self.rows-1)
425
+
426
+ def _scroll_indicator_col(self):
427
+ """ Compute the absolute scroll indicator col:
428
+ - We want the left to be only when scroll_pos==0
429
+ - We want the right to be only when scroll_pos=max_scroll_pos-1
430
+ """
431
+ if self.pick_mode:
432
+ return self._calc_indicator(
433
+ self.pick_pos, 0, self.body.row_cnt-1, 0, self.cols-1)
434
+ return self._calc_indicator(
435
+ self.scroll_pos, 0, self.max_scroll_pos, 0, self.cols-1)
436
+
437
+ def _calc_indicator(self, pos, pos0, pos9, ind0, ind9):
438
+ if self.max_scroll_pos <= 0:
439
+ return -1 # not scrollable
440
+ if pos9 - pos0 <= 0:
441
+ return -1 # not scrollable
442
+ if pos <= pos0:
443
+ return ind0
444
+ if pos >= pos9:
445
+ return ind9
446
+ ind = int(round(ind0 + (ind9-ind0+1)*(pos-pos0)/(pos9-pos0+1)))
447
+ return min(max(ind, ind0+1), ind9-1)
448
+
449
+ def render(self):
450
+ """Draw everything added. In a loop cuz curses is a
451
+ piece of shit."""
452
+ for _ in range(128):
453
+ try:
454
+ self.render_once()
455
+ return
456
+ except curses.error:
457
+ time.sleep(0.16)
458
+ self._set_screen_dims()
459
+ continue
460
+ try:
461
+ self.render_once()
462
+ except Exception:
463
+ Window.stop_curses()
464
+ print(f"""curses err:
465
+ head.row_cnt={self.head.row_cnt}
466
+ head.view_cnt={self.head.view_cnt}
467
+ hor_line_cnt={self.hor_line_cnt}
468
+ body.row_cnt={self.body.row_cnt}
469
+ scroll_pos={self.scroll_pos}
470
+ max_scroll_pos={self.max_scroll_pos}
471
+ pick_pos={self.pick_pos}
472
+ last_pick_pos={self.last_pick_pos}
473
+ pick_mode={self.pick_mode}
474
+ pick_size={self.pick_size}
475
+ rows={self.rows}
476
+ cols={self.cols}
477
+ """)
478
+ raise
479
+
480
+
481
+ def fix_positions(self, delta=0):
482
+ """ Ensure the vertical positions are on the playing field """
483
+ self.calc()
484
+ # if self.scroll_view_size <= 0:
485
+ # self.scr.refresh()
486
+ if self.pick_mode:
487
+ self.pick_pos += delta
488
+ else:
489
+ self.scroll_pos += delta
490
+ self.pick_pos += delta
491
+
492
+ indent = 0
493
+ if self.body_base < self.rows:
494
+ ind_pos = 0 if self.pick_mode else self._scroll_indicator_row()
495
+ if self.pick_mode:
496
+ self.pick_pos = max(self.pick_pos, 0)
497
+ self.pick_pos = min(self.pick_pos, self.body.row_cnt-1)
498
+ if self.pick_pos >= 0:
499
+ self.pick_pos -= (self.pick_pos % self.pick_size)
500
+ if self.pick_pos < 0:
501
+ self.scroll_pos = 0
502
+ elif self.scroll_pos > self.pick_pos:
503
+ # light position is below body bottom
504
+ self.scroll_pos = self.pick_pos
505
+ elif self.scroll_pos < self.pick_pos - (self.scroll_view_size - self.pick_size):
506
+ # light position is above body top
507
+ self.scroll_pos = self.pick_pos - (self.scroll_view_size - self.pick_size)
508
+ self.scroll_pos = max(self.scroll_pos, 0)
509
+ self.scroll_pos = min(self.scroll_pos, self.max_scroll_pos)
510
+ indent = 1
511
+ else:
512
+ self.scroll_pos = max(self.scroll_pos, 0)
513
+ self.scroll_pos = min(self.scroll_pos, self.max_scroll_pos)
514
+ self.pick_pos = self.scroll_pos + ind_pos - self.body_base
515
+ # indent = 1 if self.body.row_cnt > self.scroll_view_size else 0
516
+ return indent
517
+
518
+ def render_once(self):
519
+ """Draw everything added."""
520
+
521
+ indent = self.fix_positions()
522
+
523
+ if indent > 0 and self.pick_mode:
524
+ self.scr.vline(self.body_base, 0, ' ', self.scroll_view_size)
525
+ if self.pick_pos >= 0:
526
+ pos = self.pick_pos - self.scroll_pos + self.body_base
527
+ self.scr.addstr(pos, 0, '>', curses.A_REVERSE)
528
+
529
+ if self.head.view_cnt < self.rows:
530
+ self.scr.hline(self.head.view_cnt, 0, curses.ACS_HLINE, self.cols)
531
+ ind_pos = self._scroll_indicator_col()
532
+ if ind_pos >= 0:
533
+ bot, cnt = ind_pos, 1
534
+ if 0 < ind_pos < self.cols-1:
535
+ width = self.scroll_view_size/self.body.row_cnt*self.cols
536
+ bot = max(int(round(ind_pos-width/2)), 1)
537
+ top = min(int(round(ind_pos+width/2)), self.cols-1)
538
+ cnt = top - bot
539
+ # self.scr.addstr(self.head.view_cnt, bot, '-'*cnt, curses.A_REVERSE)
540
+ # self.scr.hline(self.head.view_cnt, bot, curses.ACS_HLINE, curses.A_REVERSE, cnt)
541
+ for idx in range(bot, bot+cnt):
542
+ self.scr.addch(self.head.view_cnt, idx, curses.ACS_HLINE, curses.A_REVERSE)
543
+
544
+ self.scr.refresh()
545
+
546
+ if self.body_base < self.rows:
547
+ if self.pick_mode:
548
+ self.highlight_picked()
549
+ self.body.pad.refresh(self.scroll_pos, 0,
550
+ self.body_base, indent, self.rows-1, self.cols-1)
551
+
552
+ if self.rows > 0:
553
+ last_row = min(self.head.view_cnt, self.rows)-1
554
+ if last_row >= 0:
555
+ self.head.pad.refresh(0, 0, 0, indent, last_row, self.cols-1)
556
+
557
+
558
+ def answer(self, prompt='Type string [then Enter]', seed='', width=80):
559
+ """Popup"""
560
+ def mod_key(key):
561
+ return 7 if key == 10 else key
562
+
563
+ # need 3 extra cols for rectangle (so we don't draw in southeast corner)
564
+ # and 3 rows (top/prompt/bottom)
565
+ # +Prompt--- -----------------+
566
+ # | Seed-for-answer |
567
+ # +---------Press ENTER to submit+
568
+ if self.rows < 3 or self.cols < 30:
569
+ return seed
570
+ width = min(width, self.cols-3) # max text width
571
+ row0, row9 = self.rows//2 - 1, self.rows//2 + 1
572
+ col0 = (self.cols - (width+2)) // 2
573
+ col9 = col0 + width + 2 - 1
574
+
575
+ self.scr.clear()
576
+ win = curses.newwin(1, width, row0+1, col0+1) # input window
577
+ rectangle(self.scr, row0, col0, row9, col9)
578
+ self.scr.addstr(row0, col0+1, prompt[0:width])
579
+ win.addstr(seed[0:width-1])
580
+ ending = 'Press ENTER to submit'[:width]
581
+ self.scr.addstr(row9, col0+1+width-len(ending), ending)
582
+ self.scr.refresh()
583
+ curses.curs_set(2)
584
+ answer = Textbox(win).edit(mod_key).strip()
585
+ curses.curs_set(0)
586
+ return answer
587
+
588
+ def alert(self, title='ALERT', message='', height=1, width=80):
589
+ """Alert box"""
590
+ def mod_key(key):
591
+ return 7 if key == 10 else key
592
+
593
+ # need 3 extra cols for rectangle (so we don't draw in southeast corner)
594
+ # and 3 rows (top/prompt/bottom)
595
+ # +Prompt--- -----------------+
596
+ # | First line for message... |
597
+ # | Last line for message. |
598
+ # +-----------Press ENTER to ack+
599
+ if self.rows < 2+height or self.cols < 30:
600
+ return
601
+ width = min(width, self.cols-3) # max text width
602
+ row0 = (self.rows+height-1)//2 - 1
603
+ row9 = row0 + height + 1
604
+ col0 = (self.cols - (width+2)) // 2
605
+ col9 = col0 + width + 2 - 1
606
+
607
+ self.scr.clear()
608
+ for row in range(self.rows):
609
+ self.scr.insstr(row, 0, ' '*self.cols, curses.A_REVERSE)
610
+ pad = curses.newpad(20, 200)
611
+ win = curses.newwin(1, 1, row9-1, col9-2) # input window
612
+ rectangle(self.scr, row0, col0, row9, col9)
613
+ self.scr.addstr(row0, col0+1, title[0:width], curses.A_REVERSE)
614
+ pad.addstr(message)
615
+ ending = 'Press ENTER to ack'[:width]
616
+ self.scr.addstr(row9, col0+1+width-len(ending), ending)
617
+ self.scr.refresh()
618
+ pad.refresh(0, 0, row0+1, col0+1, row9-1, col9-1)
619
+ Textbox(win).edit(mod_key).strip()
620
+ return
621
+
622
+ def clear(self):
623
+ """Clear in prep for new screen"""
624
+ self.scr.clear()
625
+ self.head.pad.clear()
626
+ self.body.pad.clear()
627
+ self.head.texts, self.body.texts, self.last_pick_pos = [], [], -1
628
+ self.head.row_cnt = self.body.row_cnt = 0
629
+
630
+ def prompt(self, seconds=1.0):
631
+ """Here is where we sleep waiting for commands or timeout"""
632
+ ctl_b, ctl_d, ctl_f, ctl_u = 2, 4, 6, 21
633
+ elapsed = 0.0
634
+ while elapsed < seconds:
635
+ key = self.scr.getch()
636
+ if key == curses.ERR:
637
+ elapsed += self.timeout_ms / 1000
638
+ continue
639
+ if key in (curses.KEY_RESIZE, ) or curses.is_term_resized(self.rows, self.cols):
640
+ # self.scr.erase()
641
+ self._set_screen_dims()
642
+ # self.render()
643
+ break
644
+
645
+ # App keys...
646
+ if key in self.handled_keys:
647
+ return key # return for handling
648
+
649
+ # Navigation Keys...
650
+ pos = self.pick_pos if self.pick_mode else self.scroll_pos
651
+ delta = self.pick_size if self.pick_mode else 1
652
+ was_pos = pos
653
+ if key in (ord('k'), curses.KEY_UP):
654
+ pos -= delta
655
+ elif key in (ord('j'), curses.KEY_DOWN):
656
+ pos += delta
657
+ elif key in (ctl_b, curses.KEY_PPAGE):
658
+ pos -= self.scroll_view_size
659
+ elif key in (ctl_u, ):
660
+ pos -= self.scroll_view_size//2
661
+ elif key in (ctl_f, curses.KEY_NPAGE):
662
+ pos += self.scroll_view_size
663
+ elif key in (ctl_d, ):
664
+ pos += self.scroll_view_size//2
665
+ elif key in (ord('0'), curses.KEY_HOME):
666
+ pos = 0
667
+ elif key in (ord('$'), curses.KEY_END):
668
+ pos = self.body.row_cnt - 1
669
+ elif key in (ord('H'), ):
670
+ pos = self.scroll_pos
671
+ elif key in (ord('M'), ):
672
+ pos = self.scroll_pos + self.scroll_view_size//2
673
+ elif key in (ord('L'), ):
674
+ pos = self.scroll_pos + self.scroll_view_size-1
675
+
676
+ if self.pick_mode:
677
+ self.pick_pos = pos
678
+ else:
679
+ self.scroll_pos = pos
680
+ self.pick_pos = pos
681
+
682
+ self.fix_positions()
683
+
684
+ if pos != was_pos:
685
+ self.render()
686
+ # ignore unhandled keys
687
+ return None
688
+
689
+ def no_runner():
690
+ """Appease sbrun"""
691
+
692
+ if __name__ == '__main__':
693
+ def main():
694
+ """Test program"""
695
+ def do_key(key):
696
+ nonlocal spin, win, opts
697
+ value = spin.do_key(key, win)
698
+ if key in (ord('p'), ord('s')):
699
+ win.set_pick_mode(on=opts.pick_mode, pick_size=opts.pick_size)
700
+ elif key == ord('n'):
701
+ win.alert(title='Info', message=f'got: {value}')
702
+ return value
703
+
704
+ spin = OptionSpinner()
705
+ spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
706
+ spin.add_key('pick_mode', 'p - toggle pick mode', vals=[False, True])
707
+ spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
708
+ spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
709
+ spin.add_key('mult', 'm - row multiplier', vals=[0.5, 0.9, 1.0, 1.1, 2, 4, 16])
710
+ opts = spin.default_obj
711
+
712
+ win = Window(head_line=True, keys=spin.keys)
713
+ opts.name = "[hit 'n' to enter name]"
714
+ for loop in range(100000000000):
715
+ body_size = int(round(win.scroll_view_size*opts.mult))
716
+ if opts.help_mode:
717
+ win.set_pick_mode(False)
718
+ spin.show_help_nav_keys(win)
719
+ spin.show_help_body(win)
720
+ else:
721
+ win.set_pick_mode(opts.pick_mode, opts.pick_size)
722
+ win.add_header(f'Header: {loop} "{opts.name}"')
723
+ for idx, line in enumerate(range(body_size//opts.pick_size)):
724
+ win.add_body(f'Main pick: {loop}.{line}')
725
+ for num in range(1, opts.pick_size):
726
+ win.draw(num+idx*opts.pick_size, 0, f' addon: {loop}.{line}')
727
+ win.render()
728
+ _ = do_key(win.prompt(seconds=5))
729
+ win.clear()
730
+
731
+ try:
732
+ main()
733
+ except KeyboardInterrupt:
734
+ pass
735
+ except Exception as exce:
736
+ Window.stop_curses()
737
+ print("exception:", str(exce))
738
+ print(traceback.format_exc())
739
+ if dump_str:
740
+ print(dump_str)
File without changes
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Interactive, visual thin layer atop appman
5
+ """
6
+ # pylint: disable=broad-exception-caught,consider-using-with
7
+ # pylint: disable=too-many-instance-attributes,too-many-branches
8
+ # pylint: disable=too-many-return-statements,too-many-statements
9
+
10
+ # pylint: disable=wrong-import-position,disable=wrong-import-order
11
+ # import VirtEnv
12
+ # VirtEnv.ensure_venv(__name__)
13
+
14
+ import os
15
+ import sys
16
+ import re
17
+ import subprocess
18
+ import traceback
19
+ import copy
20
+ import shutil
21
+ import curses as cs
22
+ from types import SimpleNamespace
23
+ from vappman.PowerWindow import Window, OptionSpinner
24
+
25
+
26
+ class Vappman:
27
+ """ Main class for curses atop appman"""
28
+ singleton = None
29
+
30
+ def __init__(self):
31
+ # self.cmd_loop = CmdLoop(db=False) # just running as command
32
+ assert not Vappman.singleton
33
+ Vappman.singleton = self
34
+
35
+ self.summary_lines = []
36
+ self.new_summary_lines = True
37
+
38
+ spin = self.spin = OptionSpinner()
39
+ spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
40
+
41
+ other = 'ir/q'
42
+ other_keys = set(ord(x) for x in other)
43
+ self.opts = spin.default_obj
44
+
45
+ self.actions = {} # currently available actions
46
+ self.pick_app = '' # current picked app
47
+ self.pick_is_installed = False
48
+ # self.verbs = {'i': 'install', 'r': 'remove', 'v': 'view',}
49
+ self.prev_filter = '' # string
50
+ self.filter = None # compiled pattern
51
+ self.apps = self.cmd_dict('appman list')
52
+ self.installs = self.get_installed() # dict keyed by app
53
+ self.win = Window(head_line=True, body_rows=len(self.apps)+20, head_rows=10,
54
+ keys=spin.keys ^ other_keys, mod_pick=self.mod_pick)
55
+
56
+
57
+ def cmd_dict(self, cmd, start='◆ '):
58
+ """ Get lines with the given start."""
59
+ # Define the command to run
60
+ command = cmd.split()
61
+ # Run the command and capture the output
62
+ result = subprocess.run(command, stdout=subprocess.PIPE, text=True, check=True)
63
+ lines = result.stdout.splitlines()
64
+ rv = {}
65
+ for line in lines:
66
+ if line.startswith(start):
67
+ wd1 = self.get_word1(line)
68
+ if wd1:
69
+ rv[wd1] = line
70
+ return rv
71
+
72
+ @staticmethod
73
+ def get_word1(line):
74
+ """ Get words[1] from a string. """
75
+ words = line.split(maxsplit=3)
76
+ return '' if len(words) < 1 else words[1]
77
+
78
+ def get_installed(self):
79
+ """ Get the list of lines of installed apps """
80
+ rv = self.cmd_dict('appman files --by-name')
81
+ return rv
82
+
83
+ def main_loop(self):
84
+ """ TBD """
85
+
86
+ self.opts.name = "[hit 'n' to enter name]"
87
+ while True:
88
+ if self.opts.help_mode:
89
+ self.win.set_pick_mode(False)
90
+ self.spin.show_help_nav_keys(self.win)
91
+ self.spin.show_help_body(self.win)
92
+ lines = [
93
+ 'ALWAYS AVAILABLE:',
94
+ ' / - filter apps',
95
+ ' q - quit program (CTL-C disabled)',
96
+ 'CONTEXT SENSITIVE:',
97
+ ' i - install uninstalled app',
98
+ ' r - remove installed app',
99
+ ]
100
+ for line in lines:
101
+ self.win.put_body(line)
102
+ else:
103
+ def wanted(line):
104
+ return not self.filter or self.filter.search(line)
105
+
106
+ # self.win.set_pick_mode(self.opts.pick_mode, self.opts.pick_size)
107
+ self.win.set_pick_mode(True)
108
+ self.win.add_header(self.get_keys_line(), attr=cs.A_BOLD)
109
+ for line in self.installs.values():
110
+ if wanted(line):
111
+ self.win.add_body(line)
112
+ for app, line in self.apps.items():
113
+ if app not in self.installs and wanted(line):
114
+ self.win.add_body(line)
115
+ # if self.new_summary_lines:
116
+ # self.win.pick_pos = self.win.scroll_pos = 0
117
+ # self.new_summary_lines = False
118
+ self.win.render()
119
+
120
+ _ = self.do_key(self.win.prompt(seconds=300))
121
+ self.win.clear()
122
+
123
+ def get_keys_line(self):
124
+ """ TBD """
125
+ filt = self.prev_filter if self.prev_filter else '{No-Filt}'
126
+ line = 'KEYS:'
127
+ for key, verb in self.actions.items():
128
+ line += f' {key}:{verb}'
129
+ line += f' ?:help q:quit /{filt} '
130
+ # for action in self.actions:
131
+ # line += f' {action[0]}:{action}'
132
+ return line
133
+
134
+ def get_actions(self, line):
135
+ """ Determine the type of the current line and available commands."""
136
+ app, actions = '', {}
137
+ lines = self.win.body.texts
138
+ if 0 <= self.win.pick_pos < len(lines):
139
+ line = lines[self.win.pick_pos]
140
+ app = self.get_word1(line)
141
+ self.pick_is_installed = bool(app in self.installs)
142
+ if self.pick_is_installed:
143
+ actions['r'] = 'remove'
144
+ else:
145
+ actions['i'] = 'install'
146
+
147
+ return app, actions
148
+
149
+ @staticmethod
150
+ def mod_pick(line):
151
+ """ Callback to modify the "pick line" being highlighed;
152
+ We use it to alter the state
153
+ """
154
+ this = Vappman.singleton
155
+ this.pick_app, this.actions = this.get_actions(line)
156
+ keys_line = this.get_keys_line().ljust(this.win.get_pad_width())
157
+ this.win.head.pad.addstr(0, 0, keys_line, cs.A_BOLD)
158
+
159
+ return line
160
+ # #IF WE WANT TO DO SOMETHING ON THE LINE...
161
+ # suffix = ''
162
+ # for action in actions:
163
+ # suffix += f' {action[0]}:{action}'
164
+ # block_char = "\u2588"
165
+ # suffix = f'{block_char*5} {suffix}'
166
+ # over = len(line) + len(suffix) - this.win.get_pad_width()
167
+ # if over < 0:
168
+ # line += ' '*(-over)
169
+ # elif over > 0:
170
+ # line = line[0:-over]
171
+
172
+ # return line + suffix
173
+
174
+ def do_key(self, key):
175
+ """ TBD """
176
+ def dashes(ns, ok=True):
177
+ try:
178
+ # use checkmark or circle-backslash
179
+ filler = ('\u2713' if ok else '\u2342') * (ns.end - ns.start)
180
+ line = self.summary_lines[self.win.pick_pos]
181
+ line = line[0:ns.start] + filler + line[ns.end:]
182
+ self.summary_lines[self.win.pick_pos] = line
183
+ self.win.draw(self.win.pick_pos, 0, line, width=self.win.get_pad_width(), header=False)
184
+ self.win.fix_positions(delta=1)
185
+ self.win.render()
186
+ except Exception:
187
+ pass
188
+
189
+ if not key:
190
+ return True
191
+ if key in self.spin.keys:
192
+ value = self.spin.do_key(key, self.win)
193
+ if key in (ord('p'), ord('s')):
194
+ self.win.set_pick_mode(on=self.opts.pick_mode, pick_size=self.opts.pick_size)
195
+ elif key == ord('n'):
196
+ self.win.alert(title='Info', message=f'got: {value}')
197
+ return value
198
+ if key == ord('q'):
199
+ self.win.stop_curses()
200
+ os.system('clear; stty sane')
201
+ sys.exit(0)
202
+
203
+ if key == ord('i') and not self.pick_is_installed:
204
+ # pylint: disable=protected-access
205
+ Window.stop_curses()
206
+ os.system(f'clear; stty sane; appman install {self.pick_app};'
207
+ + ' echo -e "\n\nHit ENTER to return to menu"; read FOO')
208
+ self.installs = self.get_installed()
209
+ Window._start_curses()
210
+ return None
211
+
212
+ if key == ord('r') and self.pick_is_installed:
213
+ # pylint: disable=protected-access
214
+ Window.stop_curses()
215
+ os.system(f'clear; stty sane; appman remove {self.pick_app};'
216
+ + ' echo -e "\n\nHit ENTER to return to menu"; read FOO')
217
+ self.installs = self.get_installed() # dict keyed by app
218
+ Window._start_curses()
219
+ return None
220
+
221
+ if key == ord('/'):
222
+ # pylint: disable=protected-access
223
+
224
+ prefix = ''
225
+ while True:
226
+ pattern = self.win.answer(f'{prefix}Enter filter regex:', seed=self.prev_filter)
227
+ self.prev_filter = pattern
228
+
229
+ pattern.strip()
230
+ if not pattern:
231
+ self.filter = None
232
+ return None
233
+
234
+ try:
235
+ if re.match(r'^[\-\w\s]*$', pattern):
236
+ words = pattern.split()
237
+ self.filter = re.compile(r'\b' + r'\b.*'.join(words), re.IGNORECASE)
238
+ return None
239
+ self.filter = re.compile(pattern, re.IGNORECASE)
240
+ return None
241
+ except Exception:
242
+ prefix = 'Bad regex: '
243
+
244
+ return None
245
+ return None
246
+
247
+
248
+ def main():
249
+ """ The program """
250
+
251
+ appman = Vappman()
252
+ appman.main_loop()
253
+
254
+ if __name__ == '__main__':
255
+
256
+ try:
257
+ main()
258
+ except KeyboardInterrupt:
259
+ pass
260
+ except Exception as exce:
261
+ Window.stop_curses()
262
+ print("exception:", str(exce))
263
+ print(traceback.format_exc())
264
+ # if dump_str:
265
+ # print(dump_str)
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.1
2
+ Name: vappman
3
+ Version: 0.1
4
+ Summary: A visual wrapper for appman
5
+ Author-email: Joe Defen <joedef@google.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/joedefen/vappman
8
+ Project-URL: Bug Tracker, https://github.com/joedefen/vappman/issues
9
+ Keywords: app,installer,manager,appimages
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: importlib-metadata; python_version < "3.8"
17
+ Requires-Dist: psutil>=5.9
18
+
19
+ # vappman
20
+ `vappman` presents a visual (curses) interface to `appman`.
21
+
22
+ * Install `vappman` using `pipx install vappman`, or however you do so.
23
+ * Prerequisites: install [ivan-hc/AppMan: AppImage package manager to install, update (for real) and manage ALL of them locally thanks to "AM", the ever-growing AUR-inspired database listing (for now) 1900+ portable apps and programs for GNU/Linux. Manage your AppImages with the ease of APT and the power of PacMan.](https://github.com/ivan-hc/AppMan) and all of its prerequisites.
24
+
25
+ NOTE: `vappman` is in a very preliminary state,
26
+ but it implements the most needed, basic functionality:
27
+ * filtering the app list
28
+ * installing new apps
29
+ * removing installed apps
30
+
31
+ ## Usage
32
+ * Run `vappman` from the command line.
33
+ * It presents some keys available on the top line.
34
+ * Use '?' to learn the navigation keys (e.g., you can use the mouse wheel,
35
+ arrow keys, and many `vi`-like keys)
36
+ * Then `vappman` presents a list of installed apps, followed by available/uninstalled apps.
37
+ * Enter `/` to enter a "filter" for installed/uninstalled apps, if you wish.
38
+ * If you enter plain old "words", then those words must match the beginning of words
39
+ of the apps or descriptions (in order, but not contiguously).
40
+ * Or you can enter an regular expression acceptable to python (e.g., `\b` means word
41
+ boundary, etc.)
42
+ * Use `i` to install apps, and `r` to remove apps. When you install or remove an app, `appman` drops out of `curses` mode, runs the `appman` command so you can see the result, and then prompts your to hit ENTER to return to `vappman.
43
+
44
+ ## Example Screenshot
45
+ ![vappman-with-filter](images/vappman-with-filter.png).
46
+
47
+ ---
48
+
49
+ NOTES:
50
+ * the filter is `card` so it shows apps with words starting with `card`.
51
+ * the current position is on `glabels`; thus if `i` is typed, `appman install glabels` is run.
52
+ * if the horizontal line (second line show) has no decorations, then you are looking
53
+ all the filtered apps; otherwise, the decoration suggests where you are in the
54
+ partial view of the filtered apps.
55
+
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/vappman/PowerWindow.py
5
+ src/vappman/__init__.py
6
+ src/vappman/main.py
7
+ src/vappman.egg-info/PKG-INFO
8
+ src/vappman.egg-info/SOURCES.txt
9
+ src/vappman.egg-info/dependency_links.txt
10
+ src/vappman.egg-info/entry_points.txt
11
+ src/vappman.egg-info/requires.txt
12
+ src/vappman.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vappman = vappman.main:main
@@ -0,0 +1,4 @@
1
+ psutil>=5.9
2
+
3
+ [:python_version < "3.8"]
4
+ importlib-metadata
@@ -0,0 +1 @@
1
+ vappman