Add Run Command

This commit is contained in:
Christian Kaiser 2025-08-23 22:40:47 -03:00 committed by David Capello
parent 94174509a4
commit 6f1870cff3
18 changed files with 716 additions and 7 deletions

View File

@ -1160,5 +1160,18 @@
<style id="outline_cell" extends="buttonset_item_icon" width="17" height="19"/> <style id="outline_cell" extends="buttonset_item_icon" width="17" height="19"/>
<style id="ani_button" extends="buttonset_item_icon" width="17"/> <style id="ani_button" extends="buttonset_item_icon" width="17"/>
<style id="multi_window_item" extends="buttonset_item_icon" width="17" height="17"/> <style id="multi_window_item" extends="buttonset_item_icon" width="17" height="17"/>
<style id="runner_command_item" height="15" border="3">
<background color="menuitem_hot_face" state="selected" />
<text color="text" x="3" align="left" />
<text color="disabled" x="3" align="left" state="disabled" />
<text color="menuitem_hot_text" x="3" align="left" state="selected" />
</style>
<style id="runner_extension_tag" font="mini">
<background color="menuitem_highlight_face" />
<text color="menuitem_highlight_text" align="center" />
</style>
<style id="runner_command_checked" width="8" height="8">
<icon part="check_selected" align="center middle" />
</style>
</styles> </styles>
</theme> </theme>

View File

@ -1150,5 +1150,18 @@
<style id="outline_cell" extends="buttonset_item_icon" width="17" height="19"/> <style id="outline_cell" extends="buttonset_item_icon" width="17" height="19"/>
<style id="ani_button" extends="buttonset_item_icon" width="17"/> <style id="ani_button" extends="buttonset_item_icon" width="17"/>
<style id="multi_window_item" extends="buttonset_item_icon" width="17" height="17"/> <style id="multi_window_item" extends="buttonset_item_icon" width="17" height="17"/>
<style id="runner_command_item" height="15" border="3">
<background color="menuitem_hot_face" state="selected" />
<text color="text" x="3" align="left" />
<text color="disabled" x="3" align="left" state="disabled" />
<text color="menuitem_hot_text" x="3" align="left" state="selected" />
</style>
<style id="runner_extension_tag" font="mini">
<background color="menuitem_highlight_face" />
<text color="menuitem_highlight_text" align="center" />
</style>
<style id="runner_command_checked" width="8" height="8">
<icon part="check_selected" align="center middle" />
</style>
</styles> </styles>
</theme> </theme>

View File

@ -119,6 +119,7 @@
<param name="frame" value="current" /> <param name="frame" value="current" />
</key> </key>
<key command="ReverseFrames" shortcut="Alt+I" /> <key command="ReverseFrames" shortcut="Alt+I" />
<key command="RunCommand" shortcut="Ctrl+Space" />
<key command="GotoFirstFrame" shortcut="Home" /> <key command="GotoFirstFrame" shortcut="Home" />
<key command="GotoPreviousFrame" shortcut="Left" context="Normal" /> <key command="GotoPreviousFrame" shortcut="Left" context="Normal" />
<key command="GotoNextFrame" shortcut="Right" context="Normal" /> <key command="GotoNextFrame" shortcut="Right" context="Normal" />
@ -1010,6 +1011,7 @@
<menu text="@.view" id="view_menu"> <menu text="@.view" id="view_menu">
<item command="DuplicateView" text="@.view_duplicate_view" group="view_new" /> <item command="DuplicateView" text="@.view_duplicate_view" group="view_new" />
<item command="ToggleWorkspaceLayout" text="@.view_workspace_layout" /> <item command="ToggleWorkspaceLayout" text="@.view_workspace_layout" />
<item command="RunCommand" text="@.view_run_command" />
<separator /> <separator />
<item command="ShowExtras" text="@.view_show_extras" /> <item command="ShowExtras" text="@.view_show_extras" />
<menu text="@.view_show" group="view_extras"> <menu text="@.view_show" group="view_extras">

View File

