From 8a11657460db51bed3e52d0a7e3bd42b987c6c86 Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 00:51:44 +0800 Subject: [PATCH 01/10] :sparkles: support multi-users and channels --- main.py | 53 +++++++-- nonechat/app.py | 11 +- nonechat/components/channel_selector.py | 140 ++++++++++++++++++++++ nonechat/components/chatroom/__init__.py | 9 +- nonechat/components/chatroom/history.py | 13 +- nonechat/components/sidebar.py | 91 ++++++++++++++ nonechat/components/user_selector.py | 145 +++++++++++++++++++++++ nonechat/storage/__init__.py | 79 +++++++++++- nonechat/views/horizontal.py | 50 ++++++-- 9 files changed, 565 insertions(+), 26 deletions(-) create mode 100644 nonechat/components/channel_selector.py create mode 100644 nonechat/components/sidebar.py create mode 100644 nonechat/components/user_selector.py diff --git a/main.py b/main.py index 47f23d8..3f28ac6 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,8 @@ from nonechat.backend import Backend from nonechat.setting import ConsoleSetting from nonechat.message import Text, ConsoleMessage -from nonechat.info import Event, Robot, MessageEvent +from nonechat.info import Event, Robot, MessageEvent, User +from nonechat.storage import Channel class ExampleBackend(Backend): @@ -57,16 +58,16 @@ def wrapper(func): app = Frontend( ExampleBackend, ConsoleSetting( - title="Test", - sub_title="This is a test.", - room_title="Room", + title="Multi-User Chat", + sub_title="支持多用户和频道的聊天应用", + room_title="聊天室", icon="🤖", bg_color=Color(40, 44, 52), title_color=Color(229, 192, 123), header_color=Color(90, 99, 108, 0.6), icon_color=Color.parse("#22b14c"), toolbar_exit="❌", - bot_name="Nonebot", + bot_name="ChatBot", ), ) @@ -77,8 +78,42 @@ async def send_message(message: ConsoleMessage): @app.backend.register() async def on_message(event: MessageEvent): - if str(event.message) == "ping": + """处理消息事件 - 支持多用户和频道""" + message_text = str(event.message) + + # 简单的机器人响应逻辑 + if message_text == "ping": await send_message(ConsoleMessage([Text("pong!")])) - - -app.run() + elif message_text.startswith("hello"): + user_name = event.user.nickname + await send_message(ConsoleMessage([Text(f"Hello {user_name}! 👋")])) + elif message_text == "help": + help_text = """ + 🤖 可用命令: + • ping - 测试连接 + • hello - 打招呼 + • help - 显示帮助 + • users - 显示所有用户 + • channels - 显示所有频道 + """ + await send_message(ConsoleMessage([Text(help_text)])) + elif message_text == "users": + users_list = "\n".join([f"{user.avatar} {user.nickname}" for user in app.storage.users]) + await send_message(ConsoleMessage([Text(f"👥 当前用户:\n{users_list}")])) + elif message_text == "channels": + channels_list = "\n".join([f"{channel.emoji} {channel.name}" for channel in app.storage.channels]) + await send_message(ConsoleMessage([Text(f"📺 当前频道:\n{channels_list}")])) + else: + # 在不同频道中有不同的回复 + if app.storage.current_channel: + channel_name = app.storage.current_channel.name + if "技术" in channel_name: + await send_message(ConsoleMessage([Text(f"💻 在{channel_name}中讨论技术话题很有趣!")])) + elif "游戏" in channel_name: + await send_message(ConsoleMessage([Text(f"🎮 {channel_name}中有什么好玩的游戏推荐吗?")])) + else: + await send_message(ConsoleMessage([Text(f"😊 在{channel_name}中收到了你的消息")])) + + +if __name__ == "__main__": + app.run() diff --git a/nonechat/app.py b/nonechat/app.py index 987cd48..02a23fc 100644 --- a/nonechat/app.py +++ b/nonechat/app.py @@ -7,7 +7,7 @@ from textual.binding import Binding from .backend import Backend -from .storage import Storage +from .storage import Storage, Channel from .router import RouterView from .log_redirect import FakeIO from .setting import ConsoleSetting @@ -15,7 +15,7 @@ from .components.footer import Footer from .components.header import Header from .message import Text, ConsoleMessage -from .info import User, Event, MessageEvent +from .info import User, Event, MessageEvent, Robot from .views.horizontal import HorizontalView TB = TypeVar("TB", bound=Backend) @@ -36,12 +36,17 @@ def __init__(self, backend: type[TB], setting: ConsoleSetting = ConsoleSetting() self.setting = setting self.title = setting.title # type: ignore self.sub_title = setting.sub_title # type: ignore - self.storage = Storage(User("console", setting.user_avatar, setting.user_name)) + + # 创建初始用户 + initial_user = User("console", setting.user_avatar, setting.user_name) + self.storage = Storage(initial_user) + self._fake_output = cast(TextIO, FakeIO(self.storage)) self._redirect_stdout: Optional[contextlib.redirect_stdout[TextIO]] = None self._redirect_stderr: Optional[contextlib.redirect_stderr[TextIO]] = None self.backend: TB = backend(self) + def compose(self): yield Header() yield RouterView(self.ROUTES, "main") diff --git a/nonechat/components/channel_selector.py b/nonechat/components/channel_selector.py new file mode 100644 index 0000000..c45382f --- /dev/null +++ b/nonechat/components/channel_selector.py @@ -0,0 +1,140 @@ +from typing import TYPE_CHECKING, cast + +from textual.widget import Widget +from textual.widgets import Button, Static +from textual.containers import Vertical +from textual.message import Message + +from ..storage import Channel + +if TYPE_CHECKING: + from ..app import Frontend + + +class ChannelSelectorPressed(Message): + """频道选择器按钮被按下时发送的消息""" + + def __init__(self, channel: Channel) -> None: + super().__init__() + self.channel = channel + + +class ChannelSelector(Widget): + """频道选择器组件""" + + DEFAULT_CSS = """ + ChannelSelector { + layout: vertical; + height: auto; + width: 100%; + border: round rgba(170, 170, 170, 0.7); + padding: 1; + margin: 1; + max-height: 15; + overflow-y: auto; + } + + ChannelSelector .title { + height: 1; + width: 100%; + text-align: center; + text-style: bold; + color: green; + margin-bottom: 1; + } + + ChannelSelector .channel-list { + layout: vertical; + height: auto; + width: 100%; + } + + ChannelSelector .channel-button { + width: 100%; + margin-bottom: 1; + text-align: left; + } + + ChannelSelector .channel-button.current { + background: darkgreen; + color: white; + } + + ChannelSelector .add-channel-button { + width: 100%; + margin-top: 1; + background: darkblue; + color: white; + } + """ + + def __init__(self): + super().__init__() + self.channel_buttons = {} + + @property + def app(self) -> "Frontend": + return cast("Frontend", super().app) + + def compose(self): + yield Static("📺 频道列表", classes="title") + yield Vertical(classes="channel-list", id="channel-list") + yield Button("➕ 添加频道", classes="add-channel-button", id="add-channel") + + def on_mount(self): + self.update_channel_list() + + def update_channel_list(self): + """更新频道列表""" + channel_list = self.query_one("#channel-list") + + for channel in self.app.storage.channels: + if channel.id in self.channel_buttons: + button = self.channel_buttons[channel.id][0] + else: + button = Button( + f"{channel.emoji} {channel.name}", + classes="channel-button", + id=f"channel-{channel.id}" + ) + self.channel_buttons[channel.id] = (button, channel) + channel_list.mount(button) + + # 标记当前频道 + if self.app.storage.current_channel and channel.id == self.app.storage.current_channel.id: + button.add_class("current") + else: + button.remove_class("current") + + async def on_button_pressed(self, event: Button.Pressed): + """处理按钮点击事件""" + if event.button.id == "add-channel": + await self._add_new_channel() + elif event.button.id and event.button.id.startswith("channel-"): + # 查找对应的频道 + for button, channel in self.channel_buttons.values(): + if button == event.button: + self.post_message(ChannelSelectorPressed(channel)) + break + + async def _add_new_channel(self): + """添加新频道的逻辑""" + import random + import string + + # 生成随机频道ID + channel_id = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + + # 一些预设的频道 + emojis = ["💬", "🎮", "🎵", "📚", "🎯", "🏆", "🚀", "🌟", "🔥", "💡"] + names = ["随机讨论", "游戏频道", "音乐分享", "学习讨论", "技术交流", "项目讨论", "闲聊", "问答", "分享", "创意"] + + new_channel = Channel( + id=channel_id, + name=random.choice(names), + emoji=random.choice(emojis), + description="自动生成的频道" + ) + + self.app.storage.add_channel(new_channel) + self.update_channel_list() diff --git a/nonechat/components/chatroom/__init__.py b/nonechat/components/chatroom/__init__.py index 1c6300e..48981ae 100644 --- a/nonechat/components/chatroom/__init__.py +++ b/nonechat/components/chatroom/__init__.py @@ -29,15 +29,22 @@ class ChatRoom(Widget): def __init__(self): super().__init__() self.history = ChatHistory() + self.toolbar = None def compose(self): - yield Toolbar(self.app.setting) + self.toolbar = Toolbar(self.app.setting) + yield self.toolbar yield self.history yield InputBox() def action_clear_history(self): self.history.action_clear_history() + def update_toolbar_title(self, title: str): + """更新工具栏标题""" + if self.toolbar: + self.toolbar.center_title.update(title) + @property def app(self) -> "Frontend": return cast("Frontend", super().app) diff --git a/nonechat/components/chatroom/history.py b/nonechat/components/chatroom/history.py index 3cc59a5..5ace47f 100644 --- a/nonechat/components/chatroom/history.py +++ b/nonechat/components/chatroom/history.py @@ -63,4 +63,15 @@ def action_clear_history(self): self.last_time = None for msg in self.walk_children(): cast(Widget, msg).remove() - self.storage.chat_history.clear() + self.storage.clear_chat_history() + + async def refresh_history(self): + """刷新聊天历史记录显示""" + # 清除当前显示的消息 + self.last_msg = None + self.last_time = None + for msg in self.walk_children(): + cast(Widget, msg).remove() + + # 重新加载当前频道的历史记录 + await self.on_new_message(self.storage.chat_history) diff --git a/nonechat/components/sidebar.py b/nonechat/components/sidebar.py new file mode 100644 index 0000000..f333df5 --- /dev/null +++ b/nonechat/components/sidebar.py @@ -0,0 +1,91 @@ +from typing import TYPE_CHECKING, cast + +from textual.widget import Widget +from textual.containers import Vertical +from textual.message import Message + +from .user_selector import UserSelector, UserSelectorPressed +from .channel_selector import ChannelSelector, ChannelSelectorPressed + +if TYPE_CHECKING: + from ..app import Frontend + + +class SidebarUserChanged(Message): + """侧边栏用户更改消息""" + + def __init__(self, user) -> None: + super().__init__() + self.user = user + + +class SidebarChannelChanged(Message): + """侧边栏频道更改消息""" + + def __init__(self, channel) -> None: + super().__init__() + self.channel = channel + + +class Sidebar(Widget): + """侧边栏组件,包含用户和频道选择器""" + + DEFAULT_CSS = """ + Sidebar { + layout: vertical; + width: 25%; + height: auto; + border-right: solid rgba(170, 170, 170, 0.7); + background: rgba(40, 44, 52, 0.3); + padding: 1; + } + + Sidebar UserSelector { + height: 45%; + margin-bottom: 1; + } + + Sidebar ChannelSelector { + height: 45%; + } + """ + + def __init__(self): + super().__init__() + self.user_selector = UserSelector() + self.channel_selector = ChannelSelector() + + @property + def app(self) -> "Frontend": + return cast("Frontend", super().app) + + def compose(self): + yield self.user_selector + yield self.channel_selector + + def on_user_selector_pressed(self, event: UserSelectorPressed): + """处理用户选择事件""" + # 更新当前用户 + self.app.storage.set_user(event.user) + + # 更新用户选择器显示 + self.user_selector.update_user_list() + + # 向父组件发送消息 + self.post_message(SidebarUserChanged(event.user)) + + def on_channel_selector_pressed(self, event: ChannelSelectorPressed): + """处理频道选择事件""" + # 更新当前频道 + self.app.storage.set_channel(event.channel) + + # 更新频道选择器显示 + self.channel_selector.update_channel_list() + + # 向父组件发送消息 + self.post_message(SidebarChannelChanged(event.channel)) + + def update_displays(self): + """更新显示""" + self.user_selector.update_user_list() + self.channel_selector.update_channel_list() diff --git a/nonechat/components/user_selector.py b/nonechat/components/user_selector.py new file mode 100644 index 0000000..51d75e3 --- /dev/null +++ b/nonechat/components/user_selector.py @@ -0,0 +1,145 @@ +from typing import TYPE_CHECKING, cast + +from textual.widget import Widget +from textual.widgets import Button, Static +from textual.containers import Horizontal, Vertical +from textual.message import Message + +from ..info import User + +if TYPE_CHECKING: + from ..app import Frontend + + +class UserSelectorPressed(Message): + """用户选择器按钮被按下时发送的消息""" + + def __init__(self, user: User) -> None: + super().__init__() + self.user = user + + +class UserSelector(Widget): + """用户选择器组件""" + + DEFAULT_CSS = """ + UserSelector { + layout: vertical; + height: auto; + width: 100%; + border: round rgba(170, 170, 170, 0.7); + padding: 1; + margin: 1; + max-height: 15; + overflow-y: auto; + } + + UserSelector .title { + height: 1; + width: 100%; + text-align: center; + text-style: bold; + color: cyan; + margin-bottom: 1; + } + + UserSelector .user-list { + layout: vertical; + height: auto; + width: 100%; + } + + UserSelector .user-button { + width: 100%; + margin-bottom: 1; + text-align: center; + } + + UserSelector .user-button.current { + background: darkblue; + color: white; + } + + UserSelector .add-user-button { + width: 100%; + margin-top: 1; + background: darkgreen; + color: white; + text-style: bold; + text-align: center; + } + """ + + def __init__(self): + super().__init__() + self.user_buttons: dict[str, tuple[Button, User]] = {} + + @property + def app(self) -> "Frontend": + return cast("Frontend", super().app) + + def compose(self): + yield Static("👥 用户列表", classes="title") + yield Vertical(classes="user-list", id="user-list") + yield Button("➕ 添加用户", classes="add-user-button", id="add-user") + + def on_mount(self): + self.update_user_list() + + def update_user_list(self): + """更新用户列表""" + user_list = self.query_one("#user-list") + # user_list.remove_children() + # self.user_buttons.clear() + + for user in self.app.storage.users: + if user.id in self.user_buttons: + button = self.user_buttons[user.id][0] + else: + button = Button( + f"{user.avatar} {user.nickname}", + classes="user-button", + id=f"user-{user.id}" + ) + self.user_buttons[user.id] = (button, user) + user_list.mount(button) + + # 标记当前用户 + if user.id == self.app.storage.current_user.id: + button.add_class("current") + else: + button.remove_class("current") + + + async def on_button_pressed(self, event: Button.Pressed): + """处理按钮点击事件""" + if event.button.id == "add-user": + await self._add_new_user() + elif event.button.id and event.button.id.startswith("user-"): + # 查找对应的用户 + for button, user in self.user_buttons.values(): + if button == event.button: + self.post_message(UserSelectorPressed(user)) + break + + async def _add_new_user(self): + """添加新用户的逻辑""" + # 先简单实现,稍后再创建对话框 + import random + import string + + # 生成随机用户ID + user_id = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) + + # 一些预设的用户 + avatars = ["👤", "🧑", "👩", "👨", "🧑‍💻", "👩‍💻", "👨‍💻", "🧑‍🎓", "👩‍🎓", "👨‍🎓"] + names = ["用户A", "用户B", "用户C", "Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace"] + + new_user = User( + id=user_id, + nickname=random.choice(names), + avatar=random.choice(avatars) + ) + + self.app.storage.add_user(new_user) + self.update_user_list() diff --git a/nonechat/storage/__init__.py b/nonechat/storage/__init__.py index 00fe163..d6522fd 100644 --- a/nonechat/storage/__init__.py +++ b/nonechat/storage/__init__.py @@ -1,4 +1,4 @@ -from typing import Generic, TypeVar +from typing import Generic, TypeVar, Optional from dataclasses import field, dataclass from textual.widget import Widget @@ -20,6 +20,15 @@ def __init__(self, data: T) -> None: self.data = data +@dataclass +class Channel: + """频道信息""" + id: str + name: str + description: str = "" + emoji: str = "💬" + + @dataclass class Storage: current_user: User @@ -27,11 +36,53 @@ class Storage: log_history: list[RenderableType] = field(default_factory=list) log_watchers: list[Widget] = field(default_factory=list) - chat_history: list[MessageEvent] = field(default_factory=list) + # 多用户和频道支持 + users: list[User] = field(default_factory=list) + channels: list[Channel] = field(default_factory=list) + current_channel: Optional[Channel] = field(default=None) + + # 按频道分组的聊天历史记录 + chat_history_by_channel: dict[str, list[MessageEvent]] = field(default_factory=dict) chat_watchers: list[Widget] = field(default_factory=list) + def __post_init__(self): + # 如果没有设置当前频道,创建一个默认频道 + if self.current_channel is None: + self.current_channel = Channel("general", "通用", "默认聊天频道", "💬") + self.channels.append(self.current_channel) + + # 添加当前用户到用户列表 + if self.current_user not in self.users: + self.users.append(self.current_user) + + @property + def chat_history(self) -> list[MessageEvent]: + """获取当前频道的聊天历史""" + if self.current_channel is None: + return [] + return self.chat_history_by_channel.get(self.current_channel.id, []) + def set_user(self, user: User): + """切换当前用户""" self.current_user = user + if user not in self.users: + self.users.append(user) + + def set_channel(self, channel: Channel): + """切换当前频道""" + self.current_channel = channel + if channel not in self.channels: + self.channels.append(channel) + + def add_user(self, user: User): + """添加新用户""" + if user not in self.users: + self.users.append(user) + + def add_channel(self, channel: Channel): + """添加新频道""" + if channel not in self.channels: + self.channels.append(channel) def write_log(self, *logs: RenderableType) -> None: self.log_history.extend(logs) @@ -50,11 +101,29 @@ def emit_log_watcher(self, *logs: RenderableType) -> None: watcher.post_message(StateChange(logs)) def write_chat(self, *messages: "MessageEvent") -> None: - self.chat_history.extend(messages) - if len(self.chat_history) > MAX_MSG_RECORDS: - self.chat_history = self.chat_history[-MAX_MSG_RECORDS:] + if self.current_channel is None: + return + + # 确保当前频道有聊天历史记录 + if self.current_channel.id not in self.chat_history_by_channel: + self.chat_history_by_channel[self.current_channel.id] = [] + + # 添加消息到当前频道 + current_history = self.chat_history_by_channel[self.current_channel.id] + current_history.extend(messages) + + # 限制历史记录数量 + if len(current_history) > MAX_MSG_RECORDS: + self.chat_history_by_channel[self.current_channel.id] = current_history[-MAX_MSG_RECORDS:] + self.emit_chat_watcher(*messages) + def clear_chat_history(self): + """清空当前频道的聊天历史""" + if self.current_channel is not None: + self.chat_history_by_channel[self.current_channel.id] = [] + self.emit_chat_watcher() + def add_chat_watcher(self, watcher: Widget) -> None: self.chat_watchers.append(watcher) diff --git a/nonechat/views/horizontal.py b/nonechat/views/horizontal.py index 7fc6d10..48a4e5e 100644 --- a/nonechat/views/horizontal.py +++ b/nonechat/views/horizontal.py @@ -6,6 +6,7 @@ from ..components.log import LogPanel from ..components.chatroom import ChatRoom +from ..components.sidebar import Sidebar, SidebarUserChanged, SidebarChannelChanged if TYPE_CHECKING: from ..app import Frontend @@ -21,16 +22,35 @@ class HorizontalView(Widget): width: 100%; } - HorizontalView > * { + HorizontalView > Sidebar { + width: 25%; height: 100%; - width: 100%; + min-width: 20; } - HorizontalView > .-w-50 { - width: 50% !important; + + HorizontalView > ChatRoom { + width: 75%; + height: 100%; } - + HorizontalView > LogPanel { + width: 25%; + height: 100%; border-left: solid rgba(204, 204, 204, 0.7); + display: none; + } + + HorizontalView.-show-log > ChatRoom { + width: 50%; + } + + HorizontalView.-show-log > Sidebar { + width: 25%; + } + + HorizontalView.-show-log > LogPanel { + width: 25%; + display: block; } """ @@ -40,6 +60,7 @@ class HorizontalView(Widget): def __init__(self): super().__init__() setting = self.app.setting + self.sidebar = Sidebar() self.chatroom = ChatRoom() self.log_panel = LogPanel(setting) if setting.bg_color: @@ -50,12 +71,28 @@ def app(self) -> "Frontend": return cast("Frontend", super().app) def compose(self): + yield self.sidebar yield self.chatroom yield self.log_panel def on_resize(self, event: Resize): self.responsive(event.size.width) + async def on_sidebar_user_changed(self, event: SidebarUserChanged): + """处理用户切换事件""" + # 刷新聊天室显示 + await self.chatroom.history.refresh_history() + + # 可以在这里添加其他需要更新的组件 + + async def on_sidebar_channel_changed(self, event: SidebarChannelChanged): + """处理频道切换事件""" + # 刷新聊天室显示 + await self.chatroom.history.refresh_history() + + # 更新工具栏标题 + self.chatroom.update_toolbar_title(event.channel.name) + def watch_can_show_log(self, can_show_log: bool): self._toggle_log_panel() @@ -71,5 +108,4 @@ def action_toggle_log_panel(self): def _toggle_log_panel(self): show = self.can_show_log and self.show_log self.log_panel.display = show - self.chatroom.set_class(show, "-w-50") - self.log_panel.set_class(show, "-w-50") + self.set_class(show, "-show-log") From e94ed1ba0f32a88716b88564a54dca3188b55d98 Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 01:02:57 +0800 Subject: [PATCH 02/10] :beers: `event.channel` --- main.py | 7 ++++--- nonechat/app.py | 5 ++++- nonechat/info.py | 10 ++++++++++ nonechat/storage/__init__.py | 18 ++++-------------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/main.py b/main.py index 3f28ac6..8eb8c1b 100644 --- a/main.py +++ b/main.py @@ -84,14 +84,15 @@ async def on_message(event: MessageEvent): # 简单的机器人响应逻辑 if message_text == "ping": await send_message(ConsoleMessage([Text("pong!")])) - elif message_text.startswith("hello"): + elif message_text == "inspect": user_name = event.user.nickname - await send_message(ConsoleMessage([Text(f"Hello {user_name}! 👋")])) + channel_name = event.channel.name + await send_message(ConsoleMessage([Text(f"当前频道: {channel_name}\n当前用户: {user_name}")])) elif message_text == "help": help_text = """ 🤖 可用命令: • ping - 测试连接 - • hello - 打招呼 + • inspect - 查看当前频道和用户 • help - 显示帮助 • users - 显示所有用户 • channels - 显示所有频道 diff --git a/nonechat/app.py b/nonechat/app.py index 02a23fc..c643d98 100644 --- a/nonechat/app.py +++ b/nonechat/app.py @@ -39,7 +39,8 @@ def __init__(self, backend: type[TB], setting: ConsoleSetting = ConsoleSetting() # 创建初始用户 initial_user = User("console", setting.user_avatar, setting.user_name) - self.storage = Storage(initial_user) + initial_channel = Channel("general", "通用", "默认聊天频道", "💬") + self.storage = Storage(initial_user, initial_channel) self._fake_output = cast(TextIO, FakeIO(self.storage)) self._redirect_stdout: Optional[contextlib.redirect_stdout[TextIO]] = None @@ -86,6 +87,7 @@ async def call(self, api: str, data: dict[str, Any]): self_id=self.backend.bot.id, message=data["message"], user=self.backend.bot, + channel=self.storage.current_channel, ) ) elif api == "bell": @@ -102,6 +104,7 @@ async def action_post_message(self, message: str): type="console.message", user=self.storage.current_user, message=ConsoleMessage([Text(message)]), + channel=self.storage.current_channel, ) self.storage.write_chat(msg) await self.backend.post_event(msg) diff --git a/nonechat/info.py b/nonechat/info.py index c2e4321..d3a2538 100644 --- a/nonechat/info.py +++ b/nonechat/info.py @@ -21,12 +21,22 @@ class Robot(User): nickname: str = field(default="Bot") +@dataclass(frozen=True, eq=True) +class Channel: + """频道信息""" + id: str + name: str + description: str = "" + emoji: str = "💬" + + @dataclass class Event: time: datetime self_id: str type: str user: User + channel: Channel @dataclass diff --git a/nonechat/storage/__init__.py b/nonechat/storage/__init__.py index d6522fd..905db28 100644 --- a/nonechat/storage/__init__.py +++ b/nonechat/storage/__init__.py @@ -5,7 +5,7 @@ from textual.message import Message from rich.console import RenderableType -from ..info import User, MessageEvent +from ..info import User, Channel, MessageEvent MAX_LOG_RECORDS = 500 MAX_MSG_RECORDS = 500 @@ -20,18 +20,11 @@ def __init__(self, data: T) -> None: self.data = data -@dataclass -class Channel: - """频道信息""" - id: str - name: str - description: str = "" - emoji: str = "💬" - @dataclass class Storage: current_user: User + current_channel: Channel log_history: list[RenderableType] = field(default_factory=list) log_watchers: list[Widget] = field(default_factory=list) @@ -39,18 +32,15 @@ class Storage: # 多用户和频道支持 users: list[User] = field(default_factory=list) channels: list[Channel] = field(default_factory=list) - current_channel: Optional[Channel] = field(default=None) # 按频道分组的聊天历史记录 chat_history_by_channel: dict[str, list[MessageEvent]] = field(default_factory=dict) chat_watchers: list[Widget] = field(default_factory=list) def __post_init__(self): - # 如果没有设置当前频道,创建一个默认频道 - if self.current_channel is None: - self.current_channel = Channel("general", "通用", "默认聊天频道", "💬") + if self.current_channel not in self.channels: self.channels.append(self.current_channel) - + # 添加当前用户到用户列表 if self.current_user not in self.users: self.users.append(self.current_user) From 4616a92bebc9ce6df545083d5d79adb350fd5b60 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 17:03:43 +0000 Subject: [PATCH 03/10] :rotating_light: auto fix by pre-commit hooks --- main.py | 5 +-- nonechat/app.py | 9 ++--- nonechat/components/channel_selector.py | 51 +++++++++++++++---------- nonechat/components/chatroom/history.py | 2 +- nonechat/components/sidebar.py | 27 +++++++------ nonechat/components/user_selector.py | 43 +++++++++------------ nonechat/info.py | 1 + nonechat/storage/__init__.py | 13 +++---- nonechat/views/horizontal.py | 6 +-- 9 files changed, 77 insertions(+), 80 deletions(-) diff --git a/main.py b/main.py index 8eb8c1b..a9b9549 100644 --- a/main.py +++ b/main.py @@ -8,8 +8,7 @@ from nonechat.backend import Backend from nonechat.setting import ConsoleSetting from nonechat.message import Text, ConsoleMessage -from nonechat.info import Event, Robot, MessageEvent, User -from nonechat.storage import Channel +from nonechat.info import Event, Robot, MessageEvent class ExampleBackend(Backend): @@ -80,7 +79,7 @@ async def send_message(message: ConsoleMessage): async def on_message(event: MessageEvent): """处理消息事件 - 支持多用户和频道""" message_text = str(event.message) - + # 简单的机器人响应逻辑 if message_text == "ping": await send_message(ConsoleMessage([Text("pong!")])) diff --git a/nonechat/app.py b/nonechat/app.py index c643d98..b9768d5 100644 --- a/nonechat/app.py +++ b/nonechat/app.py @@ -7,15 +7,15 @@ from textual.binding import Binding from .backend import Backend -from .storage import Storage, Channel from .router import RouterView from .log_redirect import FakeIO from .setting import ConsoleSetting from .views.log_view import LogView from .components.footer import Footer from .components.header import Header +from .storage import Channel, Storage from .message import Text, ConsoleMessage -from .info import User, Event, MessageEvent, Robot +from .info import User, Event, MessageEvent from .views.horizontal import HorizontalView TB = TypeVar("TB", bound=Backend) @@ -36,18 +36,17 @@ def __init__(self, backend: type[TB], setting: ConsoleSetting = ConsoleSetting() self.setting = setting self.title = setting.title # type: ignore self.sub_title = setting.sub_title # type: ignore - + # 创建初始用户 initial_user = User("console", setting.user_avatar, setting.user_name) initial_channel = Channel("general", "通用", "默认聊天频道", "💬") self.storage = Storage(initial_user, initial_channel) - + self._fake_output = cast(TextIO, FakeIO(self.storage)) self._redirect_stdout: Optional[contextlib.redirect_stdout[TextIO]] = None self._redirect_stderr: Optional[contextlib.redirect_stderr[TextIO]] = None self.backend: TB = backend(self) - def compose(self): yield Header() yield RouterView(self.ROUTES, "main") diff --git a/nonechat/components/channel_selector.py b/nonechat/components/channel_selector.py index c45382f..a228d37 100644 --- a/nonechat/components/channel_selector.py +++ b/nonechat/components/channel_selector.py @@ -1,9 +1,9 @@ from typing import TYPE_CHECKING, cast from textual.widget import Widget -from textual.widgets import Button, Static -from textual.containers import Vertical from textual.message import Message +from textual.containers import Vertical +from textual.widgets import Button, Static from ..storage import Channel @@ -13,7 +13,7 @@ class ChannelSelectorPressed(Message): """频道选择器按钮被按下时发送的消息""" - + def __init__(self, channel: Channel) -> None: super().__init__() self.channel = channel @@ -21,7 +21,7 @@ def __init__(self, channel: Channel) -> None: class ChannelSelector(Widget): """频道选择器组件""" - + DEFAULT_CSS = """ ChannelSelector { layout: vertical; @@ -67,23 +67,23 @@ class ChannelSelector(Widget): color: white; } """ - + def __init__(self): super().__init__() self.channel_buttons = {} - + @property def app(self) -> "Frontend": return cast("Frontend", super().app) - + def compose(self): yield Static("📺 频道列表", classes="title") yield Vertical(classes="channel-list", id="channel-list") yield Button("➕ 添加频道", classes="add-channel-button", id="add-channel") - + def on_mount(self): self.update_channel_list() - + def update_channel_list(self): """更新频道列表""" channel_list = self.query_one("#channel-list") @@ -93,9 +93,7 @@ def update_channel_list(self): button = self.channel_buttons[channel.id][0] else: button = Button( - f"{channel.emoji} {channel.name}", - classes="channel-button", - id=f"channel-{channel.id}" + f"{channel.emoji} {channel.name}", classes="channel-button", id=f"channel-{channel.id}" ) self.channel_buttons[channel.id] = (button, channel) channel_list.mount(button) @@ -105,7 +103,7 @@ def update_channel_list(self): button.add_class("current") else: button.remove_class("current") - + async def on_button_pressed(self, event: Button.Pressed): """处理按钮点击事件""" if event.button.id == "add-channel": @@ -116,25 +114,36 @@ async def on_button_pressed(self, event: Button.Pressed): if button == event.button: self.post_message(ChannelSelectorPressed(channel)) break - + async def _add_new_channel(self): """添加新频道的逻辑""" import random import string - + # 生成随机频道ID - channel_id = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) - + channel_id = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + # 一些预设的频道 emojis = ["💬", "🎮", "🎵", "📚", "🎯", "🏆", "🚀", "🌟", "🔥", "💡"] - names = ["随机讨论", "游戏频道", "音乐分享", "学习讨论", "技术交流", "项目讨论", "闲聊", "问答", "分享", "创意"] - + names = [ + "随机讨论", + "游戏频道", + "音乐分享", + "学习讨论", + "技术交流", + "项目讨论", + "闲聊", + "问答", + "分享", + "创意", + ] + new_channel = Channel( id=channel_id, name=random.choice(names), emoji=random.choice(emojis), - description="自动生成的频道" + description="自动生成的频道", ) - + self.app.storage.add_channel(new_channel) self.update_channel_list() diff --git a/nonechat/components/chatroom/history.py b/nonechat/components/chatroom/history.py index 5ace47f..5e81ee6 100644 --- a/nonechat/components/chatroom/history.py +++ b/nonechat/components/chatroom/history.py @@ -72,6 +72,6 @@ async def refresh_history(self): self.last_time = None for msg in self.walk_children(): cast(Widget, msg).remove() - + # 重新加载当前频道的历史记录 await self.on_new_message(self.storage.chat_history) diff --git a/nonechat/components/sidebar.py b/nonechat/components/sidebar.py index f333df5..79586d3 100644 --- a/nonechat/components/sidebar.py +++ b/nonechat/components/sidebar.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING, cast from textual.widget import Widget -from textual.containers import Vertical from textual.message import Message from .user_selector import UserSelector, UserSelectorPressed @@ -13,7 +12,7 @@ class SidebarUserChanged(Message): """侧边栏用户更改消息""" - + def __init__(self, user) -> None: super().__init__() self.user = user @@ -21,7 +20,7 @@ def __init__(self, user) -> None: class SidebarChannelChanged(Message): """侧边栏频道更改消息""" - + def __init__(self, channel) -> None: super().__init__() self.channel = channel @@ -29,7 +28,7 @@ def __init__(self, channel) -> None: class Sidebar(Widget): """侧边栏组件,包含用户和频道选择器""" - + DEFAULT_CSS = """ Sidebar { layout: vertical; @@ -49,42 +48,42 @@ class Sidebar(Widget): height: 45%; } """ - + def __init__(self): super().__init__() self.user_selector = UserSelector() self.channel_selector = ChannelSelector() - + @property def app(self) -> "Frontend": return cast("Frontend", super().app) - + def compose(self): yield self.user_selector yield self.channel_selector - + def on_user_selector_pressed(self, event: UserSelectorPressed): """处理用户选择事件""" # 更新当前用户 self.app.storage.set_user(event.user) - + # 更新用户选择器显示 self.user_selector.update_user_list() - + # 向父组件发送消息 self.post_message(SidebarUserChanged(event.user)) - + def on_channel_selector_pressed(self, event: ChannelSelectorPressed): """处理频道选择事件""" # 更新当前频道 self.app.storage.set_channel(event.channel) - + # 更新频道选择器显示 self.channel_selector.update_channel_list() - + # 向父组件发送消息 self.post_message(SidebarChannelChanged(event.channel)) - + def update_displays(self): """更新显示""" self.user_selector.update_user_list() diff --git a/nonechat/components/user_selector.py b/nonechat/components/user_selector.py index 51d75e3..730bebd 100644 --- a/nonechat/components/user_selector.py +++ b/nonechat/components/user_selector.py @@ -1,9 +1,9 @@ from typing import TYPE_CHECKING, cast from textual.widget import Widget -from textual.widgets import Button, Static -from textual.containers import Horizontal, Vertical from textual.message import Message +from textual.containers import Vertical +from textual.widgets import Button, Static from ..info import User @@ -13,7 +13,7 @@ class UserSelectorPressed(Message): """用户选择器按钮被按下时发送的消息""" - + def __init__(self, user: User) -> None: super().__init__() self.user = user @@ -21,7 +21,7 @@ def __init__(self, user: User) -> None: class UserSelector(Widget): """用户选择器组件""" - + DEFAULT_CSS = """ UserSelector { layout: vertical; @@ -69,23 +69,23 @@ class UserSelector(Widget): text-align: center; } """ - + def __init__(self): super().__init__() self.user_buttons: dict[str, tuple[Button, User]] = {} - + @property def app(self) -> "Frontend": return cast("Frontend", super().app) - + def compose(self): yield Static("👥 用户列表", classes="title") yield Vertical(classes="user-list", id="user-list") yield Button("➕ 添加用户", classes="add-user-button", id="add-user") - + def on_mount(self): self.update_user_list() - + def update_user_list(self): """更新用户列表""" user_list = self.query_one("#user-list") @@ -96,11 +96,7 @@ def update_user_list(self): if user.id in self.user_buttons: button = self.user_buttons[user.id][0] else: - button = Button( - f"{user.avatar} {user.nickname}", - classes="user-button", - id=f"user-{user.id}" - ) + button = Button(f"{user.avatar} {user.nickname}", classes="user-button", id=f"user-{user.id}") self.user_buttons[user.id] = (button, user) user_list.mount(button) @@ -110,7 +106,6 @@ def update_user_list(self): else: button.remove_class("current") - async def on_button_pressed(self, event: Button.Pressed): """处理按钮点击事件""" if event.button.id == "add-user": @@ -121,25 +116,21 @@ async def on_button_pressed(self, event: Button.Pressed): if button == event.button: self.post_message(UserSelectorPressed(user)) break - + async def _add_new_user(self): """添加新用户的逻辑""" # 先简单实现,稍后再创建对话框 import random import string - + # 生成随机用户ID - user_id = ''.join(random.choices(string.ascii_letters + string.digits, k=8)) - + user_id = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + # 一些预设的用户 avatars = ["👤", "🧑", "👩", "👨", "🧑‍💻", "👩‍💻", "👨‍💻", "🧑‍🎓", "👩‍🎓", "👨‍🎓"] names = ["用户A", "用户B", "用户C", "Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace"] - - new_user = User( - id=user_id, - nickname=random.choice(names), - avatar=random.choice(avatars) - ) - + + new_user = User(id=user_id, nickname=random.choice(names), avatar=random.choice(avatars)) + self.app.storage.add_user(new_user) self.update_user_list() diff --git a/nonechat/info.py b/nonechat/info.py index d3a2538..4f161f3 100644 --- a/nonechat/info.py +++ b/nonechat/info.py @@ -24,6 +24,7 @@ class Robot(User): @dataclass(frozen=True, eq=True) class Channel: """频道信息""" + id: str name: str description: str = "" diff --git a/nonechat/storage/__init__.py b/nonechat/storage/__init__.py index 905db28..129f971 100644 --- a/nonechat/storage/__init__.py +++ b/nonechat/storage/__init__.py @@ -1,5 +1,5 @@ -from typing import Generic, TypeVar, Optional from dataclasses import field, dataclass +from typing import Generic, TypeVar, Optional from textual.widget import Widget from textual.message import Message @@ -20,7 +20,6 @@ def __init__(self, data: T) -> None: self.data = data - @dataclass class Storage: current_user: User @@ -32,7 +31,7 @@ class Storage: # 多用户和频道支持 users: list[User] = field(default_factory=list) channels: list[Channel] = field(default_factory=list) - + # 按频道分组的聊天历史记录 chat_history_by_channel: dict[str, list[MessageEvent]] = field(default_factory=dict) chat_watchers: list[Widget] = field(default_factory=list) @@ -93,19 +92,19 @@ def emit_log_watcher(self, *logs: RenderableType) -> None: def write_chat(self, *messages: "MessageEvent") -> None: if self.current_channel is None: return - + # 确保当前频道有聊天历史记录 if self.current_channel.id not in self.chat_history_by_channel: self.chat_history_by_channel[self.current_channel.id] = [] - + # 添加消息到当前频道 current_history = self.chat_history_by_channel[self.current_channel.id] current_history.extend(messages) - + # 限制历史记录数量 if len(current_history) > MAX_MSG_RECORDS: self.chat_history_by_channel[self.current_channel.id] = current_history[-MAX_MSG_RECORDS:] - + self.emit_chat_watcher(*messages) def clear_chat_history(self): diff --git a/nonechat/views/horizontal.py b/nonechat/views/horizontal.py index 48a4e5e..838b373 100644 --- a/nonechat/views/horizontal.py +++ b/nonechat/views/horizontal.py @@ -82,14 +82,14 @@ async def on_sidebar_user_changed(self, event: SidebarUserChanged): """处理用户切换事件""" # 刷新聊天室显示 await self.chatroom.history.refresh_history() - + # 可以在这里添加其他需要更新的组件 - + async def on_sidebar_channel_changed(self, event: SidebarChannelChanged): """处理频道切换事件""" # 刷新聊天室显示 await self.chatroom.history.refresh_history() - + # 更新工具栏标题 self.chatroom.update_toolbar_title(event.channel.name) From a420bcefbbe271851dab596fb9465f044c0437bb Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 01:53:44 +0800 Subject: [PATCH 04/10] :sparkles: support setting light and dark background --- main.py | 4 ++-- nonechat/app.py | 28 +++++++++++++++++++++++++ nonechat/components/channel_selector.py | 12 +++++------ nonechat/components/log/__init__.py | 9 ++++---- nonechat/components/sidebar.py | 9 ++++---- nonechat/components/user_selector.py | 16 +++++++------- nonechat/setting.py | 1 + nonechat/storage/__init__.py | 2 +- nonechat/views/horizontal.py | 18 ++++++++-------- nonechat/views/log_view.py | 8 +++---- 10 files changed, 67 insertions(+), 40 deletions(-) diff --git a/main.py b/main.py index a9b9549..f99b398 100644 --- a/main.py +++ b/main.py @@ -60,8 +60,8 @@ def wrapper(func): title="Multi-User Chat", sub_title="支持多用户和频道的聊天应用", room_title="聊天室", - icon="🤖", - bg_color=Color(40, 44, 52), + icon="🤖", # 浅色模式背景色 + dark_bg_color=Color(40, 44, 52), # 暗色模式背景色 (更深一些) title_color=Color(229, 192, 123), header_color=Color(90, 99, 108, 0.6), icon_color=Color.parse("#22b14c"), diff --git a/nonechat/app.py b/nonechat/app.py index b9768d5..5679da8 100644 --- a/nonechat/app.py +++ b/nonechat/app.py @@ -66,6 +66,9 @@ def on_mount(self): stderr.__enter__() self._redirect_stderr = stderr + # 应用主题背景色 + self.apply_theme_background() + self.backend.on_console_mount() def on_unmount(self): @@ -110,3 +113,28 @@ async def action_post_message(self, message: str): async def action_post_event(self, event: Event): await self.backend.post_event(event) + + def action_toggle_dark(self) -> None: + """切换暗色模式并应用相应背景色""" + # 先调用父类的 toggle_dark 方法 + super().action_toggle_dark() + + # 应用对应的背景色 + self.apply_theme_background() + + def apply_theme_background(self) -> None: + """根据当前主题模式应用背景色设置""" + setting = self.setting + + # 查找需要更新背景色的视图 + try: + horizontal_view = self.query_one(HorizontalView) + if self.dark: + horizontal_view.styles.background = setting.dark_bg_color + else: + horizontal_view.styles.background = setting.bg_color + except Exception: + # 视图可能还没有加载 + pass + + # 如果有其他需要设置背景色的组件,可以在这里添加 diff --git a/nonechat/components/channel_selector.py b/nonechat/components/channel_selector.py index a228d37..adca6dc 100644 --- a/nonechat/components/channel_selector.py +++ b/nonechat/components/channel_selector.py @@ -33,7 +33,7 @@ class ChannelSelector(Widget): max-height: 15; overflow-y: auto; } - + ChannelSelector .title { height: 1; width: 100%; @@ -42,24 +42,24 @@ class ChannelSelector(Widget): color: green; margin-bottom: 1; } - + ChannelSelector .channel-list { layout: vertical; height: auto; width: 100%; } - + ChannelSelector .channel-button { width: 100%; margin-bottom: 1; - text-align: left; + text-align: center; } - + ChannelSelector .channel-button.current { background: darkgreen; color: white; } - + ChannelSelector .add-channel-button { width: 100%; margin-top: 1; diff --git a/nonechat/components/log/__init__.py b/nonechat/components/log/__init__.py index f4fdc9d..5ac572f 100644 --- a/nonechat/components/log/__init__.py +++ b/nonechat/components/log/__init__.py @@ -8,7 +8,6 @@ if TYPE_CHECKING: from ...app import Frontend - from ...setting import ConsoleSetting from ...storage import Storage, StateChange @@ -27,13 +26,13 @@ class LogPanel(Widget): } """ - def __init__(self, setting: "ConsoleSetting") -> None: + def __init__(self) -> None: super().__init__() self.output = RichLog(max_lines=MAX_LINES, min_width=60, wrap=True, markup=True) - if setting.bg_color: - self.styles.background = setting.bg_color - self.output.styles.background = setting.bg_color + # if setting.bg_color: + # self.styles.background = setting.bg_color + # self.output.styles.background = setting.bg_color @property def storage(self) -> "Storage": diff --git a/nonechat/components/sidebar.py b/nonechat/components/sidebar.py index 79586d3..7764306 100644 --- a/nonechat/components/sidebar.py +++ b/nonechat/components/sidebar.py @@ -35,15 +35,13 @@ class Sidebar(Widget): width: 25%; height: auto; border-right: solid rgba(170, 170, 170, 0.7); - background: rgba(40, 44, 52, 0.3); padding: 1; } - + Sidebar UserSelector { height: 45%; - margin-bottom: 1; } - + Sidebar ChannelSelector { height: 45%; } @@ -53,6 +51,9 @@ def __init__(self): super().__init__() self.user_selector = UserSelector() self.channel_selector = ChannelSelector() + # setting = self.app.setting + # if setting.bg_color: + # self.styles.background = setting.bg_color @property def app(self) -> "Frontend": diff --git a/nonechat/components/user_selector.py b/nonechat/components/user_selector.py index 730bebd..4e0a4ca 100644 --- a/nonechat/components/user_selector.py +++ b/nonechat/components/user_selector.py @@ -33,7 +33,7 @@ class UserSelector(Widget): max-height: 15; overflow-y: auto; } - + UserSelector .title { height: 1; width: 100%; @@ -42,31 +42,29 @@ class UserSelector(Widget): color: cyan; margin-bottom: 1; } - + UserSelector .user-list { layout: vertical; height: auto; width: 100%; } - + UserSelector .user-button { width: 100%; margin-bottom: 1; text-align: center; } - + UserSelector .user-button.current { - background: darkblue; + background: darkgreen; color: white; } - + UserSelector .add-user-button { width: 100%; margin-top: 1; - background: darkgreen; + background: darkblue; color: white; - text-style: bold; - text-align: center; } """ diff --git a/nonechat/setting.py b/nonechat/setting.py index 0fe926d..568b2d6 100644 --- a/nonechat/setting.py +++ b/nonechat/setting.py @@ -13,6 +13,7 @@ class ConsoleSetting: icon: Optional[str] = None icon_color: Optional[Color] = None bg_color: Optional[Color] = None + dark_bg_color: Optional[Color] = None header_color: Optional[Color] = None toolbar_exit: str = "⛔" toolbar_clear: str = "🗑️" diff --git a/nonechat/storage/__init__.py b/nonechat/storage/__init__.py index 129f971..4a18464 100644 --- a/nonechat/storage/__init__.py +++ b/nonechat/storage/__init__.py @@ -1,5 +1,5 @@ +from typing import Generic, TypeVar from dataclasses import field, dataclass -from typing import Generic, TypeVar, Optional from textual.widget import Widget from textual.message import Message diff --git a/nonechat/views/horizontal.py b/nonechat/views/horizontal.py index 838b373..e370a7c 100644 --- a/nonechat/views/horizontal.py +++ b/nonechat/views/horizontal.py @@ -27,27 +27,27 @@ class HorizontalView(Widget): height: 100%; min-width: 20; } - + HorizontalView > ChatRoom { width: 75%; height: 100%; } - + HorizontalView > LogPanel { width: 25%; height: 100%; border-left: solid rgba(204, 204, 204, 0.7); display: none; } - + HorizontalView.-show-log > ChatRoom { width: 50%; } - + HorizontalView.-show-log > Sidebar { width: 25%; } - + HorizontalView.-show-log > LogPanel { width: 25%; display: block; @@ -59,12 +59,12 @@ class HorizontalView(Widget): def __init__(self): super().__init__() - setting = self.app.setting + # setting = self.app.setting self.sidebar = Sidebar() self.chatroom = ChatRoom() - self.log_panel = LogPanel(setting) - if setting.bg_color: - self.styles.background = setting.bg_color + self.log_panel = LogPanel() + # if setting.bg_color: + # self.styles.background = setting.bg_color @property def app(self) -> "Frontend": diff --git a/nonechat/views/log_view.py b/nonechat/views/log_view.py index 4441e9e..ea4e433 100644 --- a/nonechat/views/log_view.py +++ b/nonechat/views/log_view.py @@ -20,13 +20,13 @@ class LogView(Widget): def __init__(self): super().__init__() - setting = self.app.setting - if setting.bg_color: - self.styles.background = setting.bg_color + # setting = self.app.setting + # if setting.bg_color: + # self.styles.background = setting.bg_color def compose(self): yield Toolbar(self.app.setting) - yield LogPanel(self.app.setting) + yield LogPanel() @property def app(self) -> "Frontend": From 5db926c3480de9d63dee4f590ad04bac751989d2 Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 02:50:53 +0800 Subject: [PATCH 05/10] :beers: specific Direct channel --- main.py | 2 +- nonechat/app.py | 29 ++++++++++++------------- nonechat/backend.py | 2 +- nonechat/components/channel_selector.py | 2 +- nonechat/components/chatroom/history.py | 2 +- nonechat/components/chatroom/message.py | 2 +- nonechat/components/user_selector.py | 2 +- nonechat/log_redirect.py | 4 ++++ nonechat/{info.py => model.py} | 0 nonechat/storage/__init__.py | 3 ++- 10 files changed, 26 insertions(+), 22 deletions(-) rename nonechat/{info.py => model.py} (100%) diff --git a/main.py b/main.py index f99b398..2119993 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ from nonechat.backend import Backend from nonechat.setting import ConsoleSetting from nonechat.message import Text, ConsoleMessage -from nonechat.info import Event, Robot, MessageEvent +from nonechat.model import Event, Robot, MessageEvent class ExampleBackend(Backend): diff --git a/nonechat/app.py b/nonechat/app.py index 5679da8..3915c92 100644 --- a/nonechat/app.py +++ b/nonechat/app.py @@ -1,3 +1,4 @@ +import sys import contextlib from datetime import datetime from typing import Any, TextIO, Generic, TypeVar, Optional, cast @@ -15,7 +16,7 @@ from .components.header import Header from .storage import Channel, Storage from .message import Text, ConsoleMessage -from .info import User, Event, MessageEvent +from .model import User, Event, MessageEvent from .views.horizontal import HorizontalView TB = TypeVar("TB", bound=Backend) @@ -43,8 +44,10 @@ def __init__(self, backend: type[TB], setting: ConsoleSetting = ConsoleSetting() self.storage = Storage(initial_user, initial_channel) self._fake_output = cast(TextIO, FakeIO(self.storage)) - self._redirect_stdout: Optional[contextlib.redirect_stdout[TextIO]] = None - self._redirect_stderr: Optional[contextlib.redirect_stderr[TextIO]] = None + self._origin_stdout = sys.stdout + self._origin_stderr = sys.stderr + self._textual_stdout: Optional[TextIO] = None + self._textual_stderr: Optional[TextIO] = None self.backend: TB = backend(self) def compose(self): @@ -57,14 +60,12 @@ def on_load(self): def on_mount(self): with contextlib.suppress(Exception): - stdout = contextlib.redirect_stdout(self._fake_output) - stdout.__enter__() - self._redirect_stdout = stdout + self._textual_stdout = sys.stdout + sys.stdout = self._fake_output with contextlib.suppress(Exception): - stderr = contextlib.redirect_stderr(self._fake_output) - stderr.__enter__() - self._redirect_stderr = stderr + self._textual_stderr = sys.stderr + sys.stderr = self._fake_output # 应用主题背景色 self.apply_theme_background() @@ -72,12 +73,10 @@ def on_mount(self): self.backend.on_console_mount() def on_unmount(self): - if self._redirect_stderr is not None: - self._redirect_stderr.__exit__(None, None, None) - self._redirect_stderr = None - if self._redirect_stdout is not None: - self._redirect_stdout.__exit__(None, None, None) - self._redirect_stdout = None + if self._textual_stdout is not None: + sys.stdout = self._origin_stdout + if self._textual_stderr is not None: + sys.stderr = self._origin_stderr self.backend.on_console_unmount() async def call(self, api: str, data: dict[str, Any]): diff --git a/nonechat/backend.py b/nonechat/backend.py index fb18271..b8ded25 100644 --- a/nonechat/backend.py +++ b/nonechat/backend.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from abc import ABC, abstractmethod -from .info import Event, Robot +from .model import Event, Robot if TYPE_CHECKING: from .app import Frontend diff --git a/nonechat/components/channel_selector.py b/nonechat/components/channel_selector.py index adca6dc..9772705 100644 --- a/nonechat/components/channel_selector.py +++ b/nonechat/components/channel_selector.py @@ -99,7 +99,7 @@ def update_channel_list(self): channel_list.mount(button) # 标记当前频道 - if self.app.storage.current_channel and channel.id == self.app.storage.current_channel.id: + if channel.id == self.app.storage.current_channel.id: button.add_class("current") else: button.remove_class("current") diff --git a/nonechat/components/chatroom/history.py b/nonechat/components/chatroom/history.py index 5e81ee6..cbbf9eb 100644 --- a/nonechat/components/chatroom/history.py +++ b/nonechat/components/chatroom/history.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from ...app import Frontend - from ...info import MessageEvent + from ...model import MessageEvent from ...storage import Storage, StateChange diff --git a/nonechat/components/chatroom/message.py b/nonechat/components/chatroom/message.py index f8b1429..3fea152 100644 --- a/nonechat/components/chatroom/message.py +++ b/nonechat/components/chatroom/message.py @@ -6,7 +6,7 @@ from rich.console import RenderableType from ...utils import truncate -from ...info import User, MessageEvent +from ...model import User, MessageEvent class Timer(Widget): diff --git a/nonechat/components/user_selector.py b/nonechat/components/user_selector.py index 4e0a4ca..53ed82b 100644 --- a/nonechat/components/user_selector.py +++ b/nonechat/components/user_selector.py @@ -5,7 +5,7 @@ from textual.containers import Vertical from textual.widgets import Button, Static -from ..info import User +from ..model import User if TYPE_CHECKING: from ..app import Frontend diff --git a/nonechat/log_redirect.py b/nonechat/log_redirect.py index dfcc4d7..42c8e2a 100644 --- a/nonechat/log_redirect.py +++ b/nonechat/log_redirect.py @@ -33,3 +33,7 @@ def flush(self) -> None: def _write_to_storage(self) -> None: self.storage.write_log(Text.from_ansi("".join(self._buffer), end="", tab_size=4)) + + def read(self) -> str: + self.flush() # 确保所有内容都被写入存储 + return "".join(self._buffer) diff --git a/nonechat/info.py b/nonechat/model.py similarity index 100% rename from nonechat/info.py rename to nonechat/model.py diff --git a/nonechat/storage/__init__.py b/nonechat/storage/__init__.py index 4a18464..e5592a4 100644 --- a/nonechat/storage/__init__.py +++ b/nonechat/storage/__init__.py @@ -5,7 +5,7 @@ from textual.message import Message from rich.console import RenderableType -from ..info import User, Channel, MessageEvent +from ..model import User, Channel, MessageEvent MAX_LOG_RECORDS = 500 MAX_MSG_RECORDS = 500 @@ -37,6 +37,7 @@ class Storage: chat_watchers: list[Widget] = field(default_factory=list) def __post_init__(self): + self.channels.append(Channel("_direct", "私聊", "私聊频道", "🔏")) if self.current_channel not in self.channels: self.channels.append(self.current_channel) From c99941964fdd99766fc8232f9dc1e856a5b4afec Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 02:59:52 +0800 Subject: [PATCH 06/10] :arrow_up: update Textual to latest version --- main.py | 24 ++++++---- nonechat/app.py | 2 +- pdm.lock | 120 ++++++++++++++++++++++++++++-------------------- pyproject.toml | 4 +- 4 files changed, 86 insertions(+), 64 deletions(-) diff --git a/main.py b/main.py index 2119993..8ee5a9b 100644 --- a/main.py +++ b/main.py @@ -7,8 +7,8 @@ from nonechat.app import Frontend from nonechat.backend import Backend from nonechat.setting import ConsoleSetting -from nonechat.message import Text, ConsoleMessage from nonechat.model import Event, Robot, MessageEvent +from nonechat.message import Text, Markdown, ConsoleMessage class ExampleBackend(Backend): @@ -88,15 +88,19 @@ async def on_message(event: MessageEvent): channel_name = event.channel.name await send_message(ConsoleMessage([Text(f"当前频道: {channel_name}\n当前用户: {user_name}")])) elif message_text == "help": - help_text = """ - 🤖 可用命令: - • ping - 测试连接 - • inspect - 查看当前频道和用户 - • help - 显示帮助 - • users - 显示所有用户 - • channels - 显示所有频道 - """ - await send_message(ConsoleMessage([Text(help_text)])) + help_text = """\ +🤖 可用命令: +- ping - 测试连接 +- inspect - 查看当前频道和用户 +- help - 显示帮助 +- users - 显示所有用户 +- channels - 显示所有频道 +""" + await send_message(ConsoleMessage([Markdown(help_text)])) + elif message_text == "md": + with open("README.md", encoding="utf-8") as md_file: + md_text = md_file.read() + await send_message(ConsoleMessage([Markdown(md_text)])) elif message_text == "users": users_list = "\n".join([f"{user.avatar} {user.nickname}" for user in app.storage.users]) await send_message(ConsoleMessage([Text(f"👥 当前用户:\n{users_list}")])) diff --git a/nonechat/app.py b/nonechat/app.py index 3915c92..b3b9563 100644 --- a/nonechat/app.py +++ b/nonechat/app.py @@ -128,7 +128,7 @@ def apply_theme_background(self) -> None: # 查找需要更新背景色的视图 try: horizontal_view = self.query_one(HorizontalView) - if self.dark: + if self.current_theme.dark: horizontal_view.styles.background = setting.dark_bg_color else: horizontal_view.styles.background = setting.bg_color diff --git a/pdm.lock b/pdm.lock index 5fa1f42..80e43f5 100644 --- a/pdm.lock +++ b/pdm.lock @@ -4,13 +4,16 @@ [metadata] groups = ["default", "dev"] strategy = ["cross_platform"] -lock_version = "4.4.1" -content_hash = "sha256:613eaff0a4147a66ae732046d3a00ea92627eff0c5bd0e5520f308705a3334e1" +lock_version = "4.5.0" +content_hash = "sha256:8d9ce48aade457593a220ca61cd659d33e92a63ecb6d1ef83159f15694183333" + +[[metadata.targets]] +requires_python = "~=3.9" [[package]] name = "black" -version = "24.8.0" -requires_python = ">=3.8" +version = "25.1.0" +requires_python = ">=3.9" summary = "The uncompromising code formatter." dependencies = [ "click>=8.0.0", @@ -22,24 +25,28 @@ dependencies = [ "typing-extensions>=4.0.1; python_version < \"3.11\"", ] files = [ - {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, - {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, - {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, - {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, - {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, - {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, - {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, - {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, - {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, - {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, - {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, - {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, - {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, - {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, - {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, - {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, - {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, - {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, ] [[package]] @@ -59,6 +66,7 @@ requires_python = ">=3.7" summary = "Composable command line interface toolkit" dependencies = [ "colorama; platform_system == \"Windows\"", + "importlib-metadata; python_version < \"3.8\"", ] files = [ {file = "click-8.1.5-py3-none-any.whl", hash = "sha256:e576aa487d679441d7d30abb87e1b43d24fc53bffb8758443b1a9e1cee504548"}, @@ -129,16 +137,17 @@ files = [ [[package]] name = "loguru" -version = "0.7.2" -requires_python = ">=3.5" +version = "0.7.3" +requires_python = "<4.0,>=3.5" summary = "Python logging made (stupidly) simple" dependencies = [ + "aiocontextvars>=0.2.0; python_version < \"3.7\"", "colorama>=0.3.4; sys_platform == \"win32\"", "win32-setctime>=1.0.0; sys_platform == \"win32\"", ] files = [ - {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, - {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, ] [[package]] @@ -267,6 +276,9 @@ name = "platformdirs" version = "3.8.1" requires_python = ">=3.7" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +dependencies = [ + "typing-extensions>=4.6.3; python_version < \"3.8\"", +] files = [ {file = "platformdirs-3.8.1-py3-none-any.whl", hash = "sha256:cec7b889196b9144d088e4c57d9ceef7374f6c39694ad1577a0aab50d27ea28c"}, {file = "platformdirs-3.8.1.tar.gz", hash = "sha256:f87ca4fcff7d2b0f81c6a748a77973d7af0f4d526f98f308477c3c436c74d528"}, @@ -274,7 +286,7 @@ files = [ [[package]] name = "pre-commit" -version = "3.8.0" +version = "4.2.0" requires_python = ">=3.9" summary = "A framework for managing and maintaining multi-language pre-commit hooks." dependencies = [ @@ -285,8 +297,8 @@ dependencies = [ "virtualenv>=20.10.0", ] files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, + {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, + {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, ] [[package]] @@ -350,6 +362,7 @@ summary = "Render rich text, tables, progress bars, syntax highlighting, markdow dependencies = [ "markdown-it-py>=2.2.0", "pygments<3.0.0,>=2.13.0", + "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"", ] files = [ {file = "rich-13.4.2-py3-none-any.whl", hash = "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec"}, @@ -358,28 +371,28 @@ files = [ [[package]] name = "ruff" -version = "0.5.7" +version = "0.12.2" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." files = [ - {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, - {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, - {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, - {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, - {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, - {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, - {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, - {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, - {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, + {file = "ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be"}, + {file = "ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e"}, + {file = "ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342"}, + {file = "ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a"}, + {file = "ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639"}, + {file = "ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12"}, + {file = "ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e"}, ] [[package]] @@ -394,17 +407,18 @@ files = [ [[package]] name = "textual" -version = "0.76.0" +version = "3.6.0" requires_python = "<4.0.0,>=3.8.1" summary = "Modern Text User Interface framework" dependencies = [ "markdown-it-py[linkify,plugins]>=2.1.0", + "platformdirs<5,>=3.6.0", "rich>=13.3.3", "typing-extensions<5.0.0,>=4.4.0", ] files = [ - {file = "textual-0.76.0-py3-none-any.whl", hash = "sha256:e2035609c889dba507d34a5d7b333f1c8c53a29fb170962cb92101507663517a"}, - {file = "textual-0.76.0.tar.gz", hash = "sha256:b12e8879d591090c0901b5cb8121d086e28e677353b368292d3865ec99b83b70"}, + {file = "textual-3.6.0-py3-none-any.whl", hash = "sha256:8b2fafe17b1805fd608092ab2f99b6ddeaff1809f1d5de6820bf5db5b6c51c28"}, + {file = "textual-3.6.0.tar.gz", hash = "sha256:d86b666e34758392a4a1aefc4b781b17d0d001419d24e4d47c277f36c20b1222"}, ] [[package]] @@ -445,6 +459,7 @@ summary = "Virtual Python Environment builder" dependencies = [ "distlib<1,>=0.3.6", "filelock<4,>=3.12", + "importlib-metadata>=6.6; python_version < \"3.8\"", "platformdirs<4,>=3.5.1", ] files = [ @@ -456,6 +471,9 @@ files = [ name = "wcwidth" version = "0.2.6" summary = "Measures the displayed width of unicode strings in a terminal" +dependencies = [ + "backports-functools-lru-cache>=1.2.1; python_version < \"3.2\"", +] files = [ {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, diff --git a/pyproject.toml b/pyproject.toml index 97d2acc..e486dc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,12 +18,12 @@ repository = "https://github.com/nonebot/nonechat" [tool.pdm.dev-dependencies] dev = [ - "isort>=5.13.2", + "isort==5.13.2", "black>=24.4.2", "loguru>=0.7.0", "ruff>=0.5.0", "nonemoji>=0.1", - "pre-commit>=3.7.0" + "pre-commit>=3.7.0", ] [tool.pdm.build] From 4be469d167eda3ac9fd3fe6826467064a8e0a564 Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 10:09:48 +0800 Subject: [PATCH 07/10] :sparkles: support independent history of direct message --- nonechat/components/chatroom/__init__.py | 8 +--- nonechat/components/sidebar.py | 4 +- nonechat/storage/__init__.py | 51 +++++++++++++++--------- nonechat/views/horizontal.py | 8 +++- 4 files changed, 41 insertions(+), 30 deletions(-) diff --git a/nonechat/components/chatroom/__init__.py b/nonechat/components/chatroom/__init__.py index 48981ae..fe9edc7 100644 --- a/nonechat/components/chatroom/__init__.py +++ b/nonechat/components/chatroom/__init__.py @@ -29,10 +29,9 @@ class ChatRoom(Widget): def __init__(self): super().__init__() self.history = ChatHistory() - self.toolbar = None + self.toolbar = Toolbar(self.app.setting) def compose(self): - self.toolbar = Toolbar(self.app.setting) yield self.toolbar yield self.history yield InputBox() @@ -40,11 +39,6 @@ def compose(self): def action_clear_history(self): self.history.action_clear_history() - def update_toolbar_title(self, title: str): - """更新工具栏标题""" - if self.toolbar: - self.toolbar.center_title.update(title) - @property def app(self) -> "Frontend": return cast("Frontend", super().app) diff --git a/nonechat/components/sidebar.py b/nonechat/components/sidebar.py index 7764306..47509e8 100644 --- a/nonechat/components/sidebar.py +++ b/nonechat/components/sidebar.py @@ -24,6 +24,7 @@ class SidebarChannelChanged(Message): def __init__(self, channel) -> None: super().__init__() self.channel = channel + self.direct = channel.id == "_direct" class Sidebar(Widget): @@ -51,9 +52,6 @@ def __init__(self): super().__init__() self.user_selector = UserSelector() self.channel_selector = ChannelSelector() - # setting = self.app.setting - # if setting.bg_color: - # self.styles.background = setting.bg_color @property def app(self) -> "Frontend": diff --git a/nonechat/storage/__init__.py b/nonechat/storage/__init__.py index e5592a4..209b624 100644 --- a/nonechat/storage/__init__.py +++ b/nonechat/storage/__init__.py @@ -20,6 +20,9 @@ def __init__(self, data: T) -> None: self.data = data +DIRECT = Channel("_direct", "私聊", "私聊频道", "🔏") + + @dataclass class Storage: current_user: User @@ -34,10 +37,11 @@ class Storage: # 按频道分组的聊天历史记录 chat_history_by_channel: dict[str, list[MessageEvent]] = field(default_factory=dict) + chat_history_by_user: dict[str, list[MessageEvent]] = field(default_factory=dict) chat_watchers: list[Widget] = field(default_factory=list) def __post_init__(self): - self.channels.append(Channel("_direct", "私聊", "私聊频道", "🔏")) + self.channels.append(DIRECT) if self.current_channel not in self.channels: self.channels.append(self.current_channel) @@ -45,11 +49,15 @@ def __post_init__(self): if self.current_user not in self.users: self.users.append(self.current_user) + @property + def is_direct(self) -> bool: + return self.current_channel == DIRECT + @property def chat_history(self) -> list[MessageEvent]: """获取当前频道的聊天历史""" - if self.current_channel is None: - return [] + if self.current_channel == DIRECT: + return self.chat_history_by_user.get(self.current_user.id, []) return self.chat_history_by_channel.get(self.current_channel.id, []) def set_user(self, user: User): @@ -91,28 +99,35 @@ def emit_log_watcher(self, *logs: RenderableType) -> None: watcher.post_message(StateChange(logs)) def write_chat(self, *messages: "MessageEvent") -> None: - if self.current_channel is None: - return - - # 确保当前频道有聊天历史记录 - if self.current_channel.id not in self.chat_history_by_channel: - self.chat_history_by_channel[self.current_channel.id] = [] - - # 添加消息到当前频道 - current_history = self.chat_history_by_channel[self.current_channel.id] - current_history.extend(messages) - # 限制历史记录数量 - if len(current_history) > MAX_MSG_RECORDS: - self.chat_history_by_channel[self.current_channel.id] = current_history[-MAX_MSG_RECORDS:] + if self.current_channel == DIRECT: + if self.current_user.id not in self.chat_history_by_user: + self.chat_history_by_user[self.current_user.id] = [] + # 添加消息到当前用户的私聊历史 + current_history = self.chat_history_by_user[self.current_user.id] + current_history.extend(messages) + + if len(current_history) > MAX_MSG_RECORDS: + self.chat_history_by_user[self.current_user.id] = current_history[-MAX_MSG_RECORDS:] + else: + if self.current_channel.id not in self.chat_history_by_channel: + self.chat_history_by_channel[self.current_channel.id] = [] + # 添加消息到当前频道 + current_history = self.chat_history_by_channel[self.current_channel.id] + current_history.extend(messages) + # 限制历史记录数量 + if len(current_history) > MAX_MSG_RECORDS: + self.chat_history_by_channel[self.current_channel.id] = current_history[-MAX_MSG_RECORDS:] self.emit_chat_watcher(*messages) def clear_chat_history(self): """清空当前频道的聊天历史""" - if self.current_channel is not None: + if self.current_channel == DIRECT: + self.chat_history_by_user[self.current_user.id] = [] + else: self.chat_history_by_channel[self.current_channel.id] = [] - self.emit_chat_watcher() + self.emit_chat_watcher() def add_chat_watcher(self, watcher: Widget) -> None: self.chat_watchers.append(watcher) diff --git a/nonechat/views/horizontal.py b/nonechat/views/horizontal.py index e370a7c..39ca97e 100644 --- a/nonechat/views/horizontal.py +++ b/nonechat/views/horizontal.py @@ -83,7 +83,8 @@ async def on_sidebar_user_changed(self, event: SidebarUserChanged): # 刷新聊天室显示 await self.chatroom.history.refresh_history() - # 可以在这里添加其他需要更新的组件 + if self.app.storage.is_direct: + self.chatroom.toolbar.center_title.update(self.app.storage.current_user.nickname) async def on_sidebar_channel_changed(self, event: SidebarChannelChanged): """处理频道切换事件""" @@ -91,7 +92,10 @@ async def on_sidebar_channel_changed(self, event: SidebarChannelChanged): await self.chatroom.history.refresh_history() # 更新工具栏标题 - self.chatroom.update_toolbar_title(event.channel.name) + if event.direct: + self.chatroom.toolbar.center_title.update(self.app.storage.current_user.nickname) + else: + self.chatroom.toolbar.center_title.update(event.channel.name) def watch_can_show_log(self, can_show_log: bool): self._toggle_log_panel() From 8695e802c88ff0f3459511a869833ef0a90472c0 Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 14:28:23 +0800 Subject: [PATCH 08/10] :sparkles: support fold sidebar --- main.py | 21 +++++++------ nonechat/components/chatroom/__init__.py | 4 +-- nonechat/components/chatroom/history.py | 6 ++-- nonechat/components/chatroom/input.py | 2 +- nonechat/components/chatroom/message.py | 4 +-- nonechat/components/chatroom/toolbar.py | 39 +++++++++++++++++------- nonechat/components/log/__init__.py | 4 +-- nonechat/components/log/toolbar.py | 17 ++++++----- nonechat/setting.py | 3 +- nonechat/views/horizontal.py | 29 ++++++++++++++++++ 10 files changed, 90 insertions(+), 39 deletions(-) diff --git a/main.py b/main.py index 8ee5a9b..28c38b2 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import sys +from inspect import cleandoc from asyncio import gather, create_task from loguru import logger @@ -88,17 +89,19 @@ async def on_message(event: MessageEvent): channel_name = event.channel.name await send_message(ConsoleMessage([Text(f"当前频道: {channel_name}\n当前用户: {user_name}")])) elif message_text == "help": - help_text = """\ -🤖 可用命令: -- ping - 测试连接 -- inspect - 查看当前频道和用户 -- help - 显示帮助 -- users - 显示所有用户 -- channels - 显示所有频道 -""" + help_text = cleandoc( + """ + 🤖 可用命令: + - ping - 测试连接 + - inspect - 查看当前频道和用户 + - help - 显示帮助 + - users - 显示所有用户 + - channels - 显示所有频道 + """ + ) await send_message(ConsoleMessage([Markdown(help_text)])) elif message_text == "md": - with open("README.md", encoding="utf-8") as md_file: + with open("./README.md", encoding="utf-8") as md_file: md_text = md_file.read() await send_message(ConsoleMessage([Markdown(md_text)])) elif message_text == "users": diff --git a/nonechat/components/chatroom/__init__.py b/nonechat/components/chatroom/__init__.py index fe9edc7..a08b6e1 100644 --- a/nonechat/components/chatroom/__init__.py +++ b/nonechat/components/chatroom/__init__.py @@ -8,7 +8,7 @@ from .history import ChatHistory if TYPE_CHECKING: - from ...app import Frontend + from nonechat.app import Frontend class ChatRoom(Widget): @@ -29,7 +29,7 @@ class ChatRoom(Widget): def __init__(self): super().__init__() self.history = ChatHistory() - self.toolbar = Toolbar(self.app.setting) + self.toolbar = Toolbar() def compose(self): yield self.toolbar diff --git a/nonechat/components/chatroom/history.py b/nonechat/components/chatroom/history.py index cbbf9eb..e46a412 100644 --- a/nonechat/components/chatroom/history.py +++ b/nonechat/components/chatroom/history.py @@ -7,9 +7,9 @@ from .message import Timer, Message if TYPE_CHECKING: - from ...app import Frontend - from ...model import MessageEvent - from ...storage import Storage, StateChange + from nonechat.app import Frontend + from nonechat.model import MessageEvent + from nonechat.storage import Storage, StateChange class ChatHistory(Widget): diff --git a/nonechat/components/chatroom/input.py b/nonechat/components/chatroom/input.py index 327bf35..7467632 100644 --- a/nonechat/components/chatroom/input.py +++ b/nonechat/components/chatroom/input.py @@ -5,7 +5,7 @@ from textual.binding import Binding if TYPE_CHECKING: - from ...app import Frontend + from nonechat.app import Frontend class InputBox(Widget): diff --git a/nonechat/components/chatroom/message.py b/nonechat/components/chatroom/message.py index 3fea152..9b48f77 100644 --- a/nonechat/components/chatroom/message.py +++ b/nonechat/components/chatroom/message.py @@ -5,8 +5,8 @@ from textual.widgets import Static from rich.console import RenderableType -from ...utils import truncate -from ...model import User, MessageEvent +from nonechat.utils import truncate +from nonechat.model import User, MessageEvent class Timer(Widget): diff --git a/nonechat/components/chatroom/toolbar.py b/nonechat/components/chatroom/toolbar.py index 1be3539..49139f8 100644 --- a/nonechat/components/chatroom/toolbar.py +++ b/nonechat/components/chatroom/toolbar.py @@ -3,13 +3,15 @@ from textual.widget import Widget from textual.widgets import Static +from nonechat.router import RouteChange + from ..action import Action -from ...router import RouteChange if TYPE_CHECKING: + from nonechat.app import Frontend + from nonechat.views.horizontal import HorizontalView + from .history import ChatHistory - from ...setting import ConsoleSetting - from ...views.horizontal import HorizontalView class Toolbar(Widget): @@ -42,21 +44,29 @@ class Toolbar(Widget): } """ - def __init__(self, setting: "ConsoleSetting"): + @property + def app(self) -> "Frontend": + return cast("Frontend", super().app) + + def __init__(self): super().__init__() - self.exit_button = Action(setting.toolbar_exit, id="exit", classes="left") - self.clear_button = Action(setting.toolbar_clear, id="clear", classes="left ml") + setting = self.app.setting + self.toggle_sidebar_button = Action(setting.toolbar_fold, id="toggle-sidebar", classes="left") + self.exit_button = Action(setting.toolbar_exit, id="exit", classes="left ml") + self.center_title = Static(setting.room_title, classes="center") - self.settings_button = Action(setting.toolbar_setting, id="settings", classes="right mr") + # self.settings_button = Action(setting.toolbar_setting, id="settings", classes="right mr") + self.clear_button = Action(setting.toolbar_clear, id="clear", classes="right mr") self.log_button = Action(setting.toolbar_log, id="log", classes="right") def compose(self): yield self.exit_button - yield self.clear_button + yield self.toggle_sidebar_button yield self.center_title - yield self.settings_button + # yield self.settings_button + yield self.clear_button yield self.log_button async def on_action_pressed(self, event: Action.Pressed): @@ -66,8 +76,15 @@ async def on_action_pressed(self, event: Action.Pressed): elif event.action == self.clear_button: history: ChatHistory = cast("ChatHistory", self.app.query_one("ChatHistory")) history.action_clear_history() - elif event.action == self.settings_button: - ... + elif event.action == self.toggle_sidebar_button: + view: HorizontalView = cast("HorizontalView", self.app.query_one("HorizontalView")) + view.action_toggle_sidebar() + if view.show_sidebar: + self.toggle_sidebar_button.update(self.app.setting.toolbar_fold) + else: + self.toggle_sidebar_button.update(self.app.setting.toolbar_expand) + # elif event.action == self.settings_button: + # ... elif event.action == self.log_button: view: HorizontalView = cast("HorizontalView", self.app.query_one("HorizontalView")) if view.can_show_log: diff --git a/nonechat/components/log/__init__.py b/nonechat/components/log/__init__.py index 5ac572f..07507a1 100644 --- a/nonechat/components/log/__init__.py +++ b/nonechat/components/log/__init__.py @@ -7,8 +7,8 @@ from rich.console import RenderableType if TYPE_CHECKING: - from ...app import Frontend - from ...storage import Storage, StateChange + from nonechat.app import Frontend + from nonechat.storage import Storage, StateChange MAX_LINES = 1000 diff --git a/nonechat/components/log/toolbar.py b/nonechat/components/log/toolbar.py index 6331ccb..a68f4eb 100644 --- a/nonechat/components/log/toolbar.py +++ b/nonechat/components/log/toolbar.py @@ -1,9 +1,10 @@ from textual.widget import Widget from textual.widgets import Static +from nonechat.router import RouteChange +from nonechat.setting import ConsoleSetting + from ..action import Action -from ...router import RouteChange -from ...setting import ConsoleSetting class Toolbar(Widget): @@ -35,15 +36,15 @@ class Toolbar(Widget): def __init__(self, settings: ConsoleSetting): super().__init__() - self.exit_button = Action(settings.toolbar_exit, id="exit", classes="left") - self.back_button = Action(settings.toolbar_back, id="back", classes="left ml") - self.settings_button = Action(settings.toolbar_setting, id="settings", classes="right") + self.exit_button = Action(settings.toolbar_exit, id="exit", classes="left ml") + self.back_button = Action(settings.toolbar_expand, id="back", classes="left") + # self.settings_button = Action(settings.toolbar_setting, id="settings", classes="right") def compose(self): yield self.exit_button yield self.back_button yield Static("Log", classes="center") - yield self.settings_button + # yield self.settings_button async def on_action_pressed(self, event: Action.Pressed): event.stop() @@ -51,5 +52,5 @@ async def on_action_pressed(self, event: Action.Pressed): self.app.exit() if event.action == self.back_button: self.post_message(RouteChange("main")) - elif event.action == self.settings_button: - ... + # elif event.action == self.settings_button: + # ... diff --git a/nonechat/setting.py b/nonechat/setting.py index 568b2d6..5fb1398 100644 --- a/nonechat/setting.py +++ b/nonechat/setting.py @@ -19,7 +19,8 @@ class ConsoleSetting: toolbar_clear: str = "🗑️" toolbar_setting: str = "⚙️" toolbar_log: str = "📝" - toolbar_back: str = "⏪" + toolbar_fold: str = "⏪" + toolbar_expand: str = "⏩" user_avatar: str = "👤" user_name: str = "User" bot_avatar: str = "🤖" diff --git a/nonechat/views/horizontal.py b/nonechat/views/horizontal.py index 39ca97e..c2dcdfc 100644 --- a/nonechat/views/horizontal.py +++ b/nonechat/views/horizontal.py @@ -52,10 +52,24 @@ class HorizontalView(Widget): width: 25%; display: block; } + + HorizontalView.-hide-sidebar > Sidebar { + display: none; + width: 0; + } + + HorizontalView.-hide-sidebar > ChatRoom { + width: 100%; + } + + HorizontalView.-hide-sidebar.-show-log > ChatRoom { + width: 75%; + } """ can_show_log: Reactive[bool] = Reactive(False) show_log: Reactive[bool] = Reactive(True) + show_sidebar: Reactive[bool] = Reactive(True) def __init__(self): super().__init__() @@ -113,3 +127,18 @@ def _toggle_log_panel(self): show = self.can_show_log and self.show_log self.log_panel.display = show self.set_class(show, "-show-log") + + def watch_show_sidebar(self, show_sidebar: bool): + """监控侧边栏显示状态变化""" + self._toggle_sidebar() + + def _toggle_sidebar(self): + """切换侧边栏显示状态""" + if self.show_sidebar: + self.remove_class("-hide-sidebar") + else: + self.add_class("-hide-sidebar") + + def action_toggle_sidebar(self): + """触发侧边栏显示/隐藏的动作""" + self.show_sidebar = not self.show_sidebar From 5ef7f5125f708b8d0016a2ca37269a74bcd67d54 Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 17:33:45 +0800 Subject: [PATCH 09/10] :beers: use Tabs --- nonechat/components/channel_selector.py | 112 ++++++++++-------------- nonechat/components/sidebar.py | 34 +++---- nonechat/components/user_selector.py | 72 ++++++--------- 3 files changed, 93 insertions(+), 125 deletions(-) diff --git a/nonechat/components/channel_selector.py b/nonechat/components/channel_selector.py index 9772705..77ba2fd 100644 --- a/nonechat/components/channel_selector.py +++ b/nonechat/components/channel_selector.py @@ -2,10 +2,9 @@ from textual.widget import Widget from textual.message import Message -from textual.containers import Vertical -from textual.widgets import Button, Static +from textual.widgets import Label, Button, ListItem, ListView -from ..storage import Channel +from ..model import Channel if TYPE_CHECKING: from ..app import Frontend @@ -30,88 +29,78 @@ class ChannelSelector(Widget): border: round rgba(170, 170, 170, 0.7); padding: 1; margin: 1; - max-height: 15; - overflow-y: auto; } - ChannelSelector .title { - height: 1; - width: 100%; - text-align: center; - text-style: bold; - color: green; - margin-bottom: 1; - } - - ChannelSelector .channel-list { - layout: vertical; + ChannelSelector ListView { height: auto; width: 100%; + max-height: 85%; } - ChannelSelector .channel-button { + ChannelSelector ListItem { width: 100%; - margin-bottom: 1; + margin: 1; + padding: 1; text-align: center; } - ChannelSelector .channel-button.current { - background: darkgreen; - color: white; - } - ChannelSelector .add-channel-button { width: 100%; margin-top: 1; - background: darkblue; + background: darkgreen; color: white; } """ def __init__(self): super().__init__() - self.channel_buttons = {} + self.channel_items: dict[str, tuple[ListItem, Channel]] = {} @property def app(self) -> "Frontend": return cast("Frontend", super().app) def compose(self): - yield Static("📺 频道列表", classes="title") - yield Vertical(classes="channel-list", id="channel-list") + yield ListView(id="channel-list") yield Button("➕ 添加频道", classes="add-channel-button", id="add-channel") - def on_mount(self): - self.update_channel_list() + async def on_mount(self): + await self.update_channel_list() - def update_channel_list(self): + async def update_channel_list(self): """更新频道列表""" - channel_list = self.query_one("#channel-list") - - for channel in self.app.storage.channels: - if channel.id in self.channel_buttons: - button = self.channel_buttons[channel.id][0] - else: - button = Button( - f"{channel.emoji} {channel.name}", classes="channel-button", id=f"channel-{channel.id}" - ) - self.channel_buttons[channel.id] = (button, channel) - channel_list.mount(button) - - # 标记当前频道 - if channel.id == self.app.storage.current_channel.id: - button.add_class("current") - else: - button.remove_class("current") + channel_list = self.query_one("#channel-list", ListView) + await channel_list.clear() + self.channel_items.clear() + + # 假设从 storage 中获取频道列表 + if hasattr(self.app.storage, "channels"): + for channel in self.app.storage.channels: + label = Label(f"{channel.emoji} {channel.name}") + item = ListItem(label, id=f"channel-{channel.id}") + self.channel_items[channel.id] = (item, channel) + await channel_list.append(item) + + # 标记当前频道 + if ( + hasattr(self.app.storage, "current_channel") + and channel.id == self.app.storage.current_channel.id + ): + item.add_class("current") + else: + item.remove_class("current") async def on_button_pressed(self, event: Button.Pressed): """处理按钮点击事件""" if event.button.id == "add-channel": await self._add_new_channel() - elif event.button.id and event.button.id.startswith("channel-"): + + async def on_list_view_selected(self, event: ListView.Selected): + """处理列表项选择事件""" + if event.item and event.item.id and event.item.id.startswith("channel-"): # 查找对应的频道 - for button, channel in self.channel_buttons.values(): - if button == event.button: + for item, channel in self.channel_items.values(): + if item == event.item: self.post_message(ChannelSelectorPressed(channel)) break @@ -124,26 +113,17 @@ async def _add_new_channel(self): channel_id = "".join(random.choices(string.ascii_letters + string.digits, k=8)) # 一些预设的频道 - emojis = ["💬", "🎮", "🎵", "📚", "🎯", "🏆", "🚀", "🌟", "🔥", "💡"] - names = [ - "随机讨论", - "游戏频道", - "音乐分享", - "学习讨论", - "技术交流", - "项目讨论", - "闲聊", - "问答", - "分享", - "创意", - ] + emojis = ["💬", "📢", "🎮", "🎵", "📚", "💻", "🎨", "🌍", "🔧", "⚡"] + names = ["通用", "公告", "游戏", "音乐", "学习", "技术", "艺术", "世界", "工具", "闪电"] new_channel = Channel( id=channel_id, name=random.choice(names), emoji=random.choice(emojis), - description="自动生成的频道", + description=f"这是一个{random.choice(names)}频道", ) - self.app.storage.add_channel(new_channel) - self.update_channel_list() + # 假设 storage 有添加频道的方法 + if hasattr(self.app.storage, "add_channel"): + self.app.storage.add_channel(new_channel) + await self.update_channel_list() diff --git a/nonechat/components/sidebar.py b/nonechat/components/sidebar.py index 47509e8..0c998f5 100644 --- a/nonechat/components/sidebar.py +++ b/nonechat/components/sidebar.py @@ -2,6 +2,7 @@ from textual.widget import Widget from textual.message import Message +from textual.widgets import TabPane, TabbedContent from .user_selector import UserSelector, UserSelectorPressed from .channel_selector import ChannelSelector, ChannelSelectorPressed @@ -32,19 +33,19 @@ class Sidebar(Widget): DEFAULT_CSS = """ Sidebar { - layout: vertical; width: 25%; - height: auto; + height: 100%; border-right: solid rgba(170, 170, 170, 0.7); - padding: 1; } - Sidebar UserSelector { - height: 45%; + TabPane { + padding: 0; } - Sidebar ChannelSelector { - height: 45%; + UserSelector, ChannelSelector { + margin: 1 0 0 0; + border: none; + max-height: 100%; } """ @@ -58,16 +59,19 @@ def app(self) -> "Frontend": return cast("Frontend", super().app) def compose(self): - yield self.user_selector - yield self.channel_selector + with TabbedContent(): + with TabPane("👥 用户列表", id="users"): + yield self.user_selector + with TabPane("📺 频道列表", id="channels"): + yield self.channel_selector def on_user_selector_pressed(self, event: UserSelectorPressed): """处理用户选择事件""" # 更新当前用户 self.app.storage.set_user(event.user) - # 更新用户选择器显示 - self.user_selector.update_user_list() + # 更新用���选择器显示 + # self.user_selector.update_user_list() # 向父组件发送消息 self.post_message(SidebarUserChanged(event.user)) @@ -78,12 +82,12 @@ def on_channel_selector_pressed(self, event: ChannelSelectorPressed): self.app.storage.set_channel(event.channel) # 更新频道选择器显示 - self.channel_selector.update_channel_list() + # self.channel_selector.update_channel_list() # 向父组件发送消息 self.post_message(SidebarChannelChanged(event.channel)) - def update_displays(self): + async def update_displays(self): """更新显示""" - self.user_selector.update_user_list() - self.channel_selector.update_channel_list() + await self.user_selector.update_user_list() + await self.channel_selector.update_channel_list() diff --git a/nonechat/components/user_selector.py b/nonechat/components/user_selector.py index 53ed82b..ed74a3b 100644 --- a/nonechat/components/user_selector.py +++ b/nonechat/components/user_selector.py @@ -2,8 +2,7 @@ from textual.widget import Widget from textual.message import Message -from textual.containers import Vertical -from textual.widgets import Button, Static +from textual.widgets import Label, Button, ListItem, ListView from ..model import User @@ -30,36 +29,21 @@ class UserSelector(Widget): border: round rgba(170, 170, 170, 0.7); padding: 1; margin: 1; - max-height: 15; - overflow-y: auto; } - UserSelector .title { - height: 1; - width: 100%; - text-align: center; - text-style: bold; - color: cyan; - margin-bottom: 1; - } - - UserSelector .user-list { - layout: vertical; + UserSelector ListView { height: auto; width: 100%; + max-height: 85%; } - UserSelector .user-button { + UserSelector ListItem { width: 100%; - margin-bottom: 1; + margin: 1; + padding: 1; text-align: center; } - UserSelector .user-button.current { - background: darkgreen; - color: white; - } - UserSelector .add-user-button { width: 100%; margin-top: 1; @@ -70,48 +54,48 @@ class UserSelector(Widget): def __init__(self): super().__init__() - self.user_buttons: dict[str, tuple[Button, User]] = {} + self.user_items: dict[str, tuple[ListItem, User]] = {} @property def app(self) -> "Frontend": return cast("Frontend", super().app) def compose(self): - yield Static("👥 用户列表", classes="title") - yield Vertical(classes="user-list", id="user-list") + yield ListView(id="user-list") yield Button("➕ 添加用户", classes="add-user-button", id="add-user") - def on_mount(self): - self.update_user_list() + async def on_mount(self): + await self.update_user_list() - def update_user_list(self): + async def update_user_list(self): """更新用户列表""" - user_list = self.query_one("#user-list") - # user_list.remove_children() - # self.user_buttons.clear() + user_list = self.query_one("#user-list", ListView) + await user_list.clear() + self.user_items.clear() for user in self.app.storage.users: - if user.id in self.user_buttons: - button = self.user_buttons[user.id][0] - else: - button = Button(f"{user.avatar} {user.nickname}", classes="user-button", id=f"user-{user.id}") - self.user_buttons[user.id] = (button, user) - user_list.mount(button) + label = Label(f"{user.avatar} {user.nickname}") + item = ListItem(label, id=f"user-{user.id}") + self.user_items[user.id] = (item, user) + await user_list.append(item) # 标记当前用户 if user.id == self.app.storage.current_user.id: - button.add_class("current") + item.add_class("current") else: - button.remove_class("current") + item.remove_class("current") async def on_button_pressed(self, event: Button.Pressed): """处理按钮点击事件""" if event.button.id == "add-user": await self._add_new_user() - elif event.button.id and event.button.id.startswith("user-"): + + async def on_list_view_selected(self, event: ListView.Selected): + """处理列表项选择事件""" + if event.item and event.item.id and event.item.id.startswith("user-"): # 查找对应的用户 - for button, user in self.user_buttons.values(): - if button == event.button: + for item, user in self.user_items.values(): + if item == event.item: self.post_message(UserSelectorPressed(user)) break @@ -125,10 +109,10 @@ async def _add_new_user(self): user_id = "".join(random.choices(string.ascii_letters + string.digits, k=8)) # 一些预设的用户 - avatars = ["👤", "🧑", "👩", "👨", "🧑‍💻", "👩‍💻", "👨‍💻", "🧑‍🎓", "👩‍🎓", "👨‍🎓"] + avatars = ["🟥", "🔴", "🟩", "🟦", "🟨", "🟪", "🟫"] names = ["用户A", "用户B", "用户C", "Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace"] new_user = User(id=user_id, nickname=random.choice(names), avatar=random.choice(avatars)) self.app.storage.add_user(new_user) - self.update_user_list() + await self.update_user_list() From b20044b2bd35828bcd8f6965ce4e292cc329ace6 Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Tue, 8 Jul 2025 17:35:37 +0800 Subject: [PATCH 10/10] :memo: uodate README.md --- README.md | 4 ++++ view.png | Bin 0 -> 58288 bytes 2 files changed, 4 insertions(+) create mode 100644 view.png diff --git a/README.md b/README.md index 3e8205a..514380c 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,7 @@ class ExampleBackend(Backend): app = Frontend(ExampleBackend) app.run() ``` + +## 预览 + +![](./view.png) diff --git a/view.png b/view.png new file mode 100644 index 0000000000000000000000000000000000000000..a80b679a01cc8f693b714954b321570cbc973c12 GIT binary patch literal 58288 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 literal 0 HcmV?d00001