From 1f3b570bd1a33ec4641891733722e79bcff042d8 Mon Sep 17 00:00:00 2001 From: SpookyDervish Date: Fri, 15 May 2026 14:47:35 +1000 Subject: [PATCH] moved from textual to nicegui --- desktop_app/api/message.py | 12 ++- desktop_app/api/node.py | 4 +- desktop_app/main.py | 124 +++++++++++++++++++++- desktop_app/ui/app.py | 18 ---- desktop_app/ui/assets/banner.txt | 7 -- desktop_app/ui/assets/channels_banner.txt | 8 -- desktop_app/ui/assets/global.tcss | 9 -- desktop_app/ui/assets/pair_screen.tcss | 27 ----- desktop_app/ui/screens/main_screen.py | 19 ---- desktop_app/ui/screens/pair_screen.py | 41 ------- desktop_app/ui/widgets/channels_list.py | 58 ---------- desktop_app/ui/widgets/chat_window.py | 82 -------------- desktop_app/ui/widgets/home_info.py | 25 ----- desktop_app/ui/widgets/home_sidebar.py | 39 ------- 14 files changed, 131 insertions(+), 342 deletions(-) delete mode 100644 desktop_app/ui/app.py delete mode 100644 desktop_app/ui/assets/banner.txt delete mode 100644 desktop_app/ui/assets/channels_banner.txt delete mode 100644 desktop_app/ui/assets/global.tcss delete mode 100644 desktop_app/ui/assets/pair_screen.tcss delete mode 100644 desktop_app/ui/screens/main_screen.py delete mode 100644 desktop_app/ui/screens/pair_screen.py delete mode 100644 desktop_app/ui/widgets/channels_list.py delete mode 100644 desktop_app/ui/widgets/chat_window.py delete mode 100644 desktop_app/ui/widgets/home_info.py delete mode 100644 desktop_app/ui/widgets/home_sidebar.py diff --git a/desktop_app/api/message.py b/desktop_app/api/message.py index 0db10d2..1449d9c 100644 --- a/desktop_app/api/message.py +++ b/desktop_app/api/message.py @@ -1,6 +1,14 @@ from api.node import MeshNode +from datetime import datetime class Message: - def __init__(self, content: str, sender: MeshNode): + def __init__(self, content: str, sender: MeshNode, timestamp=None): self.content = content - self.sender = sender \ No newline at end of file + self.sender = sender + self.timestamp = timestamp or "now" + + def __str__(self): + return f"{self.sender.name}: {self.content}" + + def __repr__(self): + return f"Message(content={self.content}, sender={self.sender})" \ No newline at end of file diff --git a/desktop_app/api/node.py b/desktop_app/api/node.py index 5b5cc79..6ecb1ef 100644 --- a/desktop_app/api/node.py +++ b/desktop_app/api/node.py @@ -19,9 +19,8 @@ class BluetoothPacket: data: dict class MeshNode: - def __init__(self, client: BleakClient, app): + def __init__(self, client: BleakClient): self.client = client - self.app = app self.name = "None" self.rx_queue = asyncio.Queue() @@ -50,7 +49,6 @@ class MeshNode: await self.client.write_gatt_char(NODE_BLUETOOTH_TX_UUID, b"ping") def notification_received(self, sender, data): - self.app.log(f"Receive: {data}") self.rx_queue.put_nowait(data) async def discover(app): diff --git a/desktop_app/main.py b/desktop_app/main.py index aa6a486..58b033e 100644 --- a/desktop_app/main.py +++ b/desktop_app/main.py @@ -1,7 +1,123 @@ +from nicegui import ui +import time +from uuid import uuid4 +from api.message import Message from api.node import MeshNode -from ui.app import mesh +chat_content = None -if __name__ == "__main__": - app = mesh() - app.run() \ No newline at end of file +fake = MeshNode(None) +fake.name = "Some Guy" + +me = MeshNode(None) +me.name = "SpookyDervish (You)" + +messages = { + "Cool Guy": [ + Message("hi", me), + Message("hey", fake), + Message("hru?", fake), + + Message("Good!", me), + Message("hbu?", me), + ], +} + +groups = { + "Cool Group": [ + Message("Welcome to the group chat!", fake), + Message("This is a message in the group chat.", fake), + Message("This is another message in the group chat.", me), + ] +} + +def open_chat(channel: dict, channel_name: str, is_group=False): + chat_content.clear() + + with chat_content: + with ui.row(): + ui.button("Back", icon="arrow_back", on_click=show_contacts).props("flat") + + ui.label(channel_name).classes('text-h5') + ui.icon("people" if is_group else "person").classes('text-h5 h-full') + + ui.separator() + + with ui.column().classes('w-full h-full overflow-auto'): + + current_messages = [] + + for i, msg in enumerate(channel): + current_messages.append(msg.content) + + if i == len(channel)-1 or channel[i + 1].sender != msg.sender: + ui.chat_message(current_messages, name=msg.sender.name, stamp=msg.timestamp, sent=msg.sender != me) + current_messages = [] + +def show_contacts(): + chat_content.clear() + + + + with chat_content: + ui.label('Channels').classes('text-h4') + + with ui.row().classes("w-250 gap-2"): + with ui.column().classes('w-1/2'): + with ui.list().props("bordered inset rounded").classes("w-full"): + ui.item_label("Contacts").props("header") + ui.separator() + + for user in messages.keys(): + with ui.item(on_click=lambda u=user: open_chat(messages[u], u, False)): + with ui.item_section().props("avatar"): + ui.icon("person") + with ui.item_section(): + ui.item_label(user) + ui.item_label(str(uuid4())).props("caption") + with ui.item_section().props('side'): + ui.icon('chat') + + with ui.column().classes('flex-grow'): + with ui.list().props("bordered inset rounded").classes("w-full"): + ui.item_label("Group Chats").props("header") + ui.separator() + + for group in groups.keys(): + with ui.item(on_click=lambda g=group: open_chat(groups[g], g, True)): + with ui.item_section().props("avatar"): + ui.icon("people") + with ui.item_section(): + ui.item_label(group) + ui.item_label(str(uuid4())).props("caption") + with ui.item_section().props('side'): + ui.icon('chat') + +@ui.page("/") +async def main(): + global chat_content + + with ui.header(): + ui.label("mesh").classes('text-h5') + + with ui.splitter(value=10).classes('w-full h-full') as splitter: + with splitter.before: + with ui.tabs().props('vertical').classes('w-full') as tabs: + home = ui.tab('Home', icon='home') + channels = ui.tab('Channels', icon='radio') + settings = ui.tab('Settings', icon='settings') + with splitter.after: + with ui.tab_panels(tabs, value=channels) \ + .props('vertical').classes('size-full'): + with ui.tab_panel(home): + ui.label('Summary').classes('text-h4') + with ui.tab_panel(channels): + + chat_content = ui.column().classes('w-full h-full gap-2') + + show_contacts() + with ui.tab_panel(settings): + ui.label('Settings').classes('text-h4') + +if __name__ in {"__main__", "__mp_main__"}: + ui.run(dark=True) \ No newline at end of file diff --git a/desktop_app/ui/app.py b/desktop_app/ui/app.py deleted file mode 100644 index 4d78e25..0000000 --- a/desktop_app/ui/app.py +++ /dev/null @@ -1,18 +0,0 @@ -from textual.app import App -from ui.screens.pair_screen import PairScreen -from api.node import MeshNode -from api.channel import Channel - - -class mesh(App): - CSS_PATH = "assets/global.tcss" - - def __init__(self, driver_class = None, css_path = None, watch_css = False, ansi_color = False): - super().__init__(driver_class, css_path, watch_css, ansi_color) - self.mesh_node: MeshNode = None - # key = channel name - # value = channel - self.channels: dict[str, Channel] - - def on_ready(self): - self.push_screen(PairScreen()) \ No newline at end of file diff --git a/desktop_app/ui/assets/banner.txt b/desktop_app/ui/assets/banner.txt deleted file mode 100644 index d2d02b8..0000000 --- a/desktop_app/ui/assets/banner.txt +++ /dev/null @@ -1,7 +0,0 @@ - - _ - | | - _ __ ___ ___ ___| |__ - | '_ ` _ \ / _ \/ __| '_ \ - | | | | | | __/\__ \ | | | - |_| |_| |_|\___||___/_| |_| diff --git a/desktop_app/ui/assets/channels_banner.txt b/desktop_app/ui/assets/channels_banner.txt deleted file mode 100644 index a282098..0000000 --- a/desktop_app/ui/assets/channels_banner.txt +++ /dev/null @@ -1,8 +0,0 @@ - _ _ - | | | | - ___| |__ __ _ _ __ _ __ ___| |___ - / __| '_ \ / _` | '_ \| '_ \ / _ \ / __| - | (__| | | | (_| | | | | | | | __/ \__ \ - \___|_| |_|\__,_|_| |_|_| |_|\___|_|___/ - - \ No newline at end of file diff --git a/desktop_app/ui/assets/global.tcss b/desktop_app/ui/assets/global.tcss deleted file mode 100644 index 6db09c5..0000000 --- a/desktop_app/ui/assets/global.tcss +++ /dev/null @@ -1,9 +0,0 @@ -.banner { - padding: 1; - width: 100%; - background: $primary 50%; - color: $primary-lighten-1; - text-align: center; - margin: 1; - text-style: bold underline; -} \ No newline at end of file diff --git a/desktop_app/ui/assets/pair_screen.tcss b/desktop_app/ui/assets/pair_screen.tcss deleted file mode 100644 index 1b7e258..0000000 --- a/desktop_app/ui/assets/pair_screen.tcss +++ /dev/null @@ -1,27 +0,0 @@ -PairScreen { - align: center middle; - - EffectLabel { - min-width: 50; - text-align: center; - text-style: bold; - } -} - -#middle { - border: $success round; - width: 45; - height: 15; - - padding: 0 1 1 1; - - Static { - min-width: 100%; - margin: 0 1; - } - - LoadingIndicator { - height: 1; - margin-top: 1; - } -} \ No newline at end of file diff --git a/desktop_app/ui/screens/main_screen.py b/desktop_app/ui/screens/main_screen.py deleted file mode 100644 index 5d1817f..0000000 --- a/desktop_app/ui/screens/main_screen.py +++ /dev/null @@ -1,19 +0,0 @@ -from textual.screen import Screen -from textual.widgets import Header, Footer, ContentSwitcher -from ui.widgets.home_sidebar import HomeSidebar -from ui.widgets.home_info import HomeInfo -from ui.widgets.channels_list import ChannelsList -from ui.widgets.chat_window import ChatWindow - - -class MainScreen(Screen): - def compose(self): - yield Header(show_clock=True) - yield HomeSidebar() - - with ContentSwitcher(initial="home-info"): - yield HomeInfo(id="home-info") - yield ChannelsList(id="channels-list") - yield ChatWindow(id="chat-window") - - yield Footer() \ No newline at end of file diff --git a/desktop_app/ui/screens/pair_screen.py b/desktop_app/ui/screens/pair_screen.py deleted file mode 100644 index 9a267cf..0000000 --- a/desktop_app/ui/screens/pair_screen.py +++ /dev/null @@ -1,41 +0,0 @@ -from textual.screen import Screen -from textual.containers import Vertical -from textual.widgets import Static, LoadingIndicator, DataTable -from textual import work -from ui.screens.main_screen import MainScreen -from textualeffects.widgets import EffectLabel - -from api.node import MeshNode - - -class PairScreen(Screen): - CSS_PATH = "../assets/pair_screen.tcss" - - @work - async def connect_to_node(self, is_retry = False): - if not is_retry: - self.notify("This may take a moment...", title="Discovering nearby nodes...") - self.app.mesh_node = await MeshNode.discover(self.app) - - if self.app.mesh_node == None: - self.notify("Check your node is powered on and nearby.\nRetrying...", title="Failed to find a nearby node!", severity="warning") - return self.connect_to_node(True) - - self.notify("Hurray! You're on the mesh!", title="Node connected!") - self.app.switch_screen(MainScreen()) - - - async def on_compose(self): - self.connect_to_node() - - def compose(self): - - with Vertical(id="middle") as center_window: - center_window.border_title = "Pair a Node" - - with open("ui/assets/banner.txt", "r") as f: - yield EffectLabel(f.read(), effect="Print") - - yield Static("Attempting to connect to a nearby node. Make sure your mesh network node is powered and ready to pair.") - - yield LoadingIndicator() \ No newline at end of file diff --git a/desktop_app/ui/widgets/channels_list.py b/desktop_app/ui/widgets/channels_list.py deleted file mode 100644 index 97eac2b..0000000 --- a/desktop_app/ui/widgets/channels_list.py +++ /dev/null @@ -1,58 +0,0 @@ -from textual.containers import VerticalScroll, Vertical, HorizontalGroup -from textual.widgets import Static, Button, Rule, ContentSwitcher - -from api.channel import Channel - - -class ChannelView(Button): - DEFAULT_CSS = """ - ChannelView { - margin: 0 1; - background: $boost; - border: $surface-lighten-1 tall; - content-align: left middle; - text-align: left; - width: 30; - } - """ - - def __init__(self, channel: Channel): - super().__init__(" [b]" + channel.name, flat=True) - self.channel = channel - -class ChannelsList(Vertical): - DEFAULT_CSS = """ - ChannelsList { - Rule { - color: $surface-lighten-1; - } - - #buttons { - margin-bottom: 1; - - Button { - margin: 0 1; - } - } - } - """ - - def on_button_pressed(self, event: ChannelView.Pressed): - self.screen.query_one(ContentSwitcher).current = "chat-window" - - def compose(self): - with VerticalScroll(): - yield Static("channels", classes="banner") - yield ChannelView(Channel("test channel 1", "AQ==")) - yield ChannelView(Channel("test channel 2", "AQ==")) - yield ChannelView(Channel("test channel 3", "AQ==")) - yield ChannelView(Channel("test channel 4", "AQ==")) - yield ChannelView(Channel("test channel 5", "AQ==")) - yield ChannelView(Channel("test channel 6", "AQ==")) - yield ChannelView(Channel("test channel 7", "AQ==")) - - yield Rule() - - with HorizontalGroup(id="buttons"): - yield Button("Create Channel") - yield Button("Advertise") \ No newline at end of file diff --git a/desktop_app/ui/widgets/chat_window.py b/desktop_app/ui/widgets/chat_window.py deleted file mode 100644 index 8569f19..0000000 --- a/desktop_app/ui/widgets/chat_window.py +++ /dev/null @@ -1,82 +0,0 @@ -from textual.containers import Vertical, VerticalScroll, VerticalGroup, HorizontalGroup -from textual.widgets import Input, Button, Static -from api.message import Message -from api.node import MeshNode - - -class MessageView(VerticalGroup): - DEFAULT_CSS = """ - MessageView { - margin-bottom: 1; - - #message-text { - background: $surface; - padding: 1; - width: auto; - max-width: 25; - } - - #triangle { - color: $surface; - width: auto; - } - - &.right { - align-horizontal: right; - } - - - } - """ - - def __init__(self, message: Message): - super().__init__() - self.message = message - - if self.message.sender != self.app.mesh_node: - self.notify("right side") - self.add_class("right") - - def compose(self): - user_name = self.message.sender.name - if self.message.sender == self.app.mesh_node: - user_name += " (You)" - - yield Static(f"[b u cyan]{user_name}[/]\n{self.message.content}", id="message-text") - yield Static("", id="triangle") - -class ChatWindow(Vertical): - DEFAULT_CSS = """ - ChatWindow { - #message-history { - padding: 1; - } - - #message-box { - margin-right: 2; - align: left middle; - - #message-input { - margin: 1; - width: 90%; - } - - #send-btn { - max-width: 10%; - margin: 1 0; - } - } - } - """ - - def compose(self): - with VerticalScroll(id="message-history"): - fake = MeshNode(None, self.app) - fake.name = "billy" - yield MessageView(Message("hi!!!", fake)) - yield MessageView(Message("hi!!!", fake)) - yield MessageView(Message("hi!!!", self.app.mesh_node)) - - with HorizontalGroup(id="message-box"): - yield Input(placeholder="Send a message", id="message-input") - yield Button("", flat=True, id="send-btn") \ No newline at end of file diff --git a/desktop_app/ui/widgets/home_info.py b/desktop_app/ui/widgets/home_info.py deleted file mode 100644 index a85a56a..0000000 --- a/desktop_app/ui/widgets/home_info.py +++ /dev/null @@ -1,25 +0,0 @@ -from textual.containers import Center -from textual.widgets import Static -from textualeffects.widgets import EffectLabel - - -class HomeInfo(Center): - DEFAULT_CSS = """ - HomeInfo { - width: 100%; - padding: 0 3; - - EffectLabel { - min-width: 100%; - text-align: center; - } - } - """ - - def compose(self): - with open("ui/assets/banner.txt", "r") as f: - yield EffectLabel(f.read(), effect="Print") - - yield Static("[cyan]󰍦[/] [b blink]1[/] new message(s)") - yield Static("[cyan]󰑩[/] [b]2 of 3[/] nodes online") - yield Static("[lime]󰢾[/] [b]SNR:[/] 10.0 dBm [b]| RSSI:[/] -115.0 dBm") \ No newline at end of file diff --git a/desktop_app/ui/widgets/home_sidebar.py b/desktop_app/ui/widgets/home_sidebar.py deleted file mode 100644 index 4e5e768..0000000 --- a/desktop_app/ui/widgets/home_sidebar.py +++ /dev/null @@ -1,39 +0,0 @@ -from textual.containers import Vertical -from textual.widgets import Button, ContentSwitcher - - -class SidebarButton(Button): - DEFAULT_CSS = """ - SidebarButton { - max-width: 100%; - margin-bottom: 1; - } - """ - -class HomeSidebar(Vertical): - DEFAULT_CSS = """ - HomeSidebar { - width: 11; - background: $boost; - border-right: $surface-lighten-1 tall; - padding: 1; - dock: left; - margin-top: 1; - } - """ - - def on_button_pressed(self, event: Button.Pressed): - content_switcher: ContentSwitcher = self.screen.query_one(ContentSwitcher) - - match event.button.id: - case "home-btn": - content_switcher.current = "home-info" - case "channels-btn": - content_switcher.current = "channels-list" - case "settings-btn": - content_switcher.current = "settings" - - def compose(self): - yield SidebarButton("", tooltip="Home", id="home-btn") - yield SidebarButton("", tooltip="Channels", id="channels-btn") - yield SidebarButton("", tooltip="Settings", id="settings-btn") \ No newline at end of file