@ -403,6 +403,7 @@ ReverseFrames = Reverse Frames
Rotate = Rotate {0} {1} Rotate = Rotate {0} {1}
Rotate_Selection = Selection Rotate_Selection = Selection
Rotate_Sprite = Sprite Rotate_Sprite = Sprite
RunCommand = Run Command
RunScript = Run Script RunScript = Run Script
SaveFile = Save File SaveFile = Save File
SaveFileAs = Save File As SaveFileAs = Save File As
@ -1195,6 +1196,7 @@ select_save_to_file = &Save to MSK file
view = &View view = &View
view_duplicate_view = Duplicate &View view_duplicate_view = Duplicate &View
view_workspace_layout = Workspace &Layout view_workspace_layout = Workspace &Layout
view_run_command = Run Command
view_show_extras = &Extras view_show_extras = &Extras
view_show = &Show view_show = &Show
view_show_layer_edges = &Layer Edges view_show_layer_edges = &Layer Edges
@ -2003,3 +2005,17 @@ toggle_left_diagonal = Toggle -45° Symmetry
show_options = Symmetry Options show_options = Symmetry Options
reset_position = Reset Symmetry to Center reset_position = Reset Symmetry to Center
reset_position_to_view_center = Reset Symmetry to View Center reset_position_to_view_center = Reset Symmetry to View Center
[run_command]
title = Run Command
title_help = Run Command: Help
title_expression = Run Command: Math Expressions
title_script = Run Command: Lua Script Runner
command_placeholder = Search for commands, ? for help.
more_result_count = ... ({} more results)
tip_executed_command = Executed: '{0}'
tip_code_executed = Code executed successfully.
help_search = Type to search through Aseprite's functionality.
help_expressions = You can also start with "=" to do math expressions
help_lua = or "@" to execute Lua script code.
extension_tag = Extension

View File

@ -0,0 +1,23 @@
<!-- Aseprite -->
<!-- Copyright (C) 2025 Igara Studio S.A. -->
<gui>
<window id="run_command" minwidth="256" maxwidth="256" text="@.title" help="run-command">
<vbox>
<search id="search" placeholder="@.command_placeholder" />
<label id="expression_result" visible="false" />
<vbox id="help" visible="false">
<label text="@.help_search" />
<label text="@.help_expressions" />
<label id="help_lua" text="@.help_lua" />
</vbox>
<vbox id="command_list" visible="false" />
<vbox id="more_results" visible="false">
<separator horizontal="true" />
<label id="more_count" />
</vbox>
</vbox>
</window>
</gui>

View File

@ -462,6 +462,7 @@ target_sources(app-lib PRIVATE
commands/cmd_reselect_mask.cpp commands/cmd_reselect_mask.cpp
commands/cmd_reverse_frames.cpp commands/cmd_reverse_frames.cpp
commands/cmd_rotate.cpp commands/cmd_rotate.cpp
commands/cmd_run_command.cpp
commands/cmd_save_file.cpp commands/cmd_save_file.cpp
commands/cmd_save_mask.cpp commands/cmd_save_mask.cpp
commands/cmd_save_palette.cpp commands/cmd_save_palette.cpp

View File

@ -26,7 +26,6 @@
#include "app/ui/keyboard_shortcuts.h" #include "app/ui/keyboard_shortcuts.h"
#include "app/ui/main_window.h" #include "app/ui/main_window.h"
#include "app/ui_context.h" #include "app/ui_context.h"
#include "app/util/filetoks.h"
#include "base/fs.h" #include "base/fs.h"
#include "base/string.h" #include "base/string.h"
#include "fmt/format.h" #include "fmt/format.h"
@ -463,6 +462,8 @@ void AppMenus::reload()
// Create native menus after the default + user defined keyboard // Create native menus after the default + user defined keyboard
// shortcuts are loaded correctly. // shortcuts are loaded correctly.
createNativeMenus(); createNativeMenus();
MenusLoaded();
} }
#ifdef ENABLE_SCRIPTING #ifdef ENABLE_SCRIPTING

View File

@ -74,6 +74,8 @@ public:
void removeMenuItemFromGroup(Command* cmd); void removeMenuItemFromGroup(Command* cmd);
void removeMenuItemFromGroup(Widget* menuItem); void removeMenuItemFromGroup(Widget* menuItem);
obs::signal<void()> MenusLoaded;
private: private:
template<typename Pred> template<typename Pred>
void removeMenuItemFromGroup(Pred pred); void removeMenuItemFromGroup(Pred pred);

View File

