feat(quick-select): implement quick select functionality for item selection

* Added quick select mode to allow users to select items using number keys.
* Introduced `isQuickSelectMode` and related methods in `WoxLauncherController`.
* Updated `WoxListItem` to include `isShowQuickSelect` and `quickSelectNumber`.
* Enhanced UI components to display quick select numbers.
* Improved keyboard handling in `WoxQueryBoxView` for quick select actions.
This commit is contained in:
qianlifeng 2025-07-30 23:47:44 +08:00
parent 096ee65d18
commit 38929cc506
No known key found for this signature in database
9 changed files with 348 additions and 26 deletions

View File

@ -1656,6 +1656,25 @@ func (m *Manager) QueryMRU(ctx context.Context) []QueryResultUI {
}
if restored := m.restoreFromMRU(ctx, pluginInstance, item); restored != nil {
// Add "Remove from MRU" action to each MRU result
removeMRUAction := QueryResultAction{
Id: uuid.NewString(),
Name: i18n.GetI18nManager().TranslateWox(ctx, "mru_remove_action"),
Icon: common.NewWoxImageEmoji("🗑️"),
Hotkey: "ctrl+d",
Action: func(ctx context.Context, actionContext ActionContext) {
err := setting.GetSettingManager().RemoveMRUItem(ctx, item.PluginID, item.Title, item.SubTitle)
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("failed to remove MRU item: %s", err.Error()))
} else {
util.GetLogger().Info(ctx, fmt.Sprintf("removed MRU item: %s - %s", item.Title, item.SubTitle))
}
},
}
// Add the remove action to the result
restored.Actions = append(restored.Actions, removeMRUAction)
polishedResult := m.PolishResult(ctx, pluginInstance, Query{}, *restored)
results = append(results, polishedResult.ToUI())
}

View File

@ -356,6 +356,7 @@
"plugin_manager_remove_from_favorite": "Remove from favorite",
"plugin_manager_add_to_favorite": "Add to favorite",
"plugin_manager_invalid_query_type": "Invalid query type",
"mru_remove_action": "Remove from MRU",
"plugin_ai_chat_agents": "Agents",
"plugin_ai_chat_agents_tooltip": "Configure AI agents with custom prompts and tools",
"plugin_ai_chat_agent_name": "Name",

View File

@ -356,6 +356,7 @@
"plugin_manager_remove_from_favorite": "从收藏夹移除",
"plugin_manager_add_to_favorite": "添加到收藏夹",
"plugin_manager_invalid_query_type": "无效的查询类型",
"mru_remove_action": "从最近使用中移除",
"plugin_ai_chat_agents": "智能体",
"plugin_ai_chat_agents_tooltip": "配置具有自定义提示词和工具的AI智能体",
"plugin_ai_chat_agent_name": "名称",

View File

