8 Commits

6 changed files with 193 additions and 20 deletions

View File

@@ -1,5 +1,7 @@
# q3rcon tui # q3rcon tui
[![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![PyPI - Version](https://img.shields.io/pypi/v/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui) [![PyPI - Version](https://img.shields.io/pypi/v/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui)
@@ -42,7 +44,7 @@ q3rcon-tui --host=localhost --port=28960 --password=rconpassword
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 / rcon-tui / config.env - user home directory / .config / q3rcon-tui / config.env
example .env: example .env:

View File

@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "q3rcon-tui" name = "q3rcon-tui"
dynamic = ["version"] dynamic = ["version"]
description = '' description = 'A terminal user interface for managing Q3 compatible servers using RCON.'
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = "MIT" license = "MIT"

View File

@@ -32,8 +32,8 @@ exclude = [
line-length = 88 line-length = 88
indent-width = 4 indent-width = 4
# Assume Python 3.9 # Assume Python 3.10
target-version = "py39" target-version = "py310"
[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.

View File

@@ -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.1.0' __version__ = '0.3.0'

View File

@@ -7,14 +7,14 @@
border-title-color: #88c0d0; border-title-color: #88c0d0;
border-title-style: bold; border-title-style: bold;
padding: 1 3; padding: 1 3;
grid-size: 2 3; grid-size: 3 3;
grid-gutter: 1 2; grid-gutter: 1 2;
grid-rows: auto 1fr auto; grid-rows: auto 1fr auto;
align: center middle; align: center middle;
} }
#command { #command {
column-span: 2; column-span: 3;
height: auto; height: auto;
width: 1fr; width: 1fr;
content-align: center middle; content-align: center middle;
@@ -31,7 +31,7 @@
} }
#response { #response {
column-span: 2; column-span: 3;
height: 1fr; height: 1fr;
background: #2e3440; background: #2e3440;
border: solid #4c566a; border: solid #4c566a;
@@ -57,6 +57,24 @@ Button:hover {
text-style: bold; text-style: bold;
} }
Button.success {
background: #a3be8c;
}
Button.success:hover {
background: #8fbcbb;
}
Button.warning {
background: #ebcb8b;
color: #2e3440;
}
Button.warning:hover {
background: #d08770;
color: #2e3440;
}
Button.error { Button.error {
background: #bf616a; background: #bf616a;
} }
@@ -79,6 +97,75 @@ Button.error:hover {
background: #a3be8c; background: #a3be8c;
} }
#config {
background: #ebcb8b;
color: #2e3440;
}
#config:hover {
background: #d08770;
color: #2e3440;
}
#send:hover { #send:hover {
background: #8fbcbb; background: #8fbcbb;
}
/* 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-buttons {
height: 3;
width: 100%;
align: center middle;
}
#config-buttons Button {
width: 1fr;
margin: 0 1;
} }

View File

@@ -2,13 +2,14 @@ import re
from pathlib import Path from pathlib import Path
from typing import Annotated, Type from typing import Annotated, Type
from aioq3rcon import Client from aioq3rcon import Client, RCONError
from loguru import logger from loguru import logger
from pydantic import AfterValidator, BeforeValidator from pydantic import AfterValidator, BeforeValidator
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict 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, Horizontal, Vertical
from textual.widgets import Button, Input, RichLog from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, RichLog, Static
from .__about__ import __version__ as version from .__about__ import __version__ as version
@@ -36,13 +37,16 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=( env_file=(
'.env', '.env',
Path.home() / '.config' / 'rcon-tui' / 'config.env', Path.home() / '.config' / 'q3rcon-tui' / 'config.env',
), ),
env_file_encoding='utf-8', env_file_encoding='utf-8',
env_prefix='Q3RCON_TUI_', env_prefix='Q3RCON_TUI_',
cli_prefix='', cli_prefix='',
cli_parse_args=True, cli_parse_args=True,
cli_implicit_flags=True, cli_implicit_flags=True,
# Allow field assignment for runtime updates
validate_assignment=False,
frozen=False,
) )
@classmethod @classmethod
@@ -69,6 +73,64 @@ except ValueError as e:
raise SystemExit(1) raise SystemExit(1)
class ConfigScreen(ModalScreen[bool]):
"""Modal dialog for configuring connection settings."""
def __init__(self, current_host: str, current_port: int, current_password: str):
super().__init__()
self.current_host = current_host
self.current_port = current_port
self.current_password = current_password
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.current_host, placeholder='localhost', id='host-input'
)
yield Label('Port:')
yield Input(
value=str(self.current_port), placeholder='28960', id='port-input'
)
yield Label('Password:')
yield Input(
value=self.current_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:
if event.button.id == 'config-save':
try:
new_host = (
self.query_one('#host-input', Input).value.strip() or 'localhost'
)
new_port = int(self.query_one('#port-input', Input).value or '28960')
new_password = self.query_one('#password-input', Input).value
if new_port < 1 or new_port > 65535:
raise ValueError('Port must be between 1 and 65535')
if len(new_password) < 8:
raise ValueError('Password must be at least 8 characters long')
settings.host = new_host
settings.port = new_port
settings.password = new_password
self.dismiss(True)
except ValueError:
self.app.bell()
elif event.button.id == 'config-cancel':
self.dismiss(False)
class RconApp(App): class RconApp(App):
RE_COLOR_CODES = re.compile(r'\^[0-9]') RE_COLOR_CODES = re.compile(r'\^[0-9]')
CSS_PATH = 'rcon_tui.tcss' CSS_PATH = 'rcon_tui.tcss'
@@ -77,29 +139,51 @@ class RconApp(App):
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='error', id='send'), Button('Send', variant='success', 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:
if event.key == 'enter': match event.key:
if self.query_one('#command', Input).has_focus: 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:
if event.button.id == 'quit': if event.button.id == 'quit':
self.app.exit() self.app.exit()
elif event.button.id == 'config':
result = await self.push_screen(
ConfigScreen(settings.host, settings.port, settings.password)
)
if result:
self.query_one('#response', RichLog).write(
f'Configuration updated: {settings.host}:{settings.port}'
)
return
if event.button.id != 'send':
return
if settings.refresh_output: if settings.refresh_output:
self.query_one('#response', RichLog).clear() self.query_one('#response', RichLog).clear()
async with Client(settings.host, settings.port, settings.password) as client: try:
response = await client.send_command( async with Client(
self.query_one('#command', Input).value settings.host, settings.port, settings.password
) ) as client:
response = await client.send_command(
self.query_one('#command', Input).value
)
self.query_one('#response', RichLog).write(
self.remove_color_codes(response.removeprefix('print\n'))
)
except RCONError as e:
self.query_one('#response', RichLog).write( self.query_one('#response', RichLog).write(
self.remove_color_codes(response.removeprefix('print\n')) f'{type(e).__name__}: Unable to connect to server: is the server running and are the host, port, and password correct? ({e})'
) )
self.query_one('#command', Input).value = '' self.query_one('#command', Input).value = ''