Files
nocal/src/tui/Screen.cpp

896 lines
41 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "nocal/tui/Screen.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <ctime>
#include <map>
#include <sstream>
#include <stdexcept>
#include <vector>
namespace nocal::tui {
namespace {
using namespace std::chrono;
constexpr std::array<std::string_view, 12> month_names{
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"};
constexpr std::array<std::string_view, 7> weekday_long{
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
constexpr std::array<std::string_view, 7> weekday_short{
"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"};
std::uint32_t decode_codepoint(const std::string_view text, const std::size_t at,
std::size_t& length) {
const auto first = static_cast<unsigned char>(text[at]);
if (first < 0x80) {
length = 1;
return first;
}
int continuation = 0;
std::uint32_t result = 0;
if ((first & 0xe0) == 0xc0) {
continuation = 1;
result = first & 0x1f;
} else if ((first & 0xf0) == 0xe0) {
continuation = 2;
result = first & 0x0f;
} else if ((first & 0xf8) == 0xf0) {
continuation = 3;
result = first & 0x07;
} else {
length = 1;
return 0xfffd;
}
if (at + static_cast<std::size_t>(continuation) >= text.size()) {
length = 1;
return 0xfffd;
}
for (int offset = 1; offset <= continuation; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + static_cast<std::size_t>(offset)]);
if ((byte & 0xc0) != 0x80) {
length = 1;
return 0xfffd;
}
result = (result << 6) | (byte & 0x3f);
}
length = static_cast<std::size_t>(continuation + 1);
return result;
}
int codepoint_width(const std::uint32_t value) {
if ((value >= 0x0300 && value <= 0x036f) || (value >= 0xfe00 && value <= 0xfe0f)) {
return 0;
}
if ((value >= 0x1100 && value <= 0x115f) || (value >= 0x2e80 && value <= 0xa4cf) ||
(value >= 0xac00 && value <= 0xd7a3) || (value >= 0xf900 && value <= 0xfaff) ||
(value >= 0x1f300 && value <= 0x1faff)) {
return 2;
}
return 1;
}
int display_width(const std::string_view text) {
int width = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
width += codepoint_width(cp);
at += length;
}
return width;
}
std::string printable_text(const std::string_view text, const bool preserve_newlines = false) {
std::string result{text};
for (auto& character : result) {
const auto byte = static_cast<unsigned char>(character);
if ((byte < 0x20 && !(preserve_newlines && character == '\n')) || byte == 0x7f) {
character = ' ';
}
}
return result;
}
std::string fit_text(const std::string_view text, const int requested_width,
const bool ellipsis = true) {
const int width = std::max(0, requested_width);
if (width == 0) {
return {};
}
if (display_width(text) <= width) {
std::string result{text};
result.append(static_cast<std::size_t>(width - display_width(text)), ' ');
return result;
}
const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0));
std::string result;
int used = 0;
for (std::size_t at = 0; at < text.size();) {
std::size_t length = 1;
const auto cp = decode_codepoint(text, at, length);
const int cp_width = codepoint_width(cp);
if (used + cp_width > content_limit) {
break;
}
result.append(text.substr(at, length));
used += cp_width;
at += length;
}
if (ellipsis && width > 1) {
result += "";
++used;
}
result.append(static_cast<std::size_t>(std::max(0, width - used)), ' ');
return result;
}
std::string centred(const std::string_view text, const int width) {
const int visible = std::min(display_width(text), width);
const int left = std::max(0, (width - visible) / 2);
std::string result(static_cast<std::size_t>(left), ' ');
result += fit_text(text, width - left);
return fit_text(result, width, false);
}
std::string styled(const std::string_view text, const std::string_view code, const bool ansi) {
if (!ansi || code.empty()) {
return std::string{text};
}
return "\x1b[" + std::string{code} + "m" + std::string{text} + "\x1b[0m";
}
std::string two_digits(const unsigned value) {
std::array<char, 3> result{'0', '0', '\0'};
result[0] = static_cast<char>('0' + (value / 10) % 10);
result[1] = static_cast<char>('0' + value % 10);
return std::string{result.data(), 2};
}
std::string iso_date(const year_month_day day) {
return std::to_string(static_cast<int>(day.year())) + "-" +
two_digits(static_cast<unsigned>(day.month())) + "-" +
two_digits(static_cast<unsigned>(day.day()));
}
std::string event_label(const CalendarItem& item, const int width) {
const auto title = item.title.empty() ? std::string{"(untitled)"} : printable_text(item.title);
std::string segment;
switch (item.segment) {
case ItemSegment::single: break;
case ItemSegment::start: segment = ""; break;
case ItemSegment::continuation: segment = ""; break;
case ItemSegment::end: segment = ""; break;
}
if (item.all_day || !item.time_of_day) {
return fit_text((segment.empty() ? "" : segment) + title, width);
}
const auto total = item.time_of_day->count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
if (width < 8) {
return fit_text(title, width);
}
return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + segment + title, width);
}
std::string event_label(const CalendarItem& item, const int width, const bool focused) {
if (!focused) return event_label(item, width);
if (width <= 2) return fit_text("", width);
auto label = event_label(item, width - 2);
return fit_text(" " + label, width, false);
}
std::string border(const std::vector<int>& widths, const char* left,
const char* join, const char* right) {
std::string result{left};
for (std::size_t column = 0; column < widths.size(); ++column) {
for (int position = 0; position < widths[column]; ++position) {
result += "";
}
result += column + 1 == widths.size() ? right : join;
}
return result;
}
sys_days monday_on_or_before(const sys_days date) {
const weekday day{date};
const auto offset = (day.iso_encoding() + 6) % 7;
return date - days{offset};
}
struct LocalMoment {
year_month_day date;
minutes time_of_day;
};
std::string clock_time(const minutes value) {
const auto total = value.count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
const auto minute = static_cast<unsigned>((total % 60 + 60) % 60);
return two_digits(hour) + ":" + two_digits(minute);
}
std::string friendly_date(const year_month_day value) {
if (!value.ok()) return {};
const auto weekday_index = weekday{sys_days{value}}.iso_encoding() - 1;
return std::string{weekday_long[weekday_index]} + ", " +
std::to_string(static_cast<unsigned>(value.day())) + " " +
std::string{month_names[static_cast<unsigned>(value.month()) - 1]} + " " +
std::to_string(static_cast<int>(value.year()));
}
std::vector<std::string> wrap_text(const std::string_view text, const int requested_width) {
const int width = std::max(1, requested_width);
std::vector<std::string> result;
std::size_t paragraph_start = 0;
while (paragraph_start <= text.size()) {
const auto paragraph_end = text.find('\n', paragraph_start);
const auto paragraph = text.substr(paragraph_start,
paragraph_end == std::string_view::npos ? text.size() - paragraph_start
: paragraph_end - paragraph_start);
if (paragraph.empty()) {
result.emplace_back();
} else {
std::string line;
std::size_t at = 0;
while (at < paragraph.size()) {
while (at < paragraph.size() && paragraph[at] == ' ') ++at;
const auto word_end = paragraph.find(' ', at);
const auto word = paragraph.substr(at,
word_end == std::string_view::npos ? paragraph.size() - at : word_end - at);
if (!word.empty() && display_width(word) > width) {
if (!line.empty()) {
result.push_back(line);
line.clear();
}
std::size_t word_at = 0;
while (word_at < word.size()) {
std::string part;
int used = 0;
while (word_at < word.size()) {
std::size_t length = 1;
const auto cp = decode_codepoint(word, word_at, length);
const auto cp_width = codepoint_width(cp);
if (used + cp_width > width) {
if (used == 0) {
part.append(word.substr(word_at, length));
word_at += length;
}
break;
}
part.append(word.substr(word_at, length));
word_at += length;
used += cp_width;
}
if (word_at < word.size() || !part.empty()) result.push_back(part);
}
} else if (!word.empty() &&
display_width(line) + (line.empty() ? 0 : 1) + display_width(word) <= width) {
if (!line.empty()) line += ' ';
line += word;
} else if (!word.empty()) {
result.push_back(line);
line = std::string{word};
}
if (word_end == std::string_view::npos) break;
at = word_end + 1;
}
if (!line.empty()) result.push_back(line);
}
if (paragraph_end == std::string_view::npos) break;
paragraph_start = paragraph_end + 1;
}
if (result.empty()) result.emplace_back();
return result;
}
const CalendarItem* focused_item(std::span<const CalendarItem> items, const ScreenState& state) {
if (!state.focused_event_id) return nullptr;
const auto on_selected_day = std::find_if(items.begin(), items.end(), [&](const auto& item) {
return item.id == *state.focused_event_id && item.day == state.selected_day;
});
if (on_selected_day != items.end()) return &*on_selected_day;
const auto anywhere = std::find_if(items.begin(), items.end(), [&](const auto& item) {
return item.id == *state.focused_event_id;
});
return anywhere == items.end() ? nullptr : &*anywhere;
}
bool calendar_item_less(const CalendarItem* left, const CalendarItem* right) {
const auto true_all_day = [](const CalendarItem& item) {
return item.detail_all_day || (item.detail_title.empty() && item.all_day);
};
const bool left_all_day = true_all_day(*left);
const bool right_all_day = true_all_day(*right);
if (left_all_day != right_all_day) return left_all_day;
const auto left_time = left->detail_start_time_of_day
? left->detail_start_time_of_day : left->time_of_day;
const auto right_time = right->detail_start_time_of_day
? right->detail_start_time_of_day : right->time_of_day;
return left_time.value_or(minutes{-1}) < right_time.value_or(minutes{-1});
}
std::string detail_frame(std::span<const CalendarItem> items, const ScreenState& state,
const CalendarItem& focused) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
if (width < 4 || height < 4) {
std::vector<std::string> tiny(static_cast<std::size_t>(height), std::string{});
const auto& tiny_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
tiny[0] = fit_text(tiny_title.empty() ? "(untitled)" : printable_text(tiny_title), width);
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << tiny[static_cast<std::size_t>(line)];
}
return output.str();
}
std::vector<const CalendarItem*> selected_items;
for (const auto& item : items) {
if (item.day == state.selected_day) selected_items.push_back(&item);
}
std::stable_sort(selected_items.begin(), selected_items.end(), calendar_item_less);
const auto current = std::find_if(selected_items.begin(), selected_items.end(),
[&](const auto* item) { return item->id == focused.id; });
const auto ordinal = current == selected_items.end()
? 1U : static_cast<unsigned>(current - selected_items.begin() + 1);
const auto total = std::max<std::size_t>(1, selected_items.size());
const int inner = width - 2;
std::vector<std::pair<std::string, std::string>> content;
const auto add = [&](std::string text, std::string style = {}) {
content.emplace_back(fit_text(text, inner, false), std::move(style));
};
add(focused.recurring ? " RECURRING APPOINTMENT" : " APPOINTMENT", "2;1");
const auto& title = focused.detail_title.empty() ? focused.title : focused.detail_title;
add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1");
add("");
const auto start_day = focused.start_day.ok() ? focused.start_day : focused.day;
const auto end_day = focused.end_day.ok() ? focused.end_day : start_day;
add(" Date " + friendly_date(start_day));
if (end_day != start_day) add(" Ends " + friendly_date(end_day));
const bool event_all_day = focused.detail_all_day ||
(focused.detail_title.empty() && focused.all_day);
const auto start_time = focused.detail_start_time_of_day
? focused.detail_start_time_of_day : focused.time_of_day;
if (event_all_day || !start_time) {
add(" Time All day");
} else {
auto value = clock_time(*start_time);
if (focused.end_time_of_day) value += " " + clock_time(*focused.end_time_of_day);
add(" Time " + value + " local");
}
if (!focused.location.empty()) add(" Location " + printable_text(focused.location));
if (focused.recurring) {
add(" Repeats " + (focused.recurrence_summary.empty()
? std::string{"Recurring series"}
: printable_text(focused.recurrence_summary)));
if (!focused.source_time_zone.empty()) {
add(" Source TZ " + printable_text(focused.source_time_zone));
}
add(" Series Edit and delete affect every occurrence", "1;33");
} else if (!focused.source_time_zone.empty()) {
add(" Source TZ " + printable_text(focused.source_time_zone));
}
add("");
if (!focused.description.empty()) {
add(" NOTES", "2;1");
for (const auto& line : wrap_text(printable_text(focused.description, true),
std::max(1, inner - 2))) {
add(" " + line);
}
} else {
add(" No notes for this appointment.", "2;3");
}
const auto footer = " ↑↓/Tab browse / search e edit d delete u undo Ctrl-R redo Esc back " +
std::to_string(ordinal) + "/" + std::to_string(total) + " ";
const int body_rows = height - 2;
std::ostringstream output;
output << border({inner}, "", "", "") << '\n';
for (int line = 0; line < body_rows; ++line) {
std::string value(static_cast<std::size_t>(inner), ' ');
std::string style;
if (line == body_rows - 1) {
value = fit_text(footer, inner);
style = "7";
} else if (line < static_cast<int>(content.size())) {
value = content[static_cast<std::size_t>(line)].first;
style = content[static_cast<std::size_t>(line)].second;
}
output << "" << styled(value, style, state.ansi) << "\n";
}
output << border({inner}, "", "", "");
return output.str();
}
std::string delete_confirmation_frame(const ScreenState& state, const CalendarItem& focused) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
if (width < 4 || height < 4) {
return fit_text("Delete? y/n", width);
}
const int inner = width - 2;
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
const auto title = detail_title.empty() ? std::string{"(untitled)"}
: printable_text(detail_title);
const auto question = fit_text(focused.recurring
? " Delete entire recurring series “" + title + "”?"
: " Delete “" + title + "”?",
inner);
const auto controls = fit_text(" y confirm n/Esc cancel", inner);
const int question_line = std::max(1, height / 2 - 1);
std::ostringstream output;
output << border({inner}, "", "", "") << '\n';
for (int line = 0; line < height - 2; ++line) {
std::string content(static_cast<std::size_t>(inner), ' ');
std::string style;
if (line == question_line) {
content = question;
style = "1;31";
} else if (line == question_line + 2) {
content = controls;
style = "7";
} else if (!state.notification.empty() && line == question_line + 3) {
content = fit_text(" " + printable_text(state.notification, true), inner);
style = state.notification_is_error ? "1;31" : "1;32";
}
output << "" << styled(content, style, state.ansi) << "\n";
}
output << border({inner}, "", "", "");
return output.str();
}
LocalMoment to_local(const system_clock::time_point point) {
const auto value = system_clock::to_time_t(point);
std::tm local{};
::localtime_r(&value, &local);
return {
year{local.tm_year + 1900} / month{static_cast<unsigned>(local.tm_mon + 1)} /
day{static_cast<unsigned>(local.tm_mday)},
hours{local.tm_hour} + minutes{local.tm_min},
};
}
std::string recurrence_summary(const RecurrenceRule& rule) {
std::string unit;
switch (rule.frequency) {
case RecurrenceFrequency::daily: unit = rule.interval == 1 ? "day" : "days"; break;
case RecurrenceFrequency::weekly: unit = rule.interval == 1 ? "week" : "weeks"; break;
case RecurrenceFrequency::monthly: unit = rule.interval == 1 ? "month" : "months"; break;
case RecurrenceFrequency::yearly: unit = rule.interval == 1 ? "year" : "years"; break;
}
auto result = rule.interval == 1 ? "Every " + unit
: "Every " + std::to_string(rule.interval) + " " + unit;
if (!rule.by_weekday.empty()) {
result += " on ";
for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) {
if (index != 0) result += ", ";
const auto weekday_index = rule.by_weekday[index].iso_encoding() - 1U;
if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index];
}
}
if (!rule.by_month_day.empty()) {
result += " on day ";
for (std::size_t index = 0; index < rule.by_month_day.size(); ++index) {
if (index != 0) result += ", ";
result += std::to_string(rule.by_month_day[index]);
}
}
if (!rule.by_month.empty()) {
result += " in ";
for (std::size_t index = 0; index < rule.by_month.size(); ++index) {
if (index != 0) result += ", ";
const auto month = rule.by_month[index];
result += month > 0 && month <= month_names.size()
? std::string{month_names[month - 1U]} : std::to_string(month);
}
}
if (rule.count) result += ", " + std::to_string(*rule.count) + " occurrences";
return result;
}
std::size_t event_source_index(const std::span<const Event> events, const Event* source) {
for (std::size_t index = 0; index < events.size(); ++index) {
if (&events[index] == source) return index;
}
throw std::invalid_argument("occurrence source is outside the event collection");
}
std::string source_focus_id(const std::span<const Event> events, const std::size_t index) {
const auto& uid = events[index].uid;
const bool unique = !uid.empty() &&
std::count_if(events.begin(), events.end(), [&uid](const Event& event) {
return event.uid == uid;
}) == 1;
return unique ? uid : "@nocal:" + std::to_string(index);
}
std::string search_frame(const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
std::vector<std::string> lines;
lines.reserve(static_cast<std::size_t>(height));
lines.push_back(styled(centred("SEARCH APPOINTMENTS", width), "1", state.ansi));
if (state.search_prompt) {
if (height > 1) lines.push_back(fit_text("/ " + printable_text(state.search_query) + "_",
width, false));
if (height > 2) lines.push_back(styled(fit_text(
"Search title, description, location, or calendar ID", width), "2", state.ansi));
if (height > 3) lines.push_back(styled(fit_text(
"Enter search Backspace edit Esc cancel", width), "2", state.ansi));
} else {
const auto count = state.search_results.size();
const auto summary = count == 0
? "No matches for “" + printable_text(state.search_query) + ""
: std::to_string(count) + (count == 1 ? " match for “" : " matches for “") +
printable_text(state.search_query) + "";
if (height > 1) lines.push_back(styled(fit_text(summary, width), count == 0 ? "1" : "1;36",
state.ansi));
const auto row_capacity = static_cast<std::size_t>(std::max(0, height - 4));
std::size_t first = 0;
if (row_capacity > 0 && state.search_result_index >= row_capacity) {
first = state.search_result_index - row_capacity + 1;
}
const auto last = std::min(count, first + row_capacity);
for (std::size_t index = first; index < last; ++index) {
const auto& result = state.search_results[index];
std::string when = iso_date(result.day) + " ";
if (result.all_day || !result.time_of_day) when += "all day";
else when += clock_time(*result.time_of_day);
auto title = result.title.empty() ? std::string{"(untitled)"}
: printable_text(result.title);
std::string label = (index == state.search_result_index ? " " : " ") + when +
" " + (result.recurring ? "" : "") + title;
if (!result.location.empty()) label += "" + printable_text(result.location);
lines.push_back(styled(fit_text(label, width),
index == state.search_result_index ? "7;1" : "", state.ansi));
}
while (static_cast<int>(lines.size()) < height - 2) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (height > 2) lines.push_back(styled(fit_text(
"Five years either side of the selected date", width), "2", state.ansi));
if (height > 1) lines.push_back(styled(fit_text(
"↑/↓ choose Enter jump / new search Esc close", width), "2", state.ansi));
}
while (static_cast<int>(lines.size()) < height) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
const auto first = sys_days{state.visible_month / day{1}};
const auto grid_start = monday_on_or_before(first);
std::vector<std::string> lines;
const auto title = std::string{month_names[static_cast<unsigned>(state.visible_month.month()) - 1]} +
" " + std::to_string(static_cast<int>(state.visible_month.year()));
lines.push_back(styled(centred(title, width), "1", state.ansi));
std::array<int, 7> tokens{};
for (int column = 0; column < 7; ++column) {
tokens[static_cast<std::size_t>(column)] =
width / 7 + (column < width % 7 ? 1 : 0);
}
std::string weekdays;
for (std::size_t column = 0; column < weekday_short.size(); ++column) {
weekdays += centred(weekday_short[column], tokens[column]);
}
lines.push_back(styled(weekdays, "2;1", state.ansi));
for (int week = 0; week < 6; ++week) {
std::string week_line;
for (int column = 0; column < 7; ++column) {
const auto date = year_month_day{grid_start + days{week * 7 + column}};
const auto count = std::count_if(items.begin(), items.end(),
[date](const auto& item) { return item.day == date; });
std::string label = std::to_string(static_cast<unsigned>(date.day()));
const auto token = tokens[static_cast<std::size_t>(column)];
if (count > 0 && token >= 3) {
label += "";
}
auto text = centred(label, token);
if (date == state.selected_day) {
text = styled(text, date == state.today ? "7;1;36" : "7;1", state.ansi);
} else if (date == state.today) {
text = styled(text, "1;36", state.ansi);
} else if (date.month() != state.visible_month.month()) {
text = styled(text, "2", state.ansi);
}
week_line += text;
}
lines.push_back(std::move(week_line));
}
const auto focused = focused_item(items, state);
if (height > 9 && focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title);
lines.push_back(styled(fit_text(" " + focus_title, width),
"7;1", state.ansi));
}
while (static_cast<int>(lines.size()) < height - 1) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
std::string controls;
if (!state.notification.empty()) {
controls = printable_text(state.notification, true);
} else if (focused == nullptr) {
controls = "/ search a add u undo Ctrl-R redo ? help n/p month t today q quit";
} else {
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
}
if (static_cast<int>(lines.size()) < height) {
const auto style = state.notification.empty() ? "2"
: state.notification_is_error ? "1;31" : "1;32";
lines.push_back(styled(fit_text(controls, width), style, state.ansi));
}
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
} // namespace
std::string occurrence_focus_id(const std::span<const Event> events,
const EventOccurrence& occurrence) {
const auto index = event_source_index(events, occurrence.source);
auto identity = source_focus_id(events, index);
if (occurrence.source->recurrence) {
const auto tick = occurrence.start.time_since_epoch().count();
identity += "@occ:" + std::to_string(tick);
}
return identity;
}
Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left;
if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right;
if (sequence == "k" || sequence == "\x1b[A" || sequence == "\x1bOA") return Action::up;
if (sequence == "j" || sequence == "\x1b[B" || sequence == "\x1bOB") return Action::down;
if (sequence == "n" || sequence == "\x1b[6~") return Action::next_month;
if (sequence == "p" || sequence == "\x1b[5~") return Action::previous_month;
if (sequence == "t") return Action::today;
if (sequence == "?") return Action::toggle_help;
if (sequence == "\x1b[Z") return Action::previous_event;
if (sequence == "\t" || sequence == "]") return Action::next_event;
if (sequence == "[") return Action::previous_event;
if (sequence == "\r" || sequence == "\n") return Action::select;
if (sequence == "\x1b" || sequence == "\x7f") return Action::back;
if (sequence == "a") return Action::add_event;
if (sequence == "e") return Action::edit_event;
if (sequence == "d") return Action::delete_event;
if (sequence == "u") return Action::undo;
if (sequence == "\x12") return Action::redo;
if (sequence == "/") return Action::search;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
return Action::none;
}
std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) {
if (state.search_prompt || state.show_search_results) return search_frame(state);
const auto focused = focused_item(items, state);
if (state.confirm_delete && focused != nullptr) {
return delete_confirmation_frame(state, *focused);
}
if (state.show_event_details && focused != nullptr) {
return detail_frame(items, state, *focused);
}
if (state.width < 35 || state.height < 16) {
return compact_frame(items, state);
}
const int width = state.width;
const int inner_total = width - 8; // eight vertical border glyphs
std::vector<int> widths(7, inner_total / 7);
for (int column = 0; column < inner_total % 7; ++column) {
++widths[static_cast<std::size_t>(column)];
}
const int content_rows = std::max(6, state.height - 10);
std::vector<int> row_heights(6, content_rows / 6);
for (int row = 0; row < content_rows % 6; ++row) {
++row_heights[static_cast<std::size_t>(row)];
}
const auto first = sys_days{state.visible_month / day{1}};
const auto grid_start = monday_on_or_before(first);
std::map<sys_days, std::vector<const CalendarItem*>> by_day;
for (const auto& item : items) {
if (item.day.ok()) {
by_day[sys_days{item.day}].push_back(&item);
}
}
for (auto& [_, day_items] : by_day) {
std::stable_sort(day_items.begin(), day_items.end(), calendar_item_less);
}
const auto month_index = static_cast<unsigned>(state.visible_month.month()) - 1;
const auto title = " " + std::string{month_names[month_index]} + " " +
std::to_string(static_cast<int>(state.visible_month.year())) + " ";
std::ostringstream output;
output << styled(centred(title, width), "1", state.ansi) << '\n';
output << ' ';
for (std::size_t column = 0; column < widths.size(); ++column) {
const auto name = widths[column] >= 9 ? weekday_long[column] : weekday_short[column];
output << styled(centred(name, widths[column]), "2;1", state.ansi);
output << (column + 1 == widths.size() ? ' ' : ' ');
}
output << '\n' << border(widths, "", "", "") << '\n';
for (int week = 0; week < 6; ++week) {
for (int line = 0; line < row_heights[static_cast<std::size_t>(week)]; ++line) {
output << "";
for (int column = 0; column < 7; ++column) {
const auto date_days = grid_start + days{week * 7 + column};
const auto date = year_month_day{date_days};
const auto cell_width = widths[static_cast<std::size_t>(column)];
std::string cell;
std::string style;
if (line == 0) {
cell = " " + std::to_string(static_cast<unsigned>(date.day()));
cell = fit_text(cell, cell_width, false);
if (date == state.selected_day) style = date == state.today ? "7;1;36" : "7;1";
else if (date == state.today) style = "1;36";
else if (date.month() != state.visible_month.month()) style = "2";
} else {
const auto found = by_day.find(date_days);
const auto count = found == by_day.end() ? 0U : found->second.size();
const auto appointment_line = static_cast<std::size_t>(line - 1);
const auto slots = static_cast<std::size_t>(row_heights[static_cast<std::size_t>(week)] - 1);
const bool focused_on_day = found != by_day.end() && date == state.selected_day &&
state.focused_event_id &&
std::any_of(found->second.begin(), found->second.end(),
[&](const auto* item) {
return item->id == *state.focused_event_id;
});
// With only one appointment row, focus is more useful than
// an overflow counter. Taller cells retain both.
const bool show_overflow = count > slots &&
!(slots == 1 && focused_on_day);
const auto event_capacity = show_overflow && slots > 0 ? slots - 1 : slots;
std::size_t window_start = 0;
if (found != by_day.end() && event_capacity > 0 && date == state.selected_day &&
state.focused_event_id) {
const auto focus = std::find_if(found->second.begin(), found->second.end(),
[&](const auto* item) { return item->id == *state.focused_event_id; });
if (focus != found->second.end()) {
const auto focus_index = static_cast<std::size_t>(focus - found->second.begin());
if (focus_index >= event_capacity) window_start = focus_index - event_capacity + 1;
}
}
if (show_overflow && appointment_line + 1 == slots) {
cell = fit_text(" +" + std::to_string(count - event_capacity) + " hidden", cell_width);
style = "2";
} else if (appointment_line < event_capacity &&
window_start + appointment_line < count) {
const auto* item = found->second[window_start + appointment_line];
const bool is_focused = date == state.selected_day && state.focused_event_id &&
item->id == *state.focused_event_id;
cell = event_label(*item, cell_width, is_focused);
if (is_focused) style = "7;1";
} else {
cell.assign(static_cast<std::size_t>(cell_width), ' ');
}
if (date.month() != state.visible_month.month() && style.empty()) style = "2";
}
output << styled(cell, style, state.ansi) << "";
}
output << '\n';
}
output << (week == 5 ? border(widths, "", "", "")
: border(widths, "", "", ""))
<< '\n';
}
const auto selected_count = by_day.contains(sys_days{state.selected_day})
? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status;
if (state.show_help) {
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read / search a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
} else if (!state.notification.empty()) {
status = " " + printable_text(state.notification, true);
} else if (focused != nullptr) {
const auto focus_title = focused->title.empty() ? std::string{"(untitled)"}
: printable_text(focused->title);
status = " " + iso_date(state.selected_day) + " " +
focus_title + " Tab next Enter read / search e edit d delete u undo Ctrl-R redo";
} else {
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
(selected_count == 1 ? " appointment" : " appointments") +
(selected_count > 0 ? " Tab focus" : "") +
" / search a add u undo Ctrl-R redo ? help q quit";
}
const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32")
: focused != nullptr ? "7" : "2";
output << styled(fit_text(status, width), status_style, state.ansi);
return output.str();
}
std::string render_month(const std::span<const Event> events, const ScreenState& state) {
std::vector<CalendarItem> items;
items.reserve(events.size() * 2);
const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
const auto visible_end = visible_start + days{41};
const auto occurrences = occurrences_overlapping(
events, local_day_start(year_month_day{visible_start}),
local_day_start(year_month_day{visible_end + days{1}}));
for (const auto& occurrence : occurrences) {
if (occurrence.source == nullptr) continue;
const auto& event = *occurrence.source;
const auto identity = occurrence_focus_id(events, occurrence);
const auto local_start = to_local(occurrence.start);
const auto local_finish = to_local(occurrence.end);
const auto one_second = duration_cast<system_clock::duration>(seconds{1});
const auto final_instant = occurrence.end > occurrence.start
? occurrence.end -
std::min(occurrence.end - occurrence.start, one_second)
: occurrence.start;
const auto local_end = to_local(final_instant);
const auto event_start = sys_days{local_start.date};
const auto event_last = sys_days{local_end.date};
const auto first_day = std::max(event_start, visible_start);
const auto last_day = std::min(event_last, visible_end);
for (auto date = first_day; date <= last_day; date += days{1}) {
const bool continuation = date != event_start;
ItemSegment segment = ItemSegment::single;
if (event_start != event_last) {
if (date == event_start) segment = ItemSegment::start;
else if (date == event_last) segment = ItemSegment::end;
else segment = ItemSegment::continuation;
}
items.push_back(CalendarItem{
.id = identity,
.title = event.title,
.day = year_month_day{date},
.time_of_day = event.all_day || continuation
? std::nullopt
: std::optional{local_start.time_of_day},
.all_day = event.all_day || continuation,
.end_time_of_day = event.all_day ? std::nullopt
: std::optional{local_finish.time_of_day},
.start_day = local_start.date,
.end_day = event.all_day ? local_end.date : local_finish.date,
.location = event.location,
.description = event.description,
.detail_title = event.title,
.detail_all_day = event.all_day,
.detail_start_time_of_day = event.all_day
? std::nullopt
: std::optional{local_start.time_of_day},
.segment = segment,
.recurring = event.recurrence.has_value(),
.recurrence_summary = event.recurrence
? recurrence_summary(*event.recurrence) : std::string{},
.source_time_zone = event.time_basis == TimeBasis::zoned
? event.time_zone : std::string{},
});
}
}
return render_month(std::span<const CalendarItem>{items}, state);
}
} // namespace nocal::tui