Skip to content

Commit 3503ec7

Browse files
feat: add menu pin and reorder functionality
- Add pinned_items field to AppConfig for storing pinned menu items - Add is_pinned(), pin_item(), pinned_items() methods to AppConfig - Display pinned items at the top of main menu with 📌 icon - Add "Manage Pins" option in Settings to toggle pin status - Add "Reorder Pins" option in Settings to change pin order - Support all 4 languages (en, zh-TW, zh-CN, ja) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6995b2b commit 3503ec7

File tree

7 files changed

+248
-2
lines changed

7 files changed

+248
-2
lines changed

src/core/config.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub struct AppConfig {
1414
/// How many common actions to show on the top menu
1515
#[serde(default = "default_common_actions_limit")]
1616
pub common_actions_limit: u32,
17+
/// Pinned menu items (shown at the top)
18+
#[serde(default)]
19+
pub pinned_items: Vec<String>,
1720
}
1821

1922
impl AppConfig {
@@ -31,6 +34,23 @@ impl AppConfig {
3134
pub fn common_actions_limit(&self) -> usize {
3235
self.common_actions_limit.max(1) as usize
3336
}
37+
38+
/// Check if an item is pinned
39+
pub fn is_pinned(&self, key: &str) -> bool {
40+
self.pinned_items.contains(&key.to_string())
41+
}
42+
43+
/// Pin an item (add to the end of pinned list)
44+
pub fn pin_item(&mut self, key: &str) {
45+
if !self.is_pinned(key) {
46+
self.pinned_items.push(key.to_string());
47+
}
48+
}
49+
50+
/// Get pinned items in order
51+
pub fn pinned_items(&self) -> &[String] {
52+
&self.pinned_items
53+
}
3454
}
3555

3656
fn default_common_actions_limit() -> u32 {

src/i18n/locales/en.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@
4141
"menu.language.desc" = "Switch interface language"
4242
"menu.exit" = "Exit"
4343
"menu.goodbye" = "Goodbye!"
44+
"menu.pinned.name" = "Pinned"
45+
"menu.pin.manage.name" = "Manage Pins"
46+
"menu.pin.manage.desc" = "Pin/unpin menu items"
47+
"menu.pin.prompt" = "Toggle pin status (Space to toggle, Enter to confirm)"
48+
"menu.pin.icon" = "📌"
49+
"menu.pin.count" = "{count} items pinned"
50+
"menu.pin.cleared" = "All pins cleared"
51+
"menu.pin.reorder.name" = "Reorder Pins"
52+
"menu.pin.reorder.desc" = "Change the order of pinned items"
53+
"menu.pin.reorder.prompt" = "Select items in your desired order (press Enter when done)"
54+
"menu.pin.reorder.done" = "Pin order updated"
55+
"menu.pin.reorder.empty" = "No pinned items to reorder"
4456

4557
"settings.common_count.name" = "Common actions count"
4658
"settings.common_count.desc" = "Number of frequently used actions to show"

src/i18n/locales/ja.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@
4141
"menu.language.desc" = "インターフェース言語切替"
4242
"menu.exit" = "終了"
4343
"menu.goodbye" = "さようなら!"
44+
"menu.pinned.name" = "ピン留め"
45+
"menu.pin.manage.name" = "ピン留め管理"
46+
"menu.pin.manage.desc" = "ピン留め/解除"
47+
"menu.pin.prompt" = "ピン留めを切り替え(スペースで切替、Enter で確定)"
48+
"menu.pin.icon" = "📌"
49+
"menu.pin.count" = "{count}件をピン留めしました"
50+
"menu.pin.cleared" = "すべてのピン留めを解除しました"
51+
"menu.pin.reorder.name" = "ピン留めの並べ替え"
52+
"menu.pin.reorder.desc" = "ピン留め項目の順序を変更"
53+
"menu.pin.reorder.prompt" = "順番に選択して並べ替え(完了したら Enter)"
54+
"menu.pin.reorder.done" = "ピン留めの順序を更新しました"
55+
"menu.pin.reorder.empty" = "並べ替えるピン留め項目がありません"
4456

4557
"settings.common_count.name" = "よく使う表示数"
4658
"settings.common_count.desc" = "トップに表示する件数"

src/i18n/locales/zh-CN.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@
4141
"menu.language.desc" = "切换界面语言"
4242
"menu.exit" = "退出"
4343
"menu.goodbye" = "再见!"
44+
"menu.pinned.name" = "已置顶"
45+
"menu.pin.manage.name" = "管理置顶"
46+
"menu.pin.manage.desc" = "置顶/取消置顶项目"
47+
"menu.pin.prompt" = "切换置顶状态(空格键切换,Enter 确认)"
48+
"menu.pin.icon" = "📌"
49+
"menu.pin.count" = "已置顶 {count} 个项目"
50+
"menu.pin.cleared" = "已清除所有置顶"
51+
"menu.pin.reorder.name" = "排序置顶"
52+
"menu.pin.reorder.desc" = "调整置顶项目的顺序"
53+
"menu.pin.reorder.prompt" = "依序选择项目以调整顺序(完成后按 Enter)"
54+
"menu.pin.reorder.done" = "置顶顺序已更新"
55+
"menu.pin.reorder.empty" = "没有可排序的置顶项目"
4456

4557
"settings.common_count.name" = "常用显示数量"
4658
"settings.common_count.desc" = "顶层常用项目数量"

src/i18n/locales/zh-TW.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@
4141
"menu.language.desc" = "切換介面語言"
4242
"menu.exit" = "退出"
4343
"menu.goodbye" = "再見!"
44+
"menu.pinned.name" = "已釘選"
45+
"menu.pin.manage.name" = "管理釘選"
46+
"menu.pin.manage.desc" = "釘選/取消釘選項目"
47+
"menu.pin.prompt" = "切換釘選狀態(空白鍵切換,Enter 確認)"
48+
"menu.pin.icon" = "📌"
49+
"menu.pin.count" = "已釘選 {count} 個項目"
50+
"menu.pin.cleared" = "已清除所有釘選"
51+
"menu.pin.reorder.name" = "排序釘選"
52+
"menu.pin.reorder.desc" = "調整釘選項目的順序"
53+
"menu.pin.reorder.prompt" = "依序選擇項目以調整順序(完成後按 Enter)"
54+
"menu.pin.reorder.done" = "釘選順序已更新"
55+
"menu.pin.reorder.empty" = "沒有可排序的釘選項目"
4456

4557
"settings.common_count.name" = "常用顯示數量"
4658
"settings.common_count.desc" = "頂層常用項目數量"

src/i18n/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ pub mod keys {
183183
pub const MENU_LANGUAGE_DESC: &str = "menu.language.desc";
184184
pub const MENU_EXIT: &str = "menu.exit";
185185
pub const MENU_GOODBYE: &str = "menu.goodbye";
186+
pub const MENU_PINNED: &str = "menu.pinned.name";
187+
pub const MENU_PIN_MANAGE: &str = "menu.pin.manage.name";
188+
pub const MENU_PIN_MANAGE_DESC: &str = "menu.pin.manage.desc";
189+
pub const MENU_PIN_PROMPT: &str = "menu.pin.prompt";
190+
pub const MENU_PIN_ICON: &str = "menu.pin.icon";
191+
pub const MENU_PIN_COUNT: &str = "menu.pin.count";
192+
pub const MENU_PIN_CLEARED: &str = "menu.pin.cleared";
193+
pub const MENU_PIN_REORDER: &str = "menu.pin.reorder.name";
194+
pub const MENU_PIN_REORDER_DESC: &str = "menu.pin.reorder.desc";
195+
pub const MENU_PIN_REORDER_PROMPT: &str = "menu.pin.reorder.prompt";
196+
pub const MENU_PIN_REORDER_DONE: &str = "menu.pin.reorder.done";
197+
pub const MENU_PIN_REORDER_EMPTY: &str = "menu.pin.reorder.empty";
186198

187199
pub const LANGUAGE_SELECT_PROMPT: &str = "language.select_prompt";
188200
pub const LANGUAGE_CHANGED: &str = "language.changed";

src/main.rs

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,22 +186,53 @@ fn format_action_options(items: &[MenuItem]) -> Vec<String> {
186186
.collect()
187187
}
188188

189+
fn build_pinned_actions(all_items: &[MenuItem], config: &AppConfig) -> Vec<MenuItem> {
190+
config
191+
.pinned_items()
192+
.iter()
193+
.filter_map(|key| all_items.iter().find(|item| item.name_key == key).copied())
194+
.collect()
195+
}
196+
189197
fn format_top_level_options(
198+
pinned_actions: &[MenuItem],
190199
common_actions: &[MenuItem],
191200
categories: &[Category],
192201
) -> Vec<TopLevelOption> {
193202
let settings_name = i18n::t(keys::MENU_SETTINGS);
194203
let settings_desc = i18n::t(keys::MENU_SETTINGS_DESC);
204+
let pin_icon = i18n::t(keys::MENU_PIN_ICON);
195205

196-
let max_name_width = common_actions
206+
let max_name_width = pinned_actions
197207
.iter()
208+
.chain(common_actions.iter())
198209
.map(|item| i18n::t(item.name_key).width())
199210
.chain(categories.iter().map(|cat| i18n::t(cat.name_key).width()))
200211
.max()
201212
.unwrap_or(0);
202213

203214
let mut options = Vec::new();
204215

216+
// Pinned header (only show if there are pinned items)
217+
if !pinned_actions.is_empty() {
218+
options.push(TopLevelOption {
219+
label: format!("{} {}", pin_icon, i18n::t(keys::MENU_PINNED)),
220+
choice: TopLevelChoice::Header,
221+
selectable: false,
222+
});
223+
224+
for item in pinned_actions {
225+
let name = format!(" {}", i18n::t(item.name_key));
226+
let desc = i18n::t(item.desc_key);
227+
let padding = max_name_width.saturating_sub(name.trim_start().width());
228+
options.push(TopLevelOption {
229+
label: format!("{}{} — {}", name, " ".repeat(padding), desc),
230+
choice: TopLevelChoice::Action(*item),
231+
selectable: true,
232+
});
233+
}
234+
}
235+
205236
// Common header
206237
options.push(TopLevelOption {
207238
label: i18n::t(keys::MENU_COMMON).to_string(),
@@ -294,6 +325,8 @@ fn open_settings(prompts: &Prompts, console: &Console) {
294325
keys::SETTINGS_COMMON_COUNT_NAME,
295326
keys::SETTINGS_COMMON_COUNT_DESC,
296327
),
328+
(keys::MENU_PIN_MANAGE, keys::MENU_PIN_MANAGE_DESC),
329+
(keys::MENU_PIN_REORDER, keys::MENU_PIN_REORDER_DESC),
297330
];
298331

299332
let max_name_width = settings_items
@@ -325,6 +358,8 @@ fn open_settings(prompts: &Prompts, console: &Console) {
325358
match selection_opt {
326359
Some(0) => select_language(prompts, console),
327360
Some(1) => configure_common_actions(prompts, console, &mut config),
361+
Some(2) => manage_pins(console, &mut config),
362+
Some(3) => reorder_pins(console, &mut config),
328363
_ => break,
329364
}
330365
}
@@ -355,6 +390,136 @@ fn configure_common_actions(prompts: &Prompts, console: &Console, config: &mut A
355390
}
356391
}
357392

393+
fn manage_pins(console: &Console, config: &mut AppConfig) {
394+
use dialoguer::MultiSelect;
395+
396+
let actions = all_actions();
397+
let pin_icon = i18n::t(keys::MENU_PIN_ICON);
398+
399+
// Build options with pin status
400+
let options: Vec<String> = actions
401+
.iter()
402+
.map(|item| {
403+
let name = i18n::t(item.name_key);
404+
let desc = i18n::t(item.desc_key);
405+
if config.is_pinned(item.name_key) {
406+
format!("{} {} — {}", pin_icon, name, desc)
407+
} else {
408+
format!(" {} — {}", name, desc)
409+
}
410+
})
411+
.collect();
412+
413+
// Get currently pinned indices
414+
let defaults: Vec<bool> = actions
415+
.iter()
416+
.map(|item| config.is_pinned(item.name_key))
417+
.collect();
418+
419+
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
420+
421+
let selection = MultiSelect::with_theme(&ColorfulTheme::default())
422+
.with_prompt(i18n::t(keys::MENU_PIN_PROMPT))
423+
.items(&option_refs)
424+
.defaults(&defaults)
425+
.interact_opt();
426+
427+
if let Ok(Some(selected)) = selection {
428+
// Clear all pins and re-add selected ones
429+
config.pinned_items.clear();
430+
for idx in selected {
431+
config.pin_item(actions[idx].name_key);
432+
}
433+
434+
match save_config(config) {
435+
Ok(_) => {
436+
let count = config.pinned_items().len();
437+
if count > 0 {
438+
console.success(&format!(
439+
"{} {}",
440+
pin_icon,
441+
crate::tr!(keys::MENU_PIN_COUNT, count = count)
442+
));
443+
} else {
444+
console.info(i18n::t(keys::MENU_PIN_CLEARED));
445+
}
446+
}
447+
Err(err) => console.warning(&crate::tr!(keys::CONFIG_SAVE_FAILED, error = err)),
448+
}
449+
}
450+
}
451+
452+
fn reorder_pins(console: &Console, config: &mut AppConfig) {
453+
use dialoguer::Select;
454+
455+
let actions = all_actions();
456+
let pinned_keys: Vec<String> = config.pinned_items().to_vec();
457+
458+
if pinned_keys.is_empty() {
459+
console.info(i18n::t(keys::MENU_PIN_REORDER_EMPTY));
460+
return;
461+
}
462+
463+
// Get display names for pinned items
464+
let pinned_items: Vec<(&str, String)> = pinned_keys
465+
.iter()
466+
.filter_map(|key| {
467+
actions
468+
.iter()
469+
.find(|a| a.name_key == key)
470+
.map(|a| (a.name_key, i18n::t(a.name_key).to_string()))
471+
})
472+
.collect();
473+
474+
let mut new_order: Vec<&str> = Vec::new();
475+
let mut remaining: Vec<(&str, String)> = pinned_items;
476+
477+
while !remaining.is_empty() {
478+
let options: Vec<String> = remaining
479+
.iter()
480+
.enumerate()
481+
.map(|(i, (_, name))| format!("{}. {}", i + 1, name))
482+
.collect();
483+
484+
let prompt = format!(
485+
"{} ({}/{})",
486+
i18n::t(keys::MENU_PIN_REORDER_PROMPT),
487+
new_order.len() + 1,
488+
new_order.len() + remaining.len()
489+
);
490+
491+
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
492+
493+
let selection = Select::with_theme(&ColorfulTheme::default())
494+
.with_prompt(&prompt)
495+
.items(&option_refs)
496+
.default(0)
497+
.interact_opt();
498+
499+
match selection {
500+
Ok(Some(idx)) => {
501+
let (key, _) = remaining.remove(idx);
502+
new_order.push(key);
503+
}
504+
_ => {
505+
// User cancelled - keep original order
506+
return;
507+
}
508+
}
509+
}
510+
511+
// Update config with new order
512+
config.pinned_items.clear();
513+
for key in new_order {
514+
config.pinned_items.push(key.to_string());
515+
}
516+
517+
match save_config(config) {
518+
Ok(_) => console.success(i18n::t(keys::MENU_PIN_REORDER_DONE)),
519+
Err(err) => console.warning(&crate::tr!(keys::CONFIG_SAVE_FAILED, error = err)),
520+
}
521+
}
522+
358523
fn main() {
359524
let prompts = Prompts::new();
360525
let console = Console::new();
@@ -367,8 +532,9 @@ fn main() {
367532
let config = load_config().ok().flatten().unwrap_or_default();
368533
let actions = all_actions();
369534
let categories = build_categories(&actions);
535+
let pinned_actions = build_pinned_actions(&actions, &config);
370536
let common_actions = build_common_actions(actions.clone(), &config);
371-
let options = format_top_level_options(&common_actions, &categories);
537+
let options = format_top_level_options(&pinned_actions, &common_actions, &categories);
372538
let option_refs: Vec<&str> = options.iter().map(|opt| opt.label.as_str()).collect();
373539

374540
let default_index = options.iter().position(|opt| opt.selectable).unwrap_or(0);

0 commit comments

Comments
 (0)