Reorganize project structure

This commit is contained in:
zv
2026-03-06 18:20:43 +01:00
parent 04c209ae23
commit 7e0c0afddf
26 changed files with 1520 additions and 1311 deletions

328
checkaddy_app/app.py Normal file
View File

@@ -0,0 +1,328 @@
from __future__ import annotations
import json
from typing import Iterable, Optional
from textual import on, work
from textual.app import App, ComposeResult, SystemCommand
from textual.binding import Binding
from textual.containers import Container, Horizontal, VerticalScroll
from textual.reactive import reactive
from textual.screen import Screen
from textual.timer import Timer
from textual.widgets import Button, Footer, Header, Input, Label, RadioButton, RadioSet, Static
from .api import ApiClient
from .constants import BTC, COIN_FROM_RADIO_ID, COIN_OPTIONS, COIN_RADIO_IDS, REPOSITORY_URL
from .css import APP_CSS
from .formatters import format_amount_display, format_validation_badge
from .lookup import build_lookup_result
from .models import LookupResult
from .screens import GithubRepositoryScreen, HelpScreen
from .validators import validate_address
from .widgets import DetailLine, MetricCard
class CheckAddyApp(App):
CSS = APP_CSS
TITLE = "checkaddy"
SUB_TITLE = "Public multi-chain address validation"
BINDINGS = [
Binding("enter", "lookup", "Lookup"),
Binding("ctrl+l", "clear_form", "Clear"),
Binding("ctrl+j", "toggle_json", "JSON"),
Binding("ctrl+o", "open_explorer", "Explorer"),
Binding("ctrl+g", "open_github_repository", "Repo"),
Binding("ctrl+1", "focus_coin_set", show=False),
Binding("ctrl+2", "focus_address", show=False),
Binding("ctrl+3", "focus_lookup_button", show=False),
Binding("alt+b", "select_previous_coin", show=False),
Binding("alt+t", "select_next_coin", show=False),
Binding("ctrl+left", "select_previous_coin", show=False),
Binding("ctrl+right", "select_next_coin", show=False),
Binding("f1", "show_help", "Help"),
Binding("q", "quit", "Quit"),
Binding("ctrl+c", "quit", "Quit", show=False),
]
show_json = reactive(False)
live_validation_timer: Optional[Timer] = None
def __init__(self) -> None:
super().__init__()
self.client = ApiClient()
self.last_result: Optional[LookupResult] = None
self.selected_coin: str = COIN_OPTIONS[0][0]
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Container(id="app"):
yield from self._compose_hero()
with Horizontal(id="layout"):
yield from self._compose_sidebar()
yield from self._compose_main()
yield Footer()
def _compose_hero(self) -> ComposeResult:
with Container(id="hero"):
yield Static("checkaddy", id="hero-title")
yield Static(
"Local address validation with live explorer data for UTXO and EVM public addresses.",
id="hero-subtitle",
)
yield Static("Copyright (c) 2026 zv", id="hero-credit")
def _compose_sidebar(self) -> ComposeResult:
with VerticalScroll(id="sidebar"):
with Container(classes="panel"):
yield Static("Input", classes="panel-title")
yield Label("Network")
with RadioSet(id="coin-set"):
for index, (coin, label) in enumerate(COIN_OPTIONS):
yield RadioButton(label, id=COIN_RADIO_IDS[coin], value=index == 0)
yield Label("Address", classes="subtle")
yield Input(placeholder="Paste public wallet address", id="address")
yield Static("Waiting for input", id="quick-validation")
with Container(classes="panel", id="controls"):
yield Static("Actions", classes="panel-title")
yield Button("Validate and fetch", id="lookup", variant="primary")
yield Button("Clear", id="clear")
yield Button("Toggle JSON", id="toggle-json")
with Container(classes="panel"):
yield Static("Notes", classes="panel-title")
yield Static(
"Supported: BTC, LTC, DOGE, DASH, BCH, ETH, BSC, Polygon.\n"
"EVM chains use 0x addresses; UTXO chains use Base58/Bech32/CashAddr.\n"
"Some fields can be unavailable depending on free endpoint limitations.",
classes="subtle",
)
def _compose_main(self) -> ComposeResult:
with VerticalScroll(id="main"):
with Container(classes="panel"):
yield Static("Status", classes="panel-title")
yield Static("Ready", id="status-body", classes="info")
with Container(classes="panel"):
yield Static("Overview", classes="panel-title")
with Container(id="metrics"):
yield MetricCard("Confirmed balance", "-", "metric-confirmed")
yield MetricCard("Unconfirmed balance", "-", "metric-unconfirmed")
yield MetricCard("Total received", "-", "metric-received")
yield MetricCard("Total sent", "-", "metric-sent")
yield MetricCard("Transaction count", "-", "metric-tx-count")
yield MetricCard("Data source", "-", "metric-source")
with Container(classes="panel"):
yield Static("Details", classes="panel-title")
with Container(id="details-grid"):
yield DetailLine("Coin", "-", "detail-coin")
yield DetailLine("Address", "-", "detail-address")
yield DetailLine("Validation", "-", "detail-validation")
yield DetailLine("Explorer", "-", "detail-explorer")
yield DetailLine("Fetched at UTC", "-", "detail-fetched")
with Container(classes="panel hidden", id="json-panel"):
yield Static("Normalized JSON", classes="panel-title")
yield Static("{}", id="json-box", expand=True)
def on_mount(self) -> None:
self.query_one("#address", Input).focus()
def on_unmount(self) -> None:
self.client.close()
def action_show_help(self) -> None:
self.push_screen(HelpScreen())
def action_focus_coin_set(self) -> None:
self.query_one("#coin-set", RadioSet).focus()
def action_focus_address(self) -> None:
self.query_one("#address", Input).focus()
def action_focus_lookup_button(self) -> None:
self.query_one("#lookup", Button).focus()
def select_coin(self, coin: str, *, announce: bool = False) -> None:
self.selected_coin = coin
for coin_code, radio_id in COIN_RADIO_IDS.items():
self.query_one(f"#{radio_id}", RadioButton).value = coin_code == coin
self.refresh_live_validation()
if announce:
self.set_status(f"Selected {coin}", "info")
def cycle_coin(self, step: int) -> None:
ordered_coins = [coin for coin, _ in COIN_OPTIONS]
current = self.current_coin()
try:
current_index = ordered_coins.index(current)
except ValueError:
current_index = 0
next_coin = ordered_coins[(current_index + step) % len(ordered_coins)]
self.select_coin(next_coin, announce=True)
def action_select_previous_coin(self) -> None:
self.cycle_coin(-1)
def action_select_next_coin(self) -> None:
self.cycle_coin(1)
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
yield from super().get_system_commands(screen)
yield SystemCommand(
"Open Github repository",
"Open or copy the project's GitHub URL",
self.open_github_repository_options,
)
def action_open_github_repository(self) -> None:
self.open_github_repository_options()
def open_github_repository_options(self) -> None:
self.push_screen(GithubRepositoryScreen(REPOSITORY_URL), self.handle_github_repository_choice)
def handle_github_repository_choice(self, choice: Optional[str]) -> None:
if choice == "open":
self.open_url(REPOSITORY_URL)
self.set_status("Opened repository in browser", "info")
elif choice == "copy":
self.copy_to_clipboard(REPOSITORY_URL)
self.set_status("Repository URL copied to clipboard", "ok")
def action_open_explorer(self) -> None:
if self.last_result is None:
self.set_status("No lookup result yet", "warn")
return
self.open_url(self.last_result.explorer_url)
self.set_status("Opened address explorer", "info")
def action_toggle_json(self) -> None:
panel = self.query_one("#json-panel", Container)
self.show_json = not self.show_json
if self.show_json:
panel.remove_class("hidden")
else:
panel.add_class("hidden")
def action_clear_form(self) -> None:
self.query_one("#address", Input).value = ""
self.select_coin(BTC)
self.query_one("#quick-validation", Static).update("Waiting for input")
self.set_status("Ready", "info")
self.reset_results()
self.query_one("#address", Input).focus()
def action_lookup(self) -> None:
self.start_lookup()
@on(Button.Pressed, "#lookup")
def handle_lookup_button(self) -> None:
self.start_lookup()
@on(Button.Pressed, "#clear")
def handle_clear_button(self) -> None:
self.action_clear_form()
@on(Button.Pressed, "#toggle-json")
def handle_toggle_json_button(self) -> None:
self.action_toggle_json()
@on(Input.Changed, "#address")
def handle_address_change(self) -> None:
if self.live_validation_timer is not None:
self.live_validation_timer.stop()
self.live_validation_timer = self.set_timer(0.2, self.refresh_live_validation)
@on(RadioSet.Changed, "#coin-set")
def handle_coin_change(self) -> None:
pressed_button = self.query_one("#coin-set", RadioSet).pressed_button
if pressed_button is not None and pressed_button.id is not None:
self.selected_coin = COIN_FROM_RADIO_ID.get(pressed_button.id, self.selected_coin)
self.refresh_live_validation()
def current_coin(self) -> str:
return self.selected_coin
def set_status(self, message: str, tone: str) -> None:
widget = self.query_one("#status-body", Static)
widget.update(message)
widget.set_classes(tone)
def refresh_live_validation(self) -> None:
address = self.query_one("#address", Input).value.strip()
coin = self.current_coin()
widget = self.query_one("#quick-validation", Static)
if not address:
widget.update("Waiting for input")
return
valid, reason = validate_address(coin, address)
prefix = "Format valid" if valid else "Format invalid"
widget.update(f"{prefix}: {reason}")
def reset_results(self) -> None:
self.last_result = None
self.metric("#metric-confirmed", "-")
self.metric("#metric-unconfirmed", "-")
self.metric("#metric-received", "-")
self.metric("#metric-sent", "-")
self.metric("#metric-tx-count", "-")
self.metric("#metric-source", "-")
self.detail("#detail-coin", "-")
self.detail("#detail-address", "-")
self.detail("#detail-validation", "-")
self.detail("#detail-explorer", "-")
self.detail("#detail-fetched", "-")
self.query_one("#json-box", Static).update("{}")
def metric(self, selector: str, value: str) -> None:
self.query_one(selector, MetricCard).set_value(value)
def detail(self, selector: str, value: str) -> None:
self.query_one(selector, DetailLine).set_value(value)
def start_lookup(self) -> None:
address = self.query_one("#address", Input).value.strip()
coin = self.current_coin()
if not address:
self.set_status("Address is required", "error")
self.query_one("#address", Input).focus()
return
self.set_status(f"Looking up {coin} address", "warn")
self.run_lookup(coin, address)
@work(thread=True)
def run_lookup(self, coin: str, address: str) -> None:
result = build_lookup_result(self.client, coin, address)
self.call_from_thread(self.apply_result, result)
def apply_result(self, result: LookupResult) -> None:
self.last_result = result
self.metric("#metric-confirmed", format_amount_display(result.coin, result.confirmed_balance))
self.metric("#metric-unconfirmed", format_amount_display(result.coin, result.unconfirmed_balance))
self.metric("#metric-received", format_amount_display(result.coin, result.total_received))
self.metric("#metric-sent", format_amount_display(result.coin, result.total_sent))
tx_display = str(result.tx_count) if result.tx_count is not None else "Not available via free endpoint"
self.metric("#metric-tx-count", tx_display)
self.metric("#metric-source", result.data_source)
self.detail("#detail-coin", result.coin)
self.detail("#detail-address", result.address)
self.detail(
"#detail-validation",
format_validation_badge(result.is_valid_format, result.validation_reason),
)
self.detail("#detail-explorer", result.explorer_url)
self.detail("#detail-fetched", result.fetched_at_utc)
self.query_one("#json-box", Static).update(json.dumps(result.as_dict(), indent=2, ensure_ascii=False))
self.query_one("#json-panel", Container).refresh(layout=True)
if result.api_error:
self.set_status(f"Format valid, API request failed: {result.api_error}", "warn")
elif result.api_skipped:
self.set_status("Format invalid, remote lookup skipped", "error")
else:
self.set_status("Lookup completed", "ok")