xaizek / pipedial (License: GPLv3 only) (since 2019-01-08)
One more tool for selecting something in console.
<root> / src / PipeDial.cpp (6ea47bb5b7c5ddd3a49afaea6d2ab25d1894f717) (11KiB) (mode 100644) [raw]
// 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;
}
Hints

Before first commit, do not forget to setup your git environment:
git config --global user.name "your_name_here"
git config --global user.email "your@email_here"

Clone this repository using HTTP(S):
git clone https://code.reversed.top/user/xaizek/pipedial

Clone this repository using ssh (do not forget to upload a key first):
git clone ssh://rocketgit@code.reversed.top/user/xaizek/pipedial

You are allowed to anonymously push to this repository.
This means that your pushed commits will automatically be transformed into a pull request:
... clone the repository ...
... make some changes and some commits ...
git push origin master