mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-26 18:31:11 +00:00
Tooltip bottom bar for process/filament/printer settings
This commit is contained in:
@@ -49,12 +49,6 @@ jobs:
|
||||
cmakeVersion: "~4.3.0" # use most recent 4.3.x version
|
||||
useLocalCache: true
|
||||
useCloudCache: true
|
||||
- name: Run web dialog JS tests
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
node resources/web/js/fuzzy-search.test.js
|
||||
node resources/web/dialog/SpeedDial/speeddial.test.js
|
||||
- name: Unpackage and Run Unit Tests
|
||||
timeout-minutes: 20
|
||||
shell: bash
|
||||
|
||||
@@ -150,6 +150,8 @@ var LangText = {
|
||||
sd_mode_advanced: "Advanced",
|
||||
sd_mode_expert: "Expert",
|
||||
sd_mode_develop: "Developer",
|
||||
sd_wiki: "Wiki",
|
||||
sd_no_wiki: "No wiki page for this action",
|
||||
},
|
||||
ca_ES: {
|
||||
t1: "Benvingut a Orca Slicer",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<div class="dial-count" id="count" hidden></div>
|
||||
</div>
|
||||
<div class="dial-list" id="list"></div>
|
||||
<div class="dial-detail" id="detail" hidden></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// - action.mode token -> ActionRegistry::mode_key / SpeedDialDialog::mode_label
|
||||
// - action.input "percent"/"tab" -> NativeCommands catalog (phases handled in activateEntry)
|
||||
// - action.icon SVG base name -> AppAction::icon / resources/images/<name>.svg
|
||||
// - action.desc/wiki -> AppAction::tooltip / help_url (footer detail strip)
|
||||
// - action list is frecency-sorted -> ActionRegistry::snapshot()
|
||||
|
||||
// ---- state (populated by the C++ bridge via window.HandleStudio) ----
|
||||
@@ -77,7 +78,7 @@ var tabOptions = []; // [{id,title}] - notebook pages, fetched on entering
|
||||
// ../../js/fuzzy-search.js, loaded before this script. Search is always case-insensitive.
|
||||
|
||||
// element handles, assigned in OnInit (kept null so load-time touches no DOM)
|
||||
var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null;
|
||||
var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null, detailEl = null;
|
||||
|
||||
// ---- pure helpers (no DOM; unit-tested) -------------------------------------
|
||||
// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per
|
||||
@@ -400,6 +401,11 @@ function selectedActionId(sel, actions, favIds) {
|
||||
return a && a.id;
|
||||
}
|
||||
|
||||
function actionHasWiki(a) { return !!(a && a.wiki); }
|
||||
|
||||
// Whether the action has anything for the footer strip to show (a description or a wiki link).
|
||||
function actionHasDetail(a) { return !!(a && ((a.desc && a.desc.length) || a.wiki)); }
|
||||
|
||||
function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); }
|
||||
|
||||
// Title-case a source for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro".
|
||||
@@ -996,9 +1002,43 @@ function renderList() {
|
||||
renderCommandsList();
|
||||
}
|
||||
|
||||
// The action the footer describes: the current selection resolved through the active list/fav bar.
|
||||
function currentDetailAction() {
|
||||
if (phase !== "commands") return null;
|
||||
var id = selectedActionId(sel, currentList(), currentVisibleFavs());
|
||||
return id ? byId(id) : null;
|
||||
}
|
||||
|
||||
// Footer detail strip: the selected action's description plus, when it has a wiki page, a link that
|
||||
// opens it (same path as F1). Shown only when the highlighted action has something to say, so
|
||||
// selecting a command with no description hides the strip.
|
||||
function renderDetail() {
|
||||
if (!detailEl) return;
|
||||
var a = currentDetailAction();
|
||||
var show = phase === "commands" && actionHasDetail(a);
|
||||
detailEl.hidden = !show;
|
||||
detailEl.innerHTML = "";
|
||||
if (!show) return;
|
||||
if (a && a.desc) {
|
||||
var desc = document.createElement("div");
|
||||
desc.className = "detail-desc";
|
||||
desc.textContent = a.desc;
|
||||
detailEl.appendChild(desc);
|
||||
}
|
||||
if (a && a.wiki) {
|
||||
var link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "detail-wiki";
|
||||
link.textContent = T("sd_wiki", "Wiki") + " (F1)";
|
||||
link.onclick = function (ev) { ev.stopPropagation(); SendMessage({ command: "open_wiki", id: a.id }); };
|
||||
detailEl.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
function render(opts) {
|
||||
renderFav();
|
||||
renderList();
|
||||
renderDetail();
|
||||
// Pin toggles don't move the selection, so they pass keepScroll to avoid snapping the list
|
||||
// back to a row that is currently off-screen.
|
||||
if (!(opts && opts.keepScroll))
|
||||
@@ -1148,7 +1188,7 @@ function focusInput() { setTimeout(function () { if (qEl) qEl.focus(); }, 0); }
|
||||
|
||||
// ---- init --------------------------------------------------------------------
|
||||
function OnInit() {
|
||||
qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count");
|
||||
qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count"); detailEl = $("detail");
|
||||
// text.js's TranslatePage() targets jQuery `.trans` nodes; this page has none and defines its own
|
||||
// `$`, so don't call it. Runtime strings go through T() instead.
|
||||
qEl.placeholder = T("sd_search", "Search actions");
|
||||
@@ -1187,6 +1227,15 @@ function OnInit() {
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; }
|
||||
// F1 opens the selected setting's wiki page. Settings without one flash a hint instead.
|
||||
if (e.key === "F1") {
|
||||
e.preventDefault();
|
||||
if (phase !== "commands") return;
|
||||
var help = currentDetailAction();
|
||||
if (actionHasWiki(help)) SendMessage({ command: "open_wiki", id: help.id });
|
||||
else flashHint(T("sd_no_wiki", "No wiki page for this action"));
|
||||
return;
|
||||
}
|
||||
// Pin/unpin the highlighted action: Ctrl/Cmd+B. Commands phase only (tabs/percent aren't pinnable).
|
||||
if (phase === "commands" && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey &&
|
||||
e.key.toLowerCase() === "b") {
|
||||
|
||||
@@ -334,4 +334,20 @@ assert.equal(ctx.searchActions(modePool, "retraction")[0].id, "a3",
|
||||
assert.equal(ctx.searchActions([{ id: "both", title: "Advanced", source: "Quality", group: "", mode: "advanced" }], "advanced").length,
|
||||
1, "a setting that both matches text and requires the mode appears exactly once");
|
||||
|
||||
// actionHasWiki: the footer's wiki link/F1 path is offered only when the action carries a wiki flag.
|
||||
assert.equal(ctx.actionHasWiki({ id: "x", wiki: true }), true, "a wiki-flagged setting offers the wiki action");
|
||||
assert.equal(ctx.actionHasWiki({ id: "x", wiki: false }), false, "a setting without a wiki path offers nothing");
|
||||
assert.equal(ctx.actionHasWiki({ id: "x" }), false, "a missing wiki field offers nothing");
|
||||
assert.equal(ctx.actionHasWiki(null), false, "no action selected offers nothing");
|
||||
|
||||
// actionHasDetail: the footer strip appears only when the highlighted action has a description or
|
||||
// wiki link; selecting a plain command hides it.
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "Layer height" }), true, "a description shows the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", wiki: true }), true, "a wiki link shows the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "Layer height", wiki: true }), true, "both show the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "" }), false, "an empty description hides the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "", wiki: false }), false, "empty description and false wiki hide the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a" }), false, "an action with neither hides the footer");
|
||||
assert.equal(ctx.actionHasDetail(null), false, "no selected action hides the footer");
|
||||
|
||||
console.log("ok");
|
||||
|
||||
@@ -436,3 +436,48 @@ body {
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Footer detail strip: the selected action's description plus its wiki link. Auto-sizes to the
|
||||
content; the description is clamped below. */
|
||||
.dial-detail {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dial-detail[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-wiki {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--main-color, var(--orca-accent, #009688));
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-wiki:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "MainFrame.hpp"
|
||||
#include "NativeCommands.hpp"
|
||||
#include "Notebook.hpp"
|
||||
#include "OptionsGroup.hpp"
|
||||
#include "Plater.hpp"
|
||||
#include "Search.hpp"
|
||||
#include "Tab.hpp"
|
||||
@@ -571,6 +572,11 @@ void ActionRegistry::materialize_setting_actions()
|
||||
}
|
||||
}
|
||||
|
||||
// Footer description + wiki affordance; only settings whose row declared a wiki path have one.
|
||||
action->tooltip = opt.tooltip;
|
||||
if (!opt.wiki_path.empty())
|
||||
action->help_url = into_u8(OptionsGroup::get_url(opt.wiki_path));
|
||||
|
||||
seed_from(stats, favs, id, *action);
|
||||
auto const action_id = action->id();
|
||||
auto const app_action = std::shared_ptr<AppAction>(std::move(action));
|
||||
@@ -723,7 +729,9 @@ nlohmann::json ActionRegistry::snapshot()
|
||||
{"kind", a->kind == AppActionKind::Plugin ? "plugin" : "command"},
|
||||
{"input", a->input},
|
||||
{"icon", a->icon},
|
||||
{"mode", mode_key(a->required_mode)}});
|
||||
{"mode", mode_key(a->required_mode)},
|
||||
{"desc", a->tooltip},
|
||||
{"wiki", !a->help_url.empty()}});
|
||||
};
|
||||
|
||||
nlohmann::json actions = nlohmann::json::array();
|
||||
|
||||
@@ -83,6 +83,10 @@ struct AppAction
|
||||
// Settings mode required to edit this action (SettingActions only). The palette prompts before
|
||||
// running an action whose mode is above the user's current mode. comSimple for everything else.
|
||||
ConfigOptionMode required_mode = comSimple;
|
||||
// Description shown in the Speed Dial's footer strip (SettingActions: the localized tooltip).
|
||||
std::string tooltip;
|
||||
// Full wiki URL, when the action has one (SettingActions whose row declared a label_path).
|
||||
std::string help_url;
|
||||
|
||||
virtual ~AppAction() = default;
|
||||
// Re-resolves + runs (UI thread). `param` carries an optional per-run argument for
|
||||
|
||||
@@ -244,6 +244,12 @@ void OptionsGroup::append_line(const Line& line)
|
||||
{
|
||||
m_lines.emplace_back(line);
|
||||
|
||||
// Record each option's wiki path (Line::label_path) so the Speed Dial can offer an "open wiki"
|
||||
// affordance for it. Settings tabs only; the searcher already exists by the time tabs are built.
|
||||
if (m_use_custom_ctrl && !line.label_path.empty())
|
||||
for (const auto& opt : line.get_options())
|
||||
wxGetApp().sidebar().get_searcher().set_path(opt.opt_id, static_cast<Preset::Type>(config_type()), line.label_path);
|
||||
|
||||
if (line.full_width && (line.widget != nullptr || !line.get_extra_widgets().empty()))
|
||||
return;
|
||||
|
||||
|
||||
@@ -250,6 +250,10 @@ protected:
|
||||
virtual void back_to_initial_value(const std::string& opt_key) {}
|
||||
virtual void back_to_sys_value(const std::string& opt_key) {}
|
||||
|
||||
// Preset::Type of a settings group; -1 for groups not tied to a preset. Used by append_line to
|
||||
// register each option's wiki path with the searcher. Overridden by ConfigOptionsGroup.
|
||||
virtual int config_type() const { return -1; }
|
||||
|
||||
public:
|
||||
static wxString get_url(const std::string& path_end);
|
||||
static bool launch_browser(const std::string& path_end);
|
||||
@@ -273,7 +277,7 @@ public:
|
||||
OptionsGroup(parent, wxEmptyString, wxEmptyString, true, nullptr) {}
|
||||
|
||||
const wxString& config_category() const throw() { return m_config_category; }
|
||||
int config_type() const throw() { return m_config_type; }
|
||||
int config_type() const throw() override { return m_config_type; }
|
||||
const t_opt_map& opt_map() const throw() { return m_opt_map; }
|
||||
|
||||
void set_config_category_and_type(const wxString &category, int type) { m_config_category = category; m_config_type = type; }
|
||||
|
||||
@@ -85,7 +85,7 @@ static std::string get_key(const std::string &opt_key, Preset::Type type) { retu
|
||||
|
||||
void OptionsSearcher::append_options(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode)
|
||||
{
|
||||
auto emplace = [this, type](std::vector<Option> &dst, const std::string &key, const wxString &label, ConfigOptionMode opt_mode) {
|
||||
auto emplace = [this, type](std::vector<Option> &dst, const std::string &key, const wxString &label, ConfigOptionMode opt_mode, std::string tooltip) {
|
||||
const GroupAndCategory &gc = groups_and_categories[key];
|
||||
if (gc.group.IsEmpty() || gc.category.IsEmpty()) return;
|
||||
|
||||
@@ -101,7 +101,7 @@ void OptionsSearcher::append_options(DynamicPrintConfig *config, Preset::Type ty
|
||||
if (!label.IsEmpty())
|
||||
dst.emplace_back(Option{boost::nowide::widen(key), type, (label + suffix).ToStdWstring(), (_(label) + suffix_local).ToStdWstring(), gc.group.ToStdWstring(),
|
||||
_(gc.group).ToStdWstring(), into_u8(gc.icon), gc.category.ToStdWstring(), GUI::Tab::translate_category(gc.category, type).ToStdWstring(),
|
||||
false, opt_mode});
|
||||
false, opt_mode, std::move(tooltip), gc.path});
|
||||
};
|
||||
|
||||
for (std::string opt_key : config->keys()) {
|
||||
@@ -131,8 +131,8 @@ void OptionsSearcher::append_options(DynamicPrintConfig *config, Preset::Type ty
|
||||
std::string key = get_key(opt_key, type);
|
||||
auto add = [&](const std::string &k) {
|
||||
if (in_filtered)
|
||||
emplace(options, k, label, opt.mode);
|
||||
emplace(options_all_modes, k, label, opt.mode);
|
||||
emplace(options, k, label, opt.mode, into_u8(_(opt.tooltip)));
|
||||
emplace(options_all_modes, k, label, opt.mode, into_u8(_(opt.tooltip)));
|
||||
};
|
||||
if (cnt == 0)
|
||||
add(key);
|
||||
@@ -459,7 +459,19 @@ void OptionsSearcher::dlg_msw_rescale()
|
||||
|
||||
void OptionsSearcher::add_key(const std::string &opt_key, Preset::Type type, const wxString &group, const wxString &category, const wxString &icon)
|
||||
{
|
||||
groups_and_categories[get_key(opt_key, type)] = GroupAndCategory{group, category, icon};
|
||||
// Update fields in place so a page rebuild (get_option after set_path) doesn't drop the
|
||||
// previously recorded wiki path.
|
||||
GroupAndCategory &gc = groups_and_categories[get_key(opt_key, type)];
|
||||
gc.group = group;
|
||||
gc.category = category;
|
||||
gc.icon = icon;
|
||||
}
|
||||
|
||||
void OptionsSearcher::set_path(const std::string &opt_key, Preset::Type type, const std::string &path)
|
||||
{
|
||||
if (path.empty())
|
||||
return;
|
||||
groups_and_categories[get_key(opt_key, type)].path = path;
|
||||
}
|
||||
//------------------------------------------
|
||||
// SearchItem
|
||||
|
||||
@@ -46,6 +46,7 @@ struct GroupAndCategory
|
||||
wxString group;
|
||||
wxString category;
|
||||
wxString icon; // icon of the group's own header, or empty
|
||||
std::string path; // wiki path (Line::label_path) of the option's line, or empty
|
||||
};
|
||||
|
||||
struct Option
|
||||
@@ -66,6 +67,8 @@ struct Option
|
||||
std::wstring category_local;
|
||||
bool multi_category { false };
|
||||
ConfigOptionMode mode{comSimple}; // option's visibility threshold; drives the Speed Dial's mode prompt
|
||||
std::string tooltip; // localized ConfigOptionDef::tooltip, or empty
|
||||
std::string wiki_path; // Line::label_path for the option's row, or empty
|
||||
|
||||
std::string opt_key() const;
|
||||
};
|
||||
@@ -133,6 +136,10 @@ public:
|
||||
void add_key(const std::string &opt_key, Preset::Type type, const wxString &group, const wxString &category,
|
||||
const wxString &icon = wxEmptyString);
|
||||
|
||||
// Records the wiki path of an option's row (Line::label_path) so the Speed Dial can offer a
|
||||
// "open wiki" affordance. Empty paths are ignored.
|
||||
void set_path(const std::string &opt_key, Preset::Type type, const std::string &path);
|
||||
|
||||
size_t size() const { return found_size(); }
|
||||
|
||||
const FoundOption &operator[](const size_t pos) const noexcept { return found[pos]; }
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <wx/display.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/stattext.h>
|
||||
#include <wx/utils.h>
|
||||
|
||||
#ifdef __linux__
|
||||
#include <gtk/gtk.h>
|
||||
@@ -152,6 +153,8 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
|
||||
wxGetApp().action_registry().reorder_favourites(ids);
|
||||
} else if (command == "run_action")
|
||||
run_action(payload.value("id", ""), payload.value("title", ""), payload.value("param", ""));
|
||||
else if (command == "open_wiki")
|
||||
open_wiki(payload.value("id", ""));
|
||||
else if (command == "search_tabs")
|
||||
search_tabs();
|
||||
else if (command == "resize")
|
||||
@@ -253,6 +256,15 @@ void SpeedDialWebDialog::run_action(const std::string& id, const std::string& ti
|
||||
});
|
||||
}
|
||||
|
||||
void SpeedDialWebDialog::open_wiki(const std::string& id)
|
||||
{
|
||||
const AppAction* a = wxGetApp().action_registry().by_id(id);
|
||||
if (!a || a->help_url.empty())
|
||||
return;
|
||||
Hide();
|
||||
wxLaunchDefaultBrowser(from_u8(a->help_url));
|
||||
}
|
||||
|
||||
void SpeedDialWebDialog::send_actions()
|
||||
{
|
||||
nlohmann::json snap = wxGetApp().action_registry().snapshot();
|
||||
|
||||
@@ -21,6 +21,7 @@ private:
|
||||
void handle_web_command(const nlohmann::json& payload);
|
||||
void resize_to_content(int height);
|
||||
void run_action(const std::string& id, const std::string& title, const std::string& param = "");
|
||||
void open_wiki(const std::string& id);
|
||||
void send_actions();
|
||||
void search_tabs();
|
||||
|
||||
|
||||
@@ -105,6 +105,21 @@ TEST_CASE("Command action construction keys by catalog key", "[ActionSource][Spe
|
||||
CHECK(action->icon == c.icon);
|
||||
}
|
||||
|
||||
// The footer description/wiki link is settings-only: built-in commands leave both fields empty, so
|
||||
// the palette's detail strip depends on list-level visibility for them.
|
||||
TEST_CASE("Actions default to no description or wiki link", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
const TestAppAction action;
|
||||
CHECK(action.tooltip.empty());
|
||||
CHECK(action.help_url.empty());
|
||||
|
||||
REQUIRE_FALSE(Slic3r::GUI::NativeCommands::catalog().empty());
|
||||
std::unique_ptr<AppAction> command = Slic3r::GUI::NativeCommands::make_action(Slic3r::GUI::NativeCommands::catalog().front());
|
||||
REQUIRE(command != nullptr);
|
||||
CHECK(command->tooltip.empty());
|
||||
CHECK(command->help_url.empty());
|
||||
}
|
||||
|
||||
// Two-phase commands declare the input the palette must collect before they can run.
|
||||
TEST_CASE("Two-phase commands declare their input phase", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user