librime icon indicating copy to clipboard operation
librime copied to clipboard

feat: 新增手动排序滤镜

Open amzxyz opened this issue 1 week ago • 5 comments

Pull request

Issue tracker

#1105 lua: https://github.com/amzxyz/rime_wanxiang/blob/wanxiang/lua/super_sequence.lua

Feature

新增候选项手动排序功能 (Sequence Adjuster) 。 手动排序是自动调频策略相反方向的一个需求补充,当关闭调频使用的时候,因行业、阶段和环境需求不同,可能需要在不同的时间将不同的词条放置在首选或者其他位置,这个需求无论字词方案、传统类大厂语句流、还是英文都有这样的需求在,之前往往是方案维护者维护,用户侧管理起来相对困难,现在我建议复用用户词库的管理体验,储存手动排序信息,同样实现多设备同步,快捷键操作方便,配合UI接口,手机类前端也可提供相应的长按、滑动触发的新型交互模式。

主要功能 (Main Features):

  1. Sequence Adjuster**: 允许用户通过快捷键手动调整候选项位置(置顶、上移、下移、重置)。
  2. 数据存储格式优化为 k=输入编码+词组,v=(p=位置 s=Unix时间戳)
  3. 引入方式:
  processors:
    - sequence_adjuster_processor
  filters:
    - sequence_adjuster_filter
  1. 配置参数:
    sequence_adjuster:
        enable: true
        up: Control+j
        down: Control+k
        reset: Control+l
        pin: Control+p
  1. 同步逻辑兼容 (Sync Logic Enhancement):
  • 使用独立的同步逻辑,通过判断数据库名称做区分。 核心修改 (Core Changes):
  • src/rime/gear/sequence_adjuster.cc/h:
  • 捕获快捷键UI事件,对高亮候选执行移动动作的捕获,按一下记录一次,基于绝对时间戳 (stamp) 的排序记录写入,以真实候选位置记录,这样做其实具备在滤镜末尾实现硬控某些调频词组的位置,实现调频中固定个别,但初期先设计为主翻译器关闭调频的状态下,并启用配置才能工作。 ni 你 p=0 s=1765587980 p=0置顶,p=-1重置,p>1实际位置 t毫秒级时间戳
  • src/rime/dict/user_db.cc:
  • UserDbMerger::PutUserDbImporter::Put 中增加了针对数据库名称包含 "sequence"特判逻辑
  • 采用 LWW (Last Write Wins) 策略:直接比较物理时间戳 (s),谁的时间戳大保留谁,完全绕过 Rime 原生的 UserDbValue 解析和 Tick Rebase 逻辑。
  • 墓碑机制 (Tombstone): 当 p=-1 (重置/删除) 为最新数据时,保留该记录而非物理删除,防止旧数据在多端同步时“复活”。
  1. 虚拟指令 (UI 发送此字符串),作用 上移,{Sequence_Up},无论配置如何,强制触发上移 下移,{Sequence_Down},无论配置如何,强制触发下移 置顶,{Sequence_Pin},无论配置如何,强制触发置顶 重置,{Sequence_Reset},无论配置如何,强制触发重置

Unit test

  • [ ] Done

Manual test

  • [x] Done

测试流程 (Test Cases):

  1. 基础功能: 使用快捷键置顶、上移、下移候选项,生成的 sequence.userdb 文件中包含正确的 ps (大整数时间戳) 字段。
  2. 数据持久化: 重启服务后,手动排序依然生效。
  3. 同步测试 (Sync):
    • 场景 A: 在设备 1 修改排序,同步;在设备 2 同步。设备 2 正确应用新排序。
    • 场景 B (冲突解决): 设备 1 和设备 2 同时修改同一词条。同步后,操作时间更晚(s 值更大)的排序生效。
    • 场景 C (删除同步): 在设备 1 重置词条 (p=-1)。同步后,设备 2 的该词条恢复默认排序,且不会因为旧数据覆盖而“复活”。
    • 场景 D (数据隔离): 确认普通的输入法用户词库 (userdb) 同步不受影响,依然走 Rime 原生逻辑。
  4. 默认内置生效快捷键无配置情况下为:
  load_key("up", key_up_, "Control+Shift+J");
  load_key("down", key_down_, "Control+Shift+K");
  load_key("reset", key_reset_, "Control+Shift+L");
  load_key("pin", key_pin_, "Control+Shift+P");