@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"sort"
"time"
"wox/common"
"wox/database"
@ -36,7 +38,7 @@ func NewMRUManager(db *gorm.DB) *MRUManager {
// AddMRUItem adds or updates an MRU item
func (m *MRUManager) AddMRUItem(ctx context.Context, item MRUItem) error {
hash := NewResultHash(item.PluginID, item.Title, item.SubTitle)
// Serialize icon to JSON
iconData, err := json.Marshal(item.Icon)
if err != nil {
@ -49,7 +51,7 @@ func (m *MRUManager) AddMRUItem(ctx context.Context, item MRUItem) error {
// Check if record exists
var existingRecord database.MRURecord
err = m.db.Where("hash = ?", string(hash)).First(&existingRecord).Error
if err == gorm.ErrRecordNotFound {
// Create new record
record := database.MRURecord{
@ -80,18 +82,46 @@ func (m *MRUManager) AddMRUItem(ctx context.Context, item MRUItem) error {
}
}
// GetMRUItems retrieves MRU items sorted by usage
// GetMRUItems retrieves MRU items sorted by usage with smart scoring
func (m *MRUManager) GetMRUItems(ctx context.Context, limit int) ([]MRUItem, error) {
var records []database.MRURecord
// Order by last_used DESC, then by use_count DESC for items with same last_used time
err := m.db.Order("last_used DESC, use_count DESC").Limit(limit).Find(&records).Error
// Only return items with use_count >= 3 to ensure quality
err := m.db.Where("use_count >= ?", 3).Find(&records).Error
if err != nil {
return nil, fmt.Errorf("failed to query MRU records: %w", err)
}
items := make([]MRUItem, 0, len(records))
// Calculate smart scores for each record
type scoredRecord struct {
record database.MRURecord
score int64
}
scoredRecords := make([]scoredRecord, 0, len(records))
currentTimestamp := util.GetSystemTimestamp()
for _, record := range records {
score := m.calculateMRUScore(record, currentTimestamp)
scoredRecords = append(scoredRecords, scoredRecord{
record: record,
score: score,
})
}
// Sort by score descending
sort.Slice(scoredRecords, func(i, j int) bool {
return scoredRecords[i].score > scoredRecords[j].score
})
// Apply limit
if limit > 0 && len(scoredRecords) > limit {
scoredRecords = scoredRecords[:limit]
}
items := make([]MRUItem, 0, len(scoredRecords))
for _, sr := range scoredRecords {
record := sr.record
// Deserialize icon
var icon common.WoxImage
if err := json.Unmarshal([]byte(record.Icon), &icon); err != nil {
@ -120,7 +150,7 @@ func (m *MRUManager) RemoveMRUItem(ctx context.Context, pluginID, title, subTitl
if result.Error != nil {
return fmt.Errorf("failed to remove MRU item: %w", result.Error)
}
util.GetLogger().Debug(ctx, fmt.Sprintf("removed MRU item: %s", hash))
return nil
}
@ -151,3 +181,45 @@ func (m *MRUManager) GetMRUCount(ctx context.Context) (int64, error) {
err := m.db.Model(&database.MRURecord{}).Count(&count).Error
return count, err
}
// calculateMRUScore calculates a smart score for MRU items based on usage patterns
// This algorithm is inspired by calculateResultScore in plugin/manager.go
func (m *MRUManager) calculateMRUScore(record database.MRURecord, currentTimestamp int64) int64 {
var score int64 = 0
// Base score from use count (logarithmic scaling to prevent dominance)
// Use count of 3-10: score 10-30, 11-50: score 35-70, 51+: score 75+
useCountScore := int64(math.Log(float64(record.UseCount)) * 15)
score += useCountScore
// Time-based scoring using fibonacci sequence (similar to calculateResultScore)
// More recent usage gets higher weight
hours := (currentTimestamp - record.LastUsed) / 1000 / 60 / 60
if hours < 24*7 { // Within 7 days
fibonacciIndex := int(math.Ceil(float64(hours) / 24))
if fibonacciIndex > 7 {
fibonacciIndex = 7
}
if fibonacciIndex < 1 {
fibonacciIndex = 1
}
fibonacci := []int64{5, 8, 13, 21, 34, 55, 89}
score += fibonacci[7-fibonacciIndex]
} else if hours < 24*30 { // Within 30 days but older than 7 days
score += 3 // Small bonus for recent but not very recent usage
}
// Items older than 30 days get no time bonus
// Frequency bonus: items used more frequently get higher scores
// Calculate average usage frequency (uses per day since creation)
daysSinceCreation := (currentTimestamp - record.CreatedAt.Unix()*1000) / 1000 / 60 / 60 / 24
if daysSinceCreation > 0 {
frequencyScore := int64(float64(record.UseCount) / float64(daysSinceCreation) * 10)
if frequencyScore > 50 { // Cap frequency bonus
frequencyScore = 50
}
score += frequencyScore
}
return score
}

View File

@ -64,7 +64,10 @@ class WoxImageView extends StatelessWidget {
} else if (woxImage.imageType == WoxImageTypeEnum.WOX_IMAGE_TYPE_SVG.code) {
imageWidget = SvgPicture.string(woxImage.imageData, width: width, height: height);
} else if (woxImage.imageType == WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code) {
imageWidget = Text(woxImage.imageData, style: TextStyle(fontSize: width));
imageWidget = Padding(
padding: const EdgeInsets.only(left: 2, right: 2),
child: Text(woxImage.imageData, style: const TextStyle(fontSize: 25)),
);
} else if (woxImage.imageType == WoxImageTypeEnum.WOX_IMAGE_TYPE_LOTTIE.code) {
final bytes = utf8.encode(woxImage.imageData);
imageWidget = Lottie.memory(bytes, width: width, height: height);

View File

@ -37,6 +37,34 @@ class WoxListItemView extends StatelessWidget {
}
}
Widget buildQuickSelectNumber() {
return Padding(
padding: const EdgeInsets.only(left: 10.0, right: 5.0),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: fromCssColor(isActive ? woxTheme.resultItemActiveTailTextColor : woxTheme.resultItemTailTextColor),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: fromCssColor(isActive ? woxTheme.resultItemActiveTailTextColor : woxTheme.resultItemTailTextColor).withValues(alpha: 0.3),
width: 1,
),
),
child: Center(
child: Text(
item.quickSelectNumber,
style: TextStyle(
color: fromCssColor(isActive ? woxTheme.resultItemActiveBackgroundColor : woxTheme.appBackgroundColor),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
Widget buildTails() {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: WoxSettingUtil.instance.currentSetting.appWidth / 2),
@ -103,13 +131,13 @@ class WoxListItemView extends StatelessWidget {
} else if (isHovered) {
// Use a lighter version of the active background color for hover state
if (listViewType == WoxListViewTypeEnum.WOX_LIST_VIEW_TYPE_ACTION.code) {
return fromCssColor(woxTheme.actionItemActiveBackgroundColor).withOpacity(0.3);
return fromCssColor(woxTheme.actionItemActiveBackgroundColor).withValues(alpha: 0.3);
}
if (listViewType == WoxListViewTypeEnum.WOX_LIST_VIEW_TYPE_CHAT.code) {
return fromCssColor(woxTheme.resultItemActiveBackgroundColor).withOpacity(0.3);
return fromCssColor(woxTheme.resultItemActiveBackgroundColor).withValues(alpha: 0.3);
}
if (listViewType == WoxListViewTypeEnum.WOX_LIST_VIEW_TYPE_RESULT.code) {
return fromCssColor(woxTheme.resultItemActiveBackgroundColor).withOpacity(0.3);
return fromCssColor(woxTheme.resultItemActiveBackgroundColor).withValues(alpha: 0.3);
}
}
@ -189,6 +217,8 @@ class WoxListItemView extends StatelessWidget {
),
// Tails
if (item.tails.isNotEmpty) buildTails() else const SizedBox(),
// Quick select number
if (item.isShowQuickSelect && item.quickSelectNumber.isNotEmpty) buildQuickSelectNumber(),
],
),
);

View File

@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:uuid/v4.dart';
@ -99,6 +100,12 @@ class WoxLauncherController extends GetxController {
Timer cleanToolbarTimer = Timer(const Duration(), () => {});
final cleanToolbarDelay = 1000;
// quick select related variables
final isQuickSelectMode = false.obs;
Timer? quickSelectTimer;
final quickSelectDelay = 300; // delay to show number labels
bool isQuickSelectKeyPressed = false;
@override
void onInit() {
super.onInit();
@ -271,6 +278,14 @@ class WoxLauncherController extends GetxController {
}
hideActionPanel(traceId);
// Clean up quick select state
if (isQuickSelectMode.value) {
deactivateQuickSelectMode(traceId);
}
quickSelectTimer?.cancel();
isQuickSelectKeyPressed = false;
await windowManager.hide();
await WoxApi.instance.onHide(currentQuery.value);
@ -774,22 +789,16 @@ class WoxLauncherController extends GetxController {
resizeHeight();
}
// Save user's current selection before updateItems (which calls filterItems and resets index)
var oldActionName = getCurrentActionName();
var actions = result.value.data.actions.map((e) => WoxListItem.fromResultAction(e)).toList();
var oldActionIndex = actionListViewController.activeIndex.value;
var oldActionCount = actionListViewController.items.length;
actionListViewController.updateItems(traceId, actions);
// update active index to default action
// if action panel is visible, prefer to keep the active index
if (!isShowActionPanel.value || oldActionIndex >= actions.length || oldActionCount != actions.length) {
var defaultActionIndex = actions.indexWhere((element) => element.data.isDefault);
if (defaultActionIndex != -1) {
actionListViewController.updateActiveIndex(traceId, defaultActionIndex);
} else {
actionListViewController.updateActiveIndex(traceId, 0);
}
} else {
// keep the active index, we need to this to trigger the onItemActive callback, so the toolbar info can be updated
actionListViewController.updateActiveIndex(traceId, actionListViewController.activeIndex.value);
// Restore user's selected action after refresh
var newActiveIndex = calculatePreservedActionIndex(oldActionName);
if (actionListViewController.activeIndex.value != newActiveIndex) {
actionListViewController.updateActiveIndex(traceId, newActiveIndex);
}
}
@ -804,6 +813,8 @@ class WoxLauncherController extends GetxController {
subTitle: result.value.data.subTitle,
isGroup: result.value.data.isGroup,
data: result.value.data,
isShowQuickSelect: result.value.isShowQuickSelect,
quickSelectNumber: result.value.quickSelectNumber,
));
isRequesting.remove(result.value.data.id);
@ -1113,4 +1124,125 @@ class WoxLauncherController extends GetxController {
action: () {},
);
}
// Quick select related methods
/// Check if the quick select modifier key is pressed (Cmd on macOS, Alt on Windows/Linux)
bool isQuickSelectModifierPressed() {
if (Platform.isMacOS) {
return HardwareKeyboard.instance.isMetaPressed;
} else {
return HardwareKeyboard.instance.isAltPressed;
}
}
/// Start the quick select timer when modifier key is pressed
void startQuickSelectTimer(String traceId) {
if (isQuickSelectMode.value || resultListViewController.items.isEmpty) {
return;
}
Logger.instance.debug(traceId, "Quick select: starting timer");
isQuickSelectKeyPressed = true;
quickSelectTimer?.cancel();
quickSelectTimer = Timer(Duration(milliseconds: quickSelectDelay), () {
if (isQuickSelectKeyPressed && isQuickSelectModifierPressed()) {
Logger.instance.debug(traceId, "Quick select: activating mode");
activateQuickSelectMode(traceId);
}
});
}
/// Stop the quick select timer when modifier key is released
void stopQuickSelectTimer(String traceId) {
Logger.instance.debug(traceId, "Quick select: stopping timer");
isQuickSelectKeyPressed = false;
quickSelectTimer?.cancel();
if (isQuickSelectMode.value) {
deactivateQuickSelectMode(traceId);
}
}
/// Activate quick select mode and add number labels to results
void activateQuickSelectMode(String traceId) {
Logger.instance.debug(traceId, "Quick select: activating mode");
isQuickSelectMode.value = true;
updateQuickSelectNumbers(traceId);
}
/// Deactivate quick select mode and remove number labels
void deactivateQuickSelectMode(String traceId) {
Logger.instance.debug(traceId, "Quick select: deactivating mode");
isQuickSelectMode.value = false;
updateQuickSelectNumbers(traceId);
}
/// Update quick select numbers for all result items
void updateQuickSelectNumbers(String traceId) {
var items = resultListViewController.items;
for (int i = 0; i < items.length; i++) {
var item = items[i].value;
// Update quick select properties
var updatedItem = item.copyWith(
isShowQuickSelect: isQuickSelectMode.value && !item.isGroup && i < 9,
quickSelectNumber: (isQuickSelectMode.value && !item.isGroup && i < 9) ? (i + 1).toString() : '',
);
// Directly update the reactive item to trigger UI refresh
items[i].value = updatedItem;
}
}
/// Handle number key press in quick select mode
bool handleQuickSelectNumberKey(String traceId, int number) {
if (!isQuickSelectMode.value || number < 1 || number > 9) {
return false;
}
var items = resultListViewController.items;
var targetIndex = number - 1;
if (targetIndex < items.length && !items[targetIndex].value.isGroup) {
Logger.instance.debug(traceId, "Quick select: selecting item $number");
resultListViewController.updateActiveIndex(traceId, targetIndex);
executeToolbarAction(traceId);
return true;
}
return false;
}
int calculatePreservedActionIndex(String? oldActionName) {
var items = actionListViewController.items;
// If action panel is not visible, use default action
if (!isShowActionPanel.value) {
var defaultIndex = items.indexWhere((element) => element.value.data.isDefault);
return defaultIndex != -1 ? defaultIndex : 0;
}
// Try to find the same action by name
if (oldActionName != null) {
var sameActionIndex = items.indexWhere((element) => element.value.data.name == oldActionName);
if (sameActionIndex != -1) {
return sameActionIndex;
}
}
// Fallback to default action
var defaultIndex = items.indexWhere((element) => element.value.data.isDefault);
return defaultIndex != -1 ? defaultIndex : 0;
}
String? getCurrentActionName() {
var oldActionIndex = actionListViewController.activeIndex.value;
if (actionListViewController.items.isNotEmpty && oldActionIndex < actionListViewController.items.length) {
return actionListViewController.items[oldActionIndex].value.data.name;
}
return null;
}
}

View File

@ -12,6 +12,8 @@ class WoxListItem<T> {
final bool isGroup;
final String? hotkey;
final T data; // extra data associated with the item
final bool isShowQuickSelect;
final String quickSelectNumber;
WoxListItem({
required this.id,
@ -22,6 +24,8 @@ class WoxListItem<T> {
required this.isGroup,
this.hotkey,
required this.data,
this.isShowQuickSelect = false,
this.quickSelectNumber = '',
});
WoxListItem<T> copyWith({
@ -33,6 +37,8 @@ class WoxListItem<T> {
bool? isGroup,
String? hotkey,
T? data,
bool? isShowQuickSelect,
String? quickSelectNumber,
}) {
return WoxListItem<T>(
id: id ?? this.id,
@ -43,6 +49,8 @@ class WoxListItem<T> {
isGroup: isGroup ?? this.isGroup,
hotkey: hotkey ?? this.hotkey,
data: data ?? this.data,
isShowQuickSelect: isShowQuickSelect ?? this.isShowQuickSelect,
quickSelectNumber: quickSelectNumber ?? this.quickSelectNumber,
);
}

View File

@ -33,6 +33,43 @@ class WoxQueryBoxView extends GetView<WoxLauncherController> {
callback();
}
// Helper method to convert LogicalKeyboardKey to number for quick select
int? getNumberFromKey(LogicalKeyboardKey key) {
switch (key) {
case LogicalKeyboardKey.digit1:
return 1;
case LogicalKeyboardKey.digit2:
return 2;
case LogicalKeyboardKey.digit3:
return 3;
case LogicalKeyboardKey.digit4:
return 4;
case LogicalKeyboardKey.digit5:
return 5;
case LogicalKeyboardKey.digit6:
return 6;
case LogicalKeyboardKey.digit7:
return 7;
case LogicalKeyboardKey.digit8:
return 8;
case LogicalKeyboardKey.digit9:
return 9;
default:
return null;
}
}
// Check if only the quick select modifier key is pressed (no other keys)
bool isQuickSelectModifierKeyOnly(KeyEvent event) {
if (Platform.isMacOS) {
// On macOS, check if only Cmd key is pressed
return event.logicalKey == LogicalKeyboardKey.metaLeft || event.logicalKey == LogicalKeyboardKey.metaRight;
} else {
// On Windows/Linux, check if only Alt key is pressed
return event.logicalKey == LogicalKeyboardKey.altLeft || event.logicalKey == LogicalKeyboardKey.altRight;
}
}
@override
Widget build(BuildContext context) {
if (LoggerSwitch.enablePaintLog) Logger.instance.debug(const UuidV4().generate(), "repaint: query box view");
@ -42,6 +79,25 @@ class WoxQueryBoxView extends GetView<WoxLauncherController> {
Positioned(
child: Focus(
onKeyEvent: (FocusNode node, KeyEvent event) {
var traceId = const UuidV4().generate();
// Handle number keys in quick select mode first (higher priority)
if (controller.isQuickSelectMode.value && event is KeyDownEvent) {
var numberKey = getNumberFromKey(event.logicalKey);
if (numberKey != null) {
if (controller.handleQuickSelectNumberKey(traceId, numberKey)) {
return KeyEventResult.handled;
}
}
}
// Handle quick select modifier key press/release
if (event is KeyDownEvent && isQuickSelectModifierKeyOnly(event)) {
controller.startQuickSelectTimer(traceId);
} else {
controller.stopQuickSelectTimer(traceId);
}
var isAnyModifierPressed = WoxHotkey.isAnyModifierPressed();
if (!isAnyModifierPressed) {
if (event is KeyDownEvent) {