feat(ui): complete modular main UI skeleton (guild list, channels, chat area, members, voice bar)

This commit is contained in:
ApollonG 2026-03-30 21:16:57 +03:00
parent a3650a8354
commit 9a248071c7
13 changed files with 1569 additions and 23 deletions

34
CLAUDE.md Normal file
View file

@ -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.

View file

@ -20,11 +20,33 @@ qt_add_executable(aether
main.cpp 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 qt_add_qml_module(aether
URI Aether URI Aether
VERSION 1.0 VERSION 1.0
QML_FILES QML_FILES
qml/main.qml 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 target_link_libraries(aether PRIVATE

94
src/qml/MainWindow.qml Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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() }
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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 }
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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 }
}
}
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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 }
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}
}

View file

@ -16,27 +16,11 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with Aether. If not, see <https://www.gnu.org/licenses/>. // along with Aether. If not, see <https://www.gnu.org/licenses/>.
import QtQuick import QtQuick 2.15
import QtQuick.Controls.Material import QtQuick.Controls 2.15
import QtQuick.Controls.Material 2.15
import QtQuick.Layouts 1.15
import Aether 1.0
ApplicationWindow { // Entry point instantiates the application's main window.
id: root MainWindow {}
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"
}
}

87
src/qml/styles/Theme.qml Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}