Code Review

  1. Unit and manual test pass
  2. GitHub Action CI pass
  3. At least one contributor reviews and votes
  4. Can be merged clean without conflicts
  5. PR will be merged by rebase upstream base

Additional Info

为什么必须修改 src/rime/dict/user_db.cc? (Why modify the core user_db.cc?)

Rime 原生的同步机制是为了“词频统计”设计的,它有以下两个特性导致无法用于“手动排序同步”:

  1. 权重截断: UserDbValue::Unpack 强制将 d (dee) 限制在 10000.0 以内。我们需要存储 Unix 时间戳(如 1716xxxxxx),这会导致时间戳数据丢失。
  2. 逻辑时钟重置: Tick 机制在合并时会进行 Rebase,导致我们依赖的绝对时间戳被修改,进而导致多端排序错乱。

解决方案: 本 PR 采用隔离策略。在 UserDbMerger 中检测 db_name。如果是 sequence 相关的数据库,则进入快速通道:仅对比字符串中的 s= 时间戳大小,不进行任何数学计算或逻辑时钟调整。这既保证了排序功能的健壮性,又确保了不对现有的 Rime 用户词库逻辑产生任何副作用。

存在的问题 1, 应在完整input被消耗的情况下限制排序的范围,后续的单字等不参与,未实现,但需要探讨,毕竟单字移动到前面空格也无法上屏 2.使用万象测试

  - sequence_adjuster_filter
  - uniquifier         
  - uniquifier           
  - sequence_adjuster_filter  

输入日报时,输入法崩溃,排序数据结构如下,其他多个候选的没事

# Rime user dictionary
#@/db_name	sequence
#@/db_type	userdb
#@/rime_version	1.15.0
#@/user_id	archlinux

ribk 	日报	p=0 s=1765563909

如果干脆放最后更严重输入法内存会撑爆 这里可能需要复用某些逻辑,我这里不清楚,该如何的方向修改,是限制截流,还是其他方式,起码做到用户侧放置在滤镜开始、结尾、去重前都应能正常工作.
3. 排序必须基于唯一的text索引,他不能放到去重器后面,能否调用去重,或如何调整好他们之间的关系 4. 未设置严格的界限,当候选不能发生移动的时候,高亮发生了移动,应修改逻辑高亮锁定text

amzxyz avatar Dec 13 '25 03:12 amzxyz

@lotem @WhiredPlanck @ksqsf 请各位大佬评估

amzxyz avatar Dec 13 '25 03:12 amzxyz

這個功能大概需要重新實現一個詞典吧?

手動排序跟現有的詞典無法兼容,因爲詞典不是按照輸入碼索引的,而位置與輸入碼相關。

lotem avatar Dec 13 '25 16:12 lotem

在用戶詞典記錄的順序很難直接反映到最終顯示順序,有沒有考慮過在最終完成排序的 Filter 裏記錄候選字的手動排序數據?

lotem avatar Dec 13 '25 16:12 lotem

這個功能大概需要重新實現一個詞典吧?

手動排序跟現有的詞典無法兼容,因爲詞典不是按照輸入碼索引的,而位置與輸入碼相關。

因为之前Lua实现的用的挺好,呼声挺高,所以想尝试迁移到内核,但是这里面有很多细节上的问题: 1、这个脚本最好的位置应该是放在去重的后面,以最终候选形态来操作,但现在去重后面不能有东西,现在放在OpenCC后面就会干死输入法,相关理解我还不够透彻,但不放在最后不能保证索引的唯一性,并且还有一定性能问题,可能我的实现太过于简单了,Lua都不卡,Lua只有单字母性能差点,主要是时间复杂度高; 2、储存的数据必须是输入编码,且同等seg段不可跃迁 ni nir nire都可以打出你,但位置不能相同,而原始输入编码是一个,能否做到换个输入方式如小鹤换自然码还能复用这个排序信息超出我认知;

