diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ec78b81 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# Aether Project Rules for Claude Code + +You are working on **Project Aether** — a beautiful, fast, native open-source Qt 6 client for the self-hosted Stoat backend. + +**Non-negotiable rules (always follow):** + +- Every source file (.cpp, .qml, CMakeLists.txt) must start with the exact AGPL-3.0 SPDX header: + ```qml + // SPDX-License-Identifier: AGPL-3.0-or-later + // Copyright (C) 2026 Aether Contributors + // + // This file is part of Aether. + // + // Aether is free software: you can redistribute it and/or modify it + // under the terms of the GNU Affero General Public License as published + // by the Free Software Foundation, either version 3 of the License, or + // (at your option) any later version. + +Keep the modular QML structure: separate files in src/qml/components/, src/qml/styles/, src/qml/utils/. +Every QML file must import: + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + + Theme and Constants are singletons — always use Theme.xxx and Constants.xxx. Never use hard-coded colors or sizes. + Never use letterSpacing — use font.letterSpacing. + Never use modelData directly in Repeater — always use model.modelData or named roles. + When fixing errors, change only the necessary lines. Do not rewrite whole files unless asked. + Keep code clean, well-commented, and easily changeable later. + +This is the permanent rule set for Aether. Follow it strictly in every response. \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 159b0cd..15d92c3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -20,11 +20,33 @@ qt_add_executable(aether main.cpp ) +# Mark singletons BEFORE qt_add_qml_module so the generated qmldir +# contains the required "singleton" keyword for each type. +set_source_files_properties( + qml/styles/Theme.qml + PROPERTIES QT_QML_SINGLETON_TYPE TRUE +) + +set_source_files_properties( + qml/utils/Constants.qml + PROPERTIES QT_QML_SINGLETON_TYPE TRUE +) + qt_add_qml_module(aether URI Aether VERSION 1.0 QML_FILES qml/main.qml + qml/MainWindow.qml + qml/components/GuildList.qml + qml/components/ChannelList.qml + qml/components/ChatArea.qml + qml/components/MessageList.qml + qml/components/MessageInput.qml + qml/components/MemberList.qml + qml/components/VoiceBar.qml + qml/styles/Theme.qml + qml/utils/Constants.qml ) target_link_libraries(aether PRIVATE diff --git a/src/qml/MainWindow.qml b/src/qml/MainWindow.qml new file mode 100644 index 0000000..40098c8 --- /dev/null +++ b/src/qml/MainWindow.qml @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Root application window. +/// Layout: [GuildList] | [ChannelList + VoiceBar] | SplitView([ChatArea] | [MemberList]) +ApplicationWindow { + id: root + + title: Constants.appName + width: Constants.minWindowWidth + height: Constants.minWindowHeight + minimumWidth: Constants.minWindowWidth + minimumHeight: Constants.minWindowHeight + visible: true + + Material.theme: Material.Dark + Material.accent: Material.DeepPurple + Material.background: Theme.bgBase + + background: Rectangle { color: Theme.bgBase } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // ── 1. Guild strip (fixed width, not resizable) ─────────────────────── + GuildList { + Layout.preferredWidth: Constants.guildListWidth + Layout.fillHeight: true + } + + // ── 2. Channel list + voice bar (fixed width) ───────────────────────── + ColumnLayout { + Layout.preferredWidth: Constants.channelListWidth + Layout.fillHeight: true + spacing: 0 + + ChannelList { + Layout.fillWidth: true + Layout.fillHeight: true + } + + VoiceBar { + Layout.fillWidth: true + Layout.preferredHeight: Constants.voiceBarHeight + } + } + + // ── 3. Resizable split: chat area + member list ─────────────────────── + SplitView { + Layout.fillWidth: true + Layout.fillHeight: true + orientation: Qt.Horizontal + + // Minimal 1 px handle that brightens on hover + handle: Rectangle { + implicitWidth: 1 + color: SplitHandle.hovered ? Theme.accent : Theme.separator + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + ChatArea { + SplitView.fillWidth: true + SplitView.minimumWidth: 400 + } + + MemberList { + SplitView.preferredWidth: Constants.memberListWidth + SplitView.minimumWidth: 160 + } + } + } +} diff --git a/src/qml/components/ChannelList.qml b/src/qml/components/ChannelList.qml new file mode 100644 index 0000000..1025f1c --- /dev/null +++ b/src/qml/components/ChannelList.qml @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Channel list panel: server name header + collapsible Text / Voice sections. +Rectangle { + id: root + color: Theme.bgMantle + + property int selectedChannel: 0 + property string serverName: "Stoat HQ" + + // ── Placeholder channel data ────────────────────────────────────────────── + // ListModel avoids JS-array modelData scoping issues; channelId used + // instead of id (id is a reserved word in QML ListElement context). + ListModel { + id: textChannelModel + ListElement { channelId: 0; name: "general"; icon: "#"; hasUnread: false } + ListElement { channelId: 1; name: "random"; icon: "#"; hasUnread: true } + ListElement { channelId: 2; name: "announcements"; icon: "#"; hasUnread: false } + ListElement { channelId: 3; name: "dev-talk"; icon: "#"; hasUnread: false } + } + ListModel { + id: voiceChannelModel + ListElement { channelId: 10; name: "General"; icon: "🔊"; members: 3 } + ListElement { channelId: 11; name: "Gaming"; icon: "🔊"; members: 0 } + ListElement { channelId: 12; name: "Music"; icon: "🔊"; members: 1 } + } + + property bool textSectionOpen: true + property bool voiceSectionOpen: true + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // ── Server name header ──────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Constants.headerHeight + color: Theme.bgMantle + + // Subtle bottom shadow line + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: Theme.separator + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.sp16 + anchors.rightMargin: Theme.sp12 + spacing: 0 + + Text { + Layout.fillWidth: true + text: root.serverName + font.pixelSize: Theme.font16 + font.weight: Font.DemiBold + color: Theme.textPrimary + elide: Text.ElideRight + } + + // Settings / chevron icon placeholder + Text { + text: "⌄" + font.pixelSize: Theme.font16 + color: Theme.textMuted + } + } + } + + // ── Scrollable channel sections ─────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + width: root.width + spacing: 0 + + Item { height: Theme.sp8; width: 1 } + + // ── TEXT CHANNELS section ───────────────────────────────────── + SectionHeader { + width: root.width + label: "TEXT CHANNELS" + isOpen: root.textSectionOpen + onToggle: root.textSectionOpen = !root.textSectionOpen + } + + Column { + width: root.width + visible: root.textSectionOpen + spacing: 0 + + Repeater { + model: textChannelModel + delegate: Item { + // Capture model roles here — plain Item delegates + // have full model context; inline components do not. + readonly property int _cid: model.channelId + readonly property string _name: model.name + readonly property string _icon: model.icon + readonly property bool _hasUnread: model.hasUnread + + width: root.width + height: 34 + + ChannelItem { + width: root.width + channelId: _cid + name: _name + icon: _icon + hasUnread: _hasUnread + isSelected: root.selectedChannel === _cid + onClicked: root.selectedChannel = _cid + } + } + } + } + + Item { height: Theme.sp8; width: 1 } + + // ── VOICE CHANNELS section ──────────────────────────────────── + SectionHeader { + width: root.width + label: "VOICE CHANNELS" + isOpen: root.voiceSectionOpen + onToggle: root.voiceSectionOpen = !root.voiceSectionOpen + } + + Column { + width: root.width + visible: root.voiceSectionOpen + spacing: 0 + + Repeater { + model: voiceChannelModel + delegate: Item { + readonly property int _cid: model.channelId + readonly property string _name: model.name + readonly property string _icon: model.icon + + width: root.width + height: 34 + + ChannelItem { + width: root.width + channelId: _cid + name: _name + icon: _icon + hasUnread: false + isSelected: root.selectedChannel === _cid + onClicked: root.selectedChannel = _cid + } + } + } + } + + Item { height: Theme.sp24; width: 1 } + } + } + } + + // ── Inline sub-components ───────────────────────────────────────────────── + + // Section collapsible header + component SectionHeader: Item { + height: 32 + + required property string label + required property bool isOpen + signal toggle + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.sp8 + anchors.rightMargin: Theme.sp12 + spacing: Theme.sp4 + + // Chevron + Text { + text: isOpen ? "▾" : "▸" + font.pixelSize: Theme.font10 + color: Theme.textMuted + } + + Text { + Layout.fillWidth: true + text: label + font.pixelSize: Theme.font11 + font.weight: Font.DemiBold + color: Theme.textMuted + font.letterSpacing: 0.8 + } + + // Add-channel icon placeholder + Text { + text: "+" + font.pixelSize: Theme.font16 + color: Theme.textMuted + opacity: sectionHover.hovered ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Theme.animFast } } + } + } + + HoverHandler { id: sectionHover } + TapHandler { onTapped: parent.toggle() } + } + + // Individual channel row + component ChannelItem: Item { + id: chItem + height: 34 + + required property int channelId + required property string name + required property string icon + required property bool hasUnread + required property bool isSelected + signal clicked + + Rectangle { + anchors.fill: parent + anchors.leftMargin: Theme.sp8 + anchors.rightMargin: Theme.sp8 + radius: Theme.radius4 + color: chItem.isSelected ? Theme.accentSubtle + : chHover.hovered ? Theme.bgHover + : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.sp16 + anchors.rightMargin: Theme.sp12 + spacing: Theme.sp6 + + Text { + text: chItem.icon + font.pixelSize: Theme.font14 + color: chItem.hasUnread ? Theme.textPrimary : Theme.textMuted + } + + Text { + Layout.fillWidth: true + text: chItem.name + font.pixelSize: Theme.font14 + font.weight: chItem.hasUnread ? Font.DemiBold : Font.Normal + color: chItem.isSelected ? Theme.textPrimary + : chItem.hasUnread ? Theme.textPrimary + : chHover.hovered ? Theme.textSecondary + : Theme.textMuted + elide: Text.ElideRight + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + // Unread dot + Rectangle { + width: 6 + height: 6 + radius: 3 + color: Theme.textPrimary + visible: chItem.hasUnread && !chItem.isSelected + } + } + + HoverHandler { id: chHover } + TapHandler { onTapped: chItem.clicked() } + } +} diff --git a/src/qml/components/ChatArea.qml b/src/qml/components/ChatArea.qml new file mode 100644 index 0000000..086c4e9 --- /dev/null +++ b/src/qml/components/ChatArea.qml @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Main chat panel: header + message list + message input. +Rectangle { + id: root + color: Theme.bgSurface + + property string channelName: "general" + property string channelTopic: "General discussion about Aether and Stoat" + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // ── Channel header ──────────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Constants.headerHeight + color: Theme.bgSurface + z: 1 // Render above message list so shadow shows + + // Bottom shadow line + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: Theme.separator + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.sp16 + anchors.rightMargin: Theme.sp16 + spacing: Theme.sp12 + + // Channel icon + name + Text { + text: "#" + font.pixelSize: Theme.font20 + font.weight: Font.Light + color: Theme.textMuted + } + + Text { + text: root.channelName + font.pixelSize: Theme.font16 + font.weight: Font.DemiBold + color: Theme.textPrimary + } + + // Separator + Rectangle { + width: 1 + height: Theme.font16 + color: Theme.border + } + + // Topic + Text { + Layout.fillWidth: true + text: root.channelTopic + font.pixelSize: Theme.font13 + color: Theme.textMuted + elide: Text.ElideRight + } + + // Header action icons + HeaderButton { icon: "🔍"; tooltip: "Search" } + HeaderButton { icon: "📌"; tooltip: "Pinned Messages" } + HeaderButton { icon: "👥"; tooltip: "Member List" } + } + } + + // ── Message list ────────────────────────────────────────────────────── + MessageList { + Layout.fillWidth: true + Layout.fillHeight: true + } + + // ── Message input ───────────────────────────────────────────────────── + MessageInput { + Layout.fillWidth: true + Layout.preferredHeight: Constants.inputAreaHeight + channelName: root.channelName + } + } + + // ── Inline header button component ──────────────────────────────────────── + component HeaderButton: Item { + id: hBtn + width: 32 + height: 32 + + property string icon + property string tooltip + + Rectangle { + anchors.fill: parent + radius: Theme.radius4 + color: hBtnHover.hovered ? Theme.bgHover : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + Text { + anchors.centerIn: parent + text: hBtn.icon + font.pixelSize: Theme.font16 + color: hBtnHover.hovered ? Theme.textSecondary : Theme.textMuted + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + HoverHandler { id: hBtnHover } + + ToolTip.visible: hBtnHover.hovered + ToolTip.text: hBtn.tooltip + ToolTip.delay: 400 + } +} diff --git a/src/qml/components/GuildList.qml b/src/qml/components/GuildList.qml new file mode 100644 index 0000000..69c829d --- /dev/null +++ b/src/qml/components/GuildList.qml @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Vertical strip of guild/server icons on the far left. +/// Each guild shows a circular icon with the guild initial letter. +/// Hovering reveals a pill indicator; the selected guild shows a full pill. +Rectangle { + id: root + color: Theme.bgBase + + property int selectedIndex: 0 + + // ── Placeholder guild data ──────────────────────────────────────────────── + ListModel { + id: guildsModel + ListElement { guildName: "Home"; letter: "H"; iconColor: "#7c6af7" } + ListElement { guildName: "Stoat HQ"; letter: "S"; iconColor: "#a6e3a1" } + ListElement { guildName: "Gaming"; letter: "G"; iconColor: "#f38ba8" } + ListElement { guildName: "Design"; letter: "D"; iconColor: "#fab387" } + ListElement { guildName: "Dev Talk"; letter: "V"; iconColor: "#89dceb" } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Spacer at top + Item { Layout.preferredHeight: Theme.sp8 } + + // Home button separator after first item is handled visually via spacing + + // Guild list + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: true + model: guildsModel + clip: true + spacing: Theme.sp4 + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AlwaysOff } + + delegate: Item { + id: guildItem + width: root.width + height: 64 + + readonly property bool isSelected: root.selectedIndex === model.index + readonly property bool isHovered: hoverHandler.hovered + + // ── Selection pill ──────────────────────────────────────────── + Rectangle { + id: pill + width: 4 + height: guildItem.isSelected ? 40 + : guildItem.isHovered ? 20 + : 0 + radius: 2 + color: Theme.textPrimary + anchors.verticalCenter: parent.verticalCenter + x: 0 + + Behavior on height { + NumberAnimation { duration: Theme.animNormal; easing.type: Easing.OutCubic } + } + } + + // ── Guild icon ──────────────────────────────────────────────── + Rectangle { + id: iconRect + width: Constants.guildIconSize + height: Constants.guildIconSize + radius: guildItem.isSelected || guildItem.isHovered + ? Theme.radius16 : Constants.guildIconSize / 2 + color: model.iconColor + anchors.centerIn: parent + + Behavior on radius { + NumberAnimation { duration: Theme.animNormal; easing.type: Easing.OutCubic } + } + + Text { + anchors.centerIn: parent + text: model.letter + font.pixelSize: Theme.font18 + font.weight: Font.Bold + color: "#ffffff" + } + } + + HoverHandler { id: hoverHandler } + TapHandler { onTapped: root.selectedIndex = model.index } + + ToolTip.visible: hoverHandler.hovered + ToolTip.text: model.guildName + ToolTip.delay: 500 + } + + // Separator after Home (index 0) + footer: Item { + width: root.width + height: Theme.sp8 + + Rectangle { + width: root.width * 0.6 + height: 2 + radius: 1 + color: Theme.border + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.sp4 + } + } + } + + // ── Add server button ───────────────────────────────────────────────── + Item { + Layout.preferredWidth: root.width + Layout.preferredHeight: 64 + + Rectangle { + id: addBtn + width: Constants.guildIconSize + height: Constants.guildIconSize + radius: addHover.hovered ? Theme.radius16 : Constants.guildIconSize / 2 + color: addHover.hovered ? Theme.accent : Theme.bgMantle + anchors.centerIn: parent + + Behavior on radius { NumberAnimation { duration: Theme.animNormal; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: Theme.animNormal } } + + Text { + anchors.centerIn: parent + text: "+" + font.pixelSize: Theme.font22 + font.weight: Font.Light + color: addHover.hovered ? "#ffffff" : Theme.accent + Behavior on color { ColorAnimation { duration: Theme.animNormal } } + } + } + + HoverHandler { id: addHover } + + ToolTip.visible: addHover.hovered + ToolTip.text: "Add a Server" + ToolTip.delay: 500 + } + + Item { Layout.preferredHeight: Theme.sp8 } + } +} diff --git a/src/qml/components/MemberList.qml b/src/qml/components/MemberList.qml new file mode 100644 index 0000000..c5f4963 --- /dev/null +++ b/src/qml/components/MemberList.qml @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Right sidebar listing online/offline members. +Rectangle { + id: root + color: Theme.bgMantle + + // ── Placeholder member data ─────────────────────────────────────────────── + ListModel { + id: membersModel + + // ONLINE + ListElement { username: "alice"; status: "online"; avatarColor: "#7c6af7"; role: "ONLINE" } + ListElement { username: "bob"; status: "idle"; avatarColor: "#a6e3a1"; role: "ONLINE" } + ListElement { username: "charlie"; status: "dnd"; avatarColor: "#f38ba8"; role: "ONLINE" } + ListElement { username: "diana"; status: "online"; avatarColor: "#fab387"; role: "ONLINE" } + ListElement { username: "eve"; status: "online"; avatarColor: "#89dceb"; role: "ONLINE" } + // OFFLINE + ListElement { username: "frank"; status: "offline"; avatarColor: "#585b70"; role: "OFFLINE" } + ListElement { username: "grace"; status: "offline"; avatarColor: "#585b70"; role: "OFFLINE" } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Constants.headerHeight + color: Theme.bgMantle + + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: Theme.separator + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Theme.sp16 + text: "MEMBERS" + font.pixelSize: Theme.font11 + font.weight: Font.DemiBold + color: Theme.textMuted + font.letterSpacing: 0.8 + } + } + + // Member list + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: memberListView + model: membersModel + topMargin: Theme.sp8 + + section.property: "role" + section.delegate: Item { + width: root.width + height: 28 + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Theme.sp16 + text: section + font.pixelSize: Theme.font11 + font.weight: Font.DemiBold + color: Theme.textMuted + font.letterSpacing: 0.8 + } + } + + delegate: Item { + id: memberItem + width: root.width + height: 44 + + readonly property color statusColor: + model.status === "online" ? Theme.statusOnline : + model.status === "idle" ? Theme.statusIdle : + model.status === "dnd" ? Theme.statusDnd : + Theme.statusOffline + + Rectangle { + anchors.fill: parent + anchors.leftMargin: Theme.sp8 + anchors.rightMargin: Theme.sp8 + radius: Theme.radius4 + color: memberHover.hovered ? Theme.bgHover : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.sp12 + anchors.rightMargin: Theme.sp12 + spacing: Theme.sp12 + + // Avatar + status dot + Item { + width: Constants.avatarSm + height: Constants.avatarSm + + Rectangle { + width: Constants.avatarSm + height: Constants.avatarSm + radius: Constants.avatarSm / 2 + color: model.avatarColor + + Text { + anchors.centerIn: parent + text: model.username.charAt(0).toUpperCase() + font.pixelSize: Theme.font13 + font.weight: Font.Bold + color: "#ffffff" + } + } + + // Status dot + Rectangle { + width: 10 + height: 10 + radius: 5 + color: memberItem.statusColor + border.color: Theme.bgMantle + border.width: 2 + anchors.right: parent.right + anchors.bottom: parent.bottom + } + } + + Text { + Layout.fillWidth: true + text: model.username + font.pixelSize: Theme.font14 + color: memberHover.hovered + ? Theme.textPrimary : Theme.textSecondary + elide: Text.ElideRight + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + } + + HoverHandler { id: memberHover } + } + } + } + } +} diff --git a/src/qml/components/MessageInput.qml b/src/qml/components/MessageInput.qml new file mode 100644 index 0000000..b343f53 --- /dev/null +++ b/src/qml/components/MessageInput.qml @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Message composition bar at the bottom of the chat area. +Item { + id: root + height: Constants.inputAreaHeight + + property string channelName: "general" + signal messageSent(string text) + + Rectangle { + anchors.fill: parent + anchors.leftMargin: Theme.sp16 + anchors.rightMargin: Theme.sp16 + anchors.topMargin: Theme.sp12 + anchors.bottomMargin: Theme.sp12 + radius: Theme.radius8 + color: Theme.bgOverlay + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.sp4 + anchors.rightMargin: Theme.sp4 + spacing: 0 + + // ── Attachment / add button ─────────────────────────────────────── + IconButton { + text: "+" + tooltip: "Upload File" + } + + // ── Text input ──────────────────────────────────────────────────── + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + TextArea { + id: textInput + placeholderText: "Message #" + root.channelName + placeholderTextColor: Theme.textMuted + color: Theme.textPrimary + font.pixelSize: Theme.font14 + background: null + wrapMode: TextInput.Wrap + verticalAlignment: TextEdit.AlignVCenter + leftPadding: Theme.sp8 + rightPadding: Theme.sp8 + topPadding: Theme.sp12 + bottomPadding: Theme.sp12 + + Keys.onReturnPressed: (event) => { + if (!(event.modifiers & Qt.ShiftModifier) && text.trim() !== "") { + root.messageSent(text.trim()) + text = "" + event.accepted = true + } + } + } + } + + // ── Emoji button ────────────────────────────────────────────────── + IconButton { + text: "🙂" + tooltip: "Emoji" + } + + // ── Send button ─────────────────────────────────────────────────── + IconButton { + text: "➤" + tooltip: "Send Message" + enabled: textInput.text.trim() !== "" + opacity: enabled ? 1.0 : 0.4 + Behavior on opacity { NumberAnimation { duration: Theme.animFast } } + onClicked: { + if (textInput.text.trim() !== "") { + root.messageSent(textInput.text.trim()) + textInput.text = "" + } + } + } + } + } + + // ── Inline icon button component ────────────────────────────────────────── + component IconButton: Item { + id: iconBtn + width: 40 + height: 40 + + property string text + property string tooltip + signal clicked + + Rectangle { + anchors.fill: parent + anchors.margins: Theme.sp4 + radius: Theme.radius4 + color: btnHover.hovered ? Theme.bgHover : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + Text { + anchors.centerIn: parent + text: iconBtn.text + font.pixelSize: Theme.font16 + color: btnHover.hovered ? Theme.textSecondary : Theme.textMuted + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + HoverHandler { id: btnHover } + TapHandler { onTapped: iconBtn.clicked() } + + ToolTip.visible: btnHover.hovered + ToolTip.text: iconBtn.tooltip + ToolTip.delay: 400 + } +} diff --git a/src/qml/components/MessageList.qml b/src/qml/components/MessageList.qml new file mode 100644 index 0000000..7c758f8 --- /dev/null +++ b/src/qml/components/MessageList.qml @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Scrollable list of chat messages. +/// Groups consecutive messages from the same author (compact display). +Item { + id: root + + // ── Placeholder message data ────────────────────────────────────────────── + ListModel { + id: messagesModel + ListElement { + author: "alice" + avatarColor: "#7c6af7" + content: "Hey everyone! Welcome to the Aether client. 🎉" + timestamp: "Today at 09:00" + isFirst: true + } + ListElement { + author: "bob" + avatarColor: "#a6e3a1" + content: "This looks amazing! Really clean interface." + timestamp: "Today at 09:02" + isFirst: true + } + ListElement { + author: "bob" + avatarColor: "#a6e3a1" + content: "Can't wait to connect it to a real Stoat server." + timestamp: "Today at 09:02" + isFirst: false + } + ListElement { + author: "charlie" + avatarColor: "#f38ba8" + content: "The dark theme is 🔥. Love the purple accent." + timestamp: "Today at 09:05" + isFirst: true + } + ListElement { + author: "alice" + avatarColor: "#7c6af7" + content: "Thanks! Built with Qt 6 + QML + Material. Fully native." + timestamp: "Today at 09:07" + isFirst: true + } + ListElement { + author: "alice" + avatarColor: "#7c6af7" + content: "AGPL-3.0 licensed, open source, and self-hostable." + timestamp: "Today at 09:07" + isFirst: false + } + ListElement { + author: "diana" + avatarColor: "#fab387" + content: "This is exactly what the self-hosting community needed. 💜" + timestamp: "Today at 09:10" + isFirst: true + } + } + + ListView { + id: listView + anchors.fill: parent + model: messagesModel + clip: true + topMargin: Theme.sp16 + bottomMargin: Theme.sp8 + + // Auto-scroll to bottom when new messages arrive + onCountChanged: Qt.callLater(() => listView.positionViewAtEnd()) + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Item { + id: msgItem + width: listView.width + height: msgLayout.implicitHeight + (model.isFirst ? Theme.sp16 : Theme.sp2) + + readonly property bool isHovered: msgHover.hovered + + // Hover highlight + Rectangle { + anchors.fill: parent + color: msgItem.isHovered ? Theme.bgHover : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFast } } + } + + RowLayout { + id: msgLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: model.isFirst ? Theme.sp16 : Theme.sp2 + anchors.leftMargin: Theme.sp16 + anchors.rightMargin: Theme.sp16 + spacing: Theme.sp12 + + // ── Avatar (only on first message in a group) ───────────────── + Item { + width: Constants.avatarMd + height: Constants.avatarMd + Layout.alignment: Qt.AlignTop + + Rectangle { + anchors.fill: parent + radius: Constants.avatarMd / 2 + color: model.avatarColor + visible: model.isFirst + + Text { + anchors.centerIn: parent + text: model.author.charAt(0).toUpperCase() + font.pixelSize: Theme.font16 + font.weight: Font.Bold + color: "#ffffff" + } + } + + // Hover timestamp for compact messages (no avatar) + Text { + anchors.centerIn: parent + text: model.timestamp.split(" ").slice(-1)[0] // HH:MM + font.pixelSize: Theme.font10 + color: Theme.textMuted + visible: !model.isFirst && msgItem.isHovered + } + } + + // ── Message body ────────────────────────────────────────────── + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.sp2 + + // Author + timestamp (first message only) + RowLayout { + visible: model.isFirst + spacing: Theme.sp8 + + Text { + text: model.author + font.pixelSize: Theme.font14 + font.weight: Font.DemiBold + color: Theme.textPrimary + } + + Text { + text: model.timestamp + font.pixelSize: Theme.font11 + color: Theme.textMuted + } + } + + // Message content + Text { + Layout.fillWidth: true + text: model.content + font.pixelSize: Theme.font14 + color: Theme.textSecondary + wrapMode: Text.Wrap + lineHeight: 1.375 + } + } + } + + HoverHandler { id: msgHover } + } + } +} diff --git a/src/qml/components/VoiceBar.qml b/src/qml/components/VoiceBar.qml new file mode 100644 index 0000000..eb95d1b --- /dev/null +++ b/src/qml/components/VoiceBar.qml @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 + +/// Bottom bar of the channel list column. +/// Shows current voice connection status and quick-action buttons. +Rectangle { + id: root + color: Theme.bgBase + + // ── State ───────────────────────────────────────────────────────────────── + property bool inVoice: true + property bool muted: false + property bool deafened: false + property string channelName: "General" + property string username: "me" + + // Top separator + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: Theme.separator + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.sp8 + anchors.rightMargin: Theme.sp8 + spacing: Theme.sp4 + + // ── User avatar + name + status ─────────────────────────────────────── + Rectangle { + width: 32 + height: 32 + radius: 16 + color: Theme.accent + + Text { + anchors.centerIn: parent + text: root.username.charAt(0).toUpperCase() + font.pixelSize: Theme.font13 + font.weight: Font.Bold + color: "#ffffff" + } + + // Online status dot + Rectangle { + width: 10 + height: 10 + radius: 5 + color: Theme.statusOnline + border.color: Theme.bgBase + border.width: 2 + anchors.right: parent.right + anchors.bottom: parent.bottom + } + } + + // Name + voice channel label + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + text: root.username + font.pixelSize: Theme.font13 + font.weight: Font.DemiBold + color: Theme.textPrimary + elide: Text.ElideRight + } + + Text { + visible: root.inVoice + text: "🔊 " + root.channelName + font.pixelSize: Theme.font11 + color: Theme.statusOnline + elide: Text.ElideRight + } + } + + // ── Mute button ─────────────────────────────────────────────────────── + VoiceButton { + icon: root.muted ? "🚫" : "🎤" + active: !root.muted + tooltip: root.muted ? "Unmute" : "Mute" + onClicked: root.muted = !root.muted + } + + // ── Deafen button ───────────────────────────────────────────────────── + VoiceButton { + icon: root.deafened ? "🔕" : "🎧" + active: !root.deafened + tooltip: root.deafened ? "Undeafen" : "Deafen" + onClicked: root.deafened = !root.deafened + } + + // ── Settings button ─────────────────────────────────────────────────── + VoiceButton { + icon: "⚙" + active: true + tooltip: "User Settings" + } + } + + // ── Inline button component ─────────────────────────────────────────────── + component VoiceButton: Rectangle { + id: vBtn + width: 28 + height: 28 + radius: Theme.radius4 + + required property string icon + required property bool active + required property string tooltip + + signal clicked + + color: btnHover.hovered ? Theme.bgHover : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFast } } + + Text { + anchors.centerIn: parent + text: vBtn.icon + font.pixelSize: Theme.font14 + color: vBtn.active ? Theme.textSecondary : Theme.statusDnd + } + + HoverHandler { id: btnHover } + TapHandler { onTapped: vBtn.clicked() } + + ToolTip.visible: btnHover.hovered + ToolTip.text: vBtn.tooltip + ToolTip.delay: 400 + } +} diff --git a/src/qml/main.qml b/src/qml/main.qml index da7da54..4c38bcc 100644 --- a/src/qml/main.qml +++ b/src/qml/main.qml @@ -16,27 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with Aether. If not, see . -import QtQuick -import QtQuick.Controls.Material +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Aether 1.0 -ApplicationWindow { - id: root - - title: qsTr("Aether") - width: 1280 - height: 800 - minimumWidth: 1280 - minimumHeight: 800 - visible: true - - Material.theme: Material.Dark - Material.accent: Material.DeepPurple - - Text { - anchors.centerIn: parent - text: qsTr("Aether is loading…") - color: "#ffffff" - font.pixelSize: 24 - font.family: "Segoe UI, SF Pro Display, Inter, sans-serif" - } -} +// Entry point — instantiates the application's main window. +MainWindow {} diff --git a/src/qml/styles/Theme.qml b/src/qml/styles/Theme.qml new file mode 100644 index 0000000..0b31b5e --- /dev/null +++ b/src/qml/styles/Theme.qml @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +pragma Singleton +import QtQuick 2.15 + +/// Central design-token store for the Aether UI. +/// Usage: Theme.bgBase, Theme.accent, Theme.font14, etc. +QtObject { + + // ── Backgrounds ─────────────────────────────────────────────────────────── + readonly property color bgBase: "#181825" // Guild strip (darkest) + readonly property color bgMantle: "#1e1e2e" // Channel list / panels + readonly property color bgSurface: "#313244" // Chat area + readonly property color bgOverlay: "#45475a" // Input fields, elevated cards + readonly property color bgHover: "#2d2d44" // Generic hover + + // ── Accent (deep purple) ────────────────────────────────────────────────── + readonly property color accent: "#7c6af7" + readonly property color accentHover: "#9d8fff" + readonly property color accentPressed: "#6355d6" + readonly property color accentSubtle: "#33285c" // Selected row tint + + // ── Text ────────────────────────────────────────────────────────────────── + readonly property color textPrimary: "#cdd6f4" + readonly property color textSecondary: "#a6adc8" + readonly property color textMuted: "#6c7086" + readonly property color textHeader: "#bac2de" + readonly property color textLink: "#89b4fa" + + // ── Status ──────────────────────────────────────────────────────────────── + readonly property color statusOnline: "#a6e3a1" + readonly property color statusIdle: "#f9e2af" + readonly property color statusDnd: "#f38ba8" + readonly property color statusOffline: "#585b70" + + // ── Borders / separators ────────────────────────────────────────────────── + readonly property color border: "#313244" + readonly property color separator: "#1e1e2e" + + // ── Spacing (px) ────────────────────────────────────────────────────────── + readonly property int sp2: 2 + readonly property int sp4: 4 + readonly property int sp6: 6 + readonly property int sp8: 8 + readonly property int sp12: 12 + readonly property int sp16: 16 + readonly property int sp20: 20 + readonly property int sp24: 24 + + // ── Border radii ────────────────────────────────────────────────────────── + readonly property int radius4: 4 + readonly property int radius8: 8 + readonly property int radius12: 12 + readonly property int radius16: 16 + + // ── Font sizes (px) ─────────────────────────────────────────────────────── + readonly property int font10: 10 + readonly property int font11: 11 + readonly property int font12: 12 + readonly property int font13: 13 + readonly property int font14: 14 + readonly property int font16: 16 + readonly property int font18: 18 + readonly property int font20: 20 + readonly property int font22: 22 + + // ── Transition durations (ms) ───────────────────────────────────────────── + readonly property int animFast: 100 + readonly property int animNormal: 180 + readonly property int animSlow: 300 +} diff --git a/src/qml/utils/Constants.qml b/src/qml/utils/Constants.qml new file mode 100644 index 0000000..fb36964 --- /dev/null +++ b/src/qml/utils/Constants.qml @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Aether Contributors +// +// This file is part of Aether. +// +// Aether is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Aether is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public +// License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Aether. If not, see . + +pragma Singleton +import QtQuick 2.15 + +/// App-wide layout dimensions and configuration defaults. +/// Usage: Constants.guildListWidth, Constants.appName, etc. +QtObject { + + readonly property string appName: "Aether" + readonly property string appVersion: "0.1.0" + + // ── Fixed layout dimensions ─────────────────────────────────────────────── + readonly property int guildListWidth: 72 + readonly property int channelListWidth: 240 + readonly property int memberListWidth: 240 + readonly property int voiceBarHeight: 52 + readonly property int headerHeight: 48 + readonly property int inputAreaHeight: 72 + + // ── Window constraints ──────────────────────────────────────────────────── + readonly property int minWindowWidth: 1280 + readonly property int minWindowHeight: 800 + + // ── Avatar sizes ────────────────────────────────────────────────────────── + readonly property int avatarMd: 40 + readonly property int avatarSm: 32 + readonly property int guildIconSize: 48 +}