feat: implement client backend

This includes temporary AI-generated frontend scaffolding, none of this will be used in production. It was just used during development for the backend.
This commit is contained in:
2026-10-09 19:46:02 +01:00
parent 88a64768a2
commit 5eb47e45ef
49 changed files with 3576 additions and 17 deletions
+26
View File
@@ -0,0 +1,26 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
// Coloured circle with an initial. Real avatar images (mxc:// media) are a
// planned follow-up; this is also the fallback when a user has no avatar.
export component Avatar inherits Rectangle {
in property <string> initial;
in property <color> tint: Theme.accent;
in property <length> diameter: 40px;
width: diameter;
height: diameter;
border-radius: diameter / 2;
background: tint;
Text {
text: root.initial;
color: white;
font-size: root.diameter * 0.42;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
+24
View File
@@ -0,0 +1,24 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
// Red mention counter ("3", "99+").
export component Badge inherits Rectangle {
in property <int> count;
visible: count > 0;
height: 16px;
width: max(16px, label.preferred-width + 10px);
border-radius: 8px;
background: Theme.danger;
label := Text {
text: root.count > 99 ? "99+" : "\{root.count}";
color: white;
font-size: 11px;
font-weight: 700;
horizontal-alignment: center;
vertical-alignment: center;
}
}
+175
View File
@@ -0,0 +1,175 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { ListView } from "std-widgets.slint";
import { Theme } from "../theme.slint";
import { Icons } from "../icons.slint";
import { ChannelItem, ChannelKind } from "../types.slint";
import { Icon } from "icon.slint";
import { Avatar } from "avatar.slint";
import { Badge } from "badge.slint";
component ChannelRow inherits Rectangle {
in property <ChannelItem> c;
in property <bool> selected;
callback clicked();
height: c.kind == ChannelKind.category ? 34px : (c.is-dm ? 44px : 34px);
opacity: c.joined || c.kind == ChannelKind.category ? 1.0 : 0.5;
// --- category heading ("TEXT CHANNELS") ---
if c.kind == ChannelKind.category: Text {
x: 16px;
y: 0px;
width: parent.width - 32px;
height: parent.height - 4px;
text: c.name;
color: Theme.text-muted;
font-size: 12px;
font-weight: 700;
vertical-alignment: bottom;
overflow: elide;
}
// --- text / voice channel ---
if c.kind != ChannelKind.category && !c.is-dm: Rectangle {
x: 8px;
y: 1px;
width: parent.width - 16px;
height: parent.height - 2px;
border-radius: 4px;
background: root.selected ? Theme.selected-bg : (touch.has-hover ? Theme.hover-bg : transparent);
Icon {
source: c.kind == ChannelKind.voice ? Icons.speaker : Icons.hash;
tint: root.selected || c.unread ? Theme.text-header : Theme.text-muted;
icon-size: 18px;
x: 8px;
y: (parent.height - self.height) / 2;
}
Text {
x: 34px;
width: parent.width - 34px - (c.mentions > 0 ? 40px : 8px);
height: parent.height;
text: c.name;
color: root.selected || c.unread || touch.has-hover ? Theme.text-header : Theme.text-muted;
font-weight: c.unread ? 700 : 500;
vertical-alignment: center;
overflow: elide;
}
Badge {
count: c.mentions;
x: parent.width - self.width - 8px;
y: (parent.height - self.height) / 2;
}
}
// --- direct message ---
if c.is-dm && c.kind != ChannelKind.category: Rectangle {
x: 8px;
y: 1px;
width: parent.width - 16px;
height: parent.height - 2px;
border-radius: 4px;
background: root.selected ? Theme.selected-bg : (touch.has-hover ? Theme.hover-bg : transparent);
Avatar {
initial: c.initial;
tint: c.color;
diameter: 32px;
x: 8px;
y: (parent.height - self.height) / 2;
}
Text {
x: 50px;
width: parent.width - 50px - (c.mentions > 0 ? 40px : 8px);
height: parent.height;
text: c.name;
color: root.selected || c.unread || touch.has-hover ? Theme.text-header : Theme.text-muted;
font-weight: c.unread ? 700 : 500;
vertical-alignment: center;
overflow: elide;
}
Badge {
count: c.mentions;
x: parent.width - self.width - 8px;
y: (parent.height - self.height) / 2;
}
}
touch := TouchArea {
enabled: c.kind != ChannelKind.category;
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component ChannelList inherits Rectangle {
in property <string> title;
in property <bool> is-home;
in property <[ChannelItem]> channels;
in property <string> selected-id;
callback select(string);
callback open-server-menu();
width: Theme.sidebar-width;
background: Theme.sidebar-bg;
VerticalLayout {
// Header: server name (or "Direct Messages")
Rectangle {
height: Theme.header-height;
background: header-touch.has-hover && !root.is-home ? Theme.hover-bg : transparent;
Text {
x: 16px;
width: parent.width - 52px;
height: parent.height;
text: root.title;
color: Theme.text-header;
font-size: 15px;
font-weight: 700;
vertical-alignment: center;
overflow: elide;
}
if !root.is-home: Icon {
source: Icons.chevron-down;
tint: Theme.text-header;
icon-size: 16px;
x: parent.width - 16px - self.width;
y: (parent.height - self.height) / 2;
}
// bottom hairline
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rail-bg;
}
header-touch := TouchArea {
enabled: !root.is-home;
clicked => {
root.open-server-menu();
}
}
}
ListView {
for c in root.channels: ChannelRow {
c: c;
selected: c.id == root.selected-id;
clicked => {
root.select(c.id);
}
}
}
}
}
+141
View File
@@ -0,0 +1,141 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
import { Icons } from "../icons.slint";
import { Icon } from "icon.slint";
import { ToolButton } from "tool-button.slint";
// ToolButton has a fixed height, so wrap it to centre it in the 48px header.
component HeaderTool inherits Rectangle {
in property <image> glyph;
in property <bool> active;
callback clicked();
width: 32px;
ToolButton {
glyph: root.glyph;
active: root.active;
y: (parent.height - self.height) / 2;
clicked => {
root.clicked();
}
}
}
export component ChatHeader inherits Rectangle {
in property <string> room-name;
in property <string> topic;
in property <bool> is-dm;
in property <bool> encrypted;
in-out property <bool> show-members;
callback open-threads();
callback open-pins();
callback open-search();
height: Theme.header-height;
background: Theme.chat-bg;
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rail-bg;
}
// Left: "# name | topic"
HorizontalLayout {
padding-left: 16px;
padding-right: 8px;
spacing: 8px;
Rectangle {
width: 22px;
if !root.is-dm: Icon {
source: Icons.hash;
tint: Theme.text-muted;
icon-size: 22px;
y: (parent.height - self.height) / 2;
}
if root.is-dm: Text {
text: "@";
color: Theme.text-muted;
font-size: 20px;
font-weight: 700;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Text {
text: root.room-name;
color: Theme.text-header;
font-size: 16px;
font-weight: 700;
vertical-alignment: center;
overflow: elide;
width: min(self.preferred-width, 280px);
}
if root.encrypted: Rectangle {
width: 16px;
Icon {
source: Icons.lock;
tint: Theme.text-muted;
icon-size: 14px;
y: (parent.height - self.height) / 2;
}
}
if root.topic != "": Rectangle {
width: 1px;
Rectangle {
width: 1px;
height: 24px;
y: (parent.height - self.height) / 2;
background: Theme.divider;
}
}
if root.topic != "": Text {
text: root.topic;
color: Theme.text-muted;
font-size: 14px;
vertical-alignment: center;
overflow: elide;
}
Rectangle {
width: 0px;
horizontal-stretch: 1;
}
// Right: toolbar. These open panels that aren't built yet (see docs/DISCORD-PARITY.md).
HeaderTool {
glyph: Icons.thread;
clicked => {
root.open-threads();
}
}
HeaderTool {
glyph: Icons.pin;
clicked => {
root.open-pins();
}
}
HeaderTool {
glyph: Icons.users;
active: root.show-members;
clicked => {
root.show-members = !root.show-members;
}
}
HeaderTool {
glyph: Icons.search;
clicked => {
root.open-search();
}
}
}
}
+155
View File
@@ -0,0 +1,155 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
import { Icons } from "../icons.slint";
import { Icon } from "icon.slint";
import { ToolButton } from "tool-button.slint";
// Message box. Enter sends, Shift+Enter inserts a newline (same as Discord).
// The component is deliberately stateless about *what* is being sent: the
// parent owns the reply/edit state and just tells us what banner to show.
export component Composer inherits Rectangle {
in property <string> placeholder;
in property <string> banner; // "Replying to Alice" / "Editing message"; "" hides it
in property <bool> editing;
in property <string> typing-text; // "Bob is typing…"
callback submit(string);
callback cancel-banner();
callback clear-text();
callback typing();
callback attach();
// Parent -> composer requests, as properties so the composer can live inside an `if`.
in property <string> text-to-set;
in property <int> text-nonce; // bump to replace the input text with `text-to-set` (and focus)
in property <int> focus-nonce; // bump to just focus the input
changed text-nonce => {
input.text = root.text-to-set;
input.focus();
}
changed focus-nonce => {
input.focus();
}
// Grows with content up to ~8 lines, like Discord.
property <length> input-height: min(max(input.preferred-height, 24px), 180px);
height: (root.banner != "" ? 32px : 0px) + input-height + 24px + 24px;
background: Theme.chat-bg;
// Reply / edit banner sits above the input and shares its rounded top.
if root.banner != "": Rectangle {
x: 16px;
y: 0px;
width: parent.width - 32px;
height: 32px + 8px;
background: Theme.sidebar-bg;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
Text {
x: 12px;
width: parent.width - 60px;
height: 32px;
text: root.banner;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
overflow: elide;
}
ToolButton {
glyph: Icons.close;
x: parent.width - self.width - 6px;
y: 2px;
clicked => {
root.cancel-banner();
}
}
}
box := Rectangle {
x: 16px;
y: (root.banner != "" ? 32px : 0px);
width: parent.width - 32px;
height: root.input-height + 24px;
background: Theme.input-bg;
border-radius: root.banner != "" ? 0px : 8px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
ToolButton {
glyph: Icons.plus-circle;
x: 6px;
y: parent.height - self.height - 10px;
clicked => {
root.attach();
}
}
FocusScope {
x: 44px;
y: 12px;
width: parent.width - 44px - 52px;
height: root.input-height;
// Intercept Enter before the TextInput turns it into a newline.
capture-key-pressed(event) => {
if (event.text == Key.Return && !event.modifiers.shift) {
if (input.text != "") {
root.submit(input.text);
input.text = "";
}
return accept;
}
return reject;
}
input := TextInput {
width: parent.width;
height: parent.height;
single-line: false;
wrap: word-wrap;
color: Theme.text-normal;
font-size: 15px;
selection-background-color: Theme.accent;
edited => {
root.typing();
}
}
}
if input.text == "": Text {
x: 44px;
y: 12px;
width: parent.width - 100px;
height: 24px;
text: root.placeholder;
color: Theme.text-faint;
font-size: 15px;
vertical-alignment: center;
overflow: elide;
}
ToolButton {
glyph: Icons.smile;
x: parent.width - self.width - 8px;
y: parent.height - self.height - 10px;
// TODO: emoji picker
}
}
// Typing indicator, under the box.
Text {
x: 20px;
y: parent.height - 22px;
width: parent.width - 40px;
height: 18px;
text: root.typing-text;
color: Theme.text-muted;
font-size: 12px;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
}
+14
View File
@@ -0,0 +1,14 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
// A recolourable icon. Usage: `Icon { source: Icons.hash; tint: Theme.text-muted; }`
export component Icon inherits Image {
in property <color> tint: Theme.text-muted;
in property <length> icon-size: 20px;
width: icon-size;
height: icon-size;
colorize: tint;
image-fit: contain;
}
+67
View File
@@ -0,0 +1,67 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { ListView } from "std-widgets.slint";
import { Theme } from "../theme.slint";
import { MemberItem } from "../types.slint";
import { Avatar } from "avatar.slint";
component MemberRow inherits Rectangle {
in property <MemberItem> m;
height: m.is-header ? 36px : 42px;
if m.is-header: Text {
x: 16px;
width: parent.width - 32px;
height: parent.height - 4px;
text: m.name;
color: Theme.text-muted;
font-size: 12px;
font-weight: 700;
vertical-alignment: bottom;
overflow: elide;
}
if !m.is-header: Rectangle {
x: 8px;
y: 1px;
width: parent.width - 16px;
height: parent.height - 2px;
border-radius: 4px;
background: touch.has-hover ? Theme.hover-bg : transparent;
Avatar {
initial: m.initial;
tint: m.color;
diameter: 32px;
x: 8px;
y: (parent.height - self.height) / 2;
}
Text {
x: 50px;
width: parent.width - 58px;
height: parent.height;
text: m.name;
color: touch.has-hover ? Theme.text-header : Theme.text-muted;
font-weight: 500;
vertical-alignment: center;
overflow: elide;
}
touch := TouchArea { }
}
}
export component MemberList inherits Rectangle {
in property <[MemberItem]> members;
width: Theme.member-width;
background: Theme.sidebar-bg;
ListView {
for m in root.members: MemberRow {
m: m;
}
}
}
+399
View File
@@ -0,0 +1,399 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { ListView } from "std-widgets.slint";
import { Theme } from "../theme.slint";
import { Icons } from "../icons.slint";
import { MessageItem, MessageKind, ReactionItem } from "../types.slint";
import { Icon } from "icon.slint";
import { Avatar } from "avatar.slint";
import { ToolButton } from "tool-button.slint";
component ReactionChip inherits Rectangle {
in property <ReactionItem> r;
callback clicked();
height: 24px;
width: label.preferred-width + 20px;
border-radius: 8px;
border-width: 1px;
border-color: r.mine ? Theme.accent : Theme.sidebar-bg;
background: r.mine ? Theme.accent.with-alpha(0.22) : Theme.sidebar-bg;
label := Text {
text: "\{r.key} \{r.count}";
font-size: 13px;
color: r.mine ? Theme.text-header : Theme.text-muted;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
// Floating toolbar shown on message hover (react / reply / edit / delete).
component HoverBar inherits Rectangle {
in property <bool> own;
callback react();
callback reply();
callback edit();
callback delete();
height: 32px;
width: (own ? 4 : 2) * 32px + 8px;
background: Theme.chat-bg;
border-radius: 6px;
border-width: 1px;
border-color: Theme.divider;
HorizontalLayout {
padding: 2px;
spacing: 0px;
ToolButton {
glyph: Icons.smile;
clicked => {
root.react();
}
}
ToolButton {
glyph: Icons.reply;
clicked => {
root.reply();
}
}
if root.own: ToolButton {
glyph: Icons.edit;
clicked => {
root.edit();
}
}
if root.own: ToolButton {
glyph: Icons.trash;
tint: Theme.danger;
clicked => {
root.delete();
}
}
}
}
component ChatMessage inherits Rectangle {
in property <MessageItem> m;
callback reply();
callback edit();
callback delete();
callback react(string);
background: hover.has-hover ? Theme.message-hover-bg : transparent;
// The TouchArea *wraps* the content so hover stays true while the pointer
// is over children (text, chips, the toolbar).
hover := TouchArea {
VerticalLayout {
padding-top: root.m.show-header ? 14px : 1px;
padding-bottom: 2px;
spacing: 2px;
if root.m.has-reply: Rectangle {
height: 20px;
Text {
x: Theme.gutter;
width: parent.width - Theme.gutter - 16px;
height: parent.height;
text: "↳ \{root.m.reply-sender} \{root.m.reply-body}";
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
overflow: elide;
}
}
HorizontalLayout {
padding-left: 16px;
padding-right: 16px;
spacing: 16px;
// Gutter: avatar on the first message of a group, hover-time otherwise.
Rectangle {
width: 40px;
if root.m.show-header: Avatar {
initial: root.m.initial;
tint: root.m.color;
diameter: 40px;
y: 2px;
}
if !root.m.show-header: Text {
visible: hover.has-hover;
text: root.m.short-time;
color: Theme.text-faint;
font-size: 10px;
horizontal-alignment: center;
y: 4px;
}
}
VerticalLayout {
spacing: 2px;
if root.m.show-header: HorizontalLayout {
height: 20px;
spacing: 8px;
alignment: start;
Text {
text: root.m.sender;
color: root.m.color.brighter(0.6);
font-size: 15px;
font-weight: 600;
vertical-alignment: center;
}
Text {
text: root.m.time;
color: Theme.text-faint;
font-size: 12px;
vertical-alignment: center;
}
}
// Read-only TextInput rather than Text: lets people select + copy.
TextInput {
text: root.m.body;
read-only: true;
single-line: false;
wrap: word-wrap;
color: root.m.pending ? Theme.text-muted : Theme.text-normal;
font-size: 15px;
selection-background-color: Theme.accent;
}
if root.m.edited: Text {
text: "(edited)";
color: Theme.text-faint;
font-size: 11px;
}
if root.m.reactions.length > 0: HorizontalLayout {
height: 28px;
padding-top: 4px;
spacing: 4px;
alignment: start;
for r in root.m.reactions: ReactionChip {
r: r;
clicked => {
root.react(r.key);
}
}
}
}
}
}
// Absolutely positioned => excluded from the row's height calculation.
if hover.has-hover && !root.m.pending: HoverBar {
own: root.m.own;
x: parent.width - self.width - 16px;
y: -14px;
react => {
root.react("👍");
}
reply => {
root.reply();
}
edit => {
root.edit();
}
delete => {
root.delete();
}
}
}
}
component DateDivider inherits Rectangle {
in property <string> text;
height: 40px;
Rectangle {
x: 16px;
width: parent.width - 32px;
y: parent.height / 2;
height: 1px;
background: Theme.divider;
}
Rectangle {
width: label.preferred-width + 16px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.chat-bg;
label := Text {
text: root.text;
color: Theme.text-muted;
font-size: 12px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
component NoticeRow inherits Rectangle {
in property <string> text;
height: 28px;
Text {
x: Theme.gutter;
width: parent.width - Theme.gutter - 16px;
height: parent.height;
text: root.text;
color: Theme.text-faint;
font-size: 13px;
vertical-alignment: center;
overflow: elide;
}
}
component RoomStart inherits Rectangle {
in property <string> room-name;
VerticalLayout {
padding: 16px;
padding-top: 32px;
spacing: 6px;
Text {
text: "Welcome to #\{root.room-name}!";
color: Theme.text-header;
font-size: 28px;
font-weight: 700;
wrap: word-wrap;
}
Text {
text: "This is the start of the #\{root.room-name} channel.";
color: Theme.text-muted;
font-size: 15px;
wrap: word-wrap;
}
}
}
component RowSwitch inherits Rectangle {
in property <MessageItem> m;
in property <string> room-name;
callback reply();
callback edit();
callback delete();
callback react(string);
VerticalLayout {
if root.m.kind == MessageKind.message: ChatMessage {
m: root.m;
reply => {
root.reply();
}
edit => {
root.edit();
}
delete => {
root.delete();
}
react(key) => {
root.react(key);
}
}
if root.m.kind == MessageKind.date-divider: DateDivider {
text: root.m.body;
}
if root.m.kind == MessageKind.notice: NoticeRow {
text: root.m.body;
}
if root.m.kind == MessageKind.start-of-room: RoomStart {
room-name: root.room-name;
}
}
}
export component MessageList inherits Rectangle {
in property <[MessageItem]> messages;
in property <string> room-name;
in property <bool> loading-older;
in property <int> room-nonce; // bumped by Rust whenever a different room is opened
callback reply(string, string); // id, sender
callback edit(string, string); // id, body
callback delete(string);
callback react(string, string); // id, key
callback load-older();
// "Stick to bottom": follow new messages unless the user scrolled up.
property <bool> stick: true;
property <length> last-height: 0px;
pure function bottom-y(h: length, v: length) -> length {
return min(0px, v - h);
}
changed room-nonce => {
root.stick = true;
}
background: Theme.chat-bg;
list := ListView {
height: parent.height - 8px; // small gap above the composer
changed content-height => {
if (root.stick) {
self.content-y = bottom-y(self.content-height, self.visible-height);
} else if (self.content-y > -40px && root.last-height > 0px) {
// Older messages were prepended: keep what the user was reading in place.
self.content-y = self.content-y - (self.content-height - root.last-height);
}
root.last-height = self.content-height;
}
changed content-y => {
root.stick = self.content-y <= bottom-y(self.content-height, self.visible-height) + 24px;
if (self.content-y > -24px && self.content-height > self.visible-height && !root.loading-older) {
root.load-older();
}
}
for m in root.messages: RowSwitch {
m: m;
room-name: root.room-name;
reply => {
root.reply(m.id, m.sender);
}
edit => {
root.edit(m.id, m.body);
}
delete => {
root.delete(m.id);
}
react(key) => {
root.react(m.id, key);
}
}
}
if root.loading-older: Rectangle {
y: 8px;
width: 140px;
height: 24px;
x: (parent.width - self.width) / 2;
border-radius: 12px;
background: Theme.popup-bg;
Text {
text: "Loading history…";
color: Theme.text-muted;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
+149
View File
@@ -0,0 +1,149 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { ScrollView } from "std-widgets.slint";
import { Theme } from "../theme.slint";
import { Icons } from "../icons.slint";
import { ServerItem } from "../types.slint";
import { Icon } from "icon.slint";
import { Badge } from "badge.slint";
// One round button on the rail. Morphs from circle to rounded square on hover
// or selection, with a white "pill" on the left edge.
component RailButton inherits Rectangle {
in property <bool> selected;
in property <bool> unread;
in property <int> mentions;
in property <color> idle-color: Theme.chat-bg;
in property <color> active-color: Theme.accent;
callback clicked();
height: 56px;
// Left-edge pill: tall = selected, medium = hovered, dot = unread.
Rectangle {
x: 0px;
width: 4px;
height: root.selected ? 40px : (touch.has-hover ? 20px : (root.unread ? 8px : 0px));
y: (parent.height - self.height) / 2;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
background: Theme.text-header;
animate height { duration: 120ms; }
}
body := Rectangle {
width: 48px;
height: 48px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
clip: true;
border-radius: root.selected || touch.has-hover ? 16px : 24px;
background: root.selected || touch.has-hover ? root.active-color : root.idle-color;
animate border-radius { duration: 120ms; }
@children
}
Badge {
count: root.mentions;
x: body.x + body.width - self.width + 4px;
y: body.y + body.height - self.height + 4px;
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component ServerRail inherits Rectangle {
in property <[ServerItem]> servers;
in property <string> selected-id; // "" = Home (direct messages)
in property <bool> home-unread;
in property <int> home-mentions;
callback select(string);
callback add-server();
width: Theme.rail-width;
background: Theme.rail-bg;
ScrollView {
vertical-scrollbar-policy: always-off;
horizontal-scrollbar-policy: always-off;
VerticalLayout {
padding-top: 8px;
padding-bottom: 8px;
alignment: start;
RailButton {
selected: root.selected-id == "";
unread: root.home-unread;
mentions: root.home-mentions;
clicked => {
root.select("");
}
Icon {
source: Icons.chat;
tint: white;
icon-size: 26px;
// `parent` here is the rail layout (children are projected into RailButton),
// so centre against the button's fixed 48px body instead.
x: (48px - self.width) / 2;
y: (48px - self.height) / 2;
}
}
// separator
Rectangle {
height: 10px;
Rectangle {
width: 32px;
height: 2px;
x: (parent.width - self.width) / 2;
y: 4px;
border-radius: 1px;
background: Theme.divider;
}
}
for s in root.servers: RailButton {
selected: root.selected-id == s.id;
unread: s.unread;
mentions: s.mentions;
idle-color: s.color;
active-color: s.color;
clicked => {
root.select(s.id);
}
Text {
width: 48px;
height: 48px;
text: s.initial;
color: white;
font-size: 20px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
RailButton {
idle-color: Theme.chat-bg;
active-color: Theme.success;
clicked => {
root.add-server();
}
Icon {
source: Icons.plus;
tint: Theme.success;
icon-size: 24px;
x: (48px - self.width) / 2;
y: (48px - self.height) / 2;
}
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
// Single-line text field with placeholder, built on the TextInput primitive so
// it matches the theme instead of the std-widgets style.
export component TextField inherits Rectangle {
in property <string> placeholder;
in property <bool> password;
in-out property <string> text <=> input.text;
callback accepted();
public function focus-field() {
input.focus();
}
height: 40px;
border-radius: 4px;
background: Theme.rail-bg;
border-width: 1px;
border-color: input.has-focus ? Theme.accent : Theme.divider;
input := TextInput {
x: 12px;
y: 0px;
width: parent.width - 24px;
height: parent.height;
vertical-alignment: center;
color: Theme.text-normal;
font-size: 15px;
single-line: true;
input-type: root.password ? InputType.password : InputType.text;
selection-background-color: Theme.accent;
accepted => {
root.accepted();
}
}
if input.text == "": Text {
x: 12px;
width: parent.width - 24px;
height: parent.height;
text: root.placeholder;
color: Theme.text-faint;
font-size: 15px;
vertical-alignment: center;
}
}
+33
View File
@@ -0,0 +1,33 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
import { Icon } from "icon.slint";
// Small square icon button used in headers and hover toolbars.
export component ToolButton inherits Rectangle {
in property <image> glyph;
in property <color> tint: Theme.text-muted;
in property <bool> active: false;
callback clicked();
width: 32px;
height: 28px;
border-radius: 4px;
background: touch.has-hover ? Theme.selected-bg : transparent;
Icon {
source: root.glyph;
icon-size: 18px;
tint: root.active || touch.has-hover ? Theme.text-header : root.tint;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
+95
View File
@@ -0,0 +1,95 @@
/// TEMPORARY AI GENERATED FRONTEND \\\
/// NOT TO BE USED IN PRODUCTION \\\
import { Theme } from "../theme.slint";
import { Icons } from "../icons.slint";
import { Icon } from "icon.slint";
import { Avatar } from "avatar.slint";
import { ToolButton } from "tool-button.slint";
// Bottom-left "you" bar: avatar, name, mute / deafen / settings.
export component UserPanel inherits Rectangle {
in property <string> name;
in property <string> user-id;
in property <string> initial;
in property <color> tint;
in-out property <bool> muted;
in-out property <bool> deafened;
callback open-settings();
callback logout();
height: 52px;
background: #232428;
Avatar {
initial: root.initial;
tint: root.tint;
diameter: 32px;
x: 8px;
y: (parent.height - self.height) / 2;
}
// Presence dot
Rectangle {
width: 12px;
height: 12px;
border-radius: 6px;
background: Theme.success;
border-width: 2px;
border-color: #232428;
x: 8px + 32px - 10px;
y: (parent.height - 32px) / 2 + 32px - 10px;
}
Text {
x: 48px;
y: 10px;
width: 100px;
height: 16px;
text: root.name;
color: Theme.text-header;
font-size: 13px;
font-weight: 600;
overflow: elide;
}
Text {
x: 48px;
y: 26px;
width: 100px;
height: 14px;
text: root.user-id;
color: Theme.text-muted;
font-size: 11px;
overflow: elide;
}
HorizontalLayout {
x: parent.width - self.preferred-width - 4px;
y: (parent.height - 28px) / 2;
spacing: 0px;
// Voice isn't wired yet: these only flip local state for now.
ToolButton {
glyph: Icons.mic;
tint: root.muted ? Theme.danger : Theme.text-muted;
clicked => {
root.muted = !root.muted;
}
}
ToolButton {
glyph: Icons.headphones;
tint: root.deafened ? Theme.danger : Theme.text-muted;
clicked => {
root.deafened = !root.deafened;
}
}
ToolButton {
glyph: Icons.logout;
clicked => {
root.logout();
}
}
}
}