amzxyz avatar Dec 13 '25 16:12 amzxyz

这一套是按键时更新p遇到相同的则进行交换位置,从起始到排序的最大索引顺序都记录了,滤镜翻译直接呈现即可,这种方式储存数据量大一些,但趋向于流式处理,整个流畅度较高,但最怕用户自己玩会可能搞得db储存过多的无效数据,目前这个效率高于按时序的算法

// src/rime/gear/sequence_adjuster.cc

#include <rime/translation.h>
#include <rime/context.h>
#include <rime/composition.h>
#include <rime/segmentation.h>
#include <rime/menu.h> 
#include <rime/engine.h>
#include <rime/schema.h>
#include <rime/dict/db.h>
#include <rime/dict/user_db.h>
#include <rime/gear/sequence_adjuster.h>
#include <boost/format.hpp>
#include <boost/algorithm/string.hpp>
#include <vector>
#include <map>
#include <set>
#include <sstream>
#include <mutex>
#include <algorithm>
#include <chrono>
#include <list>

namespace rime {

// 0. DB & Helper

std::string SequenceDbValue::Pack() const {
  std::ostringstream packed;
  packed << "p=" << position << " s=" << stamp;
  return packed.str();
}

SequenceDbValue SequenceDbValue::Unpack(const std::string& value) {
  SequenceDbValue v;
  std::vector<std::string> kv;
  boost::split(kv, value, boost::is_any_of(" "));
  for (const std::string& k_eq_v : kv) {
    size_t eq = k_eq_v.find('=');
    if (eq == std::string::npos) continue;
    std::string k = k_eq_v.substr(0, eq);
    std::string val = k_eq_v.substr(eq + 1);
    try {
      if (k == "p") v.position = std::stoi(val);
      else if (k == "s") v.stamp = std::stoul(val);
    } catch (...) {}
  }
  return v;
}

static std::weak_ptr<Db> g_shared_db;
static std::mutex g_db_mutex;

static an<Db> GetSharedDb(const std::string& db_name) {
  std::lock_guard<std::mutex> lock(g_db_mutex);
  an<Db> db = g_shared_db.lock();
  if (db) return db;
  auto component = UserDb::Require("userdb");
  if (!component) return nullptr;
  
  db.reset(component->Create(db_name));
  if (db) {
    if (db->Open()) {
      g_shared_db = db;
    } else {
      db.reset();
    }
  }
  return db;
}

static unsigned long GetPhysicalTimestamp() {
  auto duration = std::chrono::system_clock::now().time_since_epoch();
  return static_cast<unsigned long>(std::chrono::duration_cast<std::chrono::seconds>(duration).count());
}

// 强制取全码,保证Key稳定
static std::string GetContextCode(Context* ctx) {
  if (!ctx || ctx->composition().empty()) return "";
  const auto& segment = ctx->composition().back();
  size_t start = segment.start;
  size_t end = ctx->input().length(); 
  
  if (start >= end) return "";
  
  std::string code = ctx->input().substr(start, end - start);
  boost::trim(code);
  return code;
}

// 1. BufferedTranslation (流式架构)

class BufferedTranslation : public Translation {
 public:
  BufferedTranslation(an<Translation> source, 
                      std::map<int, std::string> layout, 
                      std::set<std::string> pending_texts)
      : source_(source), 
        layout_(std::move(layout)), 
        pending_texts_(std::move(pending_texts)) {}

  bool Next() override {
    if (exhausted()) return false;
    if (has_value_) {
        cursor_++;
        has_value_ = false;
        current_val_ = nullptr;
    }
    return true;
  }

  an<Candidate> Peek() override {
    if (has_value_) return current_val_;
    if (exhausted()) return nullptr;
    current_val_ = FetchNext();
    if (current_val_) has_value_ = true;
    return current_val_;
  }

  bool exhausted() const { 
    return !has_value_ && pool_.empty() && (!source_ || source_->exhausted());
  }
  bool exhausted() { 
    return !has_value_ && pool_.empty() && (!source_ || source_->exhausted());
  }

