IRC App: Alt scrolls, Up/Down recall sent lines; /j for /join

- Alt + ; / Alt + . scroll the Buffer back and forward
- Up / Down (Fn + ; / Fn + .) browse the last 30 sent lines, shell-style,
  returning to the draft past the newest (InputHistory, host-tested)
- /j is short for /join

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-03 20:12:33 +02:00
co-authored by Claude Opus 5.5
parent 1f0c1b2e14
commit 5ac6afa557
6 changed files with 152 additions and 4 deletions
+2 -2
View File
@@ -326,7 +326,7 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
const IrcBuffer& buf = buffers_[b]; const IrcBuffer& buf = buffers_[b];
bool inChannel = buf.type == IrcBuffer::Type::Channel; bool inChannel = buf.type == IrcBuffer::Type::Channel;
if (verb == "join") { if (verb == "join" || verb == "j") {
auto [channel, key] = firstWord(rest); auto [channel, key] = firstWord(rest);
if (channel.empty()) return info(b, "Usage: /join #channel", utc); if (channel.empty()) return info(b, "Usage: /join #channel", utc);
if (!isChannel(channel)) channel = "#" + channel; if (!isChannel(channel)) channel = "#" + channel;
@@ -362,7 +362,7 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
} else if (verb == "raw" || verb == "quote") { } else if (verb == "raw" || verb == "quote") {
if (!rest.empty()) send(rest); if (!rest.empty()) send(rest);
} else { } else {
info(b, "Unknown command /" + verb + " (try /join /part /msg /me /nick /topic /names /quit /raw)", utc); info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /quit /raw)", utc);
} }
} }
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <deque>
#include <string>
namespace roro {
// Lines already entered, browsed shell-style: up() goes back in time, down() forward, and past the
// newest line the draft being typed comes back.
class InputHistory {
public:
explicit InputHistory(size_t capacity) : capacity_(capacity) {}
void add(const std::string& line) {
browsing_ = -1;
if (line.empty() || (!lines_.empty() && lines_.back() == line)) return;
lines_.push_back(line);
if (lines_.size() > capacity_) lines_.pop_front();
}
// `current` is what the editor holds now (kept as the draft when browsing starts).
bool up(const std::string& current, std::string& out) {
if (lines_.empty() || browsing_ == 0) return false;
if (browsing_ < 0) {
draft_ = current;
browsing_ = static_cast<int>(lines_.size());
}
out = lines_[--browsing_];
return true;
}
bool down(std::string& out) {
if (browsing_ < 0) return false;
if (++browsing_ >= static_cast<int>(lines_.size())) {
browsing_ = -1;
out = draft_;
} else {
out = lines_[browsing_];
}
return true;
}
private:
size_t capacity_;
std::deque<std::string> lines_;
int browsing_ = -1; // index being shown, -1 when editing the draft
std::string draft_;
};
} // namespace roro
+14 -2
View File
@@ -80,13 +80,24 @@ bool IrcApp::onKey(const KeyEvent& e) {
} }
bool IrcApp::onChatKey(const KeyEvent& e) { bool IrcApp::onChatKey(const KeyEvent& e) {
// Alt + ; / Alt + . scroll the Buffer; Up / Down (Fn + ; / Fn + .) recall sent lines.
if (e.key == Key::Char && e.alt && (e.ch == ';' || e.ch == '.')) {
if (e.ch == ';') scroll_++; // older
else if (scroll_ > 0) scroll_--;
return true;
}
std::string recalled;
switch (e.key) { switch (e.key) {
case Key::Char: input_.insert(e.ch); break; case Key::Char: input_.insert(e.ch); break;
case Key::Delete: input_.backspace(); break; case Key::Delete: input_.backspace(); break;
case Key::Left: input_.left(); break; case Key::Left: input_.left(); break;
case Key::Right: input_.right(); break; case Key::Right: input_.right(); break;
case Key::Up: scroll_++; break; // older case Key::Up:
case Key::Down: if (scroll_ > 0) scroll_--; break; if (history_.up(input_.text(), recalled)) input_.setText(recalled);
break;
case Key::Down:
if (history_.down(recalled)) input_.setText(recalled);
break;
case Key::Tab: { case Key::Tab: {
int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); }); int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); });
view((current_ + 1) % count); view((current_ + 1) % count);
@@ -95,6 +106,7 @@ bool IrcApp::onChatKey(const KeyEvent& e) {
case Key::Select: { case Key::Select: {
std::string text = input_.text(); std::string text = input_.text();
if (text.empty()) break; if (text.empty()) break;
history_.add(text);
input_.setText(""); input_.setText("");
scroll_ = 0; scroll_ = 0;
if (lowered(text) == "/settings") { if (lowered(text) == "/settings") {
+2
View File
@@ -5,6 +5,7 @@
#include "app.h" #include "app.h"
#include "event_bus.h" #include "event_bus.h"
#include "input_history.h"
#include "line_editor.h" #include "line_editor.h"
#include "list_model.h" #include "list_model.h"
#include "services/irc_service.h" #include "services/irc_service.h"
@@ -48,6 +49,7 @@ class IrcApp : public App {
uint32_t seenRevision_ = 0; uint32_t seenRevision_ = 0;
IrcService::Status seenStatus_ = IrcService::Status::Stopped; IrcService::Status seenStatus_ = IrcService::Status::Stopped;
LineEditor input_{400}; LineEditor input_{400};
InputHistory history_{30};
ListModel fields_{theme::kContent.h / theme::kLineHeight}; ListModel fields_{theme::kContent.h / theme::kLineHeight};
bool editing_ = false; bool editing_ = false;
LineEditor fieldEditor_{63}; LineEditor fieldEditor_{63};
@@ -0,0 +1,73 @@
#include <unity.h>
#include "input_history.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_up_brings_back_previous_lines_newest_first() {
InputHistory h(10);
h.add("one");
h.add("two");
std::string line;
TEST_ASSERT_TRUE(h.up("", line));
TEST_ASSERT_EQUAL_STRING("two", line.c_str());
TEST_ASSERT_TRUE(h.up("two", line));
TEST_ASSERT_EQUAL_STRING("one", line.c_str());
TEST_ASSERT_FALSE(h.up("one", line)); // nothing older
}
void test_down_returns_to_the_draft_being_typed() {
InputHistory h(10);
h.add("one");
std::string line;
h.up("half typed", line);
TEST_ASSERT_TRUE(h.down(line));
TEST_ASSERT_EQUAL_STRING("half typed", line.c_str());
TEST_ASSERT_FALSE(h.down(line)); // already at the draft
}
void test_adding_resets_browsing_and_skips_repeats_and_empty_lines() {
InputHistory h(10);
h.add("same");
h.add("same");
h.add("");
std::string line;
h.up("", line);
TEST_ASSERT_FALSE(h.up(line, line)); // only one entry kept
h.add("next");
TEST_ASSERT_TRUE(h.up("", line));
TEST_ASSERT_EQUAL_STRING("next", line.c_str());
}
void test_keeps_only_the_most_recent_lines() {
InputHistory h(2);
h.add("a");
h.add("b");
h.add("c");
std::string line;
h.up("", line);
h.up(line, line);
TEST_ASSERT_EQUAL_STRING("b", line.c_str());
TEST_ASSERT_FALSE(h.up(line, line));
}
void test_empty_history_does_nothing() {
InputHistory h(5);
std::string line = "draft";
TEST_ASSERT_FALSE(h.up("draft", line));
TEST_ASSERT_FALSE(h.down(line));
TEST_ASSERT_EQUAL_STRING("draft", line.c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_up_brings_back_previous_lines_newest_first);
RUN_TEST(test_down_returns_to_the_draft_being_typed);
RUN_TEST(test_adding_resets_browsing_and_skips_repeats_and_empty_lines);
RUN_TEST(test_keeps_only_the_most_recent_lines);
RUN_TEST(test_empty_history_does_nothing);
return UNITY_END();
}
@@ -366,6 +366,16 @@ void test_input_message_and_commands() {
TEST_ASSERT_EQUAL_STRING("/not a command", f.session->buffer(b).lines.back().text.c_str()); TEST_ASSERT_EQUAL_STRING("/not a command", f.session->buffer(b).lines.back().text.c_str());
} }
void test_j_is_short_for_join() {
Fixture f;
f.registerNow();
f.session->input(0, "/j #short", 0);
f.session->input(0, "/j #locked k3y", 0);
f.take();
TEST_ASSERT_TRUE(f.sent("JOIN #short"));
TEST_ASSERT_TRUE(f.sent("JOIN #locked k3y"));
}
void test_msg_opens_a_query_buffer() { void test_msg_opens_a_query_buffer() {
Fixture f; Fixture f;
f.registerNow(); f.registerNow();
@@ -483,6 +493,7 @@ int main() {
RUN_TEST(test_action_and_ctcp_version); RUN_TEST(test_action_and_ctcp_version);
RUN_TEST(test_topic_is_kept); RUN_TEST(test_topic_is_kept);
RUN_TEST(test_input_message_and_commands); RUN_TEST(test_input_message_and_commands);
RUN_TEST(test_j_is_short_for_join);
RUN_TEST(test_msg_opens_a_query_buffer); RUN_TEST(test_msg_opens_a_query_buffer);
RUN_TEST(test_text_in_the_server_buffer_explains_instead_of_sending); RUN_TEST(test_text_in_the_server_buffer_explains_instead_of_sending);
RUN_TEST(test_quit_sends_and_is_remembered); RUN_TEST(test_quit_sends_and_is_remembered);