@ -23,6 +23,7 @@ public:
protected: protected:
void onLoadParams(const Params& params) override; void onLoadParams(const Params& params) override;
bool onNeedsParams() const override { return true; };
void onExecute(Context* context) override; void onExecute(Context* context) override;
std::string onGetFriendlyName() const override; std::string onGetFriendlyName() const override;
bool isListed(const Params& params) const override { return !params.get("path").empty(); } bool isListed(const Params& params) const override { return !params.get("path").empty(); }

View File

@ -23,6 +23,7 @@ public:
protected: protected:
bool onEnabled(Context* context) override; bool onEnabled(Context* context) override;
bool onNeedsParams() const override { return true; };
void onLoadParams(const Params& params) override; void onLoadParams(const Params& params) override;
void onExecute(Context* context) override; void onExecute(Context* context) override;
std::string onGetFriendlyName() const override; std::string onGetFriendlyName() const override;

View File

@ -0,0 +1,570 @@
// Aseprite
// Copyright (C) 2025 Igara Studio S.A.
//
// This program is distributed under the terms of
// the End-User License Agreement for Aseprite.
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "app/app.h"
#include "app/app_menus.h"
#include "app/commands/command.h"
#include "app/context.h"
#include "app/i18n/strings.h"
#include "app/ini_file.h"
#include "app/match_words.h"
#include "app/modules/gui.h"
#include "app/ui/app_menuitem.h"
#include "app/ui/keyboard_shortcuts.h"
#include "app/ui/main_window.h"
#include "app/ui/skin/skin_theme.h"
#include "app/ui/status_bar.h"
#include "base/chrono.h"
#include "commands.h"
#include "tinyexpr.h"
#include "ui/entry.h"
#include "ui/fit_bounds.h"
#include "ui/label.h"
#include "ui/message.h"
#include "ui/system.h"
#ifdef ENABLE_SCRIPTING
#include "app/script/engine.h"
#endif
#include "run_command.xml.h"
#include <cmath>
namespace app {
using namespace ui;
namespace {
constexpr auto kMaxVisibleResults = 10;
constexpr double kDisabledScore = 200;
struct RunnerDB {
struct Item {
std::string label;
std::vector<std::string> labelWords;
std::string searchableText;
std::string shortcutString;
Command* command;
Params params;
};
std::vector<Item> items;
};
class RunnerWindow final : public gen::RunCommand {
enum class Mode : uint8_t { Search, Script, Expression, ShowHelp };
class CommandItemView final : public HBox {
public:
explicit CommandItemView()
: m_shortcutLabel(new Label(""))
, m_pluginTagLabel(new Label(Strings::run_command_extension_tag()))
, m_checkIndicator(new Widget)
, m_item(nullptr)
{
disableFlags(IGNORE_MOUSE);
setFocusStop(true);
auto* filler = new BoxFiller();
filler->setTransparent(true);
addChild(filler);
m_pluginTagLabel->setVisible(false);
m_pluginTagLabel->InitTheme.connect([this] {
const auto* theme = skin::SkinTheme::get(this);
m_pluginTagLabel->setStyle(theme->styles.runnerExtensionTag());
});
m_pluginTagLabel->initTheme();
addChild(m_pluginTagLabel);
m_shortcutLabel->setTransparent(true);
m_shortcutLabel->setEnabled(false);
addChild(m_shortcutLabel);
m_checkIndicator->setVisible(false);
m_checkIndicator->setMinSize(gfx::Size(8, 8));
m_checkIndicator->InitTheme.connect([this] {
const auto* theme = skin::SkinTheme::get(m_checkIndicator);
m_checkIndicator->setStyle(theme->styles.runnerCommandChecked());
});
m_checkIndicator->initTheme();
addChild(m_checkIndicator);
InitTheme.connect([this] {
const auto* theme = skin::SkinTheme::get(this);
this->setStyle(theme->styles.runnerCommandItem());
});
initTheme();
}
void setChecked(bool checked) const { m_checkIndicator->setVisible(checked); }
void setItem(const RunnerDB::Item* item)
{
m_item = item;
if (item != nullptr) {
setText(item->label);
processMnemonicFromText();
m_pluginTagLabel->setVisible(item->command->isPlugin());
if (item->shortcutString.empty()) {
m_shortcutLabel->setVisible(false);
}
else {
m_shortcutLabel->setText(item->shortcutString);
m_shortcutLabel->setVisible(true);
}
}
else
setVisible(false);
layout();
}
void run()
{
if (isEnabled())
Click(m_item);
}
obs::signal<void(const RunnerDB::Item*)> Click;
protected:
bool onProcessMessage(Message* msg) override
{
if (isEnabled()) {
switch (msg->type()) {
case kMouseDownMessage: {
Click(m_item);
} break;
case kMouseEnterMessage:
case kFocusEnterMessage: {
setSelected(true);
} break;
case kMouseLeaveMessage:
case kFocusLeaveMessage: {
setSelected(false);
} break;
case kSetCursorMessage: {
set_mouse_cursor(kHandCursor);
return true;
}
}
}
return Widget::onProcessMessage(msg);
}
private:
Label* m_shortcutLabel;
Label* m_pluginTagLabel;
Widget* m_checkIndicator;
const RunnerDB::Item* m_item;
};
public:
explicit RunnerWindow(Context* ctx, RunnerDB* db) : m_db(db), m_context(ctx)
{
search()->Change.connect(&RunnerWindow::onTextChange, this);
#ifndef ENABLE_SCRIPTING
helpLua()->setVisible(false);
#endif
for (int i = 0; i <= kMaxVisibleResults; i++) {
auto* view = new CommandItemView;
view->Click.connect(&RunnerWindow::executeItem, this);
commandList()->addChild(view);
}
commandList()->InitTheme.connect([this] { commandList()->noBorderNoChildSpacing(); });
commandList()->initTheme();
setSizeable(false);
};
private:
bool onProcessMessage(Message* msg) override
{
switch (msg->type()) {
case kKeyDownMessage: {
const auto* keyMessage = static_cast<KeyMessage*>(msg);
if (!search()->hasFocus() &&
(keyMessage->scancode() == kKeyBackspace || keyMessage->scancode() == kKeyHome)) {
// Go back to the search
search()->requestFocus();
break;
}
if (keyMessage->scancode() != kKeyEnter && keyMessage->scancode() != kKeyEnterPad)
break;
switch (m_mode) {
case Mode::Search: {
for (auto* child : commandList()->children()) {
if (!child->hasFlags(HIDDEN) && child->isSelected()) {
static_cast<CommandItemView*>(child)->run();
return false;
}
}
break;
}
case Mode::Expression: {
// TODO: We could copy the result to the keyboard on Enter? Should tell the user tho.
closeWindow(nullptr);
return true;
}
#ifdef ENABLE_SCRIPTING
case Mode::Script: {
executeScript();
return true;
}
#endif
default: break;
}
}
}
return Window::onProcessMessage(msg);
}
// Avoids invalidating every keystroke.
void setText(const std::string& t)
{
if (t != text())
Window::setText(t);
}
void refit()
{
if (bounds().size() != sizeHint())
expandWindow(sizeHint());
}
void executeItem(const RunnerDB::Item* item)
{
closeWindow(nullptr);
StatusBar::instance()->showTip(1000, Strings::run_command_tip_executed_command(item->label));
m_context->executeCommand(item->command, item->params);
}
#ifdef ENABLE_SCRIPTING
void executeScript()
{
closeWindow(nullptr);
const base::Chrono timer;
const std::string& text = search()->text();
const bool result = App::instance()->scriptEngine()->evalCode(text.substr(1, text.length()));
// Give some feedback that the code executed, for errors the console will take care of that.
// We use the timer to avoid showing the tip in cases where the command was obviously
// successful, like after showing a modal window.
if (result && timer.elapsed() < 0.5)
StatusBar::instance()->showTip(1000, Strings::run_command_tip_code_executed());
}
#endif
void clear() const
{
// Switch between mode or make modeWidget()->
expressionResult()->setVisible(false);
help()->setVisible(false);
commandList()->setVisible(false);
moreResults()->setVisible(false);
}
void setMode(const Mode newMode)
{
if (m_mode != newMode)
clear();
m_mode = newMode;
}
void onTextChange()
{
const std::string& text = search()->text();
switch (text[0]) {
#ifdef ENABLE_SCRIPTING
case '@':
setMode(Mode::Script);
setText(Strings::run_command_title_script());
// Will only execute when pressing enter.
// TODO: Add an engine function to check the syntax before running it with luaL_loadstring
// && lua_isfunction
break;
#endif
case '=':
setMode(Mode::Expression);
setText(Strings::run_command_title_expression());
calculateExpression(text.substr(1, text.length()));
break;
case '?':
setMode(Mode::ShowHelp);
setText(Strings::run_command_title_help());
help()->setVisible(true);
break;
default:
setMode(Mode::Search);
setText(Strings::run_command_title());
searchCommand(text);
break;
}
refit();
}
void searchCommand(const std::string& text)
{
if (text.empty()) {
clear();
return;
}
const std::string& lowerText = base::string_to_lower(text);
const MatchWords match(lowerText);
std::multimap<double, const RunnerDB::Item*> results;
for (RunnerDB::Item& item : m_db->items) {
if (match(item.searchableText) || match.fuzzyWords(item.labelWords)) {
// Crude "score" to affect ordering in the multimap of results.
// Lower is better.
double score = 0;
const std::string& lowerLabel = base::string_to_lower(item.label);
if (lowerLabel != lowerText) {
// Deprioritize non-exact matches.
score = 50;
// Prioritize the ones starting with what we're typing.
if (lowerLabel.find(lowerText) == 0)
score -= 10;
// Simpler commands go first.
if (item.params.empty())
score -= 10;
// Prioritize listed commands or ones with keyboard shortcuts
if (item.command->isListed(item.params) || item.shortcutString.empty())
score -= 5;
// Bias against longer labels vs our search text
if (lowerLabel.size() > lowerText.size())
score += std::clamp(static_cast<int>(lowerLabel.size() - lowerText.size()), 1, 10);
}
if (item.command->isPlugin())
score += 5;
// Disabled commands go at the bottom.
item.command->loadParams(item.params);
if (!item.command->isEnabled(m_context))
score += kDisabledScore;
results.emplace(score, &item);
}
}
int resultCount = 0;
for (const auto& [score, itemPtr] : results) {
auto* view = static_cast<CommandItemView*>(commandList()->at(resultCount));
view->setVisible(true);
view->setEnabled(score < kDisabledScore);
view->setItem(itemPtr);
view->setSelected(resultCount == 0);
view->setChecked(itemPtr->command->isChecked(m_context));
resultCount++;
if (resultCount >= kMaxVisibleResults) {
moreCount()->setText(Strings::run_command_more_result_count(results.size() - resultCount));
break;
}
}
for (int i = resultCount; i <= kMaxVisibleResults; i++)
commandList()->at(i)->setVisible(false);
commandList()->setVisible(resultCount > 0);
moreResults()->setVisible(resultCount >= kMaxVisibleResults);
refit();
}
void calculateExpression(const std::string_view& expression) const
{
if (expression.empty()) {
expressionResult()->setVisible(false);
return;
}
int err = 0;
double v = te_interp(expression.data(), &err);
if (err == 0)
expressionResult()->setText(fmt::format(" = {}", v));
else
expressionResult()->setText(" = NaN");
expressionResult()->setVisible(true);
}
RunnerDB* m_db;
Context* m_context;
Mode m_mode = Mode::Search;
};
} // Unnamed namespace
class RunCommandCommand final : public Command {
public:
RunCommandCommand();
void invalidateDatabase();
protected:
bool onEnabled(Context* context) override;
void onExecute(Context* context) override;
private:
void loadDatabase();
std::unique_ptr<RunnerDB> m_db;
obs::connection m_invalidateConn;
};
RunCommandCommand::RunCommandCommand() : Command(CommandId::RunCommand())
{
}
void RunCommandCommand::invalidateDatabase()
{
m_db.reset(nullptr);
}
bool RunCommandCommand::onEnabled(Context* context)
{
return context->isUIAvailable();
}
void RunCommandCommand::onExecute(Context* context)
{
if (!m_db)
loadDatabase();
RunnerWindow window(context, m_db.get());
window.centerWindow();
fit_bounds(window.display(),
&window,
gfx::Rect(window.bounds().x, 23 * guiscale(), window.bounds().w, window.bounds().h));
window.openWindowInForeground();
}
void RunCommandCommand::loadDatabase()
{
std::vector<std::string> allCommandIds;
Commands::instance()->getAllIds(allCommandIds);
m_db.reset(new RunnerDB);
m_db->items.reserve(allCommandIds.size());
{
std::unordered_map<std::string, RunnerDB::Item> items;
for (const KeyPtr& key : *KeyboardShortcuts::instance()) {
if (const auto* cmd = key->command()) {
std::string paramString;
if (!key->params().empty()) {
std::ostringstream stream;
for (const auto& [k, v] : key->params()) {
stream << k << ' ' << v << ' ';
}
paramString = stream.str();
paramString.pop_back(); // Trailing space.
}
std::string shortcutString;
if (!key->shortcuts().empty())
shortcutString = key->shortcuts()[0].toString();
// Detect any duplicates, since multiple commands with the same parameter set
// can have both custom and default keyboard shortcuts.
const std::string itemKey = cmd->id() + paramString;
if (items.find(itemKey) != items.end()) {
if (!shortcutString.empty())
items[itemKey].shortcutString += ", " + shortcutString;
continue; // Ignore duplicate.
}
// Ideally we'd want to have translatable "tags" of some kind for commands so they're easier
// to find, for example if you search "Sprite New" you can't find "New File" (but you can
// find New Sprite from Clipboard)
std::ostringstream searchableStringStream;
searchableStringStream << key->triggerString() << ' ' << cmd->id() << ' ' << shortcutString;
// Separate the labels into words for fuzzy matching
std::vector<std::string> labelWords;
base::split_string(base::string_to_lower(key->triggerString()), labelWords, " ");
const RunnerDB::Item item{
key->triggerString(), labelWords, searchableStringStream.str(),
shortcutString, key->command(), key->params()
};
items.try_emplace(itemKey, item);
}
}
// Add any commands not covered by KeyboardShortcuts
for (const std::string& id : allCommandIds) {
// Ignore if it's already been added.
if (items.find(id) != items.end())
continue;
Command* command = Commands::instance()->byId(id.c_str());
// We can't run commands that need params without having them.
if (command->needsParams())
continue;
std::vector<std::string> labelWords;
base::split_string(base::string_to_lower(command->friendlyName()), labelWords, " ");
const RunnerDB::Item item{ command->friendlyName(),
labelWords,
command->friendlyName() + " " + command->id(),
{},
command,
{} };
items.try_emplace(id, item);
}
// Move from the result map to the item db vector
std::transform(
std::move_iterator(items.begin()),
std::move_iterator(items.end()),
std::back_inserter(m_db->items),
[](std::pair<std::string, RunnerDB::Item>&& item) { return std::move(item.second); });
}
// Sort alphabetically by label
std::sort(m_db->items.begin(),
m_db->items.end(),
[](const RunnerDB::Item& a, const RunnerDB::Item& b) { return a.label < b.label; });
if (!m_invalidateConn)
m_invalidateConn =
AppMenus::instance()->MenusLoaded.connect(&RunCommandCommand::invalidateDatabase, this);
}
Command* CommandFactory::createRunCommandCommand()
{
return new RunCommandCommand;
}
} // namespace app