 private:
  an<Candidate> FetchNext() {
    auto it_layout = layout_.find(cursor_);
    if (it_layout != layout_.end()) {
        std::string target_text = it_layout->second;
        
        for (auto it = pool_.begin(); it != pool_.end(); ++it) {
            if ((*it)->text() == target_text) {
                an<Candidate> c = *it;
                pool_.erase(it);
                return c;
            }
        }
        
        // 无限搜寻
        while (source_ && !source_->exhausted()) {
            auto c = source_->Peek();
            source_->Next(); 
            
            if (c->text() == target_text) {
                return c; 
            } else {
                pool_.push_back(c);
            }
        }
    }

    for (auto it = pool_.begin(); it != pool_.end(); ++it) {
        if (pending_texts_.find((*it)->text()) == pending_texts_.end()) {
            an<Candidate> c = *it;
            pool_.erase(it);
            return c;
        }
    }

    while (source_ && !source_->exhausted()) {
        auto c = source_->Peek();
        source_->Next();

        // 错误修正:pending_texts_ 是对象,使用 .end()
        if (pending_texts_.find(c->text()) == pending_texts_.end()) {
            return c; 
        } else {
            pool_.push_back(c); 
        }
    }

    return nullptr;
  }

  an<Translation> source_;
  std::map<int, std::string> layout_;      
  std::set<std::string> pending_texts_;    
  std::list<an<Candidate>> pool_;          
  int cursor_ = 0;
  bool has_value_ = false;
  an<Candidate> current_val_;
};

// 2. Processor
SequenceAdjusterProcessor::SequenceAdjusterProcessor(const Ticket& ticket)
    : Processor(ticket) { LoadConfig(); }
SequenceAdjusterProcessor::~SequenceAdjusterProcessor() { user_db_.reset(); }

void SequenceAdjusterProcessor::LoadConfig() {
  Config* config = engine_->schema()->config();
  bool module_enabled = true;
  config->GetBool("sequence_adjuster/enable", &module_enabled);
  if (!module_enabled) return;
  db_name_ = "sequence";
  auto load_key = [&](const std::string& key_name, KeyEvent& key, const std::string& def) {
    std::string str;
    if (config->GetString("sequence_adjuster/" + key_name, &str)) key = KeyEvent(str);
    else key = KeyEvent(def);
  };
  load_key("up", key_up_, "Control+Shift+J");
  load_key("down", key_down_, "Control+Shift+K");
  load_key("reset", key_reset_, "Control+Shift+L");
  load_key("pin", key_pin_, "Control+Shift+P");
  if (!db_name_.empty()) user_db_ = GetSharedDb(db_name_);
}

std::string SequenceAdjusterProcessor::MakeDbKey(const std::string& code, const std::string& text) {
  std::string effective_code = code;
  boost::trim(effective_code);
  if (effective_code.empty() || effective_code.back() != ' ') effective_code += ' ';
  return effective_code + "\t" + text;
}

ProcessResult SequenceAdjusterProcessor::ProcessKeyEvent(const KeyEvent& key_event) {
  if (!user_db_) return kNoop;
  Context* ctx = engine_->context();
  if (!ctx->HasMenu()) return kNoop;
  if (key_event.release()) return kNoop;
  
  if (key_event == key_up_)    return SaveAdjustment(ctx, 1, false, false) ? kAccepted : kNoop;
  if (key_event == key_down_)  return SaveAdjustment(ctx, -1, false, false) ? kAccepted : kNoop;
  if (key_event == key_pin_)   return SaveAdjustment(ctx, 0, true, false) ? kAccepted : kNoop;
  if (key_event == key_reset_) return SaveAdjustment(ctx, 0, false, true) ? kAccepted : kNoop;

  std::string repr = key_event.repr();
  if (repr == "{Sequence_Up}")    return SaveAdjustment(ctx, 1, false, false) ? kAccepted : kNoop;
  if (repr == "{Sequence_Down}")  return SaveAdjustment(ctx, -1, false, false) ? kAccepted : kNoop;
  if (repr == "{Sequence_Pin}")   return SaveAdjustment(ctx, 0, true, false) ? kAccepted : kNoop;
  if (repr == "{Sequence_Reset}") return SaveAdjustment(ctx, 0, false, true) ? kAccepted : kNoop;
  return kNoop;
}

void FetchAllPinned(an<Db> db, const std::string& db_prefix, std::map<int, std::string>& layout, std::map<std::string, int>& text_to_pos) {
    auto accessor = db->Query(db_prefix);
    if (accessor && accessor->Jump(db_prefix)) {
        std::string key, val_str;
        while (accessor->GetNextRecord(&key, &val_str)) {
            if (!boost::starts_with(key, db_prefix)) break;
            if (key.length() > db_prefix.length()) {
                std::string text = key.substr(db_prefix.length());
                SequenceDbValue val = SequenceDbValue::Unpack(val_str);
                if (val.position >= 0) {
                    layout[val.position] = text;
                    text_to_pos[text] = val.position;
                }
            }
        }
    }
}

bool SequenceAdjusterProcessor::SaveAdjustment(Context* ctx, int offset, bool is_pin, bool is_reset) {
  auto cand = ctx->GetSelectedCandidate();
  if (!cand) return false;
  
  std::string code = GetContextCode(ctx); 
  if (code.empty()) return false;
  std::string current_text = cand->text();

  std::string db_prefix = code;
  boost::trim(db_prefix);
  if (db_prefix.empty() || db_prefix.back() != ' ') db_prefix += ' ';
  db_prefix += "\t";

  auto& segment = ctx->composition().back();

  std::map<int, std::string> layout;
  std::map<std::string, int> text_to_pos;
  FetchAllPinned(user_db_, db_prefix, layout, text_to_pos);

  if (is_reset) {
      std::string key = MakeDbKey(code, current_text);
      user_db_->Erase(key);
      ctx->RefreshNonConfirmedComposition();
      return true;
  }

  int current_p = static_cast<int>(segment.selected_index);
  int target_p = 0;
  if (is_pin) {
      target_p = 0;
  } else {
      target_p = current_p - offset;
  }

  // 获取目标位置的候选词 (用于判断交换 和 边界检查)
  an<Candidate> target_cand = nullptr;
  if (!is_pin) {
      if (target_p < 0) target_p = 0;
      if (target_p != current_p) {
          target_cand = segment.GetCandidateAt(target_p);
          
          if (target_cand) {
              // 严格 Seg 边界检查:只要 Seg 属性不同,就拦截!
              if (target_cand->end() != cand->end()) {
                   return true; 
              }
          }
      }
  }

  if (target_p == current_p) return true;

  auto update_db = [&](int p, const std::string& txt) {
      std::string key = MakeDbKey(code, txt);
      SequenceDbValue val;
      val.position = p;
      val.stamp = GetPhysicalTimestamp();
      user_db_->Update(key, val.Pack());
  };

  auto safe_erase = [&](const std::string& txt) {
      std::string k = MakeDbKey(code, txt);
      user_db_->Erase(k);
  };

  if (text_to_pos.count(current_text)) {
      safe_erase(current_text);
  }

  if (is_pin) {
      if (layout.count(0)) {
           std::string other = layout[0];
           update_db(current_p, other); 
           update_db(0, current_text);
      } else {
           update_db(0, current_text);
      }
  } 
  else {
      // 核心逻辑:强制交换
      an<Candidate> target_cand_visual = target_p >= 0 ? segment.GetCandidateAt(target_p) : nullptr;
      
      if (layout.count(target_p)) {
          // 目标位置在 DB 里有人 -> 必须交换 (硬碰硬)
          std::string neighbor_text = layout[target_p];
          update_db(target_p, current_text);   
          update_db(current_p, neighbor_text); 
      } 
      // 目标位置在 DB 里没,但是 visually 确实有 -> 强制抓进来交换!
      else if (target_cand_visual) {
          std::string neighbor_text = target_cand_visual->text();
          update_db(target_p, current_text);
          update_db(current_p, neighbor_text);
      }
      // 目标位置是虚空(列表末尾),直接写入
      else {
          update_db(target_p, current_text);
      }
  }

  // 刷新 & 高亮
  ctx->RefreshNonConfirmedComposition();
  if (!ctx->composition().empty()) {
     auto& seg_ref = ctx->composition().back();
     if (target_p >= 0) {
        seg_ref.selected_index = static_cast<size_t>(target_p);
        seg_ref.status = Segment::kGuess;
     }
  }

  return true;
}

// 3. Filter
SequenceAdjusterFilter::SequenceAdjusterFilter(const Ticket& ticket) : Filter(ticket) {
  Config* config = engine_->schema()->config();
  bool module_enabled = true;
  config->GetBool("sequence_adjuster/enable", &module_enabled);
  if (module_enabled) {
      db_name_ = "sequence";
      if (!db_name_.empty()) user_db_ = GetSharedDb(db_name_);
  }
}

SequenceAdjusterFilter::~SequenceAdjusterFilter() { user_db_.reset(); }

an<Translation> SequenceAdjusterFilter::Apply(an<Translation> translation, CandidateList* candidates) {
  if (!user_db_ || !translation || translation->exhausted()) return translation;
  Context* ctx = engine_->context();
  std::string input_code = GetContextCode(ctx); // 全码
  if (input_code.empty()) return translation;

  std::map<int, std::string> layout;
  std::set<std::string> pending_texts;
  std::string db_prefix = input_code;
  boost::trim(db_prefix);
  if (db_prefix.empty() || db_prefix.back() != ' ') db_prefix += ' ';
  db_prefix += "\t";
  
  auto accessor = user_db_->Query(db_prefix);
  if (accessor && accessor->Jump(db_prefix)) {
    std::string key, val_str;
    while (accessor->GetNextRecord(&key, &val_str)) {
      if (!boost::starts_with(key, db_prefix)) break;
      if (key.length() > db_prefix.length()) {
        std::string text = key.substr(db_prefix.length());
        SequenceDbValue val = SequenceDbValue::Unpack(val_str);
        if (val.position >= 0) {
            layout[val.position] = text;
            pending_texts.insert(text);
        }
      }
    }
  }

  if (layout.empty()) return translation;

  return New<BufferedTranslation>(translation, std::move(layout), std::move(pending_texts));
}

}  // namespace rime

