14.6: the command palette on Ctrl+F
This commit is contained in:
15 files changed
+1030
-3
No files matched your search
@@ -0,0 +1,236 @@
|
||||
#include "UI/CommandPalette.h"
|
||||
#include "Logging.h"
|
||||
#include <QPointer>
|
||||
#include <QTextBoundaryFinder>
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
namespace {
|
||||
// Swift's Characters: one string a grapheme.
|
||||
QStringList characters(const QString &text)
|
||||
{
|
||||
QStringList result;
|
||||
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
|
||||
qsizetype start = 0;
|
||||
while (finder.toNextBoundary() != -1) {
|
||||
result << text.mid(start, finder.position() - start);
|
||||
start = finder.position();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
char32_t first(const QString &character)
|
||||
{
|
||||
return character.toUcs4().value(0);
|
||||
}
|
||||
|
||||
bool isLetter(const QString &character)
|
||||
{
|
||||
return QChar::isLetter(first(character));
|
||||
}
|
||||
|
||||
// Each word starts a word of `title`, in order.
|
||||
bool wordsStartWords(const QStringList &words, const QStringList &title)
|
||||
{
|
||||
qsizetype from = 0;
|
||||
for (const QString &word : words) {
|
||||
const QStringList wanted = characters(word);
|
||||
std::optional<qsizetype> found;
|
||||
for (qsizetype at = from; at + wanted.size() <= title.size() && !found; ++at)
|
||||
if (std::equal(wanted.begin(), wanted.end(), title.begin() + at) && (at == 0 || !isLetter(title[at - 1])))
|
||||
found = at + wanted.size();
|
||||
if (!found)
|
||||
return false;
|
||||
from = found.value();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void collect(const QList<QAction *> &items, const QStringList &path, const std::set<QString> &skipping, std::map<QString, int> &seen,
|
||||
std::vector<CommandPaletteEntry> &result)
|
||||
{
|
||||
for (QAction *item : items) {
|
||||
const QString name = CommandPaletteMenu::plain(item->text());
|
||||
if (item->isSeparator() || !item->isVisible() || skipping.contains(name) || name.isEmpty())
|
||||
continue;
|
||||
if (item->menu()) {
|
||||
collect(item->menu()->actions(), path + QStringList{name}, skipping, seen, result);
|
||||
continue;
|
||||
}
|
||||
const QString title = (path + QStringList{name}).join(QStringLiteral(" › "));
|
||||
const int count = seen[title]++;
|
||||
const QKeySequence key = item->shortcut();
|
||||
result.push_back({.id = count == 0 ? title : QStringLiteral("%1 (%2)").arg(title).arg(count + 1),
|
||||
.title = title,
|
||||
.shortcut = key.isEmpty() ? std::nullopt : std::optional(key.toString(QKeySequence::NativeText)),
|
||||
.isEnabled = item->isEnabled(),
|
||||
.isOn = item->isChecked(),
|
||||
.perform = [item = QPointer<QAction>(item), title] {
|
||||
if (!item) {
|
||||
qCWarning(lcApp) << "palette: menu entry gone:" << title;
|
||||
return;
|
||||
}
|
||||
item->trigger();
|
||||
}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<int> CommandPaletteSearch::score(const QString &query, const QString &text)
|
||||
{
|
||||
QStringList wanted;
|
||||
for (const QString &character : characters(query.toLower()))
|
||||
if (!QChar::isSpace(first(character)))
|
||||
wanted << character;
|
||||
if (wanted.isEmpty())
|
||||
return 0;
|
||||
const QStringList letters = characters(text.toLower());
|
||||
int total = 0;
|
||||
qsizetype from = 0;
|
||||
qsizetype previous = -2;
|
||||
for (const QString &character : wanted) {
|
||||
const qsizetype found = letters.indexOf(character, from);
|
||||
if (found < 0)
|
||||
return std::nullopt;
|
||||
const bool wordStart = found == 0 || !(isLetter(letters[found - 1]) || QChar::isNumber(first(letters[found - 1])));
|
||||
total += 1 + (wordStart ? 8 : 0) + (found == previous + 1 ? 5 : 0);
|
||||
previous = found;
|
||||
from = found + 1;
|
||||
}
|
||||
// How the text was found, best first; scattered letters last.
|
||||
const QString title = letters.join(QString());
|
||||
const QString phrase = trimmedWhitespaces(query.toLower());
|
||||
const QStringList words = phrase.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
||||
int tier = 1;
|
||||
if (wordsStartWords({phrase}, letters))
|
||||
tier = 5;
|
||||
else if (words.size() > 1 && wordsStartWords(words, letters))
|
||||
tier = 4;
|
||||
else if (title.contains(phrase))
|
||||
tier = 3;
|
||||
else if (QString(title).remove(QLatin1Char(' ')).contains(wanted.join(QString())))
|
||||
tier = 2;
|
||||
// Same tier: shorter title wins; scattered letters by fall.
|
||||
return tier * 1'000'000 + (tier == 1 ? total * 100 : 0) - int(letters.size() / 4);
|
||||
}
|
||||
|
||||
std::vector<CommandPaletteEntry> CommandPaletteSearch::rank(const std::vector<CommandPaletteEntry> &entries, const QString &query)
|
||||
{
|
||||
struct Scored {
|
||||
const CommandPaletteEntry *entry;
|
||||
int score;
|
||||
};
|
||||
std::vector<Scored> scored;
|
||||
for (const CommandPaletteEntry &entry : entries)
|
||||
if (const std::optional<int> found = score(query, entry.title))
|
||||
scored.push_back({&entry, found.value()});
|
||||
std::stable_sort(scored.begin(), scored.end(), [&](const Scored &a, const Scored &b) {
|
||||
if (a.entry->isEnabled != b.entry->isEnabled)
|
||||
return a.entry->isEnabled;
|
||||
return !query.isEmpty() && a.score > b.score;
|
||||
});
|
||||
std::vector<CommandPaletteEntry> result;
|
||||
for (const Scored &each : scored)
|
||||
result.push_back(*each.entry);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<CommandPaletteEntry> CommandPaletteMenu::entries(const QMenuBar &bar, const std::set<QString> &skipping)
|
||||
{
|
||||
std::map<QString, int> seen;
|
||||
std::vector<CommandPaletteEntry> result;
|
||||
collect(bar.actions(), {}, skipping, seen, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
QString CommandPaletteMenu::plain(const QString &text)
|
||||
{
|
||||
QString result;
|
||||
for (qsizetype at = 0; at < text.size(); ++at) {
|
||||
if (text[at] != QLatin1Char('&'))
|
||||
result += text[at];
|
||||
else if (at + 1 < text.size() && text[at + 1] == QLatin1Char('&'))
|
||||
result += text[++at];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<CommandPaletteEntry> CommandPaletteCommands::layerCommands(EditorSession &session)
|
||||
{
|
||||
const std::optional<ImageLayer> active = session.activeLayer();
|
||||
const bool canAdd = session.canEditMask() && !(active && active->mask);
|
||||
const bool selected = session.selection().has_value();
|
||||
const auto add = [&session](bool revealing) { return [session = QPointer(&session), revealing] { if (session) session->addMask(revealing); }; };
|
||||
const QString reveal = selected ? QStringLiteral("Layer › Add Layer Mask from Selection") : QStringLiteral("Layer › Add Layer Mask");
|
||||
const QString hide = selected ? QStringLiteral("Layer › Add Layer Mask Hiding Selection") : QStringLiteral("Layer › Add Layer Mask (Hide All)");
|
||||
return {{.id = reveal, .title = reveal, .shortcut = std::nullopt, .isEnabled = canAdd, .isOn = false, .perform = add(true)},
|
||||
{.id = hide, .title = hide, .shortcut = std::nullopt, .isEnabled = canAdd, .isOn = false, .perform = add(false)}};
|
||||
}
|
||||
|
||||
std::vector<CommandPaletteEntry> CommandPaletteCommands::tools(EditorSession &session)
|
||||
{
|
||||
using Setup = std::function<void(EditorSession &)>;
|
||||
const std::vector<std::tuple<QString, QString, NavigationTool, Setup>> tools{
|
||||
{"Move / Transform", "V", NavigationTool::move, nullptr},
|
||||
{"Rectangular Marquee", "M", NavigationTool::marquee, [](EditorSession &s) { s.setMarqueeKind(LassoKind::rectangle); }},
|
||||
{"Elliptical Marquee", "M", NavigationTool::marquee, [](EditorSession &s) { s.setMarqueeKind(LassoKind::ellipse); }},
|
||||
{"Lasso", "L", NavigationTool::lasso, [](EditorSession &s) { s.setLassoKind(LassoKind::freehand); }},
|
||||
{"Polygonal Lasso", "L", NavigationTool::lasso, [](EditorSession &s) { s.setLassoKind(LassoKind::polygonal); }},
|
||||
{"Magic Wand", "W", NavigationTool::wand, [](EditorSession &s) { s.setWandMode(WandMode::wand); }},
|
||||
{"Object Selection", "W", NavigationTool::wand, [](EditorSession &s) { s.setWandMode(WandMode::object); }},
|
||||
{"Crop", "C", NavigationTool::crop, nullptr},
|
||||
{"Brush", "B", NavigationTool::brush, [](EditorSession &s) { s.setBrushMode(BrushToolMode::paint); }},
|
||||
{"Eraser", "E", NavigationTool::brush, [](EditorSession &s) { s.setBrushMode(BrushToolMode::erase); }},
|
||||
{"Spot Healing Brush", "J", NavigationTool::spotHealing, nullptr},
|
||||
{"Clone Stamp", "S", NavigationTool::cloneStamp, nullptr},
|
||||
{"Liquify", "R", NavigationTool::blur, [](EditorSession &s) { s.setBlurMode(BlurToolMode::liquify); }},
|
||||
{"Blur", "R", NavigationTool::blur, [](EditorSession &s) { s.setBlurMode(BlurToolMode::blur); }},
|
||||
{"Smudge", "R", NavigationTool::blur, [](EditorSession &s) { s.setBlurMode(BlurToolMode::smudge); }},
|
||||
{"Gradient", "G", NavigationTool::gradient, nullptr},
|
||||
{"Rectangle", "U", NavigationTool::shape, [](EditorSession &s) { s.setShapeKind(ShapeKind::rectangle); }},
|
||||
{"Ellipse", "U", NavigationTool::shape, [](EditorSession &s) { s.setShapeKind(ShapeKind::ellipse); }},
|
||||
{"Line", "U", NavigationTool::shape, [](EditorSession &s) { s.setShapeKind(ShapeKind::line); }},
|
||||
{"Type", "T", NavigationTool::type, nullptr},
|
||||
{"Eyedropper", "I", NavigationTool::eyedropper, nullptr},
|
||||
{"Hand", "H", NavigationTool::hand, nullptr},
|
||||
{"Zoom", "Z", NavigationTool::zoom, nullptr}};
|
||||
std::vector<CommandPaletteEntry> result;
|
||||
for (const auto &[name, key, tool, setup] : tools) {
|
||||
const QString id = QStringLiteral("Tool › %1").arg(name);
|
||||
result.push_back({.id = id, .title = id, .shortcut = key, .isEnabled = session.document().has_value(), .isOn = false,
|
||||
.perform = [session = QPointer(&session), tool, setup] {
|
||||
if (!session)
|
||||
return;
|
||||
session->selectTool(tool);
|
||||
// Only once the tool is in hand.
|
||||
if (session->tool() == tool && setup)
|
||||
setup(*session);
|
||||
}});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void CommandPaletteModel::setQuery(const QString &query)
|
||||
{
|
||||
if (query != m_query)
|
||||
selection = 0;
|
||||
m_query = query;
|
||||
}
|
||||
|
||||
std::optional<CommandPaletteEntry> CommandPaletteModel::selected() const
|
||||
{
|
||||
const std::vector<CommandPaletteEntry> found = results();
|
||||
if (selection < 0 || selection >= int(found.size()))
|
||||
return std::nullopt;
|
||||
return found[size_t(selection)];
|
||||
}
|
||||
|
||||
void CommandPaletteModel::move(int step)
|
||||
{
|
||||
const int count = int(results().size());
|
||||
if (count == 0) {
|
||||
selection = 0;
|
||||
return;
|
||||
}
|
||||
selection = ((selection + step) % count + count) % count;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
#include "Document/EditorSession.h"
|
||||
#include <QMenuBar>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
// One thing the palette runs: a menu command or tool.
|
||||
struct CommandPaletteEntry {
|
||||
// The title, numbered when an earlier entry shares it.
|
||||
QString id;
|
||||
// Its full path, shown and searched: "Filter › Gaussian Blur…".
|
||||
QString title;
|
||||
// Its current key, "Ctrl+Alt+P", or none.
|
||||
std::optional<QString> shortcut;
|
||||
// Disabled now: listed, greyed, never run.
|
||||
bool isEnabled = true;
|
||||
// A toggle on now, checked as in its menu.
|
||||
bool isOn = false;
|
||||
std::function<void()> perform;
|
||||
};
|
||||
|
||||
// Fuzzy matching: letters in order, word starts and runs count.
|
||||
namespace CommandPaletteSearch {
|
||||
std::optional<int> score(const QString &query, const QString &text);
|
||||
// Matches best first, enabled ahead; no query keeps menu order.
|
||||
std::vector<CommandPaletteEntry> rank(const std::vector<CommandPaletteEntry> &entries, const QString &query);
|
||||
}
|
||||
|
||||
// The menu bar as entries, read each time it opens.
|
||||
namespace CommandPaletteMenu {
|
||||
// `skipping` holds titles left out: whole menus or single entries.
|
||||
std::vector<CommandPaletteEntry> entries(const QMenuBar &bar, const std::set<QString> &skipping);
|
||||
// A menu title without its mnemonic: "Fil&ter" is Filter.
|
||||
QString plain(const QString &text);
|
||||
}
|
||||
|
||||
namespace CommandPaletteCommands {
|
||||
// The Layers panel's mask button, so the palette finds it.
|
||||
std::vector<CommandPaletteEntry> layerCommands(EditorSession &session);
|
||||
// Every tool and each of its modes, by name.
|
||||
std::vector<CommandPaletteEntry> tools(EditorSession &session);
|
||||
}
|
||||
|
||||
// The query, the ranked results and which one is chosen.
|
||||
class CommandPaletteModel {
|
||||
public:
|
||||
explicit CommandPaletteModel(std::vector<CommandPaletteEntry> entries) : m_entries(std::move(entries)) {}
|
||||
|
||||
const std::vector<CommandPaletteEntry> &entries() const { return m_entries; }
|
||||
const QString &query() const { return m_query; }
|
||||
// Only a real change goes back to the top.
|
||||
void setQuery(const QString &query);
|
||||
int selection = 0;
|
||||
std::vector<CommandPaletteEntry> results() const { return CommandPaletteSearch::rank(m_entries, m_query); }
|
||||
std::optional<CommandPaletteEntry> selected() const;
|
||||
// Up and Down, wrapping at either end.
|
||||
void move(int step);
|
||||
|
||||
private:
|
||||
std::vector<CommandPaletteEntry> m_entries;
|
||||
QString m_query;
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
#include "UI/CommandPaletteView.h"
|
||||
#include "Logging.h"
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QPainter>
|
||||
#include <QApplication>
|
||||
#include <QFontDatabase>
|
||||
#include <QPainterPath>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
constexpr int panelWidth = 460;
|
||||
constexpr int panelHeight = 290;
|
||||
constexpr int outer = 6;
|
||||
|
||||
QFont sized(QFont font, int pixels)
|
||||
{
|
||||
font.setPixelSize(pixels);
|
||||
return font;
|
||||
}
|
||||
}
|
||||
|
||||
const std::set<QString> CommandPaletteController::skipped{QStringLiteral("Search Commands…")};
|
||||
|
||||
CommandPaletteRows::CommandPaletteRows(CommandPaletteModel &model, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent)
|
||||
: QWidget(parent), m_model(model), m_run(std::move(run))
|
||||
{
|
||||
setFont(sized(font(), 13));
|
||||
}
|
||||
|
||||
int CommandPaletteRows::rowHeight() const
|
||||
{
|
||||
return fontMetrics().height() + 12;
|
||||
}
|
||||
|
||||
QRect CommandPaletteRows::rowRect(int index) const
|
||||
{
|
||||
return QRect(outer, outer + index * rowHeight(), width() - 2 * outer, rowHeight());
|
||||
}
|
||||
|
||||
void CommandPaletteRows::refresh()
|
||||
{
|
||||
m_shown = m_model.results();
|
||||
setFixedHeight(outer + int(m_shown.size()) * rowHeight());
|
||||
update();
|
||||
}
|
||||
|
||||
void CommandPaletteRows::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
const QFont shortcutFont = sized(QFontDatabase::systemFont(QFontDatabase::FixedFont), 12);
|
||||
for (int index = 0; index < int(m_shown.size()); ++index) {
|
||||
const QRect row = rowRect(index);
|
||||
if (!row.intersects(event->rect()))
|
||||
continue;
|
||||
const CommandPaletteEntry &entry = m_shown[size_t(index)];
|
||||
if (index == m_model.selection) {
|
||||
QColor chosen = palette().color(QPalette::Highlight);
|
||||
chosen.setAlphaF(0.35);
|
||||
QPainterPath round;
|
||||
round.addRoundedRect(row, 6, 6);
|
||||
painter.fillPath(round, chosen);
|
||||
}
|
||||
const QRect inside = row.adjusted(10, 0, -10, 0);
|
||||
const QColor ink = palette().color(entry.isEnabled ? QPalette::Active : QPalette::Disabled, QPalette::WindowText);
|
||||
int left = inside.left();
|
||||
if (entry.isOn) {
|
||||
// Swift's checkmark, twelve points with six after.
|
||||
QPainterPath check;
|
||||
const QPointF middle(left + 6, row.center().y() + 0.5);
|
||||
check.moveTo(middle + QPointF(-4, 0));
|
||||
check.lineTo(middle + QPointF(-1.5, 3));
|
||||
check.lineTo(middle + QPointF(4, -3.5));
|
||||
painter.setPen(QPen(ink, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
|
||||
painter.drawPath(check);
|
||||
left += 12 + 6;
|
||||
}
|
||||
int right = inside.right();
|
||||
if (entry.shortcut) {
|
||||
painter.setFont(shortcutFont);
|
||||
painter.setPen(palette().color(QPalette::PlaceholderText));
|
||||
const int width = painter.fontMetrics().horizontalAdvance(entry.shortcut.value());
|
||||
painter.drawText(QRect(right - width, row.top(), width + 1, row.height()), Qt::AlignVCenter | Qt::AlignRight, entry.shortcut.value());
|
||||
right -= width + 6;
|
||||
}
|
||||
painter.setFont(font());
|
||||
painter.setPen(ink);
|
||||
const QRect text(left, row.top(), right - left, row.height());
|
||||
painter.drawText(text, Qt::AlignVCenter | Qt::AlignLeft, fontMetrics().elidedText(entry.title, Qt::ElideRight, text.width()));
|
||||
}
|
||||
}
|
||||
|
||||
void CommandPaletteRows::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() != Qt::LeftButton)
|
||||
return;
|
||||
for (int index = 0; index < int(m_shown.size()); ++index)
|
||||
if (rowRect(index).contains(event->position().toPoint())) {
|
||||
m_run(m_shown[size_t(index)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
CommandPaletteView::CommandPaletteView(std::vector<CommandPaletteEntry> entries, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent)
|
||||
: QWidget(parent), m_model(std::move(entries)), m_run(std::move(run)), m_field(new QLineEdit(this)),
|
||||
m_scroller(new QScrollArea(this)), m_rows(new CommandPaletteRows(m_model, m_run, m_scroller)), m_empty(new QLabel(QStringLiteral("No commands match"), this))
|
||||
{
|
||||
auto *column = new QVBoxLayout(this);
|
||||
column->setContentsMargins(0, 0, 0, 0);
|
||||
column->setSpacing(0);
|
||||
m_field->setObjectName(QStringLiteral("commandSearch"));
|
||||
m_field->setPlaceholderText(QStringLiteral("Search commands and tools"));
|
||||
m_field->setFrame(false);
|
||||
m_field->setFont(sized(m_field->font(), 17));
|
||||
m_field->setTextMargins(16, 13, 16, 13);
|
||||
m_field->installEventFilter(this);
|
||||
column->addWidget(m_field);
|
||||
auto *divider = new QWidget(this);
|
||||
divider->setFixedHeight(1);
|
||||
divider->setAutoFillBackground(true);
|
||||
divider->setBackgroundRole(QPalette::Mid);
|
||||
column->addWidget(divider);
|
||||
// The list runs on under the panel's bottom edge.
|
||||
m_scroller->setFrameShape(QFrame::NoFrame);
|
||||
m_scroller->setWidgetResizable(true);
|
||||
m_scroller->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
m_scroller->setWidget(m_rows);
|
||||
column->addWidget(m_scroller, 1);
|
||||
m_empty->setObjectName(QStringLiteral("noCommands"));
|
||||
m_empty->setAlignment(Qt::AlignCenter);
|
||||
m_empty->setForegroundRole(QPalette::PlaceholderText);
|
||||
connect(m_field, &QLineEdit::textChanged, this, [this](const QString &text) {
|
||||
m_model.setQuery(text);
|
||||
refresh();
|
||||
});
|
||||
refresh();
|
||||
}
|
||||
|
||||
void CommandPaletteView::refresh()
|
||||
{
|
||||
m_rows->refresh();
|
||||
m_empty->setGeometry(m_scroller->geometry());
|
||||
m_empty->setVisible(m_model.results().empty());
|
||||
m_scroller->ensureVisible(0, m_rows->rowRect(m_model.selection).center().y(), 0, m_rows->rowHeight() / 2 + outer);
|
||||
}
|
||||
|
||||
void CommandPaletteView::resizeEvent(QResizeEvent *event)
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
m_empty->setGeometry(m_scroller->geometry());
|
||||
}
|
||||
|
||||
bool CommandPaletteView::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched != m_field || event->type() != QEvent::KeyPress)
|
||||
return QWidget::eventFilter(watched, event);
|
||||
switch (static_cast<QKeyEvent *>(event)->key()) {
|
||||
case Qt::Key_Up:
|
||||
case Qt::Key_Down:
|
||||
m_model.move(static_cast<QKeyEvent *>(event)->key() == Qt::Key_Up ? -1 : 1);
|
||||
refresh();
|
||||
return true;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
if (const std::optional<CommandPaletteEntry> entry = m_model.selected())
|
||||
m_run(entry.value());
|
||||
return true;
|
||||
default:
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
}
|
||||
|
||||
void CommandPaletteController::toggle(EditorSession &session, const QMenuBar &bar, QAction *search)
|
||||
{
|
||||
if (isOpen()) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
std::vector<CommandPaletteEntry> entries = CommandPaletteMenu::entries(bar, skipped);
|
||||
for (std::vector<CommandPaletteEntry> more : {CommandPaletteCommands::layerCommands(session), CommandPaletteCommands::tools(session)})
|
||||
entries.insert(entries.end(), more.begin(), more.end());
|
||||
if (!m_panel) {
|
||||
// A popup: a click elsewhere or Escape puts it away.
|
||||
m_panel = new QWidget(&m_window, Qt::Popup);
|
||||
m_panel->setObjectName(QStringLiteral("commandPalette"));
|
||||
m_panel->setFixedSize(panelWidth, panelHeight);
|
||||
m_panel->setAutoFillBackground(true);
|
||||
new QVBoxLayout(m_panel);
|
||||
m_panel->layout()->setContentsMargins(0, 0, 0, 0);
|
||||
m_panel->addAction(search);
|
||||
m_panel->installEventFilter(this);
|
||||
}
|
||||
for (CommandPaletteView *old : m_panel->findChildren<CommandPaletteView *>(Qt::FindDirectChildrenOnly)) {
|
||||
old->hide();
|
||||
old->deleteLater();
|
||||
}
|
||||
auto *view = new CommandPaletteView(std::move(entries), [this](const CommandPaletteEntry &entry) { run(entry); }, m_panel);
|
||||
m_panel->layout()->addWidget(view);
|
||||
// A popup has no frame: its geometry is its place.
|
||||
m_panel->setGeometry(QRect(m_window.frameGeometry().center() - QPoint(panelWidth / 2, panelHeight / 2), QSize(panelWidth, panelHeight)));
|
||||
m_panel->show();
|
||||
view->field().setFocus(Qt::PopupFocusReason);
|
||||
qCInfo(lcApp) << "palette opened with" << view->model().entries().size() << "entries";
|
||||
}
|
||||
|
||||
bool CommandPaletteController::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
// Qt hands focus back only to the window's focus widget.
|
||||
if (watched == m_panel && event->type() == QEvent::Hide && m_panel->isAncestorOf(QApplication::focusWidget()))
|
||||
QApplication::focusWidget()->clearFocus();
|
||||
return QObject::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
void CommandPaletteController::close()
|
||||
{
|
||||
if (m_panel)
|
||||
m_panel->hide();
|
||||
}
|
||||
|
||||
void CommandPaletteController::run(const CommandPaletteEntry &entry)
|
||||
{
|
||||
if (!entry.isEnabled) {
|
||||
qCWarning(lcApp) << "palette: disabled:" << entry.title;
|
||||
return;
|
||||
}
|
||||
close();
|
||||
m_window.activateWindow();
|
||||
qCInfo(lcApp) << "palette runs" << entry.title;
|
||||
QMetaObject::invokeMethod(this, entry.perform, Qt::QueuedConnection);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include "UI/CommandPalette.h"
|
||||
#include <QLineEdit>
|
||||
#include <QPointer>
|
||||
#include <QScrollArea>
|
||||
|
||||
class QLabel;
|
||||
|
||||
// The ranked rows under the search field.
|
||||
class CommandPaletteRows : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
CommandPaletteRows(CommandPaletteModel &model, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent);
|
||||
|
||||
int rowHeight() const;
|
||||
// Where row `index` sits, inside the rows.
|
||||
QRect rowRect(int index) const;
|
||||
void refresh();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
|
||||
private:
|
||||
CommandPaletteModel &m_model;
|
||||
std::function<void(const CommandPaletteEntry &)> m_run;
|
||||
std::vector<CommandPaletteEntry> m_shown;
|
||||
};
|
||||
|
||||
// The palette: a search field over the ranked commands.
|
||||
class CommandPaletteView : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
CommandPaletteView(std::vector<CommandPaletteEntry> entries, std::function<void(const CommandPaletteEntry &)> run, QWidget *parent);
|
||||
|
||||
CommandPaletteModel &model() { return m_model; }
|
||||
QLineEdit &field() { return *m_field; }
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
|
||||
private:
|
||||
void refresh();
|
||||
|
||||
CommandPaletteModel m_model;
|
||||
std::function<void(const CommandPaletteEntry &)> m_run;
|
||||
QLineEdit *m_field;
|
||||
QScrollArea *m_scroller;
|
||||
CommandPaletteRows *m_rows;
|
||||
QLabel *m_empty;
|
||||
};
|
||||
|
||||
// The palette over the window; a click elsewhere hides it.
|
||||
class CommandPaletteController : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// Left out of the palette: the palette itself.
|
||||
static const std::set<QString> skipped;
|
||||
|
||||
explicit CommandPaletteController(QWidget &window) : m_window(window) {}
|
||||
// A hidden palette lets go of the focus it held.
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
bool isOpen() const { return m_panel && m_panel->isVisible(); }
|
||||
QWidget *panel() const { return m_panel; }
|
||||
// Opens over the window, or closes; `search` works inside.
|
||||
void toggle(EditorSession &session, const QMenuBar &bar, QAction *search);
|
||||
void close();
|
||||
// Closes, gives the window back its keys, then runs it.
|
||||
void run(const CommandPaletteEntry &entry);
|
||||
|
||||
private:
|
||||
QWidget &m_window;
|
||||
QPointer<QWidget> m_panel;
|
||||
};
|
||||
@@ -124,6 +124,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
[this] { session().beginFilter(FilterKind::contentAwareFill); });
|
||||
|
||||
QMenu *view = bar.addMenu(QStringLiteral("&View"));
|
||||
add(view, QStringLiteral("searchCommands"), QStringLiteral("Search Commands…"), QKeySequence(Qt::CTRL | Qt::Key_F),
|
||||
[this, &bar] { m_palette.toggle(session(), bar, action(QStringLiteral("searchCommands"))); });
|
||||
view->addSeparator();
|
||||
add(view, QStringLiteral("fit"), QStringLiteral("Fit Canvas"), QKeySequence(Qt::CTRL | Qt::Key_0), [this] { session().fit(); });
|
||||
add(view, QStringLiteral("actualPixels"), QStringLiteral("Actual Pixels"), QKeySequence(Qt::CTRL | Qt::Key_1), [this] { session().zoom(1); });
|
||||
// Swift's stops; open text keeps the keys, as NSText does.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "Rendering/EditorCanvas.h"
|
||||
#include "UI/CommandPaletteView.h"
|
||||
#include "UI/FloatingPanel.h"
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
@@ -35,6 +36,7 @@ private:
|
||||
ProjectWorkspace &m_workspace;
|
||||
QWidget &m_window;
|
||||
FloatingPanel m_shortcutsPanel{QStringLiteral("keyboardShortcuts"), m_window};
|
||||
CommandPaletteController m_palette{m_window};
|
||||
QMetaObject::Connection m_sessionWatch;
|
||||
std::optional<QUuid> m_watchedTab;
|
||||
// The field whose undo the Edit entries drive.
|
||||
|
||||
@@ -125,7 +125,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
|
||||
entry("Undo", "z", 1, true), entry("Redo", "z", 9, true), entry("New Canvas", "n", 1, true),
|
||||
entry("Open Project", "o", 1, true), entry("Save", "s", 1, true), entry("Save As", "s", 9, true),
|
||||
entry("Export PNG", "e", 9, true), entry("Export JPEG", "s", 11, true), entry("Close Project", "w", 1, true),
|
||||
entry("Fit Canvas", "0", 1, true), entry("Actual Pixels", "1", 1, true), entry("Zoom In", "=", 1, true),
|
||||
entry("Fit Canvas", "0", 1, true), entry("Search Commands", "f", 1, true), entry("Actual Pixels", "1", 1, true), entry("Zoom In", "=", 1, true),
|
||||
entry("Zoom Out", "-", 1, true), entry("Show Transform Controls", "h", 1, true), entry("Cut", "x", 1, true),
|
||||
entry("Copy", "c", 1, true), entry("Copy Merged", "c", 9, true), entry("Paste", "v", 1, true),
|
||||
entry("Fill with Foreground", backspace, 2, true), entry("Fill with Background", backspace, 1, true),
|
||||
|
||||
Reference in new issue
Block a user