View File

@ -34,6 +34,7 @@ public:
protected: protected:
void onLoadParams(const Params& params) override; void onLoadParams(const Params& params) override;
bool onNeedsParams() const override { return true; };
void onExecute(Context* context) override; void onExecute(Context* context) override;
std::string onGetFriendlyName() const override; std::string onGetFriendlyName() const override;
bool isListed(const Params& params) const override { return !params.empty(); } bool isListed(const Params& params) const override { return !params.empty(); }

View File

@ -36,6 +36,7 @@ public:
// Returns true if the command must be displayed in the Keyboard // Returns true if the command must be displayed in the Keyboard
// Shortcuts list. // Shortcuts list.
virtual bool isListed(const Params& params) const { return true; } virtual bool isListed(const Params& params) const { return true; }
virtual bool isPlugin() { return false; }
protected: protected:
virtual bool onNeedsParams() const; virtual bool onNeedsParams() const;

View File

@ -127,6 +127,7 @@ FOR_EACH_COMMAND(ReplaceColor)
FOR_EACH_COMMAND(ReselectMask) FOR_EACH_COMMAND(ReselectMask)
FOR_EACH_COMMAND(ReverseFrames) FOR_EACH_COMMAND(ReverseFrames)
FOR_EACH_COMMAND(Rotate) FOR_EACH_COMMAND(Rotate)
FOR_EACH_COMMAND(RunCommand)
FOR_EACH_COMMAND(SaveFile) FOR_EACH_COMMAND(SaveFile)
FOR_EACH_COMMAND(SaveFileAs) FOR_EACH_COMMAND(SaveFileAs)
FOR_EACH_COMMAND(SaveFileCopyAs) FOR_EACH_COMMAND(SaveFileCopyAs)