// src/rime/gear/sequence_adjuster.h
#ifndef RIME_SEQUENCE_ADJUSTER_H_
#define RIME_SEQUENCE_ADJUSTER_H_

#include <rime/common.h>
#include <rime/component.h>
#include <rime/processor.h>
#include <rime/filter.h>
#include <rime/dict/db.h>
#include <rime/key_event.h>
#include <string>

namespace rime {

class Context;

// 1. 数据结构定义
struct SequenceDbValue {
  int position = -1;       // p: 目标位置
  unsigned long stamp = 0; // s: 物理时间戳 (stamp)
  
  // 【新增】: 记录操作时的原始位置 (origin),用于计算“拔出”后的补位
  int origin = -1; 

  std::string Pack() const;
  static SequenceDbValue Unpack(const std::string& value);
};

// 2. Processor 定义
class SequenceAdjusterProcessor : public Processor {
 public:
  SequenceAdjusterProcessor(const Ticket& ticket);
  ~SequenceAdjusterProcessor();

  ProcessResult ProcessKeyEvent(const KeyEvent& key_event) override;

 protected:
  void LoadConfig();
  bool SaveAdjustment(Context* ctx, int offset, bool is_pin, bool is_reset);
  static std::string MakeDbKey(const std::string& code, const std::string& text);

  an<Db> user_db_;
  std::string db_name_; 
  
  KeyEvent key_up_;
  KeyEvent key_down_;
  KeyEvent key_pin_;
  KeyEvent key_reset_;
};

// 3. Filter 定义
class SequenceAdjusterFilter : public Filter {
 public:
  SequenceAdjusterFilter(const Ticket& ticket);
  ~SequenceAdjusterFilter();

  an<Translation> Apply(an<Translation> translation,
                        CandidateList* candidates) override;

 protected:
  static std::string MakeDbKey(const std::string& code, const std::string& text);

  an<Db> user_db_;
  std::string db_name_; 
};

}  // namespace rime

#endif  // RIME_SEQUENCE_ADJUSTER_H_

amzxyz avatar Dec 14 '25 08:12 amzxyz