Skip to content

Commit 950168c

Browse files
committed
ttktui: add experimental TermTk based text user interface
Launch in a terminal with command line argument `nicotine --tui` Depends on ceccopierangiolieugenio/pyTermTk#469
1 parent 8d15975 commit 950168c

File tree

196 files changed

+41767
-23
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

196 files changed

+41767
-23
lines changed

pynicotine/__init__.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def check_arguments():
6868
"-n", "--headless", action="store_true",
6969
help=_("start the program in headless mode (no GUI)")
7070
)
71+
parser.add_argument(
72+
"-t", "--tui", action="store_true",
73+
help=_("start the program in text terminal mode (TermTk TUI)")
74+
)
7175
parser.add_argument(
7276
"-v", "--version", action="version", version=f"{__application_name__} {__version__}",
7377
help=_("display version and exit")
@@ -94,7 +98,7 @@ def check_arguments():
9498
core.cli_interface_address = args.bindip
9599
core.cli_listen_port = args.port
96100

97-
return args.headless, args.hidden, args.ci_mode, args.isolated, args.rescan, multi_instance
101+
return args.headless, args.tui, args.hidden, args.ci_mode, args.isolated, args.rescan, multi_instance
98102

99103

100104
def check_python_version():
@@ -188,17 +192,28 @@ def run():
188192
set_up_python()
189193
rename_process(b"nicotine")
190194

191-
headless, hidden, ci_mode, isolated_mode, rescan, multi_instance = check_arguments()
195+
headless, tui, hidden, ci_mode, isolated_mode, rescan, multi_instance = check_arguments()
192196
error = check_python_version()
193197

194198
if error:
195199
print(error)
196200
return 1
197201

198-
core.init_components(
199-
enabled_components={"cli", "shares"} if rescan else None,
200-
isolated_mode=isolated_mode
201-
)
202+
if rescan:
203+
enabled_components = {"cli", "shares"}
204+
205+
elif tui:
206+
enabled_components = {
207+
"error_handler", "signal_handler", "portmapper", "network_thread", "shares", "users",
208+
"notifications", "network_filter", "now_playing", "statistics", "port_checker", "update_checker",
209+
"search", "downloads", "uploads", "interests", "userbrowse", "userinfo", "buddies",
210+
"chatrooms", "privatechat", "pluginhandler"
211+
}
212+
213+
else:
214+
enabled_components = None # Enable all components by default
215+
216+
core.init_components(enabled_components=enabled_components, isolated_mode=isolated_mode)
202217

203218
# Dump tracebacks for C modules (in addition to pure Python code)
204219
try:
@@ -214,8 +229,16 @@ def run():
214229
if rescan:
215230
return rescan_shares()
216231

217-
# Initialize GTK-based GUI
218-
if not headless:
232+
if tui:
233+
# Initialize TermTk-based TUI
234+
from pynicotine import ttktui as application
235+
exit_code = application.run()
236+
237+
if exit_code is not None:
238+
return exit_code
239+
240+
elif not headless:
241+
# Initialize GTK-based GUI
219242
from pynicotine import gtkgui as application
220243
exit_code = application.run(hidden, ci_mode, isolated_mode, multi_instance)
221244

pynicotine/cli.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
# SPDX-FileCopyrightText: 2022-2025 Nicotine+ Contributors
22
# SPDX-License-Identifier: GPL-3.0-or-later
33

4+
try:
5+
# Enable line editing and history
6+
import readline
7+
8+
try:
9+
readline.get_line_buffer() # pylint: disable=no-member
10+
except Exception as curses_error:
11+
raise ImportError from curses_error
12+
13+
READLINE_ENABLED = True
14+
15+
except ImportError:
16+
# Readline is not available on this OS
17+
READLINE_ENABLED = False
18+
419
import sys
520
import time
621

@@ -19,14 +34,7 @@ def __init__(self):
1934

2035
super().__init__(name="CLIInputProcessor", daemon=True)
2136

22-
try:
23-
# Enable line editing and history
24-
import readline # noqa: F401 # pylint:disable=unused-import
25-
26-
except ImportError:
27-
# Readline is not available on this OS
28-
pass
29-
37+
self.has_buffer = READLINE_ENABLED
3038
self.has_custom_prompt = False
3139
self.prompt_message = ""
3240
self.prompt_callback = None
@@ -43,7 +51,9 @@ def run(self):
4351

4452
except Exception as error:
4553
log.add_debug("CLI input prompt is no longer available: %s", error)
46-
return
54+
break
55+
56+
self.has_buffer = False
4757

4858
def _handle_prompt_callback(self, user_input, callback):
4959

@@ -89,6 +99,13 @@ def _handle_prompt(self):
8999
# No custom prompt, treat input as command
90100
self._handle_prompt_command(user_input)
91101

102+
def get_prompt_line(self):
103+
104+
if not self.has_buffer:
105+
return ""
106+
107+
return f"{self.prompt_message}{readline.get_line_buffer()}" # pylint: disable=no-member
108+
92109

93110
class CLI:
94111
__slots__ = ("_input_processor", "_log_message_queue", "_tty_attributes")
@@ -127,10 +144,10 @@ def prompt(self, message, callback, is_silent=False):
127144
self._input_processor.prompt_callback = callback
128145
self._input_processor.prompt_silent = is_silent
129146

130-
def _print_log_message(self, log_message):
147+
def _print_log_message(self, log_message, prompt_line=None):
131148

132149
try:
133-
print(log_message, flush=True)
150+
print(f"\x1b[2K{log_message}\n{prompt_line}", end="", flush=True)
134151

135152
except OSError:
136153
# stdout is gone, prevent future errors
@@ -149,12 +166,13 @@ def _log_message(self, timestamp_format, msg, _title, _level):
149166
else:
150167
log_message = msg
151168

152-
if self._input_processor.has_custom_prompt:
153-
# Don't print log messages while custom prompt is active
169+
if self._input_processor.has_custom_prompt and not READLINE_ENABLED:
170+
# Unless there's a way to avoid overwriting the prompt and user
171+
# input, don't print log messages while custom prompt is active
154172
self._log_message_queue.append(log_message)
155173
return
156174

157-
self._print_log_message(log_message)
175+
self._print_log_message(log_message, prompt_line=self._input_processor.get_prompt_line())
158176

159177
def _quit(self):
160178
"""Restores TTY attributes and re-enables echo on quit."""
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .abstractscrollview import *
2+
from .abstractscrollarea import *
3+
from .abstractitemmodel import *
4+
from .abstracttablemodel import *
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# MIT License
2+
#
3+
# Copyright (c) 2021 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
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.
22+
23+
__all__ = ['TTkAbstractItemModel']
24+
25+
from TermTk.TTkCore.signal import pyTTkSignal
26+
27+
class TTkAbstractItemModel():
28+
'''TTkAbstractItemModel'''
29+
__slots__ = (
30+
# Signals
31+
'dataChanged'
32+
)
33+
def __init__(self) -> None:
34+
self.dataChanged = pyTTkSignal()
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# MIT License
2+
#
3+
# Copyright (c) 2021 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
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.
22+
23+
__all__ = ['TTkAbstractScrollArea']
24+
25+
from dataclasses import dataclass
26+
from typing import List,Any,Type
27+
28+
from TermTk.TTkCore.constant import TTkK
29+
# from TermTk.TTkCore.log import TTkLog
30+
from TermTk.TTkCore.signal import pyTTkSlot
31+
from TermTk.TTkWidgets.widget import TTkWidget
32+
from TermTk.TTkWidgets.container import TTkContainer
33+
from TermTk.TTkWidgets.scrollbar import TTkScrollBar
34+
from TermTk.TTkLayouts.gridlayout import TTkGridLayout
35+
from TermTk.TTkAbstract.abstractscrollview import TTkAbstractScrollViewInterface
36+
37+
@dataclass
38+
class _ForwardData():
39+
forwardClass: Type
40+
instance: str
41+
signals: List[str]
42+
methods: List[str]
43+
44+
class TTkAbstractScrollArea(TTkContainer):
45+
__slots__ = (
46+
'_processing', # this flag is required to avoid unnecessary loop on edge cases
47+
'_viewport',
48+
'_verticalScrollBar', '_verticalScrollBarPolicy',
49+
'_horizontalScrollBar', '_horizontalScrollBarPolicy',)
50+
51+
def __init__(self, *,
52+
verticalScrollBarPolicy:TTkK.ScrollBarPolicy=TTkK.ScrollBarPolicy.ScrollBarAsNeeded,
53+
horizontalScrollBarPolicy:TTkK.ScrollBarPolicy=TTkK.ScrollBarPolicy.ScrollBarAsNeeded,
54+
**kwargs) -> None:
55+
self._processing = False
56+
self._viewport = None
57+
# self.setLayout(TTkGridLayout())
58+
self._verticalScrollBar = TTkScrollBar(orientation=TTkK.VERTICAL, visible=False)
59+
self._horizontalScrollBar = TTkScrollBar(orientation=TTkK.HORIZONTAL, visible=False)
60+
self._verticalScrollBarPolicy = verticalScrollBarPolicy
61+
self._horizontalScrollBarPolicy = horizontalScrollBarPolicy
62+
super().__init__(**kwargs)
63+
self.layout().addWidget(self._verticalScrollBar)
64+
self.layout().addWidget(self._horizontalScrollBar)
65+
66+
def _resizeEvent(self):
67+
if self._processing: return
68+
self._processing = True
69+
w,h = self.size()
70+
vert = 1 if self._verticalScrollBar.isVisible() else 0
71+
hori = 1 if self._horizontalScrollBar.isVisible() else 0
72+
if vert:
73+
self._verticalScrollBar.setGeometry(w-1,0,1,h-hori)
74+
if hori:
75+
self._horizontalScrollBar.setGeometry(0,h-1,w-vert,1)
76+
if self._viewport:
77+
self._viewport.setGeometry(0,0,w-vert,h-hori)
78+
self._processing = False
79+
80+
def resizeEvent(self, w: int, h: int):
81+
self._resizeEvent()
82+
83+
@pyTTkSlot()
84+
def _viewportChanged(self):
85+
if not self.isVisible(): return
86+
w,h = self.size()
87+
fw, fh = self._viewport.viewFullAreaSize()
88+
dw, dh = self._viewport.viewDisplayedSize()
89+
ox, oy = self._viewport.getViewOffsets()
90+
if 0 in [fw,fh,dw,dh]:
91+
return
92+
hpage = dw
93+
vpage = dh
94+
hrange = fw - dw
95+
vrange = fh - dh
96+
# TTkLog.debug(f"f:{fw,fh=}, d:{dw,dh=}, o:{ox,oy=}")
97+
self._verticalScrollBar.setPageStep(vpage)
98+
self._verticalScrollBar.setRange(0, vrange)
99+
self._verticalScrollBar.setValue(oy)
100+
self._horizontalScrollBar.setPageStep(hpage)
101+
self._horizontalScrollBar.setRange(0, hrange)
102+
self._horizontalScrollBar.setValue(ox)
103+
104+
if self._verticalScrollBarPolicy == TTkK.ScrollBarAsNeeded:
105+
if h<=4 or w<=1 or vrange<=0:
106+
self._verticalScrollBar.hide()
107+
elif dh>self._verticalScrollBar.minimumHeight()+1:
108+
# we need enough space to display the bar to avoid endless loop
109+
self._verticalScrollBar.show()
110+
elif self._verticalScrollBarPolicy == TTkK.ScrollBarAlwaysOn:
111+
self._verticalScrollBar.show()
112+
else:
113+
self._verticalScrollBar.hide()
114+
115+
if self._horizontalScrollBarPolicy == TTkK.ScrollBarAsNeeded:
116+
if w<=4 or h<=1 or hrange<=0:
117+
self._horizontalScrollBar.hide()
118+
elif dw>self._horizontalScrollBar.minimumWidth()+1:
119+
# we need enough space to display the bar to avoid endless loop
120+
self._horizontalScrollBar.show()
121+
elif self._horizontalScrollBarPolicy == TTkK.ScrollBarAlwaysOn:
122+
self._horizontalScrollBar.show()
123+
else:
124+
self._horizontalScrollBar.hide()
125+
self._resizeEvent()
126+
127+
@pyTTkSlot(int)
128+
def _vscrollMoved(self, val):
129+
ox, _ = self._viewport.getViewOffsets()
130+
self._viewport.viewMoveTo(ox, val)
131+
132+
@pyTTkSlot(int)
133+
def _hscrollMoved(self, val):
134+
_, oy = self._viewport.getViewOffsets()
135+
self._viewport.viewMoveTo(val, oy)
136+
137+
def setViewport(self, viewport):
138+
if not isinstance(viewport, TTkAbstractScrollViewInterface):
139+
raise TypeError("TTkAbstractScrollViewInterface is required in TTkAbstractScrollArea.setVewport(viewport)")
140+
if self._viewport:
141+
self._viewport.viewChanged.disconnect(self._viewportChanged)
142+
# TODO: Remove this check once
143+
# unified "addWidget" and "addItem" in the TTKGridLayout
144+
if isinstance(viewport, TTkWidget):
145+
self.layout().removeWidget(self._viewport)
146+
else:
147+
self.layout().removeItem(self._viewport)
148+
self._viewport = viewport
149+
self._viewport.viewChanged.connect(self._viewportChanged)
150+
self._verticalScrollBar.sliderMoved.connect(self._vscrollMoved)
151+
self._horizontalScrollBar.sliderMoved.connect(self._hscrollMoved)
152+
# TODO: Remove this check once
153+
# unified "addWidget" and "addItem" in the TTKGridLayout
154+
if isinstance(viewport, TTkWidget):
155+
self.layout().addWidget(viewport)
156+
else:
157+
self.layout().addItem(viewport)
158+
self._resizeEvent()
159+
160+
def setVerticalScrollBarPolicy(self, policy):
161+
if policy != self._verticalScrollBarPolicy:
162+
self._verticalScrollBarPolicy = policy
163+
self._viewportChanged()
164+
165+
def setHorizontalScrollBarPolicy(self, policy):
166+
if policy != self._horizontalScrollBarPolicy:
167+
self._horizontalScrollBarPolicy = policy
168+
self._viewportChanged()
169+
170+
def viewport(self):
171+
return self._viewport
172+
173+
def update(self, repaint=True, updateLayout=False, updateParent=False):
174+
if self._viewport:
175+
self._viewport.update(repaint, updateLayout, updateParent=False)
176+
return super().update(repaint, updateLayout, updateParent)

0 commit comments

Comments
 (0)