diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp index 3b3f9b2..4f698ee 100644 --- a/lib/irc/src/irc_session.cpp +++ b/lib/irc/src/irc_session.cpp @@ -326,7 +326,7 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) { const IrcBuffer& buf = buffers_[b]; bool inChannel = buf.type == IrcBuffer::Type::Channel; - if (verb == "join") { + if (verb == "join" || verb == "j") { auto [channel, key] = firstWord(rest); if (channel.empty()) return info(b, "Usage: /join #channel", utc); 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") { if (!rest.empty()) send(rest); } 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); } } diff --git a/lib/ui/src/input_history.h b/lib/ui/src/input_history.h new file mode 100644 index 0000000..29e79f3 --- /dev/null +++ b/lib/ui/src/input_history.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +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(lines_.size()); + } + out = lines_[--browsing_]; + return true; + } + + bool down(std::string& out) { + if (browsing_ < 0) return false; + if (++browsing_ >= static_cast(lines_.size())) { + browsing_ = -1; + out = draft_; + } else { + out = lines_[browsing_]; + } + return true; + } + + private: + size_t capacity_; + std::deque lines_; + int browsing_ = -1; // index being shown, -1 when editing the draft + std::string draft_; +}; + +} // namespace roro diff --git a/src/apps/irc_app.cpp b/src/apps/irc_app.cpp index d37bd6f..cff1930 100644 --- a/src/apps/irc_app.cpp +++ b/src/apps/irc_app.cpp @@ -80,13 +80,24 @@ bool IrcApp::onKey(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) { 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::Up: + if (history_.up(input_.text(), recalled)) input_.setText(recalled); + break; + case Key::Down: + if (history_.down(recalled)) input_.setText(recalled); + break; case Key::Tab: { int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); }); view((current_ + 1) % count); @@ -95,6 +106,7 @@ bool IrcApp::onChatKey(const KeyEvent& e) { case Key::Select: { std::string text = input_.text(); if (text.empty()) break; + history_.add(text); input_.setText(""); scroll_ = 0; if (lowered(text) == "/settings") { diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h index 77e8215..6bcd375 100644 --- a/src/apps/irc_app.h +++ b/src/apps/irc_app.h @@ -5,6 +5,7 @@ #include "app.h" #include "event_bus.h" +#include "input_history.h" #include "line_editor.h" #include "list_model.h" #include "services/irc_service.h" @@ -48,6 +49,7 @@ class IrcApp : public App { uint32_t seenRevision_ = 0; IrcService::Status seenStatus_ = IrcService::Status::Stopped; LineEditor input_{400}; + InputHistory history_{30}; ListModel fields_{theme::kContent.h / theme::kLineHeight}; bool editing_ = false; LineEditor fieldEditor_{63}; diff --git a/test/test_input_history/test_input_history.cpp b/test/test_input_history/test_input_history.cpp new file mode 100644 index 0000000..3375d91 --- /dev/null +++ b/test/test_input_history/test_input_history.cpp @@ -0,0 +1,73 @@ +#include + +#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(); +} diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp index a44e303..8d7563c 100644 --- a/test/test_irc_session/test_irc_session.cpp +++ b/test/test_irc_session/test_irc_session.cpp @@ -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()); } +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() { Fixture f; f.registerNow(); @@ -483,6 +493,7 @@ int main() { RUN_TEST(test_action_and_ctcp_version); RUN_TEST(test_topic_is_kept); 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_text_in_the_server_buffer_explains_instead_of_sending); RUN_TEST(test_quit_sends_and_is_remembered);