mirror of
https://github.com/onyx-and-iris/q3rcon-tui.git
synced 2026-02-26 11:09:11 +00:00
Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install "virtualenv<21" hatch
|
pip install hatch
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: hatch build
|
run: hatch build
|
||||||
|
|||||||
24
README.md
24
README.md
@ -1,7 +1,5 @@
|
|||||||
# q3rcon tui
|
# q3rcon tui
|
||||||
|
|
||||||
[](https://github.com/pypa/hatch)
|
|
||||||
[](https://github.com/astral-sh/ruff)
|
|
||||||
[](https://pypi.org/project/q3rcon-tui)
|
[](https://pypi.org/project/q3rcon-tui)
|
||||||
[](https://pypi.org/project/q3rcon-tui)
|
[](https://pypi.org/project/q3rcon-tui)
|
||||||
|
|
||||||
@ -40,37 +38,21 @@ Pass `--host`, `--port` and `--password` as flags:
|
|||||||
q3rcon-tui --host=localhost --port=28960 --password=rconpassword
|
q3rcon-tui --host=localhost --port=28960 --password=rconpassword
|
||||||
```
|
```
|
||||||
|
|
||||||
Additional flags:
|
|
||||||
|
|
||||||
- `--raw`: Boolean flag, if set the RichLog will print raw responses without rendering tables.
|
|
||||||
- `--min-status`: Boolean flag, if set the status command will print a minified table.
|
|
||||||
- note, this will have no effect if in *raw* mode.
|
|
||||||
- `--append`: Boolean flag, if set the RichLog output will append each response continuously.
|
|
||||||
- `--version`: Print the version of the TUI.
|
|
||||||
- `--help`: Print the help message.
|
|
||||||
|
|
||||||
#### Environment Variables
|
#### Environment Variables
|
||||||
|
|
||||||
Store and load from dotenv files located at:
|
Store and load from dotenv files located at:
|
||||||
- .env in the cwd
|
- .env in the cwd
|
||||||
- user home directory / .config / q3rcon-tui / config.env
|
- user home directory / .config / rcon-tui / config.env
|
||||||
|
|
||||||
example .env:
|
example .env:
|
||||||
|
|
||||||
```env
|
```bash
|
||||||
Q3RCON_TUI_HOST=localhost
|
Q3RCON_TUI_HOST=localhost
|
||||||
Q3RCON_TUI_PORT=28960
|
Q3RCON_TUI_PORT=28960
|
||||||
Q3RCON_TUI_PASSWORD=rconpassword
|
Q3RCON_TUI_PASSWORD=rconpassword
|
||||||
Q3RCON_TUI_MIN_STATUS=false
|
Q3RCON_TUI_REFRESH_OUTPUT=true
|
||||||
Q3RCON_TUI_RAW=false
|
|
||||||
Q3RCON_TUI_APPEND=false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Special Thanks
|
|
||||||
|
|
||||||
- [lapetus-11](https://github.com/Iapetus-11) for writing the [aio-q3-rcon](https://github.com/Iapetus-11/aio-q3-rcon) package.
|
|
||||||
- The developers at [Textualize](https://github.com/Textualize) for writing the [textual](https://github.com/Textualize/textual) package.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
`q3rcon-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
`q3rcon-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
||||||
|
|||||||
BIN
img/tui.png
BIN
img/tui.png
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
|||||||
[project]
|
[project]
|
||||||
name = "q3rcon-tui"
|
name = "q3rcon-tui"
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
description = 'A terminal user interface for managing Q3 compatible servers using RCON.'
|
description = ''
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -17,7 +17,6 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -32,8 +32,8 @@ exclude = [
|
|||||||
line-length = 88
|
line-length = 88
|
||||||
indent-width = 4
|
indent-width = 4
|
||||||
|
|
||||||
# Assume Python 3.10
|
# Assume Python 3.9
|
||||||
target-version = "py310"
|
target-version = "py39"
|
||||||
|
|
||||||
[lint]
|
[lint]
|
||||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
|
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
__version__ = '0.7.1'
|
__version__ = '0.1.0'
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.containers import Horizontal, Vertical
|
|
||||||
from textual.screen import ModalScreen
|
|
||||||
from textual.widgets import Button, Input, Label, Static
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigScreen(ModalScreen[bool]):
|
|
||||||
"""Modal dialog for configuring connection settings."""
|
|
||||||
|
|
||||||
def __init__(self, tui):
|
|
||||||
super().__init__()
|
|
||||||
self._settings = tui._settings
|
|
||||||
self._original_settings = self._settings.model_copy()
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Vertical(id='config-dialog'):
|
|
||||||
yield Static('Connection Configuration', id='config-title')
|
|
||||||
with Vertical(id='config-form'):
|
|
||||||
yield Label('Host:')
|
|
||||||
yield Input(
|
|
||||||
value=self._original_settings.host,
|
|
||||||
placeholder='localhost',
|
|
||||||
id='host-input',
|
|
||||||
)
|
|
||||||
yield Label('Port:')
|
|
||||||
yield Input(
|
|
||||||
value=str(self._original_settings.port),
|
|
||||||
placeholder='28960',
|
|
||||||
id='port-input',
|
|
||||||
)
|
|
||||||
yield Label('Password:')
|
|
||||||
yield Input(
|
|
||||||
value=self._original_settings.password,
|
|
||||||
placeholder='Enter password',
|
|
||||||
password=True,
|
|
||||||
id='password-input',
|
|
||||||
)
|
|
||||||
with Horizontal(id='config-buttons'):
|
|
||||||
yield Button('Save', variant='success', id='config-save')
|
|
||||||
yield Button('Cancel', variant='error', id='config-cancel')
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
match event.button.id:
|
|
||||||
case 'config-save':
|
|
||||||
self._save_button_handler()
|
|
||||||
case 'config-cancel':
|
|
||||||
self._cancel_button_handler()
|
|
||||||
|
|
||||||
def _save_button_handler(self):
|
|
||||||
self._clear_field_errors()
|
|
||||||
|
|
||||||
try:
|
|
||||||
new_host: str = (
|
|
||||||
self.query_one('#host-input', Input).value.strip() or 'localhost'
|
|
||||||
)
|
|
||||||
new_port: int | Literal[28960] = (
|
|
||||||
self.query_one('#port-input', Input).value or 28960
|
|
||||||
)
|
|
||||||
new_password: str = self.query_one('#password-input', Input).value
|
|
||||||
|
|
||||||
self._settings.host = new_host
|
|
||||||
self._settings.port = new_port
|
|
||||||
self._settings.password = new_password
|
|
||||||
|
|
||||||
self.dismiss(True)
|
|
||||||
except ValidationError as e:
|
|
||||||
for error in e.errors():
|
|
||||||
field_name = error.get('loc', ())
|
|
||||||
error_msg = error.get('msg', str(error))
|
|
||||||
|
|
||||||
match field_name:
|
|
||||||
case ('host',):
|
|
||||||
self._show_field_error('host-input', error_msg)
|
|
||||||
case ('port',):
|
|
||||||
self._show_field_error('port-input', error_msg)
|
|
||||||
case ('password',):
|
|
||||||
self._show_field_error('password-input', error_msg)
|
|
||||||
break # Only show the first error
|
|
||||||
|
|
||||||
def _show_field_error(self, field_id: str, error_message: str):
|
|
||||||
"""Show error message in the specified input field."""
|
|
||||||
self._clear_field_errors()
|
|
||||||
|
|
||||||
field = self.query_one(f'#{field_id}', Input)
|
|
||||||
field.add_class('error')
|
|
||||||
field.value = ''
|
|
||||||
|
|
||||||
field.placeholder = error_message
|
|
||||||
field.focus()
|
|
||||||
|
|
||||||
self.app.bell()
|
|
||||||
|
|
||||||
def _clear_field_errors(self):
|
|
||||||
"""Clear error styling from all input fields."""
|
|
||||||
for field_id in ['host-input', 'port-input', 'password-input']:
|
|
||||||
field = self.query_one(f'#{field_id}', Input)
|
|
||||||
field.remove_class('error')
|
|
||||||
|
|
||||||
match field_id:
|
|
||||||
case 'host-input':
|
|
||||||
field.placeholder = 'localhost'
|
|
||||||
case 'port-input':
|
|
||||||
field.placeholder = '28960'
|
|
||||||
case 'password-input':
|
|
||||||
field.placeholder = 'Enter password'
|
|
||||||
|
|
||||||
def _cancel_button_handler(self):
|
|
||||||
self.dismiss(False)
|
|
||||||
@ -1,265 +0,0 @@
|
|||||||
#dialog {
|
|
||||||
height: 30;
|
|
||||||
margin: 4 8;
|
|
||||||
background: #1e1e2f;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: heavy #3b4252;
|
|
||||||
border-title-color: #88c0d0;
|
|
||||||
border-title-style: bold;
|
|
||||||
padding: 1 3;
|
|
||||||
grid-size: 3 3;
|
|
||||||
grid-gutter: 1 2;
|
|
||||||
grid-rows: auto 1fr auto;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#command {
|
|
||||||
column-span: 3;
|
|
||||||
height: auto;
|
|
||||||
width: 1fr;
|
|
||||||
content-align: center middle;
|
|
||||||
background: #2e3440;
|
|
||||||
border: solid #4c566a;
|
|
||||||
border-title-color: #81a1c1;
|
|
||||||
padding: 0 1;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#command:focus {
|
|
||||||
border: solid #88c0d0;
|
|
||||||
background: #3b4252;
|
|
||||||
}
|
|
||||||
|
|
||||||
#response {
|
|
||||||
column-span: 3;
|
|
||||||
height: 1fr;
|
|
||||||
background: #2e3440;
|
|
||||||
border: solid #4c566a;
|
|
||||||
border-title-color: #a3be8c;
|
|
||||||
padding: 1;
|
|
||||||
margin: 0 1;
|
|
||||||
scrollbar-background: #3b4252;
|
|
||||||
scrollbar-color: #5e81ac;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 100%;
|
|
||||||
height: 4;
|
|
||||||
margin: 0 1;
|
|
||||||
background: #5e81ac;
|
|
||||||
color: #eceff4;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #5e81ac;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button:hover {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button:focus {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.success {
|
|
||||||
background: #a3be8c;
|
|
||||||
border: solid #a3be8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.success:hover {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.success:focus {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.warning {
|
|
||||||
background: #ebcb8b;
|
|
||||||
color: #2e3440;
|
|
||||||
border: solid #ebcb8b;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.warning:hover {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.warning:focus {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.error {
|
|
||||||
background: #bf616a;
|
|
||||||
border: solid #bf616a;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.error:hover {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.error:focus {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#send {
|
|
||||||
background: #a3be8c;
|
|
||||||
border: solid #a3be8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
#send:focus {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#send:hover {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config {
|
|
||||||
background: #ebcb8b;
|
|
||||||
color: #2e3440;
|
|
||||||
border: solid #ebcb8b;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config:hover {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config:focus {
|
|
||||||
background: #88c0d0;
|
|
||||||
color: #2e3440;
|
|
||||||
text-style: bold;
|
|
||||||
border: solid #88c0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#quit {
|
|
||||||
background: #bf616a;
|
|
||||||
border: solid #bf616a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#quit:hover {
|
|
||||||
background: #d08770;
|
|
||||||
color: #eceff4;
|
|
||||||
text-style: bold;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#quit:focus {
|
|
||||||
background: #d08770;
|
|
||||||
color: #eceff4;
|
|
||||||
text-style: bold;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Configuration Dialog Styles */
|
|
||||||
#config-dialog {
|
|
||||||
background: #2e3440;
|
|
||||||
border: heavy #4c566a;
|
|
||||||
border-title-color: #ebcb8b;
|
|
||||||
border-title-style: bold;
|
|
||||||
padding: 1 2;
|
|
||||||
width: 60;
|
|
||||||
height: 30;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-title {
|
|
||||||
content-align: center middle;
|
|
||||||
text-style: bold;
|
|
||||||
color: #88c0d0;
|
|
||||||
height: 3;
|
|
||||||
width: 100%;
|
|
||||||
background: #3b4252;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-form {
|
|
||||||
height: auto;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-form Label {
|
|
||||||
margin-bottom: 1;
|
|
||||||
color: #d8dee9;
|
|
||||||
text-style: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-form Input {
|
|
||||||
height: 3;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 1;
|
|
||||||
background: #3b4252;
|
|
||||||
border: solid #4c566a;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-form Input:focus {
|
|
||||||
border: solid #88c0d0;
|
|
||||||
background: #434c5e;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-form Input.error {
|
|
||||||
border: solid #bf616a;
|
|
||||||
background: #3c2a2a;
|
|
||||||
color: #d08770;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-form Input.error:focus {
|
|
||||||
border: solid #d08770;
|
|
||||||
background: #4a2c2c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
height: 0;
|
|
||||||
color: #bf616a;
|
|
||||||
text-style: bold;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message.visible {
|
|
||||||
height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-buttons {
|
|
||||||
height: 3;
|
|
||||||
width: 100%;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#config-buttons Button {
|
|
||||||
width: 1fr;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
84
src/q3rcon_tui/rcon_tui.tcss
Normal file
84
src/q3rcon_tui/rcon_tui.tcss
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
#dialog {
|
||||||
|
height: 30;
|
||||||
|
margin: 4 8;
|
||||||
|
background: #1e1e2f;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: heavy #3b4252;
|
||||||
|
border-title-color: #88c0d0;
|
||||||
|
border-title-style: bold;
|
||||||
|
padding: 1 3;
|
||||||
|
grid-size: 2 3;
|
||||||
|
grid-gutter: 1 2;
|
||||||
|
grid-rows: auto 1fr auto;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command {
|
||||||
|
column-span: 2;
|
||||||
|
height: auto;
|
||||||
|
width: 1fr;
|
||||||
|
content-align: center middle;
|
||||||
|
background: #2e3440;
|
||||||
|
border: solid #4c566a;
|
||||||
|
border-title-color: #81a1c1;
|
||||||
|
padding: 0 1;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command:focus {
|
||||||
|
border: solid #88c0d0;
|
||||||
|
background: #3b4252;
|
||||||
|
}
|
||||||
|
|
||||||
|
#response {
|
||||||
|
column-span: 2;
|
||||||
|
height: 1fr;
|
||||||
|
background: #2e3440;
|
||||||
|
border: solid #4c566a;
|
||||||
|
border-title-color: #a3be8c;
|
||||||
|
padding: 1;
|
||||||
|
margin: 0 1;
|
||||||
|
scrollbar-background: #3b4252;
|
||||||
|
scrollbar-color: #5e81ac;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: 100%;
|
||||||
|
height: 3;
|
||||||
|
margin: 0 1;
|
||||||
|
background: #5e81ac;
|
||||||
|
color: #eceff4;
|
||||||
|
border: none;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button:hover {
|
||||||
|
background: #81a1c1;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button.error {
|
||||||
|
background: #bf616a;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button.error:hover {
|
||||||
|
background: #d08770;
|
||||||
|
}
|
||||||
|
|
||||||
|
#quit {
|
||||||
|
background: #bf616a;
|
||||||
|
border: solid #bf616a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#quit:hover {
|
||||||
|
background: #d08770;
|
||||||
|
border: solid #ebcb8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
#send {
|
||||||
|
background: #a3be8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
#send:hover {
|
||||||
|
background: #8fbcbb;
|
||||||
|
}
|
||||||
@ -1,84 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Annotated, Type
|
|
||||||
|
|
||||||
from pydantic import (
|
|
||||||
AfterValidator,
|
|
||||||
AliasChoices,
|
|
||||||
BeforeValidator,
|
|
||||||
Field,
|
|
||||||
)
|
|
||||||
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
|
|
||||||
|
|
||||||
from .__about__ import __version__ as version
|
|
||||||
|
|
||||||
|
|
||||||
def version_callback(value: bool) -> bool | None:
|
|
||||||
if value:
|
|
||||||
print(f'q3rcon-tui version: {version}')
|
|
||||||
raise SystemExit(0)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_host(host: str) -> str | None:
|
|
||||||
if not host:
|
|
||||||
raise ValueError('Host cannot be empty')
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_port(port: int) -> int | None:
|
|
||||||
if port < 1 or port > 65535:
|
|
||||||
raise ValueError('Port must be between 1 and 65535')
|
|
||||||
return port
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_password(password: str) -> str | None:
|
|
||||||
if len(password) < 8:
|
|
||||||
raise ValueError('Password must be at least 8 characters long')
|
|
||||||
return password
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
host: Annotated[str, AfterValidator(is_valid_host)] = 'localhost'
|
|
||||||
port: Annotated[int, AfterValidator(is_valid_port)] = 28960
|
|
||||||
password: Annotated[str, AfterValidator(is_valid_password)] = ''
|
|
||||||
append: bool = False
|
|
||||||
min_status: bool = Field(
|
|
||||||
default=False,
|
|
||||||
alias='min-status',
|
|
||||||
validation_alias=AliasChoices(
|
|
||||||
'min-status',
|
|
||||||
'Q3RCON_TUI_MIN_STATUS',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raw: bool = False
|
|
||||||
version: Annotated[bool, BeforeValidator(version_callback)] = False
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=(
|
|
||||||
'.env',
|
|
||||||
Path.home() / '.config' / 'q3rcon-tui' / 'config.env',
|
|
||||||
),
|
|
||||||
env_file_encoding='utf-8',
|
|
||||||
env_prefix='Q3RCON_TUI_',
|
|
||||||
cli_prefix='',
|
|
||||||
cli_parse_args=True,
|
|
||||||
cli_implicit_flags=True,
|
|
||||||
validate_assignment=True,
|
|
||||||
frozen=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def settings_customise_sources(
|
|
||||||
cls,
|
|
||||||
settings_cls: Type[BaseSettings],
|
|
||||||
init_settings: ...,
|
|
||||||
env_settings: ...,
|
|
||||||
dotenv_settings: ...,
|
|
||||||
file_secret_settings: ...,
|
|
||||||
) -> tuple:
|
|
||||||
return (
|
|
||||||
CliSettingsSource(settings_cls),
|
|
||||||
env_settings,
|
|
||||||
dotenv_settings,
|
|
||||||
init_settings,
|
|
||||||
)
|
|
||||||
@ -1,91 +1,114 @@
|
|||||||
from aioq3rcon import Client, RCONError
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Type
|
||||||
|
|
||||||
|
from aioq3rcon import Client
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import AfterValidator, BeforeValidator
|
||||||
|
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
from textual.widgets import Button, Input, RichLog
|
from textual.widgets import Button, Input, RichLog
|
||||||
|
|
||||||
from .configscreen import ConfigScreen
|
from .__about__ import __version__ as version
|
||||||
from .settings import Settings
|
|
||||||
from .writable import Writable
|
|
||||||
|
|
||||||
|
|
||||||
class Q3RconTUI(App):
|
def is_valid_password(password: str) -> str | None:
|
||||||
CSS_PATH = 'q3rcon_tui.tcss'
|
if len(password) < 8:
|
||||||
|
raise ValueError('Password must be at least 8 characters long')
|
||||||
|
return password
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
def version_callback(value: bool) -> bool | None:
|
||||||
self._settings = Settings()
|
if value:
|
||||||
self.writable = Writable(self)
|
print(f'q3rcon-tui version: {version}')
|
||||||
|
raise SystemExit(0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
host: str = 'localhost'
|
||||||
|
port: int = 28960
|
||||||
|
password: Annotated[str, AfterValidator(is_valid_password)] = ''
|
||||||
|
refresh_output: bool = False
|
||||||
|
version: Annotated[bool, BeforeValidator(version_callback)] = False
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=(
|
||||||
|
'.env',
|
||||||
|
Path.home() / '.config' / 'rcon-tui' / 'config.env',
|
||||||
|
),
|
||||||
|
env_file_encoding='utf-8',
|
||||||
|
env_prefix='Q3RCON_TUI_',
|
||||||
|
cli_prefix='',
|
||||||
|
cli_parse_args=True,
|
||||||
|
cli_implicit_flags=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def settings_customise_sources(
|
||||||
|
cls,
|
||||||
|
settings_cls: Type[BaseSettings],
|
||||||
|
init_settings: ...,
|
||||||
|
env_settings: ...,
|
||||||
|
dotenv_settings: ...,
|
||||||
|
file_secret_settings: ...,
|
||||||
|
) -> tuple:
|
||||||
|
return (
|
||||||
|
CliSettingsSource(settings_cls),
|
||||||
|
env_settings,
|
||||||
|
dotenv_settings,
|
||||||
|
init_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = Settings()
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(e)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
class RconApp(App):
|
||||||
|
RE_COLOR_CODES = re.compile(r'\^[0-9]')
|
||||||
|
CSS_PATH = 'rcon_tui.tcss'
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Grid(
|
yield Grid(
|
||||||
Input('status', placeholder='Enter a rcon command', id='command'),
|
Input('status', placeholder='Enter a rcon command', id='command'),
|
||||||
RichLog(id='response'),
|
RichLog(id='response'),
|
||||||
Button('Send', variant='success', id='send'),
|
Button('Send', variant='error', id='send'),
|
||||||
Button('Config', variant='warning', id='config'),
|
|
||||||
Button('Quit', variant='primary', id='quit'),
|
Button('Quit', variant='primary', id='quit'),
|
||||||
id='dialog',
|
id='dialog',
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_key(self, event) -> None:
|
async def on_key(self, event) -> None:
|
||||||
# prevent keypresses from ConfigScreen from triggering actions in RconApp
|
if event.key == 'enter':
|
||||||
if self.screen and isinstance(self.screen, ConfigScreen):
|
if self.query_one('#command', Input).has_focus:
|
||||||
return
|
|
||||||
|
|
||||||
match event.key:
|
|
||||||
case 'enter' if self.query_one('#command', Input).has_focus:
|
|
||||||
self.query_one('#send', Button).press()
|
self.query_one('#send', Button).press()
|
||||||
case 'f2':
|
|
||||||
self.query_one('#config', Button).press()
|
|
||||||
|
|
||||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
match event.button.id:
|
if event.button.id == 'quit':
|
||||||
case 'quit':
|
|
||||||
self._quit_button_handler()
|
|
||||||
case 'config':
|
|
||||||
await self._config_button_handler()
|
|
||||||
case 'send':
|
|
||||||
await self._send_button_handler()
|
|
||||||
|
|
||||||
def _quit_button_handler(self):
|
|
||||||
self.app.exit()
|
self.app.exit()
|
||||||
|
|
||||||
async def _config_button_handler(self):
|
if settings.refresh_output:
|
||||||
result = await self.push_screen(ConfigScreen(self))
|
|
||||||
if result:
|
|
||||||
self.query_one('#response', RichLog).write(
|
|
||||||
f'Configuration updated: {self._settings.host}:{self._settings.port}'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _send_button_handler(self):
|
|
||||||
if not self._settings.append:
|
|
||||||
self.query_one('#response', RichLog).clear()
|
self.query_one('#response', RichLog).clear()
|
||||||
|
|
||||||
cmd = self.query_one('#command', Input).value.strip()
|
async with Client(settings.host, settings.port, settings.password) as client:
|
||||||
if not cmd:
|
response = await client.send_command(
|
||||||
self.app.bell()
|
self.query_one('#command', Input).value
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with Client(
|
|
||||||
self._settings.host, self._settings.port, self._settings.password
|
|
||||||
) as client:
|
|
||||||
response = await client.send_command(cmd)
|
|
||||||
self.query_one('#response', RichLog).write(
|
|
||||||
self.writable.parse(cmd, response)
|
|
||||||
)
|
|
||||||
except RCONError:
|
|
||||||
output = (
|
|
||||||
f'Unable to execute command {cmd}.',
|
|
||||||
'It may be due to a map change or a server restart.',
|
|
||||||
'If the problem persists, please check your connection settings and ensure the server is running.',
|
|
||||||
)
|
)
|
||||||
self.query_one('#response', RichLog).write(
|
self.query_one('#response', RichLog).write(
|
||||||
self.writable.error('\n'.join(output))
|
self.remove_color_codes(response.removeprefix('print\n'))
|
||||||
)
|
)
|
||||||
|
|
||||||
self.query_one('#command', Input).value = ''
|
self.query_one('#command', Input).value = ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_color_codes(s: str) -> str:
|
||||||
|
return RconApp.RE_COLOR_CODES.sub('', s)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = Q3RconTUI()
|
app = RconApp()
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
@ -1,136 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from rich.table import Table
|
|
||||||
from rich.text import Text
|
|
||||||
|
|
||||||
Renderable = Text | Table | str
|
|
||||||
|
|
||||||
|
|
||||||
class Writable:
|
|
||||||
RE_COLOR_CODES = re.compile(r'\^[0-9]')
|
|
||||||
RE_MAP_FROM_STATUS = re.compile(r'^map: (?P<mapname>mp_[a-z_]+)$')
|
|
||||||
RE_PLAYER_FROM_STATUS = re.compile(
|
|
||||||
r'^\s*(?P<slot>[0-9]+)\s+'
|
|
||||||
r'(?P<score>[0-9-]+)\s+'
|
|
||||||
r'(?P<ping>[0-9]+)\s+'
|
|
||||||
r'(?P<guid>[0-9a-f]+)\s+'
|
|
||||||
r'(?P<name>.*?)\s+'
|
|
||||||
r'(?P<last>[0-9]+?)\s*'
|
|
||||||
r'(?P<ip>(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}'
|
|
||||||
r'(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])):?'
|
|
||||||
r'(?P<port>-?[0-9]{1,5})\s*'
|
|
||||||
r'(?P<qport>-?[0-9]{1,5})\s+'
|
|
||||||
r'(?P<rate>[0-9]+)$',
|
|
||||||
re.IGNORECASE | re.VERBOSE,
|
|
||||||
)
|
|
||||||
RE_CVAR = re.compile(
|
|
||||||
r'^["](?P<name>[a-z_]+)["]\sis[:]\s'
|
|
||||||
r'["](?P<value>.*?)["]\s'
|
|
||||||
r'default[:]\s'
|
|
||||||
r'["](?P<default>.*?)["]\s'
|
|
||||||
r'info[:]\s'
|
|
||||||
r'["](?P<info>.*?)["]$'
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, tui):
|
|
||||||
self._settings = tui._settings
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def remove_color_codes(s: str) -> str:
|
|
||||||
return Writable.RE_COLOR_CODES.sub('', s)
|
|
||||||
|
|
||||||
def parse(self, cmd, response: str, style=None) -> Renderable:
|
|
||||||
response = self.remove_color_codes(response.removeprefix('print\n'))
|
|
||||||
if self._settings.raw:
|
|
||||||
return Text(response, style=style)
|
|
||||||
|
|
||||||
if response in ['Bad rcon']:
|
|
||||||
return Text('Incorrect RCON password', style='#c73d4b')
|
|
||||||
|
|
||||||
match cmd:
|
|
||||||
case 'status':
|
|
||||||
return self.status_table(response)
|
|
||||||
case _:
|
|
||||||
if m := self.RE_CVAR.match(response):
|
|
||||||
return self.cvar_table(m)
|
|
||||||
else:
|
|
||||||
return Text(response, style=style)
|
|
||||||
|
|
||||||
def error(self, message: str) -> Text:
|
|
||||||
return Text(message, style='#c73d4b')
|
|
||||||
|
|
||||||
def status_table(self, status_response: str) -> Table | str:
|
|
||||||
table = Table(show_header=True, header_style='bold #88c0d0')
|
|
||||||
columns = [
|
|
||||||
('Slot', 'center'),
|
|
||||||
('Score', 'center'),
|
|
||||||
('Ping', 'center'),
|
|
||||||
('GUID', 'center'),
|
|
||||||
('Name', 'center'),
|
|
||||||
]
|
|
||||||
for column, justify in columns:
|
|
||||||
table.add_column(column, justify=justify)
|
|
||||||
if not self._settings.min_status:
|
|
||||||
table.add_column('Last', justify='center')
|
|
||||||
if self._settings.min_status:
|
|
||||||
table.add_column('IP', justify='center')
|
|
||||||
else:
|
|
||||||
table.add_column('IP:Port', justify='center')
|
|
||||||
columns = [
|
|
||||||
('QPort', 'center'),
|
|
||||||
('Rate', 'center'),
|
|
||||||
]
|
|
||||||
if not self._settings.min_status:
|
|
||||||
for column, justify in columns:
|
|
||||||
table.add_column(column, justify=justify)
|
|
||||||
|
|
||||||
mapname = 'unable to parse map name'
|
|
||||||
for line in status_response.splitlines():
|
|
||||||
if m := self.RE_PLAYER_FROM_STATUS.match(line):
|
|
||||||
name = m.group('name')
|
|
||||||
if name == '':
|
|
||||||
name = '[no name]'
|
|
||||||
row = [
|
|
||||||
m.group('slot'),
|
|
||||||
m.group('score'),
|
|
||||||
m.group('ping'),
|
|
||||||
m.group('guid'),
|
|
||||||
name,
|
|
||||||
]
|
|
||||||
if self._settings.min_status:
|
|
||||||
row.append(m.group('ip'))
|
|
||||||
else:
|
|
||||||
row.append(f'{m.group("ip")}:{m.group("port")}')
|
|
||||||
row.append(m.group('last'))
|
|
||||||
row.append(m.group('qport'))
|
|
||||||
row.append(m.group('rate'))
|
|
||||||
table.add_row(*row)
|
|
||||||
elif m := self.RE_MAP_FROM_STATUS.match(line):
|
|
||||||
mapname = m.group('mapname')
|
|
||||||
|
|
||||||
out = Text(f'Map: {mapname}\n', style='bold #88c0d0')
|
|
||||||
if len(table.rows) == 0:
|
|
||||||
return out.append('No players connected', style='#c73d4b')
|
|
||||||
else:
|
|
||||||
table.title = out
|
|
||||||
return table
|
|
||||||
|
|
||||||
def cvar_table(self, m: re.Match) -> Table:
|
|
||||||
table = Table(show_header=True, header_style='bold #88c0d0')
|
|
||||||
columns = [
|
|
||||||
('Name', 'center'),
|
|
||||||
('Value', 'center'),
|
|
||||||
('Default', 'center'),
|
|
||||||
('Info', 'center'),
|
|
||||||
]
|
|
||||||
for column, justify in columns:
|
|
||||||
table.add_column(column, justify=justify)
|
|
||||||
|
|
||||||
table.add_row(
|
|
||||||
m.group('name'),
|
|
||||||
m.group('value'),
|
|
||||||
m.group('default'),
|
|
||||||
m.group('info'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return table
|
|
||||||
Loading…
x
Reference in New Issue
Block a user