#include "ncurses_ui.hh" #include "display_buffer.hh" #include "event_manager.hh" #include "exception.hh" #include "file.hh" #include "keys.hh" #include "ranges.hh" #include "string_utils.hh" #include #include #include #include #include #include constexpr char control(char c) { return c & 037; } namespace Kakoune { using std::min; using std::max; static void set_cursor_pos(DisplayCoord coord) { printf("\033[%d;%dH", (int)coord.line + 1, (int)coord.column + 1); } void NCursesUI::Window::create(const DisplayCoord& p, const DisplayCoord& s) { pos = p; size = s; lines.resize((int)size.line); } void NCursesUI::Window::destroy() { pos = DisplayCoord{}; size = DisplayCoord{}; lines.clear(); } void NCursesUI::Window::refresh(bool force) { if (lines.empty()) return; static constexpr int fg_table[]{ 39, 30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97 }; static constexpr int bg_table[]{ 49, 40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107 }; static constexpr int attr_table[]{ 0, 4, 7, 5, 1, 2, 3 }; auto set_color = [](bool fg, const Color& color) { if (color.isRGB()) printf(";%d;2;%d;%d;%d", fg ? 38 : 48, color.r, color.g, color.b); else printf(";%d", (fg ? fg_table : bg_table)[(int)(char)color.color]); }; auto set_attributes = [](const Attribute& attributes) { for (int i = 0; i < sizeof(attr_table) / sizeof(int); ++i) { if (attributes & (Attribute)(1 << i)) printf(";%d", attr_table[i]); } }; DisplayCoord cursor_pos = pos; for (auto& line : lines) { set_cursor_pos(cursor_pos); for (auto& atom : line) { printf("\033["); set_attributes(atom.face.attributes); set_color(true, atom.face.fg); set_color(false, atom.face.bg); printf("m"); fputs(atom.text.c_str(), stdout); } ++cursor_pos.line; } } void NCursesUI::Window::move_cursor(DisplayCoord coord) { cursor = coord; } void NCursesUI::Window::clear_line() { auto& line = lines[(int)cursor.line]; auto it = line.begin(); ColumnCount column = 0; for (; it != line.end() and column < cursor.column; ++it) column += it->text.column_length(); line.erase(it, line.end()); if (column > cursor.column) { auto& text = line.back().text; auto new_length = text.column_length() - (column - cursor.column); text.resize(text.byte_count_to(new_length), 0); } } void NCursesUI::Window::draw(ConstArrayView atoms, const Face& default_face) { clear_line(); for (const DisplayAtom& atom : atoms) { StringView content = atom.content(); if (content.empty()) continue; auto face = merge_faces(default_face, atom.face); if (content.back() == '\n') { lines[(int)cursor.line].push_back({content.substr(0, content.length()-1).str(), face}); lines[(int)cursor.line].push_back({" ", face}); } else lines[(int)cursor.line].push_back({content.str(), face}); cursor.column += content.column_length(); } if (cursor.column < size.column) lines[(int)cursor.line].push_back({String(' ', size.column - cursor.column), default_face}); } constexpr int NCursesUI::default_shift_function_key; static constexpr StringView assistant_cat[] = { R"( ___ )", R"( (__ \ )", R"( / / ╭)", R"( .' '·. │)", R"( ' ” │)", R"( ╰ /\_/| │)", R"( | . \ │)", R"( ╰_J` | | | ╯)", R"( ' \__- _/ )", R"( \_\ \_\ )", R"( )"}; static constexpr StringView assistant_clippy[] = { " ╭──╮ ", " │ │ ", " @ @ ╭", " ││ ││ │", " ││ ││ ╯", " │╰─╯│ ", " ╰───╯ ", " " }; static constexpr StringView assistant_dilbert[] = { R"( დოოოოოდ )", R"( | | )", R"( | | ╭)", R"( |-ᱛ ᱛ-| │)", R"( Ͼ ∪ Ͽ │)", R"( | | ╯)", R"( ˏ`-.ŏ.-´ˎ )", R"( @ )", R"( @ )", R"( )"}; template T sq(T x) { return x * x; } static sig_atomic_t resize_pending = 0; static sig_atomic_t stdin_closed = 0; template static void signal_handler(int) { *signal_flag = 1; EventManager::instance().force_signal(0); } NCursesUI::NCursesUI() : m_cursor{CursorMode::Buffer, {}}, m_stdin_watcher{STDIN_FILENO, FdEvents::Read, EventMode::Urgent, [this](FDWatcher&, FdEvents, EventMode) { if (not m_on_key) return; while (auto key = get_next_key()) { if (key == ctrl('z')) kill(0, SIGTSTP); // We suspend at this line else m_on_key(*key); } }}, m_assistant(assistant_clippy) { if (not isatty(1)) throw runtime_error("stdout is not a tty"); tcgetattr(STDIN_FILENO, &m_original_termios); setup_terminal(); set_raw_mode(); enable_mouse(true); set_signal_handler(SIGWINCH, &signal_handler<&resize_pending>); set_signal_handler(SIGHUP, &signal_handler<&stdin_closed>); set_signal_handler(SIGTSTP, [](int){ NCursesUI::instance().suspend(); }); check_resize(true); redraw(false); } NCursesUI::~NCursesUI() { enable_mouse(false); restore_terminal(); tcsetattr(STDIN_FILENO, TCSAFLUSH, &m_original_termios); set_signal_handler(SIGWINCH, SIG_DFL); set_signal_handler(SIGHUP, SIG_DFL); set_signal_handler(SIGTSTP, SIG_DFL); } void NCursesUI::suspend() { bool mouse_enabled = m_mouse_enabled; enable_mouse(false); restore_terminal(); auto current = set_signal_handler(SIGTSTP, SIG_DFL); sigset_t unblock_sigtstp, old_mask; sigemptyset(&unblock_sigtstp); sigaddset(&unblock_sigtstp, SIGTSTP); sigprocmask(SIG_UNBLOCK, &unblock_sigtstp, &old_mask); raise(SIGTSTP); // suspend here tcsetattr(STDIN_FILENO, TCSAFLUSH, &m_original_termios); set_signal_handler(SIGTSTP, current); sigprocmask(SIG_SETMASK, &old_mask, nullptr); setup_terminal(); check_resize(true); set_raw_mode(); enable_mouse(mouse_enabled); refresh(true); } void NCursesUI::set_raw_mode() const { termios attr = m_original_termios; attr.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON); attr.c_oflag &= ~OPOST; attr.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); attr.c_lflag |= NOFLSH; attr.c_cflag &= ~(CSIZE | PARENB); attr.c_cflag |= CS8; attr.c_cc[VMIN] = attr.c_cc[VTIME] = 0; tcsetattr(STDIN_FILENO, TCSANOW, &attr); } void NCursesUI::redraw(bool force) { m_window.refresh(force); if (m_menu.columns != 0 or m_menu.pos.column > m_status_len) m_menu.refresh(false); m_info.refresh(false); if (m_cursor.mode == CursorMode::Prompt) set_cursor_pos({m_status_on_top ? 0 : m_dimensions.line, m_cursor.coord.column}); else set_cursor_pos(m_cursor.coord + content_line_offset()); fflush(stdout); } void NCursesUI::set_cursor(CursorMode mode, DisplayCoord coord) { m_cursor = Cursor{mode, coord}; } void NCursesUI::refresh(bool force) { if (m_dirty or force) redraw(force); m_dirty = false; } static const DisplayLine empty_line = { String(" "), {} }; void NCursesUI::draw(const DisplayBuffer& display_buffer, const Face& default_face, const Face& padding_face) { check_resize(); const DisplayCoord dim = dimensions(); const LineCount line_offset = content_line_offset(); LineCount line_index = line_offset; for (const DisplayLine& line : display_buffer.lines()) { m_window.move_cursor(line_index++); m_window.draw(line.atoms(), default_face); } auto face = merge_faces(default_face, padding_face); while (line_index < dim.line + line_offset) { m_window.move_cursor(line_index++); m_window.draw(m_padding_char, face); const auto padding_len = m_padding_char.length(); for (auto col = padding_len; m_padding_fill and col < dim.column; col += padding_len) m_window.draw(m_padding_char, face); } m_dirty = true; } void NCursesUI::draw_status(const DisplayLine& status_line, const DisplayLine& mode_line, const Face& default_face) { const LineCount status_line_pos = m_status_on_top ? 0 : m_dimensions.line; m_window.move_cursor(status_line_pos); m_window.draw(status_line.atoms(), default_face); const auto mode_len = mode_line.length(); m_status_len = status_line.length(); const auto remaining = m_dimensions.column - m_status_len; if (mode_len < remaining) { ColumnCount col = m_dimensions.column - mode_len; m_window.move_cursor({status_line_pos, col}); m_window.draw(mode_line.atoms(), default_face); } else if (remaining > 2) { DisplayLine trimmed_mode_line = mode_line; trimmed_mode_line.trim(mode_len + 2 - remaining, remaining - 2); trimmed_mode_line.insert(trimmed_mode_line.begin(), { "…", {} }); kak_assert(trimmed_mode_line.length() == remaining - 1); ColumnCount col = m_dimensions.column - remaining + 1; m_window.move_cursor({status_line_pos, col}); m_window.draw(trimmed_mode_line.atoms(), default_face); } if (m_set_title) { constexpr char suffix[] = " - Kakoune\007"; char buf[4 + 511 + 2] = "\033]2;"; // Fill title escape sequence buffer, removing non ascii characters auto buf_it = &buf[4], buf_end = &buf[4 + 511 - (sizeof(suffix) - 2)]; for (auto& atom : mode_line) { const auto str = atom.content(); for (auto it = str.begin(), end = str.end(); it != end and buf_it != buf_end; utf8::to_next(it, end)) *buf_it++ = (*it >= 0x20 and *it <= 0x7e) ? *it : '?'; } for (auto c : suffix) *buf_it++ = c; fputs(buf, stdout); fflush(stdout); } m_dirty = true; } void NCursesUI::check_resize(bool force) { if (not force and not resize_pending) return; resize_pending = 0; const int fd = open("/dev/tty", O_RDWR); if (fd < 0) return; auto close_fd = on_scope_end([fd]{ ::close(fd); }); winsize ws; if (::ioctl(fd, TIOCGWINSZ, &ws) != 0) return; const bool info = (bool)m_info; const bool menu = (bool)m_menu; if (m_window) m_window.destroy(); if (info) m_info.destroy(); if (menu) m_menu.destroy(); m_window.create({0, 0}, {ws.ws_row, ws.ws_col}); kak_assert(m_window); m_dimensions = DisplayCoord{ws.ws_row-1, ws.ws_col}; // if (char* csr = tigetstr((char*)"csr")) // putp(tparm(csr, 0, ws.ws_row)); if (menu) menu_show(Vector(std::move(m_menu.items)), m_menu.anchor, m_menu.fg, m_menu.bg, m_menu.style); if (info) info_show(m_info.title, m_info.content, m_info.anchor, m_info.face, m_info.style); set_resize_pending(); } Optional NCursesUI::get_next_key() { if (stdin_closed) { set_signal_handler(SIGWINCH, SIG_DFL); set_signal_handler(SIGHUP, SIG_DFL); if (m_window) m_window.destroy(); m_stdin_watcher.disable(); return {}; } check_resize(); if (m_resize_pending) { m_resize_pending = false; return resize(dimensions()); } static auto get_char = []() -> Optional { if (not fd_readable(STDIN_FILENO)) return {}; if (unsigned char c = 0; read(STDIN_FILENO, &c, 1) == 1) return c; stdin_closed = 1; return {}; }; const auto c = get_char(); if (not c) return {}; const cc_t erase = m_original_termios.c_cc[VERASE]; auto convert = [erase](Codepoint c) -> Codepoint { if (c == control('m') or c == control('j')) return Key::Return; if (c == control('i')) return Key::Tab; if (c == erase) return Key::Backspace; if (c == 127) // when it's not backspace return Key::Delete; if (c == 27) return Key::Escape; return c; }; auto parse_key = [&convert](unsigned char c) -> Key { if (Codepoint cp = convert(c); cp > 255) return Key{cp}; // Special case: you can type NUL with Ctrl-2 or Ctrl-Shift-2 or // Ctrl-Backtick, but the most straightforward way is Ctrl-Space. if (c == 0) return ctrl(' '); // Represent Ctrl-letter combinations in lower-case, to be clear // that Shift is not involved. if (c < 27) return ctrl(c - 1 + 'a'); // Represent Ctrl-symbol combinations in "upper-case", as they are // traditionally-rendered. // Note that Escape is handled elsewhere. if (c < 32) return ctrl(c - 1 + 'A'); struct Sentinel{}; struct CharIterator { unsigned char operator*() { if (not c) c = get_char().value_or((unsigned char)0); return *c; } CharIterator& operator++() { c.reset(); return *this; } bool operator==(const Sentinel&) const { return false; } Optional c; }; return Key{utf8::codepoint(CharIterator{c}, Sentinel{})}; }; auto parse_mask = [](int mask) { Key::Modifiers mod = Key::Modifiers::None; if (mask & 1) mod |= Key::Modifiers::Shift; if (mask & 2) mod |= Key::Modifiers::Alt; if (mask & 4) mod |= Key::Modifiers::Control; return mod; }; auto parse_csi = [this, &convert, &parse_mask]() -> Optional { auto next_char = [] { return get_char().value_or((unsigned char)0xff); }; int params[16] = {}; auto c = next_char(); char private_mode = 0; if (c == '?' or c == '<' or c == '=' or c == '>') { private_mode = c; c = next_char(); } for (int count = 0; count < 16 and c >= 0x30 && c <= 0x3f; c = next_char()) { if (isdigit(c)) params[count] = params[count] * 10 + c - '0'; else if (c == ';') ++count; else return {}; } if (c != '$' and (c < 0x40 or c > 0x7e)) return {}; auto mouse_button = [this](Key::Modifiers mod, Key::MouseButton button, Codepoint coord, bool release) { auto mask = 1 << (int)button; if (not release) { mod |= (m_mouse_state & mask) ? Key::Modifiers::MousePos : Key::Modifiers::MousePress; m_mouse_state |= mask; } else { mod |= Key::Modifiers::MouseRelease; m_mouse_state &= ~mask; } return Key{mod | Key::to_modifier(button), coord}; }; auto mouse_scroll = [this](Key::Modifiers mod, bool down) -> Key { return {mod | Key::Modifiers::Scroll, (Codepoint)((down ? 1 : -1) * m_wheel_scroll_amount)}; }; auto masked_key = [&](Codepoint key) { int mask = std::max(params[1] - 1, 0); Key::Modifiers modifiers = parse_mask(mask); if (is_basic_alpha(key) and (modifiers & Key::Modifiers::Shift)) { modifiers &= ~Key::Modifiers::Shift; key = to_upper(key); } return Key{modifiers, key}; }; switch (c) { case '$': switch (params[0]) { case 23: case 24: return Key{Key::Modifiers::Shift, Key::F11 + params[0] - 23}; // rxvt style } return {}; case 'A': return masked_key(Key::Up); case 'B': return masked_key(Key::Down); case 'C': return masked_key(Key::Right); case 'D': return masked_key(Key::Left); case 'E': return masked_key('5'); // Numeric keypad 5 case 'F': return masked_key(Key::End); // PC/xterm style case 'H': return masked_key(Key::Home); // PC/xterm style case 'P': return masked_key(Key::F1); case 'Q': return masked_key(Key::F2); case 'R': return masked_key(Key::F3); case 'S': return masked_key(Key::F4); case '~': switch (params[0]) { case 1: return masked_key(Key::Home); // VT220/tmux style case 2: return masked_key(Key::Insert); case 3: return masked_key(Key::Delete); case 4: return masked_key(Key::End); // VT220/tmux style case 5: return masked_key(Key::PageUp); case 6: return masked_key(Key::PageDown); case 7: return masked_key(Key::Home); // rxvt style case 8: return masked_key(Key::End); // rxvt style case 11: case 12: case 13: case 14: case 15: return masked_key(Key::F1 + params[0] - 11); case 17: case 18: case 19: case 20: case 21: return masked_key(Key::F6 + params[0] - 17); case 23: case 24: return masked_key(Key::F11 + params[0] - 23); case 25: case 26: return Key{Key::Modifiers::Shift, Key::F3 + params[0] - 25}; // rxvt style case 28: case 29: return Key{Key::Modifiers::Shift, Key::F5 + params[0] - 28}; // rxvt style case 31: case 32: return Key{Key::Modifiers::Shift, Key::F7 + params[0] - 31}; // rxvt style case 33: case 34: return Key{Key::Modifiers::Shift, Key::F9 + params[0] - 33}; // rxvt style } return {}; case 'u': return masked_key(convert(static_cast(params[0]))); case 'Z': return shift(Key::Tab); case 'I': return {Key::FocusIn}; case 'O': return {Key::FocusOut}; case 'M': case 'm': const bool sgr = private_mode == '<'; if (not sgr and c != 'M') return {}; const Codepoint b = sgr ? params[0] : next_char() - 32; const int x = (sgr ? params[1] : next_char() - 32) - 1; const int y = (sgr ? params[2] : next_char() - 32) - 1; auto coord = encode_coord({y - content_line_offset(), x}); Key::Modifiers mod = parse_mask((b >> 2) & 0x7); switch (const int code = b & 0x43; code) { case 0: case 1: case 2: return mouse_button(mod, Key::MouseButton{code}, coord, c == 'm'); case 3: if (sgr) return {}; else if (int guess = ffs(m_mouse_state) - 1; 0 <= guess and guess < 3) return mouse_button(mod, Key::MouseButton{guess}, coord, true); break; case 64: return mouse_scroll(mod, false); case 65: return mouse_scroll(mod, true); } return Key{Key::Modifiers::MousePos, coord}; } return {}; }; auto parse_ss3 = [&parse_mask]() -> Optional { int raw_mask = 0; char code = '0'; do { raw_mask = raw_mask * 10 + (code - '0'); code = get_char().value_or((unsigned char)0xff); } while (code >= '0' and code <= '9'); Key::Modifiers mod = parse_mask(std::max(raw_mask - 1, 0)); switch (code) { case ' ': return Key{mod, ' '}; case 'A': return Key{mod, Key::Up}; case 'B': return Key{mod, Key::Down}; case 'C': return Key{mod, Key::Right}; case 'D': return Key{mod, Key::Left}; case 'F': return Key{mod, Key::End}; case 'H': return Key{mod, Key::Home}; case 'I': return Key{mod, Key::Tab}; case 'M': return Key{mod, Key::Return}; case 'P': return Key{mod, Key::F1}; case 'Q': return Key{mod, Key::F2}; case 'R': return Key{mod, Key::F3}; case 'S': return Key{mod, Key::F4}; case 'X': return Key{mod, '='}; case 'j': return Key{mod, '*'}; case 'k': return Key{mod, '+'}; case 'l': return Key{mod, ','}; case 'm': return Key{mod, '-'}; case 'n': return Key{mod, '.'}; case 'o': return Key{mod, '/'}; case 'p': return Key{mod, '0'}; case 'q': return Key{mod, '1'}; case 'r': return Key{mod, '2'}; case 's': return Key{mod, '3'}; case 't': return Key{mod, '4'}; case 'u': return Key{mod, '5'}; case 'v': return Key{mod, '6'}; case 'w': return Key{mod, '7'}; case 'x': return Key{mod, '8'}; case 'y': return Key{mod, '9'}; default: return {}; } }; if (*c != 27) return parse_key(*c); if (auto next = get_char()) { if (*next == '[') // potential CSI return parse_csi().value_or(alt('[')); if (*next == 'O') // potential SS3 return parse_ss3().value_or(alt('O')); return alt(parse_key(*next)); } return Key{Key::Escape}; } template T div_round_up(T a, T b) { return (a - T(1)) / b + T(1); } void NCursesUI::draw_menu() { // menu show may have not created the window if it did not fit. // so be tolerant. if (not m_menu) return; const int item_count = (int)m_menu.items.size(); if (m_menu.columns == 0) { const auto win_width = m_menu.size.column - 4; kak_assert(m_menu.size.line == 1); ColumnCount pos = 0; m_menu.move_cursor({0, 0}); m_menu.draw(DisplayAtom(m_menu.first_item > 0 ? "< " : " "), m_menu.bg); int i = m_menu.first_item; for (; i < item_count and pos < win_width; ++i) { const DisplayLine& item = m_menu.items[i]; const ColumnCount item_width = item.length(); auto& face = i == m_menu.selected_item ? m_menu.fg : m_menu.bg; m_menu.draw(item.atoms(), face); if (pos + item_width < win_width) m_menu.draw(DisplayAtom(" "), m_menu.bg); else { m_menu.move_cursor({0, win_width+2}); m_menu.draw(DisplayAtom("…"), m_menu.bg); } pos += item_width + 1; } m_menu.move_cursor({0, win_width+3}); m_menu.draw(DisplayAtom(i == item_count ? " " : ">"), m_menu.bg); m_dirty = true; return; } const LineCount menu_lines = div_round_up(item_count, m_menu.columns); const LineCount win_height = m_menu.size.line; kak_assert(win_height <= menu_lines); const ColumnCount column_width = (m_menu.size.column - 1) / m_menu.columns; const LineCount mark_height = min(div_round_up(sq(win_height), menu_lines), win_height); const int menu_cols = div_round_up(item_count, (int)m_menu.size.line); const int first_col = m_menu.first_item / (int)m_menu.size.line; const LineCount mark_line = (win_height - mark_height) * first_col / max(1, menu_cols - m_menu.columns); for (auto line = 0_line; line < win_height; ++line) { for (int col = 0; col < m_menu.columns; ++col) { m_menu.move_cursor({line, col * column_width}); int item_idx = (first_col + col) * (int)m_menu.size.line + (int)line; auto& face = item_idx < item_count and item_idx == m_menu.selected_item ? m_menu.fg : m_menu.bg; auto atoms = item_idx < item_count ? m_menu.items[item_idx].atoms() : ConstArrayView{}; m_menu.draw(atoms, face); } const bool is_mark = line >= mark_line and line < mark_line + mark_height; m_menu.move_cursor({line, m_menu.size.column - 1}); m_menu.draw(DisplayAtom(is_mark ? "█" : "░"), m_menu.bg); } m_dirty = true; } static LineCount height_limit(MenuStyle style) { switch (style) { case MenuStyle::Inline: return 10_line; case MenuStyle::Prompt: return 10_line; case MenuStyle::Search: return 3_line; } kak_assert(false); return 0_line; } void NCursesUI::menu_show(ConstArrayView items, DisplayCoord anchor, Face fg, Face bg, MenuStyle style) { if (m_menu) { m_menu.destroy(); m_dirty = true; } m_menu.fg = fg; m_menu.bg = bg; m_menu.style = style; m_menu.anchor = anchor; if (m_dimensions.column <= 2) return; const int item_count = items.size(); m_menu.items.clear(); // make sure it is empty m_menu.items.reserve(item_count); const auto longest = accumulate(items | transform(&DisplayLine::length), 1_col, [](auto&& lhs, auto&& rhs) { return std::max(lhs, rhs); }); const ColumnCount max_width = m_dimensions.column - 1; const bool is_inline = style == MenuStyle::Inline; const bool is_search = style == MenuStyle::Search; m_menu.columns = is_search ? 0 : (is_inline ? 1 : max((int)(max_width / (longest+1)), 1)); const LineCount max_height = min(height_limit(style), max(anchor.line, m_dimensions.line - anchor.line - 1)); const LineCount height = is_search ? 1 : (min(max_height, div_round_up(item_count, m_menu.columns))); const ColumnCount maxlen = (m_menu.columns > 1 and item_count > 1) ? max_width / m_menu.columns - 1 : max_width; for (auto& item : items) { m_menu.items.push_back(item); m_menu.items.back().trim(0, maxlen); kak_assert(m_menu.items.back().length() <= maxlen); } if (is_inline) anchor.line += content_line_offset(); LineCount line = anchor.line + 1; ColumnCount column = std::max(0_col, std::min(anchor.column, m_dimensions.column - longest - 1)); if (is_search) { line = m_status_on_top ? 0_line : m_dimensions.line; column = m_dimensions.column / 2; } else if (not is_inline) line = m_status_on_top ? 1_line : m_dimensions.line - height; else if (line + height > m_dimensions.line) line = anchor.line - height; const auto width = is_search ? m_dimensions.column - m_dimensions.column / 2 : (is_inline ? min(longest+1, m_dimensions.column) : m_dimensions.column); m_menu.create({line, column}, {height, width}); m_menu.selected_item = item_count; m_menu.first_item = 0; draw_menu(); if (m_info) info_show(m_info.title, m_info.content, m_info.anchor, m_info.face, m_info.style); } void NCursesUI::menu_select(int selected) { const int item_count = m_menu.items.size(); if (selected < 0 or selected >= item_count) { m_menu.selected_item = -1; m_menu.first_item = 0; } else if (m_menu.columns == 0) // Do not columnize { m_menu.selected_item = selected; const ColumnCount width = m_menu.size.column - 3; int first = 0; ColumnCount item_col = 0; for (int i = 0; i <= selected; ++i) { const ColumnCount item_width = m_menu.items[i].length() + 1; if (item_col + item_width > width) { first = i; item_col = item_width; } else item_col += item_width; } m_menu.first_item = first; } else { m_menu.selected_item = selected; const int menu_cols = div_round_up(item_count, (int)m_menu.size.line); const int first_col = m_menu.first_item / (int)m_menu.size.line; const int selected_col = m_menu.selected_item / (int)m_menu.size.line; if (selected_col < first_col) m_menu.first_item = selected_col * (int)m_menu.size.line; if (selected_col >= first_col + m_menu.columns) m_menu.first_item = min(selected_col, menu_cols - m_menu.columns) * (int)m_menu.size.line; } draw_menu(); } void NCursesUI::menu_hide() { if (not m_menu) return; m_menu.items.clear(); m_menu.destroy(); m_dirty = true; // Recompute info as it does not have to avoid the menu anymore if (m_info) info_show(m_info.title, m_info.content, m_info.anchor, m_info.face, m_info.style); } static DisplayCoord compute_pos(DisplayCoord anchor, DisplayCoord size, NCursesUI::Rect rect, NCursesUI::Rect to_avoid, bool prefer_above) { DisplayCoord pos; if (prefer_above) { pos = anchor - DisplayCoord{size.line}; if (pos.line < 0) prefer_above = false; } auto rect_end = rect.pos + rect.size; if (not prefer_above) { pos = anchor + DisplayCoord{1_line}; if (pos.line + size.line > rect_end.line) pos.line = max(rect.pos.line, anchor.line - size.line); } if (pos.column + size.column > rect_end.column) pos.column = max(rect.pos.column, rect_end.column - size.column); if (to_avoid.size != DisplayCoord{}) { DisplayCoord to_avoid_end = to_avoid.pos + to_avoid.size; DisplayCoord end = pos + size; // check intersection if (not (end.line < to_avoid.pos.line or end.column < to_avoid.pos.column or pos.line > to_avoid_end.line or pos.column > to_avoid_end.column)) { pos.line = min(to_avoid.pos.line, anchor.line) - size.line; // if above does not work, try below if (pos.line < 0) pos.line = max(to_avoid_end.line, anchor.line); } } return pos; } static DisplayLineList wrap_lines(const DisplayLineList& lines, ColumnCount max_width) { DisplayLineList result; for (auto line : lines) { ColumnCount column = 0; for (auto it = line.begin(); it != line.end(); ) { auto length = it->length(); column += length; if (column > max_width) { auto content = it->content().substr(0, length - (column - max_width)); auto pos = find_if(content | reverse(), [](char c) { return not is_word(c); }); if (pos != content.rend()) content = {content.begin(), pos.base()}; if (not content.empty()) it = ++line.split(it, content.column_length()); result.push_back(AtomList(std::make_move_iterator(line.begin()), std::make_move_iterator(it))); it = line.erase(line.begin(), it); column = 0; } else ++it; } result.push_back(std::move(line)); } return result; } void NCursesUI::info_show(const DisplayLine& title, const DisplayLineList& content, DisplayCoord anchor, Face face, InfoStyle style) { info_hide(); m_info.title = title; m_info.content = content; m_info.anchor = anchor; m_info.face = face; m_info.style = style; const bool framed = style == InfoStyle::Prompt or style == InfoStyle::Modal; const bool assisted = style == InfoStyle::Prompt and m_assistant.size() != 0; DisplayCoord max_size = m_dimensions; if (style == InfoStyle::MenuDoc) max_size.column = std::max(m_dimensions.column - (m_menu.pos.column + m_menu.size.column), m_menu.pos.column); else if (style != InfoStyle::Modal) max_size.line -= m_menu.size.line; const auto max_content_width = max_size.column - (framed ? 4 : 2) - (assisted ? m_assistant[0].column_length() : 0); if (max_content_width <= 0) return; auto compute_size = [](const DisplayLineList& lines) -> DisplayCoord { return {(int)lines.size(), accumulate(lines, 0_col, [](ColumnCount c, const DisplayLine& l) { return std::max(c, l.length()); })}; }; DisplayCoord content_size = compute_size(content); const bool wrap = content_size.column > max_content_width; DisplayLineList wrapped_content; if (wrap) { wrapped_content = wrap_lines(content, max_content_width); content_size = compute_size(wrapped_content); } const auto& lines = wrap ? wrapped_content : content; DisplayCoord size{content_size.line, std::max(content_size.column, title.length() + (framed ? 2 : 0))}; if (framed) size += {2, 4}; if (assisted) size = {std::max(LineCount{(int)m_assistant.size()-1}, size.line), size.column + m_assistant[0].column_length()}; size = {std::min(max_size.line, size.line), std::min(max_size.column, size.column)}; if ((framed and size.line < 3) or size.line <= 0) return; const Rect rect = {content_line_offset(), m_dimensions}; if (style == InfoStyle::Prompt) { anchor = DisplayCoord{m_status_on_top ? 0 : m_dimensions.line, m_dimensions.column-1}; anchor = compute_pos(anchor, size, rect, m_menu, style == InfoStyle::InlineAbove); } else if (style == InfoStyle::Modal) { auto half = [](const DisplayCoord& c) { return DisplayCoord{c.line / 2, c.column / 2}; }; anchor = rect.pos + half(rect.size) - half(size); } else if (style == InfoStyle::MenuDoc) { const auto right_max_width = m_dimensions.column - (m_menu.pos.column + m_menu.size.column); const auto left_max_width = m_menu.pos.column; anchor.line = m_menu.pos.line; if (size.column <= right_max_width or right_max_width >= left_max_width) anchor.column = m_menu.pos.column + m_menu.size.column; else anchor.column = m_menu.pos.column - size.column; } else { anchor = compute_pos(anchor, size, rect, m_menu, style == InfoStyle::InlineAbove); anchor.line += content_line_offset(); } m_info.create(anchor, size); auto draw_atoms = [&](auto&&... args) { auto draw = overload( [&](String str) { m_info.draw(DisplayAtom{std::move(str)}, face); }, [&](const DisplayLine& atoms) { m_info.draw(atoms.atoms(), face); }); (draw(args), ...); }; for (auto line = 0_line; line < size.line; ++line) { constexpr Codepoint dash{L'─'}; constexpr Codepoint dotted_dash{L'┄'}; m_info.move_cursor(line); if (assisted) { const auto assistant_top_margin = (size.line - m_assistant.size()+1) / 2; StringView assistant_line = (line >= assistant_top_margin) ? m_assistant[(int)min(line - assistant_top_margin, LineCount{(int)m_assistant.size()}-1)] : m_assistant[(int)m_assistant.size()-1]; draw_atoms(assistant_line.str()); } if (not framed) draw_atoms(lines[(int)line]); else if (line == 0) { if (title.atoms().empty() or content_size.column < 2) draw_atoms("╭─" + String{dash, content_size.column} + "─╮"); else { auto trimmed_title = title; trimmed_title.trim(0, content_size.column - 2); auto dash_count = content_size.column - trimmed_title.length() - 2; String left{dash, dash_count / 2}; String right{dash, dash_count - dash_count / 2}; draw_atoms("╭─" + left + "┤", trimmed_title, "├" + right +"─╮"); } } else if (line < size.line - 1 and line <= lines.size()) { auto info_line = lines[(int)line - 1]; const bool trimmed = info_line.trim(0, content_size.column); const ColumnCount padding = content_size.column - info_line.length(); draw_atoms("│ ", info_line, String{' ', padding} + (trimmed ? "…│" : " │")); } else if (line == std::min((int)lines.size() + 1, size.line - 1)) draw_atoms("╰─" + String(line > lines.size() ? dash : dotted_dash, content_size.column) + "─╯"); } m_dirty = true; } void NCursesUI::info_hide() { if (not m_info) return; m_info.destroy(); m_dirty = true; } void NCursesUI::set_on_key(OnKeyCallback callback) { m_on_key = std::move(callback); EventManager::instance().force_signal(0); } DisplayCoord NCursesUI::dimensions() { return m_dimensions; } LineCount NCursesUI::content_line_offset() const { return m_status_on_top ? 1 : 0; } void NCursesUI::set_resize_pending() { m_resize_pending = true; EventManager::instance().force_signal(0); } void NCursesUI::setup_terminal() { // enable alternative screen buffer fputs("\033[?1049h", stdout); // enable focus notify fputs("\033[?1004h", stdout); // request CSI u style key reporting fputs("\033[>4;1m", stdout); // save the current window title fputs("\033[22t", stdout); // hide cursor fputs("\033[?25l", stdout); // set application keypad mode, so the keypad keys send unique codes fputs("\033=", stdout); fflush(stdout); } void NCursesUI::restore_terminal() { fputs("\033>", stdout); fputs("\033[?25h", stdout); fputs("\033[23t", stdout); fputs("\033[>4;0m", stdout); fputs("\033[?1004l", stdout); fputs("\033[?1049l", stdout); // set the terminal output back to default colours and style fputs("\033[m", stdout); fflush(stdout); } void NCursesUI::enable_mouse(bool enabled) { if (enabled == m_mouse_enabled) return; m_mouse_enabled = enabled; if (enabled) { // force SGR mode fputs("\033[?1006h", stdout); // enable mouse fputs("\033[?1000h", stdout); // force enable report mouse position fputs("\033[?1002h", stdout); } else { fputs("\033[?1002l", stdout); fputs("\033[?1000l", stdout); fputs("\033[?1006l", stdout); } fflush(stdout); } void NCursesUI::set_ui_options(const Options& options) { { auto it = options.find("ncurses_assistant"_sv); if (it == options.end() or it->value == "clippy") m_assistant = assistant_clippy; else if (it->value == "cat") m_assistant = assistant_cat; else if (it->value == "dilbert") m_assistant = assistant_dilbert; else if (it->value == "none" or it->value == "off") m_assistant = ConstArrayView{}; } { auto it = options.find("ncurses_status_on_top"_sv); m_status_on_top = it != options.end() and (it->value == "yes" or it->value == "true"); } { auto it = options.find("ncurses_set_title"_sv); m_set_title = it == options.end() or (it->value == "yes" or it->value == "true"); } { auto it = options.find("ncurses_shift_function_key"_sv); m_shift_function_key = it != options.end() ? str_to_int_ifp(it->value).value_or(default_shift_function_key) : default_shift_function_key; } { auto enable_mouse_it = options.find("ncurses_enable_mouse"_sv); enable_mouse(enable_mouse_it == options.end() or enable_mouse_it->value == "yes" or enable_mouse_it->value == "true"); auto wheel_up_it = options.find("ncurses_wheel_up_button"_sv); m_wheel_up_button = wheel_up_it != options.end() ? str_to_int_ifp(wheel_up_it->value).value_or(4) : 4; auto wheel_down_it = options.find("ncurses_wheel_down_button"_sv); m_wheel_down_button = wheel_down_it != options.end() ? str_to_int_ifp(wheel_down_it->value).value_or(5) : 5; auto wheel_scroll_amount_it = options.find("ncurses_wheel_scroll_amount"_sv); m_wheel_scroll_amount = wheel_scroll_amount_it != options.end() ? str_to_int_ifp(wheel_scroll_amount_it->value).value_or(3) : 3; } { auto it = options.find("ncurses_padding_char"_sv); if (it == options.end()) // Defaults to tilde. m_padding_char = DisplayAtom("~"); else if (it->value.column_length() < 1) // Do not allow empty string, use space instead. m_padding_char = DisplayAtom(" "); else m_padding_char = DisplayAtom(it->value); } { auto it = options.find("ncurses_padding_fill"_sv); m_padding_fill = it != options.end() and (it->value == "yes" or it->value == "true"); } } }