moved from textual to nicegui
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
from api.node import MeshNode
|
from api.node import MeshNode
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
class Message:
|
class Message:
|
||||||
def __init__(self, content: str, sender: MeshNode):
|
def __init__(self, content: str, sender: MeshNode, timestamp=None):
|
||||||
self.content = content
|
self.content = content
|
||||||
self.sender = sender
|
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})"
|
||||||
@@ -19,9 +19,8 @@ class BluetoothPacket:
|
|||||||
data: dict
|
data: dict
|
||||||
|
|
||||||
class MeshNode:
|
class MeshNode:
|
||||||
def __init__(self, client: BleakClient, app):
|
def __init__(self, client: BleakClient):
|
||||||
self.client = client
|
self.client = client
|
||||||
self.app = app
|
|
||||||
self.name = "None"
|
self.name = "None"
|
||||||
|
|
||||||
self.rx_queue = asyncio.Queue()
|
self.rx_queue = asyncio.Queue()
|
||||||
@@ -50,7 +49,6 @@ class MeshNode:
|
|||||||
await self.client.write_gatt_char(NODE_BLUETOOTH_TX_UUID, b"ping")
|
await self.client.write_gatt_char(NODE_BLUETOOTH_TX_UUID, b"ping")
|
||||||
|
|
||||||
def notification_received(self, sender, data):
|
def notification_received(self, sender, data):
|
||||||
self.app.log(f"Receive: {data}")
|
|
||||||
self.rx_queue.put_nowait(data)
|
self.rx_queue.put_nowait(data)
|
||||||
|
|
||||||
async def discover(app):
|
async def discover(app):
|
||||||
|
|||||||
@@ -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 api.node import MeshNode
|
||||||
from ui.app import mesh
|
|
||||||
|
|
||||||
|
chat_content = None
|
||||||
|
|
||||||
if __name__ == "__main__":
|
fake = MeshNode(None)
|
||||||
app = mesh()
|
fake.name = "Some Guy"
|
||||||
app.run()
|
|
||||||
|
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)
|
||||||
@@ -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())
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
|
|
||||||
_
|
|
||||||
| |
|
|
||||||
_ __ ___ ___ ___| |__
|
|
||||||
| '_ ` _ \ / _ \/ __| '_ \
|
|
||||||
| | | | | | __/\__ \ | | |
|
|
||||||
|_| |_| |_|\___||___/_| |_|
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
_ _
|
|
||||||
| | | |
|
|
||||||
___| |__ __ _ _ __ _ __ ___| |___
|
|
||||||
/ __| '_ \ / _` | '_ \| '_ \ / _ \ / __|
|
|
||||||
| (__| | | | (_| | | | | | | | __/ \__ \
|
|
||||||
\___|_| |_|\__,_|_| |_|_| |_|\___|_|___/
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
Reference in New Issue
Block a user