// pipedial -- terminal element picker // Copyright (C) 2019 xaizek <xaizek@posteo.net> // // This file is part of pipedial. // // pipedial is free software: you can redistribute it and/or modify // it under the terms of version 3 of the GNU General Public License // as published by the Free Software Foundation. // // pipedial is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with pipedial. If not, see <https://www.gnu.org/licenses/>. #include "PipeDial.hpp" #include <algorithm> #include <locale> #include <string> #include <utility> #include <vector> #include "cursed/List.hpp" #include "cursed/Prompt.hpp" #include "cursed/Track.hpp" #include "cursedrl/PromptRequest.hpp" #include "vle/KeyDispatcher.hpp" #include "vle/Mode.hpp" #include "vle/Modes.hpp" #include "vle/keys.hpp" static std::wstring::size_type find(const std::wstring &str, const std::wstring &what, bool caseSensitive, std::wstring::size_type from = 0U); PipeDial::PipeDial(std::wstring titleText, std::vector<std::wstring> initLines) : lines(std::move(initLines)), input(cursed::Keypad::Disabled), title(std::move(titleText)), quit(false) { cursed::Format titleBg; titleBg.setForeground(cursed::Color::Cyan); titleBg.setBackground(cursed::Color::Black); titleBg.setReversed(true); titleBg.setBold(true); title.setBackground(titleBg); inputBuf.setBackground(std::move(titleBg)); help.setLines(buildHelpMessage()); list.setItems({ lines.cbegin(), lines.cend() }); track.addItem(&title); track.addItem(&list); track.addItem(&inputBuf); matchHi.setBold(true); matchHi.setReversed(true); std::vector<vle::Mode> allModes; allModes.emplace_back(buildNormalMode()); allModes.emplace_back(buildHelpMode()); modes.setModes(std::move(allModes)); modes.switchTo("normal"); } std::vector<cursed::ColorTree> PipeDial::buildHelpMessage() { cursed::Format mainTitle; mainTitle.setBold(true); cursed::Format modeTitle; modeTitle.setBold(true); cursed::Format shortcut; shortcut.setBold(true); std::vector<cursed::ColorTree> lines; lines.emplace_back(); lines.emplace_back(mainTitle( L" SUMMARY OF PIPEDIAL SHORTCUTS" )); lines.emplace_back(mainTitle( L" =============================" )); lines.emplace_back(); lines.emplace_back( shortcut(L"{Escape}") + L" -- abort partially typed shortcut." ); lines.emplace_back(); lines.emplace_back(modeTitle(L"Normal Mode")); lines.emplace_back(modeTitle(L"-----------")); lines.emplace_back(); lines.emplace_back( shortcut(L"q") + L" -- quit the application with no selection." ); lines.emplace_back(); lines.emplace_back(shortcut(L"h") + L" -- enter Help mode."); lines.emplace_back(); lines.emplace_back( shortcut(L"{Enter}") + L" -- select current item and quit." ); lines.emplace_back(); lines.emplace_back( shortcut(L"e") + L" -- edit current item and accept the result." ); lines.emplace_back(); lines.emplace_back( shortcut(L"/") + L" -- start interactive filtering." ); lines.emplace_back(); lines.emplace_back( shortcut(L"[count]gg") + L" -- put cursor on the first or " + shortcut(L"[count]") + L"-th element." ); lines.emplace_back(); lines.emplace_back( shortcut(L"[count]G") + L" -- put cursor on the last or " + shortcut(L"[count]") + L"-th element." ); lines.emplace_back(); lines.emplace_back( shortcut(L"[count]j") + L" -- move cursor " + shortcut(L"[count]") + L" (1 by default) elements down." ); lines.emplace_back(); lines.emplace_back( shortcut(L"[count]k") + L" -- move cursor " + shortcut(L"[count]") + L" (1 by default) elements up." ); lines.emplace_back(); lines.emplace_back(shortcut(L"^E") + L" -- scroll one line down."); lines.emplace_back(); lines.emplace_back(shortcut(L"^Y") + L" -- scroll one line up."); lines.emplace_back(); lines.emplace_back(modeTitle(L"Help Mode")); lines.emplace_back(modeTitle(L"---------")); lines.emplace_back(); lines.emplace_back(shortcut(L"h") + L" -- return to Normal mode."); lines.emplace_back(); lines.emplace_back(shortcut(L"gg") + L" -- scroll to the top."); lines.emplace_back(); lines.emplace_back(shortcut(L"G") + L" -- scroll to the bottom."); lines.emplace_back(); lines.emplace_back( shortcut(L"j") + L"/" + shortcut(L"^E") + L" -- scroll one line down." ); lines.emplace_back(); lines.emplace_back( shortcut(L"k") + L"/" + shortcut(L"^Y") + L" -- scroll one line up." ); lines.emplace_back(); return lines; } vle::Mode PipeDial::buildNormalMode() { vle::Mode normalMode("normal"); normalMode.setUsesCount(true); normalMode.addShortcut({ L"G", [&](int count) { if (count < 0) { list.moveToLast(); } else { list.moveToPos(count - 1); } }, "go to the last or [count]-th item" }); normalMode.addShortcut({ L"gg", [&](int count) { if (count < 0) { list.moveToFirst(); } else { list.moveToPos(count - 1); } }, "go to the first or [count]-th item" }); normalMode.addShortcut({ L"j", [&](int count) { list.moveDown(count < 0 ? 1 : count); }, "go [count] items below" }); normalMode.addShortcut({ L"k", [&](int count) { list.moveUp(count < 0 ? 1 : count); }, "go [count] items above" }); normalMode.addShortcut({ L"q", [&]() { quit = true; }, "quit the application with no selection" }); normalMode.addShortcut({ L"\n", [&]() { quit = true; result = list.getCurrent(); }, "select current item and quit" }); normalMode.addShortcut({ L"h", [&]() { modes.switchTo("help"); screen.replaceTopWidget(&help); }, "display help" }); normalMode.addShortcut({ L"e", [this]() { editAndAccept(); }, "edit and accept" }); normalMode.addShortcut({ vle::Key(L'e').ctrl(), [&]() { list.scrollDown(); }, "scroll one line down" }); normalMode.addShortcut({ vle::Key(L'y').ctrl(), [&]() { list.scrollUp(); }, "scroll one line up" }); normalMode.addShortcut({ L"/", [this]() { filter(); }, "start interactive filtering" }); return normalMode; } void PipeDial::editAndAccept() { cursed::Prompt prompt; cursed::Track tmpTrack; tmpTrack.addItem(&title); tmpTrack.addItem(&list); tmpTrack.addItem(&prompt); cursedrl::PromptRequest request(screen, prompt); screen.replaceTopWidget(&tmpTrack); if (cursedrl::PromptResult r = request.prompt(L"Edit and accept: ", list.getCurrent())) { result = r.getResult(); quit = true; } else { screen.replaceTopWidget(&track); } } void PipeDial::filter() { cursed::List filterList; filterList.setItems({ lines.cbegin(), lines.cend() }); cursed::Prompt prompt; cursed::Track tmpTrack; tmpTrack.addItem(&title); tmpTrack.addItem(&filterList); tmpTrack.addItem(&prompt); std::vector<cursed::ColorTree> filtered(lines.cbegin(), lines.cend()); auto update = [&](const std::wstring &filter) { if (filter.empty()) { filtered.assign(lines.cbegin(), lines.cend()); filterList.setItems(filtered); return; } filtered.clear(); for (int tries = 0; tries < 2; ++tries) { bool caseSensitive = (tries == 0); for (const std::wstring &line : lines) { auto pos = find(line, filter, caseSensitive); if (pos == std::wstring::npos) { continue; } cursed::ColorTree item; decltype(pos) from = 0U; while (pos != std::wstring::npos) { item += line.substr(from, pos - from); item += matchHi(line.substr(pos, filter.size())); from = pos + filter.size(); pos = find(line, filter, caseSensitive, from); } item += line.substr(from); filtered.emplace_back(std::move(item)); } if (!filtered.empty()) { break; } } filterList.setItems(filtered); }; cursedrl::PromptRequest request(screen, prompt); request.setOnInputChanged(update); screen.replaceTopWidget(&tmpTrack); if (request.prompt(L"&/", L"")) { list.setItems(std::move(filtered)); } screen.replaceTopWidget(&track); } // A `std::string::find()`-like function that can do case-insensitive searches. static std::wstring::size_type find(const std::wstring &where, const std::wstring &what, bool caseSensitive, std::wstring::size_type from) { if (caseSensitive) { return where.find(what, from); } std::locale loc; auto it = std::search(where.cbegin() + from, where.cend(), what.cbegin(), what.cend(), [&loc](wchar_t a, wchar_t b) { return std::toupper(a, loc) == std::toupper(b, loc); }); if (it == where.cend()) { return std::wstring::npos; } return it - where.cbegin(); } vle::Mode PipeDial::buildHelpMode() { vle::Mode helpMode("help"); helpMode.addShortcut({ L"h", [&]() { modes.switchTo("normal"); screen.replaceTopWidget(&track); }, "display help" }); helpMode.addShortcut({ L"G", [&]() { help.scrollToBottom(); }, "scroll to the top" }); helpMode.addShortcut({ L"gg", [&]() { help.scrollToTop(); }, "scroll to the bottom" }); helpMode.addShortcut({ { L"j", vle::Key(L'e').ctrl() }, [&]() { help.scrollDown(); }, "scroll one line down" }); helpMode.addShortcut({ { L"k", vle::Key(L'y').ctrl() }, [&]() { help.scrollUp(); }, "scroll one line up" }); return helpMode; } std::wstring PipeDial::run(bool filterOnStart) { // Make sure curses lets go of the screen when this method ends. struct S { cursed::Screen &screen; ~S() { screen.stopTUI(); } } s { screen }; screen.replaceTopWidget(&track); if (filterOnStart) { filter(); } screen.draw(); vle::KeyDispatcher dispatcher; while (cursed::InputElement ie = input.read()) { if (ie.isTerminalResize()) { screen.resize(); screen.draw(); continue; } if (!ie.isFunctionalKey()) { dispatcher.dispatch(ie); } if (quit) { break; } inputBuf.setText(dispatcher.getPendingInput()); screen.draw(); } return result; }