M1 step 5: IRC App

- Chat: header (Buffer n/N, unread elsewhere, topic or connection
  state), wrapped hh:mm <nick> lines (own in accent, Mentions green,
  info grey), input line; Tab cycles Buffers, Fn+Up/Down scrolls back,
  Enter sends, Back leaves while the IRC Service keeps running
- /settings: server form (host, port, TLS, self-signed pinning, nick,
  SASL, NickServ, auto-join); Save & reconnect restarts the session
- Opening the App starts the IRC Service; viewing a Buffer clears its
  unread count; IrcSession gains a revision counter for redraws

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 15:14:59 +02:00
co-authored by Claude Opus 5.5
parent 74a341418e
commit 076baa77d1
8 changed files with 397 additions and 3 deletions
+304
View File
@@ -0,0 +1,304 @@
#include "irc_app.h"
#include "ui/fonts.h"
#include "ui/widgets.h"
namespace roro {
using Status = IrcService::Status;
namespace {
const char* statusText(Status s) {
switch (s) {
case Status::Stopped: return "disconnected";
case Status::WaitingForWifi: return "waiting for Wi-Fi";
case Status::Connecting: return "connecting...";
case Status::Registering: return "logging in...";
case Status::Online: return "";
case Status::Paused: return "paused (Wi-Fi monitoring)";
case Status::Retrying: return "retrying soon";
}
return "";
}
uint16_t lineColor(const IrcLine& l, bool mention) {
switch (l.kind) {
case IrcLine::Kind::Own:
case IrcLine::Kind::OwnAction: return theme::kAccent;
case IrcLine::Kind::Info:
case IrcLine::Kind::Notice: return theme::kMuted;
default: return mention ? theme::kMessage : theme::kText;
}
}
std::string lowered(std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
} // namespace
void IrcApp::onEnter() {
page_ = Page::Chat;
editing_ = false;
irc_.connect();
view(current_);
}
void IrcApp::onExit() {
irc_.withSession([](IrcSession& s) { s.setViewing(-1); });
}
void IrcApp::view(int buffer) {
irc_.withSession([&](IrcSession& s) {
if (buffer < 0 || buffer >= s.bufferCount()) buffer = 0;
current_ = buffer;
s.setViewing(buffer);
});
scroll_ = 0;
requestRedraw();
}
void IrcApp::warn(const std::string& text) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
}
void IrcApp::update(uint32_t) {
uint32_t revision = irc_.withSession([](IrcSession& s) { return s.revision(); });
Status status = irc_.status();
if (revision != seenRevision_ || status != seenStatus_) {
seenRevision_ = revision;
seenStatus_ = status;
requestRedraw();
}
}
bool IrcApp::onKey(const KeyEvent& e) {
requestRedraw();
return page_ == Page::Chat ? onChatKey(e) : onSettingsKey(e);
}
bool IrcApp::onChatKey(const KeyEvent& e) {
switch (e.key) {
case Key::Char: input_.insert(e.ch); break;
case Key::Delete: input_.backspace(); break;
case Key::Left: input_.left(); break;
case Key::Right: input_.right(); break;
case Key::Up: scroll_++; break; // older
case Key::Down: if (scroll_ > 0) scroll_--; break;
case Key::Tab: {
int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); });
view((current_ + 1) % count);
break;
}
case Key::Select: {
std::string text = input_.text();
if (text.empty()) break;
input_.setText("");
scroll_ = 0;
if (lowered(text) == "/settings") {
page_ = Page::Settings;
fields_.setCount(kFields);
break;
}
if (!irc_.running()) irc_.connect(); // e.g. typing after /quit reconnects
irc_.withSession([&](IrcSession& s) { s.input(current_, text, clock_.utcNow()); });
// /msg or /join may have opened a Buffer: follow a new private chat.
if (lowered(text).rfind("/msg ", 0) == 0) {
std::string who = text.substr(5, text.find(' ', 5) == std::string::npos ? std::string::npos : text.find(' ', 5) - 5);
irc_.withSession([&](IrcSession& s) {
for (int i = 0; i < s.bufferCount(); i++)
if (lowered(s.buffer(i).name) == lowered(who)) current_ = i;
s.setViewing(current_);
});
}
break;
}
default: return false; // Back leaves the App; IRC keeps running
}
return true;
}
std::string IrcApp::fieldLabel(int f) const {
static const char* labels[] = {"Server", "Port", "TLS", "Self-signed", "Nick", "SASL user", "SASL password",
"NickServ password", "Auto-join", "Save & reconnect"};
return labels[f];
}
std::string IrcApp::fieldValue(int f) const {
const IrcConfig& c = irc_.config();
auto secret = [](const std::string& s) { return s.empty() ? std::string("-") : std::string("(set)"); };
switch (f) {
case kHost: return c.host;
case kPort: return std::to_string(c.port);
case kTls: return c.tls ? "On" : "Off";
case kSelfSigned: return c.allowSelfSigned ? (c.pinnedSha256.empty() ? "Pin on first use" : "Pinned") : "Off";
case kNick: return c.nick;
case kSaslUser: return c.saslUser.empty() ? "-" : c.saslUser;
case kSaslPass: return secret(c.saslPassword);
case kNickserv: return secret(c.nickservPassword);
case kAutojoin: return c.autojoin.empty() ? "-" : IrcConfig::formatChannels(c.autojoin);
default: return "";
}
}
void IrcApp::startEditing(int f) {
const IrcConfig& c = irc_.config();
fieldEditor_ = LineEditor(f == kAutojoin ? 200 : 63);
switch (f) {
case kHost: fieldEditor_.setText(c.host); break;
case kPort: fieldEditor_.setText(std::to_string(c.port)); break;
case kNick: fieldEditor_.setText(c.nick); break;
case kSaslUser: fieldEditor_.setText(c.saslUser); break;
case kAutojoin: fieldEditor_.setText(IrcConfig::formatChannels(c.autojoin)); break;
default: break; // passwords start empty
}
editing_ = true;
}
void IrcApp::finishEditing() {
IrcConfig& c = irc_.config();
const std::string& v = fieldEditor_.text();
switch (fields_.selected()) {
case kHost: c.host = v; break;
case kPort: c.port = std::atoi(v.c_str()); break;
case kNick: c.nick = v; break;
case kSaslUser: c.saslUser = v; break;
case kSaslPass: c.saslPassword = v; break;
case kNickserv: c.nickservPassword = v; break;
case kAutojoin: c.autojoin = IrcConfig::parseChannels(v); break;
}
editing_ = false;
}
bool IrcApp::onSettingsKey(const KeyEvent& e) {
if (editing_) {
switch (e.key) {
case Key::Char: fieldEditor_.insert(e.ch); break;
case Key::Delete: fieldEditor_.backspace(); break;
case Key::Left: fieldEditor_.left(); break;
case Key::Right: fieldEditor_.right(); break;
case Key::Select: finishEditing(); break;
case Key::Back: editing_ = false; break;
default: break;
}
return true;
}
IrcConfig& c = irc_.config();
switch (e.key) {
case Key::Up: fields_.up(); break;
case Key::Down: fields_.down(); break;
case Key::Back:
c.load(c.nick); // discard unsaved edits
page_ = Page::Chat;
break;
case Key::Select:
switch (fields_.selected()) {
case kTls: c.tls = !c.tls; break;
case kSelfSigned:
c.allowSelfSigned = !c.allowSelfSigned;
c.pinnedSha256.clear();
break;
case kSave: {
std::string error = irc_.saveConfig();
if (!error.empty()) {
warn(error);
break;
}
page_ = Page::Chat;
view(0);
break;
}
default: startEditing(fields_.selected()); break;
}
break;
default: break;
}
return true;
}
void IrcApp::draw(Canvas& c) {
if (page_ == Page::Chat) drawChat(c);
else drawSettings(c);
}
void IrcApp::drawChat(Canvas& c) {
const auto& area = theme::kContent;
const int inputH = theme::kLineHeight + 4;
const int headerH = 11;
const theme::Rect messages{area.x, area.y + headerH, area.w, area.h - headerH - inputH - 1};
std::vector<std::pair<std::string, uint16_t>> lines; // wrapped, oldest first
std::string header;
irc_.withSession([&](IrcSession& s) {
if (current_ >= s.bufferCount()) current_ = 0;
const IrcBuffer& b = s.buffer(current_);
header = b.name + " " + std::to_string(current_ + 1) + "/" + std::to_string(s.bufferCount());
int others = s.totalUnread() - b.unread;
if (others > 0) header += " [" + std::to_string(others) + "]";
std::string state = statusText(irc_.status());
header += " " + (state.empty() ? b.topic : state);
auto measure = widgets::bodyMeasure(c);
std::string me = lowered(s.nick());
for (const IrcLine& l : b.lines) {
std::string when = l.utc >= 0 ? ClockModel::formatLocalTime(l.utc) + " " : "";
std::string text;
switch (l.kind) {
case IrcLine::Kind::Action:
case IrcLine::Kind::OwnAction: text = "* " + l.nick + " " + l.text; break;
case IrcLine::Kind::Notice: text = "-" + l.nick + "- " + l.text; break;
case IrcLine::Kind::Info: text = l.text; break;
default: text = "<" + l.nick + "> " + l.text; break;
}
bool mention = l.kind == IrcLine::Kind::Message && lowered(l.text).find(me) != std::string::npos;
for (auto& w : wrapText(when + text, messages.w - 8, measure)) lines.push_back({w, lineColor(l, mention)});
}
});
// Header
c.setFont(&fonts::small);
c.setTextColor(theme::kAccent);
c.setClipRect(area.x, area.y, area.w, headerH);
c.drawString(header.c_str(), 3, area.y + 2);
c.clearClipRect();
// Messages, newest at the bottom, scrolled back by scroll_ lines.
int rows = messages.h / theme::kLineHeight;
int total = static_cast<int>(lines.size());
if (scroll_ > total - rows) scroll_ = total > rows ? total - rows : 0;
int first = total - rows - scroll_;
if (first < 0) first = 0;
c.setFont(&fonts::body);
c.setClipRect(messages.x, messages.y, messages.w, messages.h);
for (int r = 0; r < rows && first + r < total; r++) {
c.setTextColor(lines[first + r].second);
c.drawString(lines[first + r].first.c_str(), 4, messages.y + r * theme::kLineHeight + 1);
}
c.clearClipRect();
if (scroll_ > 0) {
c.setFont(&fonts::small);
c.setTextColor(theme::kWarning);
c.drawString(("^ " + std::to_string(scroll_)).c_str(), area.w - 30, messages.y + 1);
}
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
}
void IrcApp::drawSettings(Canvas& c) {
const auto& area = theme::kContent;
if (editing_) {
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro #meshtastic" : "Enter: OK `: cancel", 4,
area.y + 44);
return;
}
widgets::list(
c, fields_, area, [this](int i) { return fieldLabel(i); }, [this](int i) { return fieldValue(i); });
}
} // namespace roro
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <string>
#include "app.h"
#include "event_bus.h"
#include "line_editor.h"
#include "list_model.h"
#include "services/irc_service.h"
#include "ui/theme.h"
namespace roro {
// IRC: one Buffer at a time with an input line; /settings opens the server form. Opening the App
// starts the IRC Service, which keeps running after the App is left.
class IrcApp : public App {
public:
IrcApp(IrcService& irc, ClockService& clock, EventBus& bus) : irc_(irc), clock_(clock), bus_(bus) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
bool textEntryActive() const override { return page_ == Page::Chat || editing_; }
void draw(Canvas& c) override;
private:
enum class Page { Chat, Settings };
enum Field { kHost, kPort, kTls, kSelfSigned, kNick, kSaslUser, kSaslPass, kNickserv, kAutojoin, kSave, kFields };
bool onChatKey(const KeyEvent& e);
bool onSettingsKey(const KeyEvent& e);
void view(int buffer);
void drawChat(Canvas& c);
void drawSettings(Canvas& c);
std::string fieldLabel(int f) const;
std::string fieldValue(int f) const;
void startEditing(int f);
void finishEditing();
void warn(const std::string& text);
IrcService& irc_;
ClockService& clock_;
EventBus& bus_;
Page page_ = Page::Chat;
int current_ = 0;
int scroll_ = 0; // wrapped lines scrolled back from the bottom
uint32_t seenRevision_ = 0;
IrcService::Status seenStatus_ = IrcService::Status::Stopped;
LineEditor input_{400};
ListModel fields_{theme::kContent.h / theme::kLineHeight};
bool editing_ = false;
LineEditor fieldEditor_{63};
};
} // namespace roro