896 lines
41 KiB
C++
896 lines
41 KiB
C++
#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
|