View File

@ -12,6 +12,7 @@
#include "base/split_string.h" #include "base/split_string.h"
#include "base/string.h" #include "base/string.h"
#include <algorithm>
#include <string> #include <string>
#include <vector> #include <vector>
@ -22,11 +23,40 @@ public:
MatchWords(const std::string& search = {}) MatchWords(const std::string& search = {})
{ {
base::split_string(base::string_to_lower(search), m_parts, " "); base::split_string(base::string_to_lower(search), m_parts, " ");
if (m_parts.size() > 1 && m_parts.back().empty())
m_parts.pop_back(); // Avoid an empty part when the search text is something like "hello "
}
// Finds a match with the given word using Levenshtein distance
// Expects lower case input, unlike operator()
bool fuzzy(const std::string& word, size_t threshold = 3) const
{
std::size_t matches = 0;
for (const auto& part : m_parts) {
if (levenshteinDistance(part, word) < threshold)
++matches;
}
return (matches == m_parts.size());
}
// Finds a match with any of the given words using Levenshtein distance
// Expects lower case input, unlike operator()
bool fuzzyWords(const std::vector<std::string>& words, size_t threshold = 3) const
{
std::size_t matches = 0;
for (const auto& part : m_parts) {
for (const auto& word : words) {
if (levenshteinDistance(part, word) < threshold)
++matches;
}
}
return (matches == m_parts.size());
} }
bool operator()(const std::string& item) const bool operator()(const std::string& item) const
{ {
std::string lowerItem = base::string_to_lower(item); const std::string& lowerItem = base::string_to_lower(item);
std::size_t matches = 0; std::size_t matches = 0;
for (const auto& part : m_parts) { for (const auto& part : m_parts) {
@ -37,6 +67,33 @@ public:
return (matches == m_parts.size()); return (matches == m_parts.size());
} }
static size_t levenshteinDistance(const std::string_view& word1, const std::string_view& word2)
{
if (word1 == word2)
return 0;
std::vector distance1(word2.size() + 1, 0);
std::vector distance2(word2.size() + 1, 0);
for (int i = 0; i <= word2.size(); ++i)
distance1[i] = i;
for (int i = 0; i < word1.size(); ++i) {
distance2[0] = i + 1;
for (int j = 0; j < word2.size(); ++j) {
const int deletionCost = distance1[j + 1] + 1;
const int insertionCost = distance2[j] + 1;
const int substitutionCost = (word1[i] == word2[j]) ? distance1[j] : distance1[j] + 1;
distance2[j + 1] = std::min({ deletionCost, insertionCost, substitutionCost });
}
std::swap(distance1, distance2);
}
return distance1[word2.size()];
}
private: private:
std::vector<std::string> m_parts; std::vector<std::string> m_parts;
}; };

View File

@ -55,6 +55,8 @@ public:
} }
} }
bool isPlugin() override { return true; }
protected: protected:
std::string onGetFriendlyName() const override { return m_title; } std::string onGetFriendlyName() const override { return m_title; }

View File

@ -201,13 +201,13 @@ private:
KeyContext m_keycontext; KeyContext m_keycontext;
// for KeyType::Command // for KeyType::Command
Command* m_command; Command* m_command = nullptr;
Params m_params; Params m_params;
tools::Tool* m_tool; // for KeyType::Tool or Quicktool tools::Tool* m_tool = nullptr; // for KeyType::Tool or Quicktool
KeyAction m_action; // for KeyType::Action KeyAction m_action; // for KeyType::Action
WheelAction m_wheelAction; // for KeyType::WheelAction / DragAction WheelAction m_wheelAction; // for KeyType::WheelAction / DragAction
DragVector m_dragVector; // for KeyType::DragAction DragVector m_dragVector; // for KeyType::DragAction
}; };
// Clears collection with strings that depends on the current // Clears collection with strings that depends on the current

View File

@ -1599,6 +1599,10 @@ void Widget::processMnemonicFromText(const int escapeChar, const bool requireMod
if (!chr) { if (!chr) {
break; // Ill-formed string (it ends with escape character) break; // Ill-formed string (it ends with escape character)
} }
if (std::isspace(chr)) {
// Avoid mnemonics for space characters.
newText.push_back(escapeChar);
}
else if (chr != escapeChar) { else if (chr != escapeChar) {
setMnemonic(chr, requireModifiers); setMnemonic(chr, requireModifiers);
} }