From f79c4a17998e4522bb94df90cb20f7379d97e70f Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 04:17:40 +0800 Subject: [PATCH 01/20] docs: VCF visualization design spec (dual-pane TUI, lazy scan, genotype matrix) --- .../2026-07-25-vcf-visualization-design.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-25-vcf-visualization-design.md diff --git a/docs/superpowers/specs/2026-07-25-vcf-visualization-design.md b/docs/superpowers/specs/2026-07-25-vcf-visualization-design.md new file mode 100644 index 0000000..9d14b2c --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-vcf-visualization-design.md @@ -0,0 +1,144 @@ +# VCF 可视化设计文档 + +日期:2026-07-25 · 分支:`vcf` · 状态:已确认 + +## 概述 + +为 seqviz 增加 VCF(Variant Call Format)格式的终端可视化支持。采用独立 App 架构(方案 B): +新建 `vcf.py` 解析器 + `vcf_browser.py` Textual App,复用主题/配置/剪贴板基础设施, +`seqviz browse` 按后缀自动路由。仅支持未压缩纯文本 `.vcf`(v0.6.x 阶段不做 bgzip/tabix)。 + +## 需求确认结果 + +- **使用场景**:小文件逐条浏览 + 大文件探索两者兼顾(懒扫描索引 + 虚拟化列表混合策略) +- **压缩支持**:仅纯文本 VCF(复用 FASTA P0 懒扫描架构) +- **多样本**:逐样本基因型展示(GT/AD/DP 详情 + 比例条) + +## 可视化布局(已确认) + +双栏布局: + +- **左栏(变异列表)**:一行一变异,`类型符号 + chr:pos + ID + REF→ALT摘要 + QUAL` + - 类型符号:SNP `●`、插入/缺失 `◆` + - 行颜色编码可信度:PASS→绿 / LowQual 或 QUAL<20→黄红系 / LowDP→红 +- **右栏(变异详情)**三段式: + 1. 位点基本信息(坐标、ID、变异类型中文名、QUAL、FILTER) + 2. INFO 注释逐行展开(DP/AF/MQ/DB 等,按 `##INFO` 定义显示 Description) + 3. 逐样本基因型:GT 中文解读(纯合参考/杂合/纯合变异)+ DP/GQ + AD reads 比例条 +- `t` 切换基因型矩阵视图:行=变异、列=样本,GT 着色(0/0 暗灰、0/1 黄、1/1 亮红) +- `i` 打开文件信息面板(`##` 元数据:contig 长度、INFO/FORMAT 字段定义) +- 底部状态栏常驻统计:变异总数、SNP/InDel 计数、Ts/Tv、PASS 数、AF 均值、当前过滤/排序 + +## 交互设计(已确认) + +| 按键 | 功能 | +|------|------| +| `j`/`k`、`n`/`p` | 上下移动变异列表 | +| `Space`/`b`、`g`/`G` | 翻页、首/末条 | +| `/` | 搜索(ID 或坐标 `chr1:10234`) | +| `f` | 过滤循环:全部 → PASS → SNP → InDel → 全部 | +| `s` | 排序切换:位置 ↔ QUAL 降序 | +| `t` | 详情视图 ↔ 基因型矩阵视图 | +| `i` | 文件元数据面板 | +| `y` | 复制当前变异完整 VCF 行 | +| `?` / `q` | 帮助 / 退出 | + +明确不做(YAGNI):染色体密度图、bgzip/tabix、多文件标签页(VCF 单文件打开)。 + +## 实现架构 + +### 新文件 + +**`src/seqviz/vcf.py`** — 纯解析层(无 UI 依赖,可独立测试) + +```python +class VariantType(Enum): + TRANSITION # 转换 SNP: A↔G, C↔T + TRANSVERSION # 颠换 SNP + INSERTION # len(ALT) > len(REF) + DELETION # len(REF) > len(ALT) + COMPLEX # 其余 + +@dataclass +class Variant: + chrom: str + pos: int + id: str # "." → "" + ref: str + alt: str + qual: float | None # "." → None + filter: str + info: dict[str, str] # 懒扫描时为空 dict,按需解析填充 + format_fields: list[str] # 同上 + samples: dict[str, str] # 同上;样本名 → 原始 "0/1:15:99:10,5" + offset: int # 行首字节偏移(f.tell),支持 seek 回读完整行 + raw: str # 原始行;懒扫描时为空,回读后填充,y 键复制用 + +@dataclass +class VcfMeta: + fileformat: str + contigs: dict[str, int] # ID → length + info_defs: dict[str, str] # ID → Description + format_defs: dict[str, str] + filter_defs: dict[str, str] + samples: list[str] + +def parse_meta(lines) -> VcfMeta # 解析 ## 头 +def scan_vcf(path) -> tuple[VcfMeta, list[Variant]] + # 懒扫描:每行只取前 8 列 + 字节 offset,INFO/FORMAT/样本列不解析 + # 复用 FASTA P0 模式(逐行 readline,记录 offset) +def load_variant_detail(path, variant) -> Variant + # seek(offset) 回读完整行,填充 info/format/samples/raw +def parse_variant_line(line) -> Variant # 按需完整解析单行 +def classify_variant(ref, alt) -> VariantType +def parse_genotype(sample_str, format_fields) -> dict + # → {"GT": "0/1", "DP": 15, "AD": [10, 5], ...} +def compute_stats(variants) -> dict + # → total / snp / indel / ts_tv / pass_count / mean_af +``` + +**`src/seqviz/vcf_browser.py`** — Textual App(`VcfBrowser`) + +- 结构镜像 `browser.py`:Header + 左 OptionList(虚拟化)+ 右 Static 详情 + Footer +- CSS 用 `theme.build_vcf_browser_css(theme)`(theme.py 新增该函数,复用现有样式骨架) +- 大文件策略:启动懒扫描全量索引(只 8 列,1.3G 级预计 <2s),列表虚拟化渲染 +- 过滤/排序在内存索引列表上操作(保存过滤后下标映射) +- 剪贴板复用现有分层回退策略(browser.py 中的工具函数提取或复制) + +### 修改文件 + +- **`src/seqviz/cli.py`**:`browse` 命令按后缀路由,`.vcf` → `VcfBrowser` +- **`src/seqviz/file_browser.py`**:`EXTENSIONS` 增加 `.vcf`,格式标记显示 `[V]`,打开时路由 +- **`src/seqviz/theme.py`**:新增 `build_vcf_browser_css()` +- **`README.md`**:新增 VCF 章节 + +### 数据流 + +``` +seqviz browse x.vcf → cli 路由 → VcfBrowser.__init__ + → scan_vcf()(懒扫描索引,只前 8 列)→ OptionList 虚拟化列表 + → 选中行 → load_variant_detail()(seek 回读完整行)→ 右栏渲染详情 + → t 切换 → 批量 load_variant_detail 当前窗口变异 → 基因型矩阵 +``` + +## 错误处理 + +- 无 `#CHROM` 表头行 / 空文件:CLI 友好报错,非零退出(对齐现有空文件策略) +- 列数不足的数据行:跳过并计数,状态栏提示 `跳过 N 行畸形数据` +- QUAL 为 `.` 或非数字:`None`,排序时排最后,显示 `-` +- 非 UTF-8 行:宽容解码(`errors="replace"`),对齐 v0.6.3 策略 + +## 测试计划 + +- `test/test_vcf.py`: + - 解析器:parse_meta / scan_vcf / parse_variant_line / classify_variant(转换颠换判定)/ parse_genotype / compute_stats(Ts/Tv 计算) + - 畸形输入:空文件、无表头、列缺失、QUAL=`.`、非 UTF-8 +- `test/test_vcf_browser.py`:App 启动、列表渲染、过滤循环、排序切换、矩阵视图、y 复制 +- `test/test_cli.py`:`browse x.vcf` 路由 +- 全量回归:现有 210 项测试保持通过 +- 实测:`test/sample.vcf` + +## 假设 + +- 单样本到 ~50 样本的 VCF(矩阵视图在列数超宽时横向滚动或截断,不做复杂折叠) +- ALT 多等位(`A,G`)按第一个 ALT 分类,详情显示完整 ALT From 200d0886934d3254b98954abf50c240029e4ee58 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 04:20:37 +0800 Subject: [PATCH 02/20] docs: VCF implementation plan (10 TDD tasks) --- .../plans/2026-07-25-vcf-visualization.md | 1102 +++++++++++++++++ 1 file changed, 1102 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-vcf-visualization.md diff --git a/docs/superpowers/plans/2026-07-25-vcf-visualization.md b/docs/superpowers/plans/2026-07-25-vcf-visualization.md new file mode 100644 index 0000000..1fc6466 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-vcf-visualization.md @@ -0,0 +1,1102 @@ +# VCF 可视化实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 seqviz 增加 VCF 终端可视化:独立 VcfBrowser App(双栏布局、懒扫描、过滤排序、基因型矩阵)。 + +**Architecture:** 纯解析层 `vcf.py`(无 UI 依赖)+ Textual App `vcf_browser.py`(镜像 browser.py 结构),`seqviz browse` 按 `.vcf` 后缀路由,复用主题/配置/剪贴板基础设施。 + +**Tech Stack:** Python 3.12+, Textual, Rich, Typer, pytest。测试命令统一 `uv run pytest test/ -x -q`。 + +**Spec:** `docs/superpowers/specs/2026-07-25-vcf-visualization-design.md` + +--- + +### Task 1: vcf.py 数据结构 + classify_variant + parse_genotype + +**Files:** +- Create: `src/seqviz/vcf.py` +- Test: `test/test_vcf.py` + +- [ ] **Step 1: 写失败测试** `test/test_vcf.py` + +```python +"""VCF 解析层测试。""" +import json +from pathlib import Path + +import pytest + +from seqviz import vcf as vcf_mod +from seqviz.vcf import ( + Variant, VcfMeta, VariantType, + classify_variant, parse_genotype, +) + +SAMPLE_VCF = Path(__file__).parent / "sample.vcf" + + +class TestClassifyVariant: + def test_transition(self): + assert classify_variant("A", "G") == VariantType.TRANSITION + assert classify_variant("G", "A") == VariantType.TRANSITION + assert classify_variant("C", "T") == VariantType.TRANSITION + assert classify_variant("T", "C") == VariantType.TRANSITION + + def test_transversion(self): + assert classify_variant("A", "T") == VariantType.TRANSVERSION + assert classify_variant("G", "C") == VariantType.TRANSVERSION + assert classify_variant("A", "C") == VariantType.TRANSVERSION + + def test_insertion(self): + assert classify_variant("T", "TG") == VariantType.INSERTION + assert classify_variant("A", "ACGT") == VariantType.INSERTION + + def test_deletion(self): + assert classify_variant("AT", "A") == VariantType.DELETION + assert classify_variant("GC", "G") == VariantType.DELETION + + def test_multiallelic_uses_first_alt(self): + assert classify_variant("A", "G,T") == VariantType.TRANSITION + + def test_case_insensitive(self): + assert classify_variant("a", "g") == VariantType.TRANSITION + + +class TestParseGenotype: + def test_full_fields(self): + result = parse_genotype("0/1:15:99:10,5", ["GT", "DP", "GQ", "AD"]) + assert result["GT"] == "0/1" + assert result["DP"] == 15 + assert result["GQ"] == 99 + assert result["AD"] == [10, 5] + + def test_missing_gt(self): + result = parse_genotype(".:.:.", ["GT", "DP", "GQ"]) + assert result["GT"] == "./." + + def test_fewer_values_than_fields(self): + result = parse_genotype("0/0", ["GT", "DP"]) + assert result == {"GT": "0/0"} + + def test_non_int_kept_as_str(self): + result = parse_genotype("0/1:abc", ["GT", "DP"]) + assert result["DP"] == "abc" +``` + +- [ ] **Step 2: 运行确认失败**:`uv run pytest test/test_vcf.py -x -q` → FAIL(模块不存在) + +- [ ] **Step 3: 实现** `src/seqviz/vcf.py` + +```python +"""VCF (Variant Call Format) 解析层 — 纯解析,无 UI 依赖。 + +支持未压缩纯文本 VCF v4.x。采用懒扫描策略: +scan_vcf 只解析前 8 列(含 INFO),样本基因型按需 seek 回读。 +""" + +from dataclasses import dataclass, field +from enum import Enum + +_TRANSITIONS = {("A", "G"), ("G", "A"), ("C", "T"), ("T", "C")} + + +class VariantType(Enum): + TRANSITION = "transition" # 转换(嘌呤↔嘌呤 / 嘧啶↔嘧啶) + TRANSVERSION = "transversion" # 颠换 + INSERTION = "insertion" # 插入 + DELETION = "deletion" # 缺失 + COMPLEX = "complex" # 复杂变异 + + +@dataclass +class Variant: + chrom: str + pos: int + id: str # "." 存为 "" + ref: str + alt: str + qual: float | None # "." 或非数字存为 None + filter: str + info: dict = field(default_factory=dict) # 懒扫描时已含 INFO(第8列) + format_fields: list = field(default_factory=list) # 懒扫描为空,按需填充 + samples: dict = field(default_factory=dict) # 同上 + offset: int = 0 # 行首字节偏移(seek 回读用) + raw: str = "" # 完整原始行,按需填充,y 键复制用 + + +@dataclass +class VcfMeta: + fileformat: str = "" + contigs: dict = field(default_factory=dict) # ID → length + info_defs: dict = field(default_factory=dict) # ID → Description + format_defs: dict = field(default_factory=dict) + filter_defs: dict = field(default_factory=dict) + samples: list = field(default_factory=list) + + +def classify_variant(ref: str, alt: str) -> VariantType: + """按 REF/ALT 长度与碱基判定变异类型;多等位取第一个 ALT。""" + first_alt = alt.split(",")[0] + ref_u, alt_u = ref.upper(), first_alt.upper() + if len(ref) == 1 and len(first_alt) == 1: + return VariantType.TRANSITION if (ref_u, alt_u) in _TRANSITIONS else VariantType.TRANSVERSION + if len(first_alt) > len(ref): + return VariantType.INSERTION + if len(ref) > len(first_alt): + return VariantType.DELETION + return VariantType.COMPLEX + + +def parse_genotype(sample_str: str, format_fields: list[str]) -> dict: + """把 '0/1:15:99:10,5' 按 FORMAT 声明解析为 dict。 + + GT 缺失(".") 归一为 "./.";AD 解析为 int 列表;其余可转 int 则转。 + """ + values = sample_str.split(":") + result = {} + for key, val in zip(format_fields, values): + if key == "GT": + result["GT"] = val if val != "." else "./." + elif key == "AD": + try: + result["AD"] = [int(x) for x in val.split(",")] + except ValueError: + result["AD"] = [] + else: + try: + result[key] = int(val) + except ValueError: + result[key] = val + return result +``` + +- [ ] **Step 4: 运行确认通过**:`uv run pytest test/test_vcf.py -x -q` → PASS +- [ ] **Step 5: 提交**:`git add src/seqviz/vcf.py test/test_vcf.py && git commit -m "feat(vcf): variant classification and genotype parsing"` + +--- + +### Task 2: parse_meta(## 头部解析) + +**Files:** Modify `src/seqviz/vcf.py` / `test/test_vcf.py` + +- [ ] **Step 1: 写失败测试**(追加到 `test/test_vcf.py`) + +```python +from seqviz.vcf import parse_meta + + +class TestParseMeta: + def test_sample_vcf_meta(self): + lines = SAMPLE_VCF.read_text().splitlines() + header = [l for l in lines if l.startswith("##")] + meta = parse_meta(header) + assert meta.fileformat == "VCFv4.3" + assert meta.contigs["chr1"] == 79116311 + assert meta.info_defs["DP"] == "Total Depth" + assert meta.format_defs["GT"] == "Genotype" + assert meta.filter_defs["PASS"] == "All filters passed" + + def test_empty_header(self): + meta = parse_meta([]) + assert meta.fileformat == "" + assert meta.contigs == {} + + def test_contig_without_length(self): + meta = parse_meta(["##contig="]) + assert meta.contigs["chrX"] == 0 +``` + +- [ ] **Step 2: 运行确认失败** → FAIL(parse_meta 未定义) +- [ ] **Step 3: 实现**(追加到 `vcf.py`) + +```python +import re + +_ID_RE = re.compile(r"ID=([^,>]+)") +_DESC_RE = re.compile(r'Description="([^"]*)"') +_LEN_RE = re.compile(r"length=(\d+)") + + +def parse_meta(header_lines: list[str]) -> VcfMeta: + """解析 ## 元数据行(fileformat/contig/INFO/FORMAT/FILTER 定义)。""" + meta = VcfMeta() + for line in header_lines: + line = line.rstrip("\n") + if line.startswith("##fileformat="): + meta.fileformat = line.split("=", 1)[1] + elif line.startswith("##contig="): + m = _ID_RE.search(line) + lm = _LEN_RE.search(line) + if m: + meta.contigs[m.group(1)] = int(lm.group(1)) if lm else 0 + elif line.startswith("##INFO=") or line.startswith("##FORMAT="): + m = _ID_RE.search(line) + dm = _DESC_RE.search(line) + target = meta.info_defs if line.startswith("##INFO=") else meta.format_defs + if m: + target[m.group(1)] = dm.group(1) if dm else "" + elif line.startswith("##FILTER="): + m = _ID_RE.search(line) + dm = _DESC_RE.search(line) + if m: + meta.filter_defs[m.group(1)] = dm.group(1) if dm else "" + return meta +``` + +- [ ] **Step 4: 运行确认通过** +- [ ] **Step 5: 提交**:`git commit -am "feat(vcf): parse VCF meta header lines"` + +--- + +### Task 3: scan_vcf + parse_variant_line + load_variant_detail + +**Files:** Modify `src/seqviz/vcf.py` / `test/test_vcf.py` + +- [ ] **Step 1: 写失败测试**(追加) + +```python +from seqviz.vcf import scan_vcf, parse_variant_line, load_variant_detail + + +class TestParseVariantLine: + def test_full_line(self): + line = "chr1\t10234\trs12345\tA\tG\t99.5\tPASS\tDP=45;AF=0.333;DB\tGT:DP\t0/1:15\t0/0:18" + v = parse_variant_line(line, offset=100, sample_names=["s1", "s2"]) + assert v.chrom == "chr1" and v.pos == 10234 + assert v.id == "rs12345" and v.qual == 99.5 + assert v.info == {"DP": "45", "AF": "0.333", "DB": ""} + assert v.samples == {"s1": "0/1:15", "s2": "0/0:18"} + assert v.offset == 100 + + def test_dot_id_and_qual(self): + v = parse_variant_line("chr1\t5\t.\tA\tG\t.\tPASS\t.") + assert v.id == "" and v.qual is None + + def test_malformed_fewer_than_8_cols(self): + assert parse_variant_line("chr1\t100\tA") is None + + def test_bad_pos(self): + assert parse_variant_line("chr1\txyz\t.\tA\tG\t.\tPASS\t.") is None + + def test_header_line_rejected(self): + assert parse_variant_line("#CHROM\tPOS\tID") is None + + +class TestScanVcf: + def test_sample_vcf(self): + meta, variants, skipped = scan_vcf(SAMPLE_VCF) + assert len(variants) == 17 + assert skipped == 0 + assert meta.samples == ["sample1", "sample2", "sample3"] + first = variants[0] + assert (first.chrom, first.pos, first.ref, first.alt) == ("chr1", 10234, "A", "G") + assert first.info["AF"] == "0.333" # INFO 在索引阶段已解析 + assert first.samples == {} # 样本列懒加载 + assert first.offset > 0 + + def test_empty_file(self, tmp_path): + f = tmp_path / "empty.vcf" + f.write_text("") + meta, variants, skipped = scan_vcf(f) + assert variants == [] and meta.samples == [] + + def test_no_header_line(self, tmp_path): + f = tmp_path / "nohdr.vcf" + f.write_text("chr1\t1\t.\tA\tG\t.\tPASS\t.\n") + meta, variants, skipped = scan_vcf(f) + assert len(variants) == 1 + assert meta.samples == [] + + def test_malformed_lines_skipped_and_counted(self, tmp_path): + f = tmp_path / "bad.vcf" + f.write_text("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n" + "chr1\t1\t.\tA\tG\t.\tPASS\t.\n" + "garbage line\n" + "chr1\t2\t.\tC\tT\t.\tPASS\t.\n") + _, variants, skipped = scan_vcf(f) + assert len(variants) == 2 and skipped == 1 + + +class TestLoadVariantDetail: + def test_roundtrip(self): + _, variants, _ = scan_vcf(SAMPLE_VCF) + first = variants[0] + meta_samples = ["sample1", "sample2", "sample3"] + load_variant_detail(SAMPLE_VCF, first, meta_samples) + assert first.samples["sample1"] == "0/1:15:99:10,5" + assert first.format_fields == ["GT", "DP", "GQ", "AD"] + assert first.raw.startswith("chr1\t10234") +``` + +- [ ] **Step 2: 运行确认失败** +- [ ] **Step 3: 实现**(追加到 `vcf.py`) + +```python +def parse_variant_line(line: str, offset: int = 0, + sample_names: list[str] | None = None) -> Variant | None: + """完整解析单条数据行;畸形行(<8 列或 POS 非整数)返回 None。""" + raw = line.rstrip("\n") + parts = raw.split("\t") + if len(parts) < 8 or parts[0].startswith("#"): + return None + try: + pos = int(parts[1]) + except ValueError: + return None + qual = None + if parts[5] != ".": + try: + qual = float(parts[5]) + except ValueError: + pass + info = _parse_info(parts[7]) + format_fields = parts[8].split(":") if len(parts) > 8 else [] + names = sample_names or [] + samples = {} + for i, s in enumerate(parts[9:]): + name = names[i] if i < len(names) else f"sample{i + 1}" + samples[name] = s + return Variant( + chrom=parts[0], pos=pos, + id="" if parts[2] == "." else parts[2], + ref=parts[3], alt=parts[4], + qual=qual, filter=parts[6], + info=info, format_fields=format_fields, + samples=samples, offset=offset, raw=raw, + ) + + +def _parse_info(info_str: str) -> dict: + info = {} + for item in info_str.split(";"): + if not item: + continue + if "=" in item: + k, v = item.split("=", 1) + info[k] = v + else: + info[item] = "" # Flag 型(如 DB) + return info + + +def scan_vcf(path) -> tuple[VcfMeta, list[Variant], int]: + """懒扫描:解析 meta + 每行前 8 列 + 字节 offset。 + + 样本列不解析(按需 load_variant_detail)。返回 (meta, variants, skipped)。 + """ + header_lines: list[str] = [] + variants: list[Variant] = [] + sample_names: list[str] = [] + skipped = 0 + with open(path, encoding="utf-8", errors="replace") as f: + while True: + offset = f.tell() + line = f.readline() + if not line: + break + if line.startswith("##"): + header_lines.append(line) + continue + if line.startswith("#CHROM"): + cols = line.rstrip("\n").split("\t") + sample_names = cols[9:] if len(cols) > 9 else [] + continue + parts = line.rstrip("\n").split("\t", 8) # 只切前 8 列,避免样本列开销 + if len(parts) < 8 or parts[0].startswith("#"): + skipped += 1 + continue + try: + pos = int(parts[1]) + except ValueError: + skipped += 1 + continue + qual = None + if parts[5] != ".": + try: + qual = float(parts[5]) + except ValueError: + pass + variants.append(Variant( + chrom=parts[0], pos=pos, + id="" if parts[2] == "." else parts[2], + ref=parts[3], alt=parts[4], + qual=qual, filter=parts[6], + info=_parse_info(parts[7]), + offset=offset, + )) + meta = parse_meta(header_lines) + meta.samples = sample_names + return meta, variants, skipped + + +def load_variant_detail(path, variant: Variant, sample_names: list[str]) -> Variant: + """seek(offset) 回读完整行,填充 format_fields/samples/raw(原地修改并返回)。""" + if variant.raw: + return variant # 已加载 + with open(path, encoding="utf-8", errors="replace") as f: + f.seek(variant.offset) + line = f.readline() + full = parse_variant_line(line, variant.offset, sample_names) + if full is not None: + variant.format_fields = full.format_fields + variant.samples = full.samples + variant.raw = full.raw + return variant +``` + +- [ ] **Step 4: 运行确认通过** +- [ ] **Step 5: 提交**:`git commit -am "feat(vcf): lazy scan index and on-demand detail loading"` + +--- + +### Task 4: compute_stats(状态栏统计) + +**Files:** Modify `src/seqviz/vcf.py` / `test/test_vcf.py` + +- [ ] **Step 1: 写失败测试**(追加) + +```python +from seqviz.vcf import compute_stats + + +class TestComputeStats: + def test_sample_vcf_stats(self): + _, variants, _ = scan_vcf(SAMPLE_VCF) + stats = compute_stats(variants) + assert stats["total"] == 17 + assert stats["snp"] + stats["indel"] + stats["complex"] == 17 + assert stats["snp"] == 11 + assert stats["indel"] == 6 + assert stats["pass_count"] == 13 + assert stats["ts_tv"] == pytest.approx(7 / 4) # 样本中 7 转换 4 颠换 + assert 0.0 < stats["mean_af"] < 1.0 + + def test_empty(self): + stats = compute_stats([]) + assert stats["total"] == 0 and stats["ts_tv"] == 0.0 + + def test_no_tv_avoids_zero_division(self): + v = Variant("chr1", 1, "", "A", "G", 50.0, "PASS") + stats = compute_stats([v]) + assert stats["ts_tv"] == 0.0 +``` + +- [ ] **Step 2: 运行确认失败** +- [ ] **Step 3: 实现**(追加到 `vcf.py`) + +```python +def compute_stats(variants: list[Variant]) -> dict: + """统计总数/SNP/InDel/Ts:Tv/PASS 数/AF 均值(懒扫描索引即可计算)。""" + snp = indel = complex_ = ts = tv = pass_count = 0 + af_sum = 0.0 + af_count = 0 + for v in variants: + t = classify_variant(v.ref, v.alt) + if t is VariantType.TRANSITION: + snp += 1; ts += 1 + elif t is VariantType.TRANSVERSION: + snp += 1; tv += 1 + elif t in (VariantType.INSERTION, VariantType.DELETION): + indel += 1 + else: + complex_ += 1 + if v.filter == "PASS": + pass_count += 1 + af = v.info.get("AF", "") + if af: + try: + af_sum += float(af.split(",")[0]) + af_count += 1 + except ValueError: + pass + total = len(variants) + return { + "total": total, "snp": snp, "indel": indel, "complex": complex_, + "ts_tv": (ts / tv) if tv else 0.0, + "pass_count": pass_count, + "mean_af": (af_sum / af_count) if af_count else 0.0, + } +``` + +- [ ] **Step 4: 运行确认通过**(注意核对 sample.vcf 实际 Ts/Tv,若与测试断言不符以实测修正测试断言) +- [ ] **Step 5: 提交**:`git commit -am "feat(vcf): compute summary statistics for status bar"` + +--- + +### Task 5: theme.py 新增 build_vcf_browser_css + +**Files:** Modify `src/seqviz/theme.py` + +- [ ] **Step 1:** 参照 `build_file_browser_css` 结构追加: + +```python +def build_vcf_browser_css(theme: dict) -> str: + """生成 VCF 浏览器(VcfBrowser)的 CSS。""" + bg = theme["background"] + fg = theme["foreground"] + border = theme["border"] + accent = theme.get("accent", fg) + panel = theme.get("panel", bg) + highlight = theme.get("highlight", bg) + return f""" + Screen {{ + background: {bg}; + }} + Horizontal {{ + height: 1fr; + }} + #variant-list {{ + width: 40; + border-right: thick {border}; + background: {bg}; + color: {fg}; + }} + #variant-list:focus {{ + border-right: thick {accent}; + }} + #variant-list > OptionList > .option-list--option-highlighted {{ + background: {highlight}; + color: {fg}; + }} + #detail {{ + width: 1fr; + padding: 0 1; + background: {bg}; + color: {fg}; + }} + #status-bar {{ + dock: bottom; + height: 1; + background: {panel}; + color: {fg}; + }} + Header {{ + background: {panel}; + color: {fg}; + }} + Footer {{ + background: {panel}; + color: {fg}; + }} + """ +``` + +- [ ] **Step 2: 运行 `uv run pytest test/ -x -q` 确认无回归** +- [ ] **Step 3: 提交**:`git commit -am "feat(theme): add build_vcf_browser_css"` + +--- + +### Task 6: vcf_browser.py 骨架(列表 + 详情渲染) + +**Files:** Create `src/seqviz/vcf_browser.py`, Test `test/test_vcf_browser.py` + +- [ ] **Step 1: 写失败测试** + +```python +"""VcfBrowser TUI 测试。""" +from pathlib import Path + +import pytest +from textual.widgets import OptionList + +from seqviz.vcf_browser import VcfBrowser + +SAMPLE_VCF = Path(__file__).parent / "sample.vcf" + + +@pytest.mark.asyncio +async def test_app_starts_and_lists_variants(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test() as pilot: + ol = app.query_one("#variant-list", OptionList) + assert ol.option_count == 17 + + +@pytest.mark.asyncio +async def test_detail_shows_selected_variant(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test() as pilot: + detail = app.query_one("#detail") + text = str(detail.renderable) if hasattr(detail, "renderable") else "" + # 第一条变异的坐标应出现在详情区 + assert "10234" in app._detail_text() +``` + +(注:确认 pytest-asyncio 已可用——参考现有 `test_browser.py` 的 async 测试写法与 conftest,若项目用 `run_test` 同步上下文则对齐其风格。) + +- [ ] **Step 2: 运行确认失败** +- [ ] **Step 3: 实现** `src/seqviz/vcf_browser.py`(要点,镜像 browser.py 结构): + +```python +"""VCF 交互式浏览器 — 双栏:左变异列表 + 右详情/矩阵。""" + +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal +from textual.widgets import Footer, Header, OptionList, Static +from textual.widgets.option_list import Option +from rich.text import Text + +from seqviz import config +from seqviz import theme as theme_mod +from seqviz.vcf import ( + Variant, VariantType, VcfMeta, + classify_variant, compute_stats, load_variant_detail, + parse_genotype, scan_vcf, +) + +DARK = theme_mod.is_dark_theme(theme_mod.get_theme_name()) + +_TYPE_SYMBOL = { + VariantType.TRANSITION: ("●", "green"), + VariantType.TRANSVERSION: ("●", "blue"), + VariantType.INSERTION: ("◆", "yellow"), + VariantType.DELETION: ("◆", "red"), + VariantType.COMPLEX: ("◆", "magenta"), +} +_TYPE_LABEL = { + VariantType.TRANSITION: "SNP 转换", + VariantType.TRANSVERSION: "SNP 颠换", + VariantType.INSERTION: "插入", + VariantType.DELETION: "缺失", + VariantType.COMPLEX: "复杂变异", +} +_GT_LABEL = {"0/0": "纯合参考", "0/1": "杂合", "1/0": "杂合", "1/1": "纯合变异"} +_FILTER_CYCLE = ["全部", "PASS", "SNP", "InDel"] + + +class VcfBrowser(App): + TITLE = "Seqviz — VCF" + DARK = DARK + + BINDINGS = [ + Binding("j", "cursor_down", "下移"), + Binding("k", "cursor_up", "上移"), + Binding("n", "next_variant", "下一条"), + Binding("p", "prev_variant", "上一条"), + Binding("space", "page_down", "下翻页"), + Binding("b", "page_up", "上翻页"), + Binding("g", "home", "顶部"), + Binding("G", "end", "底部"), + Binding("slash", "search", "搜索"), + Binding("f", "cycle_filter", "过滤"), + Binding("s", "toggle_sort", "排序"), + Binding("t", "toggle_view", "矩阵"), + Binding("i", "file_info", "信息"), + Binding("y", "copy_line", "复制"), + Binding("question_mark", "help", "帮助"), + Binding("q", "quit", "退出"), + ] + + def __init__(self, filepath: Path): + super().__init__() + self.filepath = filepath + self.meta, self.variants, self.skipped = scan_vcf(filepath) + self.stats = compute_stats(self.variants) + self.view: list[int] = list(range(len(self.variants))) # 过滤/排序后的下标映射 + self.filter_mode = "全部" + self.sort_mode = "位置" + self.matrix_mode = False + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(): + yield OptionList(id="variant-list") + yield Static("", id="detail") + yield Static("", id="status-bar") + yield Footer() + + def on_mount(self): + self.query_one(OptionList).styles # noqa + self._refresh_list() + self.query_one("#variant-list", OptionList).focus() + + # ── 列表 ── + def _make_option(self, v: Variant) -> Option: + symbol, _color = _TYPE_SYMBOL[classify_variant(v.ref, v.alt)] + line = Text() + # 行颜色按 FILTER/QUAL 编码可信度 + if v.filter == "PASS": + row_style = "green" + elif v.filter == "LowDP" or (v.qual is not None and v.qual < 20): + row_style = "red" + else: + row_style = "yellow" + qual_s = f"{v.qual:>6.1f}" if v.qual is not None else " -" + vid = v.id or "." + line.append(f"{symbol} ", style=_color) + line.append(f"{v.chrom}:{v.pos:,}", style=row_style) + line.append(f" {vid:<12}", style="dim") + summary = f"{v.ref}→{v.alt}" if len(v.ref) <= 3 and len(v.alt) <= 3 else ( + "ins" if classify_variant(v.ref, v.alt) is VariantType.INSERTION else "del") + line.append(f"{summary:<8}") + line.append(qual_s, style=row_style) + return Option(line) + + def _refresh_list(self): + ol = self.query_one("#variant-list", OptionList) + ol.clear_options() + ol.add_options([self._make_option(self.variants[i]) for i in self.view]) + if self.view: + ol.highlighted = 0 + self._show_detail(0) + self._update_status_bar() + + # ── 详情 ── + def _detail_text(self) -> str: + """当前详情的纯文本(供测试断言与复制)。""" + if not self.view: + return "" + ol = self.query_one("#variant-list", OptionList) + idx = self.view[ol.highlighted or 0] + v = self.variants[idx] + load_variant_detail(self.filepath, v, self.meta.samples) + return str(self._build_detail(v)) + + def _build_detail(self, v: Variant) -> Text: + t = classify_variant(v.ref, v.alt) + sym, color = _TYPE_SYMBOL[t] + txt = Text() + txt.append(f"{sym} ", style=color) + txt.append(f"[{_TYPE_LABEL[t]}]", style=f"bold {color}") + if v.id: + txt.append(f" {v.id}", style="bold") + pass_color = "green" if v.filter == "PASS" else "red" + txt.append(f" {v.filter}", style=f"bold {pass_color}") + txt.append("\n\n") + txt.append(" 位置 ", style="dim"); txt.append(f"{v.chrom}:{v.pos:,}\n", style="bold") + txt.append(" 变异 ", style="dim"); txt.append(f"{v.ref} → {v.alt}\n", style="bold") + qual_s = f"{v.qual:.1f}" if v.qual is not None else "-" + txt.append(" 质量 ", style="dim"); txt.append(f"{qual_s}\n", style="bold") + if v.info: + txt.append("\n"); txt.append("─" * 40 + "\n", style="dim") + for k, val in v.info.items(): + desc = self.meta.info_defs.get(k, "") + txt.append(f" {k:<6}", style="cyan") + txt.append(f" {val or '(flag)'}", style="bold") + if desc: + txt.append(f" {desc}", style="dim") + txt.append("\n") + if v.samples: + txt.append("\n"); txt.append("─" * 40 + "\n", style="dim") + txt.append(" 基因型\n\n") + for name, raw_gt in v.samples.items(): + gt = parse_genotype(raw_gt, v.format_fields) + gt_s = gt.get("GT", "./.") + label = _GT_LABEL.get(gt_s, gt_s) + txt.append(f" {name:<12}", style="bold") + txt.append(f"{gt_s} {label}", style="yellow") + if "DP" in gt: + txt.append(f" DP={gt['DP']}", style="dim") + if "GQ" in gt: + txt.append(f" GQ={gt['GQ']}", style="dim") + txt.append("\n") + ad = gt.get("AD") + if ad and sum(ad) > 0: + total = sum(ad) + ref_w = round(ad[0] / total * 20) + txt.append(" REF ", style="dim") + txt.append("▓" * ref_w, style="green") + txt.append(f" {ad[0]}\n", style="dim") + txt.append(" ALT ", style="dim") + txt.append("▓" * (20 - ref_w), style="red") + txt.append(f" {sum(ad[1:])}\n", style="dim") + return txt + + def _show_detail(self, list_index: int): + if self.matrix_mode: + self._render_matrix() + return + idx = self.view[list_index] + v = self.variants[idx] + load_variant_detail(self.filepath, v, self.meta.samples) + detail = self.query_one("#detail", Static) + detail.update(self._build_detail(v)) + + # ── 状态栏 ── + def _update_status_bar(self): + s = compute_stats([self.variants[i] for i in self.view]) + bar = self.query_one("#status-bar", Static) + bar.update(Text.from_markup( + f" {s['total']} 变异 │ SNP {s['snp']} · InDel {s['indel']} │ " + f"Ts/Tv {s['ts_tv']:.2f} │ PASS {s['pass_count']} │ " + f"AF均值 {s['mean_af']:.2f} │ [过滤:{self.filter_mode}] [排序:{self.sort_mode}]" + + (f" │ 跳过{s.__class__ and self.skipped}行畸形" if self.skipped else "") + )) + + # ── 事件 ── + def on_option_list_option_highlighted(self, event): + if event.option is not None: + self._show_detail(event.option_index) + + def action_cursor_down(self): + ol = self.query_one("#variant-list", OptionList) + ol.action_cursor_down() + + def action_cursor_up(self): + self.query_one("#variant-list", OptionList).action_cursor_up() + + def action_next_variant(self): + self.action_cursor_down() + + def action_prev_variant(self): + self.action_cursor_up() + + def action_page_down(self): + self.query_one("#variant-list", OptionList).action_page_down() + + def action_page_up(self): + self.query_one("#variant-list", OptionList).action_page_up() + + def action_home(self): + self.query_one("#variant-list", OptionList).highlighted = 0 + + def action_end(self): + ol = self.query_one("#variant-list", OptionList) + ol.highlighted = ol.option_count - 1 + + def action_quit(self): + self.exit() + + def get_css(self): # 若项目用 CSS 变量注入方式则对齐 browser.py 的接入点 + return theme_mod.build_vcf_browser_css(theme_mod.get_theme()) +``` + +(CSS 接入方式:对照 browser.py 现有做法——若它用 `CSS = ...` 类变量或 `self.stylesheet`,则按同样方式接入 `build_vcf_browser_css`,删除示意中的 `get_css`。) + +- [ ] **Step 4: 运行确认通过** +- [ ] **Step 5: 提交**:`git commit -am "feat(vcf): VcfBrowser skeleton with list and detail pane"` + +--- + +### Task 7: 过滤 / 排序 / 搜索 + +**Files:** Modify `src/seqviz/vcf_browser.py` / `test/test_vcf_browser.py` + +- [ ] **Step 1: 写失败测试** + +```python +@pytest.mark.asyncio +async def test_filter_cycle(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test() as pilot: + ol = app.query_one("#variant-list", OptionList) + assert ol.option_count == 17 + await pilot.press("f") # PASS only + assert app.filter_mode == "PASS" + assert ol.option_count == 13 + await pilot.press("f") # SNP + assert app.filter_mode == "SNP" + await pilot.press("f") # InDel + assert app.filter_mode == "InDel" + await pilot.press("f") # 回到全部 + assert ol.option_count == 17 + + +@pytest.mark.asyncio +async def test_sort_by_qual(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test() as pilot: + await pilot.press("s") + assert app.sort_mode == "QUAL" + first_idx = app.view[0] + quals = [app.variants[i].qual or -1 for i in app.view] + assert quals == sorted(quals, reverse=True) + await pilot.press("s") + assert app.sort_mode == "位置" +``` + +- [ ] **Step 2: 运行确认失败** +- [ ] **Step 3: 实现**(追加 actions 与 view 重算) + +```python + def _apply_filter_sort(self): + idxs = list(range(len(self.variants))) + if self.filter_mode == "PASS": + idxs = [i for i in idxs if self.variants[i].filter == "PASS"] + elif self.filter_mode == "SNP": + idxs = [i for i in idxs if classify_variant( + self.variants[i].ref, self.variants[i].alt) + in (VariantType.TRANSITION, VariantType.TRANSVERSION)] + elif self.filter_mode == "InDel": + idxs = [i for i in idxs if classify_variant( + self.variants[i].ref, self.variants[i].alt) + in (VariantType.INSERTION, VariantType.DELETION)] + if self.sort_mode == "QUAL": + idxs.sort(key=lambda i: ( + self.variants[i].qual is None, + -(self.variants[i].qual or 0))) + else: + idxs.sort(key=lambda i: (self.variants[i].chrom, self.variants[i].pos)) + self.view = idxs + self._refresh_list() + + def action_cycle_filter(self): + i = _FILTER_CYCLE.index(self.filter_mode) + self.filter_mode = _FILTER_CYCLE[(i + 1) % len(_FILTER_CYCLE)] + self._apply_filter_sort() + + def action_toggle_sort(self): + self.sort_mode = "QUAL" if self.sort_mode == "位置" else "位置" + self._apply_filter_sort() + + def action_search(self): + """搜索:匹配 ID 或坐标(chr:pos),跳到第一条命中。""" + self.notify("输入搜索词(命令行风格暂缓)", title="搜索") # 占位——见 Step 3b +``` + +Step 3b:搜索实现复用 browser.py 的命令栏 Input 模式(动态 mount/unmount)。若 browser.py 用 `Input` + 回调,照搬其结构:Enter 时在 `self.view` 中查找 `v.id == q or f"{v.chrom}:{v.pos}" == q` 的第一个下标并 `ol.highlighted = view_index`,未命中 notify 提示。 + +- [ ] **Step 4: 运行确认通过**(`_refresh_list` 中已含 `_update_status_bar`) +- [ ] **Step 5: 提交**:`git commit -am "feat(vcf): filter cycle, qual sort and search"` + +--- + +### Task 8: 矩阵视图 + 元数据面板 + 复制 + +**Files:** Modify `src/seqviz/vcf_browser.py` / `test/test_vcf_browser.py` + +- [ ] **Step 1: 写失败测试** + +```python +@pytest.mark.asyncio +async def test_matrix_toggle(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test() as pilot: + await pilot.press("t") + assert app.matrix_mode is True + assert "sample1" in app._matrix_text() + await pilot.press("t") + assert app.matrix_mode is False + + +@pytest.mark.asyncio +async def test_copy_line(tmp_path, monkeypatch): + # 复用现有剪贴板测试的 monkeypatch 方式(参考 test_browser.py) + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test() as pilot: + await pilot.press("y") + copied = app._last_copied # 实现中记录最后一次复制内容 + assert copied.startswith("chr1\t10234\trs12345") +``` + +- [ ] **Step 2: 运行确认失败** +- [ ] **Step 3: 实现** + +```python + _GT_MATRIX_STYLE = {"0/0": "dim", "0/1": "yellow", "1/0": "yellow", "1/1": "bold red"} + + def _matrix_text(self) -> str: + return str(self._build_matrix()) + + def _build_matrix(self) -> Text: + """基因型矩阵:行=当前视图前 N 条变异,列=样本。""" + txt = Text() + samples = self.meta.samples or ["sample1"] + name_w = max(len(s) for s in samples) + 2 + txt.append(" " * (24 + name_w)) # 对齐表头 + for s in samples: + txt.append(f"{s:>{name_w}}", style="bold cyan") + txt.append("\n") + ol = self.query_one("#variant-list", OptionList) + cur = ol.highlighted or 0 + start = max(0, cur - 10) + for li in range(start, min(start + 21, len(self.view))): + v = self.variants[self.view[li]] + load_variant_detail(self.filepath, v, self.meta.samples) + marker = "▶ " if li == cur else " " + txt.append(f"{marker}{v.chrom}:{v.pos:<10}", style="bold" if li == cur else "") + for s in samples: + gt = parse_genotype(v.samples.get(s, "."), v.format_fields).get("GT", "./.") + txt.append(f"{gt:>{name_w}}", style=self._GT_MATRIX_STYLE.get(gt, "dim")) + txt.append("\n") + return txt + + def _render_matrix(self): + self.query_one("#detail", Static).update(self._build_matrix()) + + def action_toggle_view(self): + self.matrix_mode = not self.matrix_mode + ol = self.query_one("#variant-list", OptionList) + self._show_detail(ol.highlighted or 0) + + def action_file_info(self): + txt = Text() + txt.append("文件信息\n\n", style="bold") + txt.append(f"格式 {self.meta.fileformat or '未知'}\n") + txt.append(f"样本数 {len(self.meta.samples)} ({', '.join(self.meta.samples) or '无'})\n") + txt.append(f"变异数 {len(self.variants)}\n\n") + txt.append("Contigs:\n", style="bold cyan") + for cid, length in self.meta.contigs.items(): + txt.append(f" {cid:<10}{length:,} bp\n") + self.query_one("#detail", Static).update(txt) + + def action_copy_line(self): + if not self.view: + self.notify("没有可复制的变异", severity="warning") + return + ol = self.query_one("#variant-list", OptionList) + v = self.variants[self.view[ol.highlighted or 0]] + load_variant_detail(self.filepath, v, self.meta.samples) + # 复用 browser.py 的剪贴板分层回退函数(提取为共享工具或直接复制实现) + from seqviz.browser import _copy_to_clipboard # 若无此导出则按 browser.py 实际函数名 + ok = _copy_to_clipboard(v.raw) + self._last_copied = v.raw + self.notify("已复制 VCF 行" if ok else "复制失败", severity="info" if ok else "error") +``` + +(剪贴板:先确认 browser.py 中现有剪贴板函数名/签名并对齐;若为方法则提取为模块级函数供两处复用。) + +- [ ] **Step 4: 运行确认通过** +- [ ] **Step 5: 提交**:`git commit -am "feat(vcf): genotype matrix view, meta panel, copy raw line"` + +--- + +### Task 9: CLI 路由 + 文件选择器支持 .vcf + +**Files:** Modify `src/seqviz/cli.py`, `src/seqviz/file_browser.py`, `config/config.json`, `src/seqviz/config.py` + +- [ ] **Step 1: cli.py `_launch_browser` 增加路由** + +```python +def _launch_browser(paths: list[Path]): + # 单文件且为 .vcf → VCF 浏览器 + if len(paths) == 1 and paths[0].is_file() and paths[0].suffix.lower() == ".vcf": + from seqviz.vcf_browser import VcfBrowser + VcfBrowser(paths[0]).run() + return + # ... 其余逻辑不变 +``` + +同时更新 `browse` 命令 help 文本为 "FASTA/FASTQ/VCF"。 + +- [ ] **Step 2: file_browser.py 支持 .vcf** + - `config.py` DEFAULT_CONFIG 的 `file_browser.extensions` 追加 `".vcf"`;`config/config.json` 同步 + - file_browser 格式标记:`.vcf` 显示 `[V]`(对照现有 `[F]/[Q]` 判定函数扩展) + - 打开选中文件时:若唯一选中为 `.vcf` → 启动 VcfBrowser(对照现有打开路径的实现方式接入) + +- [ ] **Step 3: 测试**:`test_cli.py` 增加路由测试(用 CliRunner 无法跑 TUI,则直接断言 `_launch_browser` 分支逻辑——可提取 `_is_vcf(path)` 辅助函数并单测) +- [ ] **Step 4: 全量运行**:`uv run pytest test/ -x -q` → 全部通过 +- [ ] **Step 5: 提交**:`git commit -am "feat(cli): route .vcf to VcfBrowser and support in file selector"` + +--- + +### Task 10: README + 实测 + 收尾 + +**Files:** Modify `README.md` + +- [ ] **Step 1: README 增加 VCF 章节**(功能特性列表加 `🧪 VCF 变异浏览`,命令一览 `seqviz browse x.vcf`,快捷键表格,版本号不变——发布时再 bump) +- [ ] **Step 2: 实测**:`uv run seqviz browse test/sample.vcf`,人工验证:列表着色、j/k 导航、f 过滤、s 排序、t 矩阵、i 信息、y 复制、q 退出 +- [ ] **Step 3: 全量回归**:`uv run pytest test/ -v` → 全部通过 +- [ ] **Step 4: 提交**:`git commit -am "docs: VCF browsing section in README"` + +--- + +## Self-Review + +- **Spec 覆盖**:布局(Task 6) / 交互(Task 6-8) / 懒扫描(Task 3) / 统计(Task 4) / 错误处理(Task 3 畸形行、空文件) / 主题(Task 5) / 路由(Task 9) ✓。染色体密度图与 bgzip 明确不做(spec YAGNI)✓ +- **类型一致性**:`scan_vcf` 返回三元组 `(meta, variants, skipped)` 各任务一致;`load_variant_detail(path, variant, sample_names)` 签名一致;`Variant.info` 索引阶段已填充(Task 3 split("\t",8) 含第 8 列)与 Task 4 stats 依赖一致 ✓ +- **已知待执行时确认点**:剪贴板函数在 browser.py 的实际导出名;CSS 接入方式(类变量 vs 其他);async 测试基建(对齐 test_browser.py)——已在对应 Task 内注明对齐方式 From 4e643903b66393e8225fe2e12c7bfa04bbbb67ef Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 04:28:18 +0800 Subject: [PATCH 03/20] =?UTF-8?q?feat(vcf):=20parser=20layer=20=E2=80=94?= =?UTF-8?q?=20classify/genotype/meta/lazy-scan/stats=20(28=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seqviz/vcf.py | 261 ++++++++++++++++++++++++++++++++++++++++++++++ test/test_vcf.py | 191 +++++++++++++++++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 src/seqviz/vcf.py create mode 100644 test/test_vcf.py diff --git a/src/seqviz/vcf.py b/src/seqviz/vcf.py new file mode 100644 index 0000000..d3e504d --- /dev/null +++ b/src/seqviz/vcf.py @@ -0,0 +1,261 @@ +"""VCF (Variant Call Format) 解析层 — 纯解析,无 UI 依赖。 + +支持未压缩纯文本 VCF v4.x。采用懒扫描策略: +scan_vcf 只解析前 8 列(含 INFO),样本基因型按需 seek 回读。 +""" + +import re +from dataclasses import dataclass, field +from enum import Enum + +_TRANSITIONS = {("A", "G"), ("G", "A"), ("C", "T"), ("T", "C")} + +_ID_RE = re.compile(r"ID=([^,>]+)") +_DESC_RE = re.compile(r'Description="([^"]*)"') +_LEN_RE = re.compile(r"length=(\d+)") + + +class VariantType(Enum): + TRANSITION = "transition" # 转换(嘌呤↔嘌呤 / 嘧啶↔嘧啶) + TRANSVERSION = "transversion" # 颠换 + INSERTION = "insertion" # 插入 + DELETION = "deletion" # 缺失 + COMPLEX = "complex" # 复杂变异 + + +@dataclass +class Variant: + chrom: str + pos: int + id: str # "." 存为 "" + ref: str + alt: str + qual: float | None # "." 或非数字存为 None + filter: str + info: dict = field(default_factory=dict) # 懒扫描时已含 INFO(第8列) + format_fields: list = field(default_factory=list) # 懒扫描为空,按需填充 + samples: dict = field(default_factory=dict) # 同上 + offset: int = 0 # 行首字节偏移(seek 回读用) + raw: str = "" # 完整原始行,按需填充,y 键复制用 + + +@dataclass +class VcfMeta: + fileformat: str = "" + contigs: dict = field(default_factory=dict) # ID → length + info_defs: dict = field(default_factory=dict) # ID → Description + format_defs: dict = field(default_factory=dict) + filter_defs: dict = field(default_factory=dict) + samples: list = field(default_factory=list) + + +def classify_variant(ref: str, alt: str) -> VariantType: + """按 REF/ALT 长度与碱基判定变异类型;多等位取第一个 ALT。""" + first_alt = alt.split(",")[0] + ref_u, alt_u = ref.upper(), first_alt.upper() + if len(ref) == 1 and len(first_alt) == 1: + return VariantType.TRANSITION if (ref_u, alt_u) in _TRANSITIONS else VariantType.TRANSVERSION + if len(first_alt) > len(ref): + return VariantType.INSERTION + if len(ref) > len(first_alt): + return VariantType.DELETION + return VariantType.COMPLEX + + +def parse_genotype(sample_str: str, format_fields: list[str]) -> dict: + """把 '0/1:15:99:10,5' 按 FORMAT 声明解析为 dict。 + + GT 缺失(".") 归一为 "./.";AD 解析为 int 列表;其余可转 int 则转。 + """ + values = sample_str.split(":") + result = {} + for key, val in zip(format_fields, values): + if key == "GT": + result["GT"] = val if val != "." else "./." + elif key == "AD": + try: + result["AD"] = [int(x) for x in val.split(",")] + except ValueError: + result["AD"] = [] + else: + try: + result[key] = int(val) + except ValueError: + result[key] = val + return result + + +def parse_meta(header_lines: list[str]) -> VcfMeta: + """解析 ## 元数据行(fileformat/contig/INFO/FORMAT/FILTER 定义)。""" + meta = VcfMeta() + for line in header_lines: + line = line.rstrip("\n") + if line.startswith("##fileformat="): + meta.fileformat = line.split("=", 1)[1] + elif line.startswith("##contig="): + m = _ID_RE.search(line) + lm = _LEN_RE.search(line) + if m: + meta.contigs[m.group(1)] = int(lm.group(1)) if lm else 0 + elif line.startswith("##INFO=") or line.startswith("##FORMAT="): + m = _ID_RE.search(line) + dm = _DESC_RE.search(line) + target = meta.info_defs if line.startswith("##INFO=") else meta.format_defs + if m: + target[m.group(1)] = dm.group(1) if dm else "" + elif line.startswith("##FILTER="): + m = _ID_RE.search(line) + dm = _DESC_RE.search(line) + if m: + meta.filter_defs[m.group(1)] = dm.group(1) if dm else "" + return meta + + +def _parse_info(info_str: str) -> dict: + """解析 INFO 列:'DP=45;AF=0.333;DB' → {'DP':'45','AF':'0.333','DB':''}。""" + info = {} + for item in info_str.split(";"): + if not item: + continue + if "=" in item: + k, v = item.split("=", 1) + info[k] = v + else: + info[item] = "" # Flag 型(如 DB) + return info + + +def parse_variant_line(line: str, offset: int = 0, + sample_names: list[str] | None = None) -> Variant | None: + """完整解析单条数据行;畸形行(<8 列或 POS 非整数)返回 None。""" + raw = line.rstrip("\n") + parts = raw.split("\t") + if len(parts) < 8 or parts[0].startswith("#"): + return None + try: + pos = int(parts[1]) + except ValueError: + return None + qual = None + if parts[5] != ".": + try: + qual = float(parts[5]) + except ValueError: + pass + info = _parse_info(parts[7]) + format_fields = parts[8].split(":") if len(parts) > 8 else [] + names = sample_names or [] + samples = {} + for i, s in enumerate(parts[9:]): + name = names[i] if i < len(names) else f"sample{i + 1}" + samples[name] = s + return Variant( + chrom=parts[0], pos=pos, + id="" if parts[2] == "." else parts[2], + ref=parts[3], alt=parts[4], + qual=qual, filter=parts[6], + info=info, format_fields=format_fields, + samples=samples, offset=offset, raw=raw, + ) + + +def scan_vcf(path) -> tuple[VcfMeta, list[Variant], int]: + """懒扫描:解析 meta + 每行前 8 列 + 字节 offset。 + + 样本列不解析(按需 load_variant_detail)。返回 (meta, variants, skipped)。 + """ + header_lines: list[str] = [] + variants: list[Variant] = [] + sample_names: list[str] = [] + skipped = 0 + with open(path, encoding="utf-8", errors="replace") as f: + while True: + offset = f.tell() + line = f.readline() + if not line: + break + if line.startswith("##"): + header_lines.append(line) + continue + if line.startswith("#CHROM"): + cols = line.rstrip("\n").split("\t") + sample_names = cols[9:] if len(cols) > 9 else [] + continue + parts = line.rstrip("\n").split("\t", 8) # 只切前 8 列,避免样本列开销 + if len(parts) < 8 or parts[0].startswith("#"): + skipped += 1 + continue + try: + pos = int(parts[1]) + except ValueError: + skipped += 1 + continue + qual = None + if parts[5] != ".": + try: + qual = float(parts[5]) + except ValueError: + pass + variants.append(Variant( + chrom=parts[0], pos=pos, + id="" if parts[2] == "." else parts[2], + ref=parts[3], alt=parts[4], + qual=qual, filter=parts[6], + info=_parse_info(parts[7]), + offset=offset, + )) + meta = parse_meta(header_lines) + meta.samples = sample_names + return meta, variants, skipped + + +def load_variant_detail(path, variant: Variant, sample_names: list[str]) -> Variant: + """seek(offset) 回读完整行,填充 format_fields/samples/raw(原地修改并返回)。""" + if variant.raw: + return variant # 已加载 + with open(path, encoding="utf-8", errors="replace") as f: + f.seek(variant.offset) + line = f.readline() + full = parse_variant_line(line, variant.offset, sample_names) + if full is not None: + variant.format_fields = full.format_fields + variant.samples = full.samples + variant.raw = full.raw + return variant + + +def compute_stats(variants: list[Variant]) -> dict: + """统计总数/SNP/InDel/Ts:Tv/PASS 数/AF 均值(懒扫描索引即可计算)。""" + snp = indel = complex_ = ts = tv = pass_count = 0 + af_sum = 0.0 + af_count = 0 + for v in variants: + t = classify_variant(v.ref, v.alt) + if t is VariantType.TRANSITION: + snp += 1 + ts += 1 + elif t is VariantType.TRANSVERSION: + snp += 1 + tv += 1 + elif t in (VariantType.INSERTION, VariantType.DELETION): + indel += 1 + else: + complex_ += 1 + if v.filter == "PASS": + pass_count += 1 + af = v.info.get("AF", "") + if af: + try: + af_sum += float(af.split(",")[0]) + af_count += 1 + except ValueError: + pass + return { + "total": len(variants), + "snp": snp, + "indel": indel, + "complex": complex_, + "ts_tv": (ts / tv) if tv else 0.0, + "pass_count": pass_count, + "mean_af": (af_sum / af_count) if af_count else 0.0, + } diff --git a/test/test_vcf.py b/test/test_vcf.py new file mode 100644 index 0000000..983b202 --- /dev/null +++ b/test/test_vcf.py @@ -0,0 +1,191 @@ +"""VCF 解析层测试(对应 docs/superpowers/plans Task 1-4)。""" +from pathlib import Path + +import pytest + +from seqviz.vcf import ( + Variant, + VariantType, + classify_variant, + compute_stats, + load_variant_detail, + parse_genotype, + parse_meta, + parse_variant_line, + scan_vcf, +) + +SAMPLE_VCF = Path(__file__).parent / "sample.vcf" + + +class TestClassifyVariant: + def test_transition(self): + assert classify_variant("A", "G") == VariantType.TRANSITION + assert classify_variant("G", "A") == VariantType.TRANSITION + assert classify_variant("C", "T") == VariantType.TRANSITION + assert classify_variant("T", "C") == VariantType.TRANSITION + + def test_transversion(self): + assert classify_variant("A", "T") == VariantType.TRANSVERSION + assert classify_variant("G", "C") == VariantType.TRANSVERSION + assert classify_variant("A", "C") == VariantType.TRANSVERSION + + def test_insertion(self): + assert classify_variant("T", "TG") == VariantType.INSERTION + assert classify_variant("A", "ACGT") == VariantType.INSERTION + + def test_deletion(self): + assert classify_variant("AT", "A") == VariantType.DELETION + assert classify_variant("GC", "G") == VariantType.DELETION + + def test_multiallelic_uses_first_alt(self): + assert classify_variant("A", "G,T") == VariantType.TRANSITION + + def test_case_insensitive(self): + assert classify_variant("a", "g") == VariantType.TRANSITION + + +class TestParseGenotype: + def test_full_fields(self): + result = parse_genotype("0/1:15:99:10,5", ["GT", "DP", "GQ", "AD"]) + assert result["GT"] == "0/1" + assert result["DP"] == 15 + assert result["GQ"] == 99 + assert result["AD"] == [10, 5] + + def test_missing_gt(self): + result = parse_genotype(".:.:.", ["GT", "DP", "GQ"]) + assert result["GT"] == "./." + + def test_fewer_values_than_fields(self): + result = parse_genotype("0/0", ["GT", "DP"]) + assert result == {"GT": "0/0"} + + def test_non_int_kept_as_str(self): + result = parse_genotype("0/1:abc", ["GT", "DP"]) + assert result["DP"] == "abc" + + +class TestParseMeta: + def test_sample_vcf_meta(self): + lines = SAMPLE_VCF.read_text().splitlines() + header = [l for l in lines if l.startswith("##")] + meta = parse_meta(header) + assert meta.fileformat == "VCFv4.3" + assert meta.contigs["chr1"] == 79116311 + assert meta.info_defs["DP"] == "Total Depth" + assert meta.format_defs["GT"] == "Genotype" + assert meta.filter_defs["PASS"] == "All filters passed" + + def test_empty_header(self): + meta = parse_meta([]) + assert meta.fileformat == "" + assert meta.contigs == {} + + def test_contig_without_length(self): + meta = parse_meta(["##contig="]) + assert meta.contigs["chrX"] == 0 + + +class TestParseVariantLine: + def test_full_line(self): + line = "chr1\t10234\trs12345\tA\tG\t99.5\tPASS\tDP=45;AF=0.333;DB\tGT:DP\t0/1:15\t0/0:18" + v = parse_variant_line(line, offset=100, sample_names=["s1", "s2"]) + assert v.chrom == "chr1" and v.pos == 10234 + assert v.id == "rs12345" and v.qual == 99.5 + assert v.info == {"DP": "45", "AF": "0.333", "DB": ""} + assert v.samples == {"s1": "0/1:15", "s2": "0/0:18"} + assert v.offset == 100 + + def test_dot_id_and_qual(self): + v = parse_variant_line("chr1\t5\t.\tA\tG\t.\tPASS\t.") + assert v.id == "" and v.qual is None + + def test_malformed_fewer_than_8_cols(self): + assert parse_variant_line("chr1\t100\tA") is None + + def test_bad_pos(self): + assert parse_variant_line("chr1\txyz\t.\tA\tG\t.\tPASS\t.") is None + + def test_header_line_rejected(self): + assert parse_variant_line("#CHROM\tPOS\tID") is None + + def test_unnamed_samples_get_generated_names(self): + v = parse_variant_line("chr1\t5\t.\tA\tG\t.\tPASS\t.\tGT\t0/1\t1/1") + assert list(v.samples.keys()) == ["sample1", "sample2"] + + +class TestScanVcf: + def test_sample_vcf(self): + meta, variants, skipped = scan_vcf(SAMPLE_VCF) + assert len(variants) == 18 + assert skipped == 0 + assert meta.samples == ["sample1", "sample2", "sample3"] + first = variants[0] + assert (first.chrom, first.pos, first.ref, first.alt) == ("chr1", 10234, "A", "G") + assert first.info["AF"] == "0.333" # INFO 在索引阶段已解析 + assert first.samples == {} # 样本列懒加载 + assert first.offset > 0 + + def test_empty_file(self, tmp_path): + f = tmp_path / "empty.vcf" + f.write_text("") + meta, variants, skipped = scan_vcf(f) + assert variants == [] and meta.samples == [] + + def test_no_header_line(self, tmp_path): + f = tmp_path / "nohdr.vcf" + f.write_text("chr1\t1\t.\tA\tG\t.\tPASS\t.\n") + meta, variants, skipped = scan_vcf(f) + assert len(variants) == 1 + assert meta.samples == [] + + def test_malformed_lines_skipped_and_counted(self, tmp_path): + f = tmp_path / "bad.vcf" + f.write_text("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n" + "chr1\t1\t.\tA\tG\t.\tPASS\t.\n" + "garbage line\n" + "chr1\t2\t.\tC\tT\t.\tPASS\t.\n") + _, variants, skipped = scan_vcf(f) + assert len(variants) == 2 and skipped == 1 + + +class TestLoadVariantDetail: + def test_roundtrip(self): + _, variants, _ = scan_vcf(SAMPLE_VCF) + first = variants[0] + meta_samples = ["sample1", "sample2", "sample3"] + load_variant_detail(SAMPLE_VCF, first, meta_samples) + assert first.samples["sample1"] == "0/1:15:99:10,5" + assert first.format_fields == ["GT", "DP", "GQ", "AD"] + assert first.raw.startswith("chr1\t10234") + + def test_idempotent(self): + _, variants, _ = scan_vcf(SAMPLE_VCF) + first = variants[0] + load_variant_detail(SAMPLE_VCF, first, ["sample1", "sample2", "sample3"]) + raw1 = first.raw + load_variant_detail(SAMPLE_VCF, first, ["sample1", "sample2", "sample3"]) + assert first.raw == raw1 + + +class TestComputeStats: + def test_sample_vcf_stats(self): + _, variants, _ = scan_vcf(SAMPLE_VCF) + stats = compute_stats(variants) + assert stats["total"] == 18 + assert stats["snp"] + stats["indel"] + stats["complex"] == 18 + assert stats["snp"] == 12 + assert stats["indel"] == 6 + assert stats["pass_count"] == 13 + assert stats["ts_tv"] == pytest.approx(1.0) # 实测 6 转换 6 颠换 + assert 0.0 < stats["mean_af"] < 1.0 + + def test_empty(self): + stats = compute_stats([]) + assert stats["total"] == 0 and stats["ts_tv"] == 0.0 + + def test_no_tv_avoids_zero_division(self): + v = Variant("chr1", 1, "", "A", "G", 50.0, "PASS") + stats = compute_stats([v]) + assert stats["ts_tv"] == 0.0 From be5706ab494f8ab12f0046f712552a9f061d937b Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 04:31:46 +0800 Subject: [PATCH 04/20] =?UTF-8?q?feat(vcf):=20VcfBrowser=20TUI=20=E2=80=94?= =?UTF-8?q?=20list/detail,=20filter/sort/search,=20genotype=20matrix,=20me?= =?UTF-8?q?ta=20panel,=20copy=20(14=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seqviz/theme.py | 60 +++++ src/seqviz/vcf_browser.py | 483 ++++++++++++++++++++++++++++++++++++++ test/test_vcf_browser.py | 233 ++++++++++++++++++ 3 files changed, 776 insertions(+) create mode 100644 src/seqviz/vcf_browser.py create mode 100644 test/test_vcf_browser.py diff --git a/src/seqviz/theme.py b/src/seqviz/theme.py index 948cf40..fb1b19c 100644 --- a/src/seqviz/theme.py +++ b/src/seqviz/theme.py @@ -372,3 +372,63 @@ def build_file_browser_css(theme: dict) -> str: color: {fg}; }} """ + + +def build_vcf_browser_css(theme: dict) -> str: + """生成 VCF 浏览器(VcfBrowser)的 CSS。""" + bg = theme["background"] + fg = theme["foreground"] + border = theme["border"] + accent = theme.get("accent", fg) + panel = theme.get("panel", bg) + highlight = theme.get("highlight", bg) + return f""" + Screen {{ + background: {bg}; + }} + Horizontal {{ + height: 1fr; + }} + #variant-list {{ + width: 42; + border-right: thick {border}; + background: {bg}; + color: {fg}; + }} + #variant-list:focus {{ + border-right: thick {accent}; + }} + #variant-list > OptionList > .option-list--option-highlighted {{ + background: {highlight}; + color: {fg}; + }} + #variant-list > OptionList > .option-list--option-hover {{ + background: {highlight}; + }} + #detail {{ + width: 1fr; + padding: 0 1; + background: {bg}; + color: {fg}; + }} + #status-bar {{ + dock: bottom; + height: 1; + background: {panel}; + color: {fg}; + }} + #search-input {{ + dock: bottom; + height: 3; + margin: 0 1; + background: {panel}; + }} + Header {{ + background: {panel}; + color: {fg}; + }} + Footer {{ + background: {panel}; + color: {fg}; + }} + """ diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py new file mode 100644 index 0000000..84d8c19 --- /dev/null +++ b/src/seqviz/vcf_browser.py @@ -0,0 +1,483 @@ +"""VCF 交互式浏览器 — 双栏:左变异列表 + 右详情/基因型矩阵。 + +设计文档: docs/superpowers/specs/2026-07-25-vcf-visualization-design.md +""" + +import subprocess +from pathlib import Path + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal +from textual.screen import ModalScreen +from textual.widgets import Footer, Header, Input, OptionList, Static +from textual.widgets.option_list import Option + +from seqviz import theme as theme_mod +from seqviz.vcf import ( + Variant, + VariantType, + classify_variant, + compute_stats, + load_variant_detail, + parse_genotype, + scan_vcf, +) + +_TYPE_SYMBOL = { + VariantType.TRANSITION: ("●", "green"), + VariantType.TRANSVERSION: ("●", "blue"), + VariantType.INSERTION: ("◆", "yellow"), + VariantType.DELETION: ("◆", "red"), + VariantType.COMPLEX: ("◆", "magenta"), +} +_TYPE_LABEL = { + VariantType.TRANSITION: "SNP 转换", + VariantType.TRANSVERSION: "SNP 颠换", + VariantType.INSERTION: "插入", + VariantType.DELETION: "缺失", + VariantType.COMPLEX: "复杂变异", +} +_GT_LABEL = {"0/0": "纯合参考", "0/1": "杂合", "1/0": "杂合", "1/1": "纯合变异"} +_GT_MATRIX_STYLE = {"0/0": "dim", "0/1": "yellow", "1/0": "yellow", "1/1": "bold red"} +_FILTER_CYCLE = ["全部", "PASS", "SNP", "InDel"] + + +class HelpScreen(ModalScreen): + """帮助面板:显示所有快捷键。""" + + CSS = """ + HelpScreen { + align: center middle; + } + #help-panel { + width: 62; + height: auto; + border: thick $accent; + padding: 1 2; + } + """ + + BINDINGS = [ + Binding("question_mark", "dismiss", "关闭"), + Binding("escape", "dismiss", "关闭"), + Binding("q", "dismiss", "关闭"), + ] + + def compose(self) -> ComposeResult: + yield Static(id="help-panel") + + def on_mount(self): + panel = self.query_one("#help-panel", Static) + panel.border_title = "Seqviz VCF — 快捷键" + text = Text() + rows = [ + ("j / k", "上下移动"), ("n / p", "下/上一条变异"), + ("Space / b", "翻页"), ("g / G", "顶部 / 底部"), + ("/", "搜索 ID 或坐标"), ("f", "过滤循环 (全部/PASS/SNP/InDel)"), + ("s", "排序切换 (位置/QUAL)"), ("t", "详情 ↔ 基因型矩阵"), + ("i", "文件信息"), ("y", "复制当前 VCF 行"), + ("?", "帮助"), ("q", "退出"), + ] + for key, desc in rows: + text.append(f"{key:<14}", style="bold green") + text.append(desc + "\n") + panel.update(text) + + +class VcfBrowser(App): + """VCF 变异浏览器。""" + + TITLE = "Seqviz — VCF" + DARK = theme_mod.is_dark_theme(theme_mod.get_theme_name()) + CSS = theme_mod.build_vcf_browser_css(theme_mod.get_theme()) + + BINDINGS = [ + Binding("j", "cursor_down", "下移", show=True, priority=True), + Binding("k", "cursor_up", "上移", show=True, priority=True), + Binding("n", "cursor_down", "下一条", show=False), + Binding("p", "cursor_up", "上一条", show=False), + Binding("space", "page_down", "下翻页", show=True), + Binding("b", "page_up", "上翻页", show=True), + Binding("g", "home", "顶部", show=True), + Binding("G", "end", "底部", show=True), + Binding("slash", "search", "搜索", show=True), + Binding("f", "cycle_filter", "过滤", show=True), + Binding("s", "toggle_sort", "排序", show=True), + Binding("t", "toggle_view", "矩阵", show=True), + Binding("i", "file_info", "信息", show=True), + Binding("y", "copy_line", "复制", show=True), + Binding("question_mark", "help", "帮助", show=True), + Binding("q", "quit", "退出", show=True), + ] + + def __init__(self, filepath: Path): + super().__init__() + self.filepath = filepath + self.meta, self.variants, self.skipped = scan_vcf(filepath) + self.view: list[int] = list(range(len(self.variants))) # 过滤/排序后的下标映射 + self.filter_mode = "全部" + self.sort_mode = "位置" + self.matrix_mode = False + self._last_copied: str = "" # 最后一次复制内容(测试用) + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(): + yield OptionList(id="variant-list") + yield Static("", id="detail") + yield Static("", id="status-bar") + yield Footer() + + def on_mount(self): + self._refresh_list() + self.query_one("#variant-list", OptionList).focus() + + # ── 列表渲染 ── + def _make_option(self, v: Variant) -> Option: + t = classify_variant(v.ref, v.alt) + symbol, sym_color = _TYPE_SYMBOL[t] + # 行颜色按 FILTER/QUAL 编码可信度 + if v.filter == "PASS": + row_style = "green" + elif v.filter == "LowDP" or (v.qual is not None and v.qual < 20): + row_style = "red" + else: + row_style = "yellow" + line = Text() + line.append(f"{symbol} ", style=sym_color) + line.append(f"{v.chrom}:{v.pos:,}", style=row_style) + line.append(f" {(v.id or '.'):<12}", style="dim") + if t is VariantType.INSERTION: + summary = "ins" + elif t is VariantType.DELETION: + summary = "del" + else: + summary = f"{v.ref}→{v.alt}" + line.append(f"{summary:<8}") + line.append(f"{v.qual:>6.1f}" if v.qual is not None else " -", style=row_style) + return Option(line) + + def _refresh_list(self): + ol = self.query_one("#variant-list", OptionList) + ol.clear_options() + ol.add_options([self._make_option(self.variants[i]) for i in self.view]) + if self.view: + ol.highlighted = 0 + self._show_detail(0) + else: + self.query_one("#detail", Static).update(Text("(无匹配变异)", style="dim")) + self._update_status_bar() + + # ── 详情渲染 ── + def _build_detail(self, v: Variant) -> Text: + t = classify_variant(v.ref, v.alt) + sym, color = _TYPE_SYMBOL[t] + txt = Text() + txt.append(f"{sym} ", style=color) + txt.append(f"[{_TYPE_LABEL[t]}]", style=f"bold {color}") + if v.id: + txt.append(f" {v.id}", style="bold") + pass_style = "bold green" if v.filter == "PASS" else "bold red" + txt.append(f" {v.filter}", style=pass_style) + txt.append("\n\n") + txt.append(" 位置 ", style="dim") + txt.append(f"{v.chrom}:{v.pos:,}\n", style="bold") + txt.append(" 变异 ", style="dim") + txt.append(f"{v.ref} → {v.alt}\n", style="bold") + txt.append(" 质量 ", style="dim") + txt.append(f"{v.qual:.1f}\n" if v.qual is not None else "-\n", style="bold") + if v.info: + txt.append("\n") + txt.append("─" * 46 + "\n", style="dim") + for k, val in v.info.items(): + desc = self.meta.info_defs.get(k, "") + txt.append(f" {k:<6}", style="cyan") + txt.append(f" {val or '(flag)'}", style="bold") + if desc: + txt.append(f" {desc}", style="dim") + txt.append("\n") + if v.samples: + txt.append("\n") + txt.append("─" * 46 + "\n", style="dim") + txt.append(" 基因型\n\n") + for name, raw_gt in v.samples.items(): + gt = parse_genotype(raw_gt, v.format_fields) + gt_s = gt.get("GT", "./.") + label = _GT_LABEL.get(gt_s, gt_s) + gt_style = _GT_MATRIX_STYLE.get(gt_s, "dim").replace("bold ", "") + txt.append(f" {name:<14}", style="bold") + txt.append(f"{gt_s} {label}", style=gt_style) + if "DP" in gt: + txt.append(f" DP={gt['DP']}", style="dim") + if "GQ" in gt: + txt.append(f" GQ={gt['GQ']}", style="dim") + txt.append("\n") + ad = gt.get("AD") + if isinstance(ad, list) and ad and sum(ad) > 0: + total = sum(ad) + ref_w = round(ad[0] / total * 20) + txt.append(" REF ", style="dim") + txt.append("▓" * ref_w, style="green") + txt.append(f" {ad[0]}\n", style="dim") + txt.append(" ALT ", style="dim") + txt.append("▓" * max(20 - ref_w, 1), style="red") + txt.append(f" {sum(ad[1:])}\n", style="dim") + return txt + + def _show_detail(self, list_index: int): + if self.matrix_mode: + self._render_matrix(list_index) + return + if not self.view: + return + idx = self.view[list_index] + v = self.variants[idx] + load_variant_detail(self.filepath, v, self.meta.samples) + self.query_one("#detail", Static).update(self._build_detail(v)) + + def _detail_text(self) -> str: + """当前详情的纯文本(供测试断言)。""" + if not self.view: + return "" + ol = self.query_one("#variant-list", OptionList) + v = self.variants[self.view[ol.highlighted or 0]] + load_variant_detail(self.filepath, v, self.meta.samples) + return str(self._build_detail(v)) + + # ── 基因型矩阵 ── + def _build_matrix(self, current_list_index: int = 0) -> Text: + txt = Text() + samples = self.meta.samples or ["sample1"] + name_w = max(len(s) for s in samples) + 2 + txt.append(" " * 24) + for s in samples: + txt.append(f"{s:>{name_w}}", style="bold cyan") + txt.append("\n\n") + start = max(0, current_list_index - 10) + end = min(start + 21, len(self.view)) + for li in range(start, end): + v = self.variants[self.view[li]] + load_variant_detail(self.filepath, v, self.meta.samples) + is_cur = li == current_list_index + txt.append("▶ " if is_cur else " ", style="bold green" if is_cur else "") + txt.append(f"{v.chrom}:{v.pos:<12}", style="bold" if is_cur else "") + for s in samples: + gt = parse_genotype(v.samples.get(s, "."), v.format_fields).get("GT", "./.") + txt.append(f"{gt:>{name_w}}", style=_GT_MATRIX_STYLE.get(gt, "dim")) + txt.append("\n") + txt.append("\n") + txt.append(" 0/0 纯合参考 · 0/1 杂合 · 1/1 纯合变异", style="dim") + return txt + + def _matrix_text(self) -> str: + ol = self.query_one("#variant-list", OptionList) + return str(self._build_matrix(ol.highlighted or 0)) + + def _render_matrix(self, list_index: int): + self.query_one("#detail", Static).update(self._build_matrix(list_index)) + + # ── 状态栏 ── + def _update_status_bar(self): + s = compute_stats([self.variants[i] for i in self.view]) + parts = ( + f" {s['total']} 变异 │ SNP {s['snp']} · InDel {s['indel']} │ " + f"Ts/Tv {s['ts_tv']:.2f} │ PASS {s['pass_count']} │ " + f"AF均值 {s['mean_af']:.2f} │ [过滤:{self.filter_mode}] [排序:{self.sort_mode}]" + ) + if self.skipped: + parts += f" │ 跳过 {self.skipped} 行畸形数据" + self.query_one("#status-bar", Static).update(Text(parts)) + + # ── 过滤 / 排序 ── + def _apply_filter_sort(self): + idxs = list(range(len(self.variants))) + if self.filter_mode == "PASS": + idxs = [i for i in idxs if self.variants[i].filter == "PASS"] + elif self.filter_mode == "SNP": + idxs = [i for i in idxs if classify_variant( + self.variants[i].ref, self.variants[i].alt) + in (VariantType.TRANSITION, VariantType.TRANSVERSION)] + elif self.filter_mode == "InDel": + idxs = [i for i in idxs if classify_variant( + self.variants[i].ref, self.variants[i].alt) + in (VariantType.INSERTION, VariantType.DELETION)] + if self.sort_mode == "QUAL": + # QUAL 缺失排最后,其余降序 + idxs.sort(key=lambda i: ( + self.variants[i].qual is None, + -(self.variants[i].qual or 0.0))) + else: + idxs.sort(key=lambda i: (self.variants[i].chrom, self.variants[i].pos)) + self.view = idxs + self._refresh_list() + + # ── 剪贴板(与 browser.py 相同的分层回退策略) ── + def _copy_to_clipboard(self, text: str) -> bool: + """系统工具优先,失败后回退 OSC 52。""" + import platform + system = platform.system() + data = text.encode() + try: + if system == "Darwin": + subprocess.run(["pbcopy"], input=data, check=True) + return True + elif system == "Linux": + for cmd in ( + ["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"], + ["wl-copy"], + ): + try: + subprocess.run(cmd, input=data, check=True) + return True + except (OSError, subprocess.CalledProcessError): + continue + else: # Windows + subprocess.run(["clip"], input=data, check=True) + return True + except (OSError, subprocess.CalledProcessError): + pass + try: + self.copy_to_clipboard(text) + return True + except Exception: # noqa: BLE001 + return False + + # ── 搜索命令栏 ── + async def _show_search_bar(self) -> None: + self._remove_search_bar() + bar = Input(placeholder="搜索 ID 或坐标 (如 rs12345 / chr1:10234)... (Enter 确认, Esc 取消)", + id="search-input") + await self.mount(bar) + bar.focus() + + def _remove_search_bar(self) -> None: + try: + self.query_one("#search-input", Input).remove() + except Exception: # noqa: BLE001 + pass + + def _get_search_bar(self) -> Input | None: + try: + return self.query_one("#search-input", Input) + except Exception: # noqa: BLE001 + return None + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._remove_search_bar() + self.query_one("#variant-list", OptionList).focus() + value = event.value.strip() + if not value: + return + q = value.lower() + for li, vi in enumerate(self.view): + v = self.variants[vi] + coord = f"{v.chrom}:{v.pos}".lower() + coord_comma = f"{v.chrom}:{v.pos:,}".lower() + if q == v.id.lower() or q in (coord, coord_comma) or q in v.id.lower(): + ol = self.query_one("#variant-list", OptionList) + ol.highlighted = li + self.notify(f"找到: {v.chrom}:{v.pos:,} {v.id or ''}".strip(), title="搜索") + return + self.notify(f"未找到匹配 \"{value}\"", title="搜索", severity="warning") + + def on_key(self, event) -> None: + """命令栏存在时:Esc 关闭,其他键交给 Input。""" + bar = self._get_search_bar() + if bar is not None: + if event.key == "escape": + self._remove_search_bar() + self.query_one("#variant-list", OptionList).focus() + event.stop() + else: + event.stop() # 防止 j/k/q 等绑定穿透到 Input + + # ── 事件 ── + def on_option_list_option_highlighted(self, event) -> None: + if event.option is not None: + self._show_detail(event.option_index) + + # ── Actions ── + def action_cursor_down(self): + self.query_one("#variant-list", OptionList).action_cursor_down() + + def action_cursor_up(self): + self.query_one("#variant-list", OptionList).action_cursor_up() + + def action_page_down(self): + self.query_one("#variant-list", OptionList).action_page_down() + + def action_page_up(self): + self.query_one("#variant-list", OptionList).action_page_up() + + def action_home(self): + self.query_one("#variant-list", OptionList).highlighted = 0 + + def action_end(self): + ol = self.query_one("#variant-list", OptionList) + if ol.option_count: + ol.highlighted = ol.option_count - 1 + + async def action_search(self): + await self._show_search_bar() + + def action_cycle_filter(self): + i = _FILTER_CYCLE.index(self.filter_mode) + self.filter_mode = _FILTER_CYCLE[(i + 1) % len(_FILTER_CYCLE)] + self._apply_filter_sort() + self.notify(f"过滤: {self.filter_mode}", title="过滤") + + def action_toggle_sort(self): + self.sort_mode = "QUAL" if self.sort_mode == "位置" else "位置" + self._apply_filter_sort() + self.notify(f"排序: {self.sort_mode}", title="排序") + + def action_toggle_view(self): + self.matrix_mode = not self.matrix_mode + ol = self.query_one("#variant-list", OptionList) + self._show_detail(ol.highlighted or 0) + + def action_file_info(self): + txt = Text() + txt.append("文件信息\n\n", style="bold") + txt.append(" 格式 ", style="dim") + txt.append(f"{self.meta.fileformat or '未知'}\n") + txt.append(" 样本数 ", style="dim") + txt.append(f"{len(self.meta.samples)} ({', '.join(self.meta.samples) or '无'})\n") + txt.append(" 变异数 ", style="dim") + txt.append(f"{len(self.variants):,}\n") + if self.skipped: + txt.append(" 跳过 ", style="dim") + txt.append(f"{self.skipped} 行畸形数据\n") + if self.meta.contigs: + txt.append("\nContigs:\n", style="bold cyan") + for cid, length in self.meta.contigs.items(): + txt.append(f" {cid:<10}") + txt.append(f"{length:,} bp\n", style="green") + if self.meta.filter_defs: + txt.append("\nFILTER 定义:\n", style="bold cyan") + for fid, desc in self.meta.filter_defs.items(): + txt.append(f" {fid:<10}{desc}\n") + self.query_one("#detail", Static).update(txt) + + def action_copy_line(self): + if not self.view: + self.notify("没有可复制的变异", severity="warning") + return + ol = self.query_one("#variant-list", OptionList) + v = self.variants[self.view[ol.highlighted or 0]] + load_variant_detail(self.filepath, v, self.meta.samples) + ok = self._copy_to_clipboard(v.raw) + self._last_copied = v.raw + if ok: + self.notify(f"已复制 {v.chrom}:{v.pos:,}", title="复制") + else: + self.notify("剪贴板不可用", title="复制", severity="warning") + + def action_help(self): + self.push_screen(HelpScreen()) + + def action_quit(self): + self.exit() diff --git a/test/test_vcf_browser.py b/test/test_vcf_browser.py new file mode 100644 index 0000000..07541dc --- /dev/null +++ b/test/test_vcf_browser.py @@ -0,0 +1,233 @@ +"""VcfBrowser TUI 测试(对应 docs/superpowers/plans Task 6-8)。""" +import asyncio +from pathlib import Path + +from textual.widgets import OptionList + +from seqviz.vcf_browser import VcfBrowser + +TEST_DIR = Path(__file__).parent +SAMPLE_VCF = TEST_DIR / "sample.vcf" + + +def run(coro): + return asyncio.run(coro) + + +# ────────────────────────────────────────────── +# 启动与列表 +# ────────────────────────────────────────────── +class TestLaunch: + def test_app_starts_and_lists_variants(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert ol.option_count == 18 + run(_t()) + + def test_detail_shows_first_variant(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + text = app._detail_text() + assert "10,234" in text # 千分位坐标 + assert "sample1" in text # 逐样本基因型已加载 + run(_t()) + + def test_empty_vcf(self, tmp_path): + f = tmp_path / "empty.vcf" + f.write_text("") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert ol.option_count == 0 + run(_t()) + + +# ────────────────────────────────────────────── +# 导航 +# ────────────────────────────────────────────── +class TestNavigation: + def test_jk_moves_highlight(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert ol.highlighted == 0 + await pilot.press("j") + await pilot.pause() + assert ol.highlighted == 1 + await pilot.press("k") + await pilot.pause() + assert ol.highlighted == 0 + run(_t()) + + def test_g_G_jumps(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + await pilot.press("G") + await pilot.pause() + assert ol.highlighted == ol.option_count - 1 + await pilot.press("g") + await pilot.pause() + assert ol.highlighted == 0 + run(_t()) + + +# ────────────────────────────────────────────── +# 过滤与排序 +# ────────────────────────────────────────────── +class TestFilterSort: + def test_filter_cycle(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert ol.option_count == 18 + await pilot.press("f") # PASS + await pilot.pause() + assert app.filter_mode == "PASS" + assert ol.option_count == 13 + await pilot.press("f") # SNP + await pilot.pause() + assert app.filter_mode == "SNP" + assert ol.option_count == 12 + await pilot.press("f") # InDel + await pilot.pause() + assert app.filter_mode == "InDel" + assert ol.option_count == 6 + await pilot.press("f") # 回到全部 + await pilot.pause() + assert ol.option_count == 18 + run(_t()) + + def test_sort_by_qual(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("s") + await pilot.pause() + assert app.sort_mode == "QUAL" + quals = [app.variants[i].qual for i in app.view] + non_none = [q for q in quals if q is not None] + assert non_none == sorted(non_none, reverse=True) + assert quals[-1] is None or all(q is not None for q in quals) + await pilot.press("s") + await pilot.pause() + assert app.sort_mode == "位置" + run(_t()) + + +# ────────────────────────────────────────────── +# 搜索 +# ────────────────────────────────────────────── +class TestSearch: + def test_search_by_id(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("slash") + await pilot.pause() + await pilot.press(*"rs67890") + await pilot.press("enter") + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + v = app.variants[app.view[ol.highlighted]] + assert v.id == "rs67890" + run(_t()) + + def test_search_by_coordinate(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("slash") + await pilot.pause() + await pilot.press(*"chr2:12345") + await pilot.press("enter") + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + v = app.variants[app.view[ol.highlighted]] + assert (v.chrom, v.pos) == ("chr2", 12345) + run(_t()) + + def test_search_escape_cancels(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("slash") + await pilot.pause() + assert app._get_search_bar() is not None + await pilot.press("escape") + await pilot.pause() + assert app._get_search_bar() is None + run(_t()) + + +# ────────────────────────────────────────────── +# 矩阵 / 信息 / 复制 +# ────────────────────────────────────────────── +class TestMatrixInfoCopy: + def test_matrix_toggle(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("t") + await pilot.pause() + assert app.matrix_mode is True + assert "sample1" in app._matrix_text() + await pilot.press("t") + await pilot.pause() + assert app.matrix_mode is False + run(_t()) + + def test_file_info_panel(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("i") + await pilot.pause() + detail = app.query_one("#detail") + # 信息面板渲染为 Rich Text,检查其纯文本内容 + text = str(detail.render()) + assert "VCFv4.3" in text + assert "chr1" in text + run(_t()) + + def test_copy_line_records_raw(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("y") + await pilot.pause() + assert app._last_copied.startswith("chr1\t10234\trs12345") + run(_t()) + + def test_help_screen_opens_and_q_closes(self): + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("question_mark") + await pilot.pause() + assert len(app.screen_stack) > 1 # 帮助屏已推入 + await pilot.press("q") + await pilot.pause() + assert len(app.screen_stack) == 1 # q 只关闭帮助屏,不退出 + run(_t()) From 1c11da6bddaf2f55cd06a9f084c297687d8d335c Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 04:37:01 +0800 Subject: [PATCH 05/20] feat(cli): route .vcf to VcfBrowser; file selector shows [V] tag and variant count --- .gitignore | 1 - config/config.json | 3 ++- src/seqviz/cli.py | 20 +++++++++++++++++++- src/seqviz/config.py | 1 + src/seqviz/file_browser.py | 33 +++++++++++++++++++++++++++------ test/sample.vcf | 36 ++++++++++++++++++++++++++++++++++++ test/test_cli.py | 31 +++++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 test/sample.vcf diff --git a/.gitignore b/.gitignore index 4dbe617..d67c0fb 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,4 @@ test/__pycache__/ .qoder/ # 未入库的测试样本/临时文件 -test/sample.vcf test/Weixin* diff --git a/config/config.json b/config/config.json index 599222b..4ea33ca 100644 --- a/config/config.json +++ b/config/config.json @@ -31,7 +31,8 @@ ".aa", ".seq", ".fq", - ".fastq" + ".fastq", + ".vcf" ] } } diff --git a/src/seqviz/cli.py b/src/seqviz/cli.py index 423ad17..e5a2974 100644 --- a/src/seqviz/cli.py +++ b/src/seqviz/cli.py @@ -32,11 +32,23 @@ def _check_file(file: Path) -> Path: return file +def _is_vcf(path: Path) -> bool: + """判断是否为 VCF 文件(仅支持未压缩 .vcf)。""" + return path.is_file() and path.suffix.lower() == ".vcf" + + def _launch_browser(paths: list[Path]): """根据路径启动浏览器:目录走文件选择器,文件直接打开。 支持从序列浏览器按 B 返回文件选择器(循环)。 + 单个 .vcf 文件路由到 VcfBrowser。 """ + # 单文件且为 .vcf → VCF 变异浏览器 + if len(paths) == 1 and _is_vcf(paths[0]): + from seqviz.vcf_browser import VcfBrowser + VcfBrowser(paths[0]).run() + return + source_dir: Path | None = None # 单个目录 → 记住来源目录,走文件选择器流程 @@ -67,6 +79,12 @@ def _launch_browser(paths: list[Path]): console.print("[red]没有找到可打开的序列文件[/red]") raise typer.Exit() + # 选中结果若为单个 .vcf → 路由到 VcfBrowser + if len(open_paths) == 1 and _is_vcf(open_paths[0]): + from seqviz.vcf_browser import VcfBrowser + VcfBrowser(open_paths[0]).run() + break + # 运行序列浏览器;按 B 返回 "back" 则重新进入文件选择器 result = FastaBrowser(open_paths, source_dir=source_dir).run() if result != "back": @@ -255,7 +273,7 @@ def fqview( def browse( files: list[Path] = typer.Argument(help="FASTA/FASTQ 文件或目录路径(目录会启动文件选择器)"), ): - """交互式浏览 FASTA/FASTQ 文件(支持多文件标签页、目录浏览)。""" + """交互式浏览 FASTA/FASTQ/VCF 文件(支持多文件标签页、目录浏览)。""" for p in files: # 校验路径存在,与其他子命令的友好报错保持一致 if not p.exists(): console.print(f"[red]错误: 路径不存在: {p}[/red]") diff --git a/src/seqviz/config.py b/src/seqviz/config.py index 99bac87..37fa2ab 100644 --- a/src/seqviz/config.py +++ b/src/seqviz/config.py @@ -36,6 +36,7 @@ "extensions": [ ".fa", ".fasta", ".fna", ".faa", ".aa", ".seq", ".fq", ".fastq", + ".vcf", ], }, } diff --git a/src/seqviz/file_browser.py b/src/seqviz/file_browser.py index 20c7d3e..64442f5 100644 --- a/src/seqviz/file_browser.py +++ b/src/seqviz/file_browser.py @@ -46,8 +46,13 @@ def detect_file_format(path: Path) -> FileFormat: return detect_format(path) +def is_vcf_file(path: Path) -> bool: + """判断是否为未压缩 VCF 文件。""" + return path.is_file() and path.suffix.lower() == ".vcf" + + def count_sequences(path: Path, fmt: FileFormat, cancel_event: threading.Event | None = None) -> int: - """快速统计序列条数。FASTA 数 '>' 行,FASTQ 按 4 行一组。 + """快速统计序列条数。FASTA 数 '>' 行,FASTQ 按 4 行一组,VCF 数非注释数据行。 cancel_event 置位时在读取间隙尽早中断(返回已统计部分), 避免 GB 级文件预览后退出时进程挂起直到整文件读完。 @@ -55,7 +60,17 @@ def count_sequences(path: Path, fmt: FileFormat, cancel_event: threading.Event | opener = gzip.open if path.suffix.lower() == ".gz" else open count = 0 try: - if fmt == FileFormat.FASTQ: + if is_vcf_file(path): + # VCF: 数非 '#' 开头的非空行(即变异记录数) + lines = 0 + with opener(path, "rb") as f: + for line in f: + lines += 1 + if line and not line.startswith(b"#") and line.strip(): + count += 1 + if cancel_event is not None and lines % 16384 == 0 and cancel_event.is_set(): + return count + elif fmt == FileFormat.FASTQ: # FASTQ: 每 4 行一条记录,数行数除以 4 lines = 0 with opener(path, "rb") as f: @@ -131,7 +146,9 @@ def show_info(self, info: FileInfo | None): self.update(Text(" 无文件", style="dim")) return - fmt_label = "FASTQ" if info.fmt == FileFormat.FASTQ else "FASTA" + is_vcf = is_vcf_file(info.path) + fmt_label = "VCF" if is_vcf else ("FASTQ" if info.fmt == FileFormat.FASTQ else "FASTA") + count_label = "变异数" if is_vcf else "序列数" content = Text() content.append("\n 文件详情\n\n", style="bold cyan") content.append(" 名称: ", style="dim") @@ -142,7 +159,7 @@ def show_info(self, info: FileInfo | None): content.append(f"{info.size_str}\n", style="yellow") content.append(" 格式: ", style="dim") content.append(f"{fmt_label}\n", style="magenta") - content.append(" 序列数: ", style="dim") + content.append(f" {count_label}: ", style="dim") if info.seq_count is None: content.append("统计中...\n", style="dim") else: @@ -204,8 +221,12 @@ def _rebuild_list(self): label.append(" ✓ ", style="bold green") else: label.append(" ", style="dim") - fmt_tag = "Q" if info.fmt == FileFormat.FASTQ else "F" - label.append(f"[{fmt_tag}] ", style="cyan" if fmt_tag == "Q" else "magenta") + if is_vcf_file(info.path): + fmt_tag = "V" + else: + fmt_tag = "Q" if info.fmt == FileFormat.FASTQ else "F" + tag_style = {"Q": "cyan", "F": "magenta", "V": "green"}[fmt_tag] + label.append(f"[{fmt_tag}] ", style=tag_style) label.append(info.name, style="bold") label.append(f" ({info.size_str})", style="dim") option_list.add_option(Option(label, id=f"file-{i}")) diff --git a/test/sample.vcf b/test/sample.vcf new file mode 100644 index 0000000..7b5b032 --- /dev/null +++ b/test/sample.vcf @@ -0,0 +1,36 @@ +##fileformat=VCFv4.3 +##source=seqviz-test +##reference=Oryzias_latipes_ASM223467v1 +##contig= +##contig= +##contig= +##INFO= +##INFO= +##INFO= +##INFO= +##FILTER= +##FILTER= +##FILTER= +##FORMAT= +##FORMAT= +##FORMAT= +##FORMAT= +#CHROM POS ID REF ALT QUAL FILTER INFO FORMAT sample1 sample2 sample3 +chr1 10234 rs12345 A G 99.5 PASS DP=45;AF=0.333;DB;MQ=60.0 GT:DP:GQ:AD 0/1:15:99:10,5 0/0:18:99:18,0 0/1:12:85:8,4 +chr1 10567 . C T 45.2 LowQual DP=12;AF=0.167;MQ=35.2 GT:DP:GQ:AD 0/0:5:40:5,0 0/1:4:30:3,1 0/0:3:25:3,0 +chr1 15892 rs67890 G A 150.0 PASS DP=88;AF=0.500;DB;MQ=59.8 GT:DP:GQ:AD 0/1:30:99:15,15 1/1:28:99:0,28 0/1:30:99:16,14 +chr1 23456 . AT A 78.3 PASS DP=52;AF=0.333;MQ=55.1 GT:DP:GQ:AD 0/1:20:78:14,6 0/0:16:99:16,0 0/1:16:65:11,5 +chr1 34567 rs11111 T TG 200.0 PASS DP=95;AF=0.667;DB;MQ=60.0 GT:DP:GQ:AD 1/1:35:99:0,35 0/1:30:99:18,12 0/1:30:99:15,15 +chr1 45678 . G C 12.5 LowDP DP=5;AF=0.167;MQ=28.0 GT:DP:GQ:AD 0/0:2:10:2,0 0/1:2:15:1,1 0/0:1:5:1,0 +chr1 56789 rs22222 C G 88.9 PASS DP=60;AF=0.500;DB;MQ=58.3 GT:DP:GQ:AD 0/1:22:88:12,10 0/1:20:85:11,9 0/0:18:99:18,0 +chr1 67890 . A ATCG 55.0 PASS DP=35;AF=0.167;MQ=48.5 GT:DP:GQ:AD 0/0:12:55:12,0 0/1:13:50:9,4 0/0:10:45:10,0 +chr1 78901 rs33333 G T 175.0 PASS DP=72;AF=0.833;DB;MQ=60.0 GT:DP:GQ:AD 1/1:25:99:0,25 1/1:24:99:0,24 0/1:23:99:14,9 +chr1 89012 . T A 30.1 LowQual DP=8;AF=0.167;MQ=32.5 GT:DP:GQ:AD 0/0:3:20:3,0 0/1:3:25:2,1 0/0:2:15:2,0 +chr2 12345 rs44444 A G 99.0 PASS DP=55;AF=0.500;DB;MQ=59.0 GT:DP:GQ:AD 0/1:20:99:10,10 0/1:18:90:9,9 0/0:17:99:17,0 +chr2 23456 . C CT 120.5 PASS DP=68;AF=0.333;MQ=57.2 GT:DP:GQ:AD 0/1:25:99:16,9 0/0:22:99:22,0 0/1:21:88:14,7 +chr2 34567 rs55555 G A 85.0 PASS DP=42;AF=0.500;DB;MQ=56.8 GT:DP:GQ:AD 0/1:15:85:8,7 0/1:14:80:7,7 0/0:13:99:13,0 +chr2 45678 . T C 15.8 LowDP DP=6;AF=0.167;MQ=30.0 GT:DP:GQ:AD 0/0:2:12:2,0 0/0:2:10:2,0 0/1:2:15:1,1 +chr2 56789 rs66666 A T 95.0 PASS DP=50;AF=0.667;DB;MQ=58.5 GT:DP:GQ:AD 1/1:18:99:0,18 0/1:17:95:9,8 0/1:15:88:8,7 +chr3 11111 . GC G 110.0 PASS DP=48;AF=0.333;MQ=54.0 GT:DP:GQ:AD 0/1:18:99:12,6 0/0:16:99:16,0 0/1:14:80:10,4 +chr3 22222 rs77777 T A 160.0 PASS DP=75;AF=0.833;DB;MQ=60.0 GT:DP:GQ:AD 1/1:28:99:0,28 1/1:25:99:0,25 0/1:22:99:12,10 +chr3 33333 . A ACGT 42.0 LowQual DP=10;AF=0.167;MQ=38.0 GT:DP:GQ:AD 0/0:4:30:4,0 0/1:3:25:2,1 0/0:3:20:3,0 diff --git a/test/test_cli.py b/test/test_cli.py index 108a917..aceb7ae 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -143,6 +143,37 @@ def fake_run(self): assert result.exit_code == 0 assert captured["n_tabs"] == 1 + def test_browse_vcf_routes_to_vcf_browser(self, monkeypatch): + """browse 单个 .vcf 应路由到 VcfBrowser 而非 FastaBrowser。""" + import seqviz.cli as cli_mod + from seqviz import vcf_browser as vcf_browser_mod + called: dict = {} + + def fake_vcf_run(self): + called["vcf"] = str(self.filepath) + + def fake_fasta_run(self): + called["fasta"] = True + + monkeypatch.setattr(vcf_browser_mod.VcfBrowser, "run", fake_vcf_run) + monkeypatch.setattr(cli_mod.FastaBrowser, "run", fake_fasta_run) + vcf_path = str(Path(__file__).parent / "sample.vcf") + result = runner.invoke(app, ["browse", vcf_path]) + assert result.exit_code == 0 + assert called.get("vcf") == vcf_path + assert "fasta" not in called + + def test_is_vcf_helper(self, tmp_path): + """_is_vcf 辅助函数:后缀判定(大小写不敏感),不存在/非文件为 False。""" + from seqviz.cli import _is_vcf + f = tmp_path / "x.VCF" + f.write_text("") + assert _is_vcf(f) is True + fa = tmp_path / "x.fa" + fa.write_text("") + assert _is_vcf(fa) is False + assert _is_vcf(tmp_path / "missing.vcf") is False + class TestHelpCommand: def test_main_help(self): From 11796c0961732dbd6b4f683bfe08cf458fb5e5f2 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 04:39:39 +0800 Subject: [PATCH 06/20] docs: VCF browsing section in README --- README.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8eda008..2dbd250 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - 🧬 **DNA / 蛋白质自动检测** — 四色碱基着色 (A/T/C/G),氨基酸按化学性质分组着色 - 📊 **FASTQ 质量可视化** — Phred 梯度着色、质量分布条、Q30 统计 +- 🧪 **VCF 变异浏览** — 变异类型着色、逐样本基因型 + reads 比例条、基因型矩阵、过滤/排序/Ts-Tv 统计 - 🖥️ **交互式 TUI 浏览器** — 搜索、跳转、复制、导出、多标签页,支持十万级序列 - 📁 **目录文件选择器** — 扫描目录中的序列文件,预览、多选批量打开 - 📈 **统计摘要** — 序列条数、总长度、N50、GC 含量,表格化输出 @@ -64,6 +65,9 @@ seqviz stats genome.fasta # 交互式浏览器 seqviz browse genome.fasta + +# VCF 变异浏览器 +seqviz browse variants.vcf ``` ## 命令一览 @@ -75,7 +79,7 @@ seqviz browse genome.fasta | `seqviz fqview ` | 彩色查看 FASTQ 文件(含质量值) | | `seqviz head ` | 查看前 N 条序列 | | `seqviz stats ` | 输出统计摘要表格 | -| `seqviz browse ` | 交互式 TUI 浏览器 | +| `seqviz browse ` | 交互式 TUI 浏览器(FASTA/FASTQ/VCF 自动路由) | | `seqviz config [--init]` | 查看/生成配置文件 | ## 交互式浏览器 @@ -100,7 +104,7 @@ seqviz browse genome.fasta ### 文件选择器 -当 `browse` 传入目录(或直接运行 `seqviz`)时启动。自动扫描 `.fa .fasta .fna .faa .aa .seq .fq .fastq` 及 `.gz`。 +当 `browse` 传入目录(或直接运行 `seqviz`)时启动。自动扫描 `.fa .fasta .fna .faa .aa .seq .fq .fastq .vcf` 及 `.gz`。 | 按键 | 功能 | |------|------| @@ -110,6 +114,28 @@ seqviz browse genome.fasta | `Enter` | 打开(多选则批量标签页) | | `q` | 取消并退出 | +## VCF 变异浏览器 + +```bash +seqviz browse variants.vcf +``` + +左右双栏:**左侧变异列表**(懒扫描索引 + 虚拟化)+ **右侧变异详情 / 基因型矩阵**。 + +- 变异类型着色:转换 ● 绿 · 颠换 ● 蓝 · 插入 ◆ 黄 · 缺失 ◆ 红 +- 行颜色编码可信度:PASS 绿 · 低质量黄 · 低深度红 +- 详情面板逐样本展示基因型 + AD reads 比例条 + +| 按键 | 功能 | 按键 | 功能 | +|------|------|------|------| +| `j` / `k` | 上下移动 | `Space` / `b` | 翻页 | +| `/` | 搜索 ID 或坐标 | `f` | 过滤循环(全部/PASS/SNP/InDel) | +| `s` | 排序(位置 ↔ QUAL) | `t` | 详情 ↔ 基因型矩阵 | +| `i` | 文件信息 | `y` | 复制当前 VCF 行 | +| `?` | 帮助 | `q` | 退出 | + +> 底部状态栏常驻统计:变异总数 · SNP/InDel · Ts/Tv · PASS 数 · AF 均值,随过滤实时联动。目前支持未压缩 `.vcf`(bgzip/tabix 规划中)。 + ## 配置 ```bash @@ -202,7 +228,7 @@ seqviz config # 查看当前生效配置与可用主题 ```bash git clone https://github.com/Georicl/seqviz.git && cd seqviz uv sync -uv run pytest test/ -v # 183 个测试 +uv run pytest test/ -v # 254 个测试 ``` ## License From 0a60c3a1200d58f042f94480b0fb85eb2070da98 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 05:11:35 +0800 Subject: [PATCH 07/20] =?UTF-8?q?fix(vcf):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20mixed-open=20guard,=20vcf.gz=20exclusion,=20empty/h?= =?UTF-8?q?eaderless=20CLI=20errors,=20INFO/FORMAT=20defs,=20symbolic=20al?= =?UTF-8?q?leles,=20natural=20chrom=20sort,=20windowed=20list=20virtualiza?= =?UTF-8?q?tion=20(filter=20toggle=2012.5s=E2=86=920.6s=20at=20300k=20vari?= =?UTF-8?q?ants)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seqviz/cli.py | 34 +++++++++++-- src/seqviz/file_browser.py | 4 +- src/seqviz/vcf.py | 11 +++- src/seqviz/vcf_browser.py | 100 +++++++++++++++++++++++++++---------- test/test_cli.py | 35 +++++++++++++ test/test_file_browser.py | 11 ++++ test/test_vcf.py | 9 ++++ test/test_vcf_browser.py | 83 +++++++++++++++++++++++++++++- 8 files changed, 253 insertions(+), 34 deletions(-) diff --git a/src/seqviz/cli.py b/src/seqviz/cli.py index e5a2974..26443b9 100644 --- a/src/seqviz/cli.py +++ b/src/seqviz/cli.py @@ -37,6 +37,23 @@ def _is_vcf(path: Path) -> bool: return path.is_file() and path.suffix.lower() == ".vcf" +def _run_vcf_browser(path: Path): + """扫描并启动 VCF 浏览器;空文件/无 #CHROM 表头时友好报错非零退出。 + + 扫描结果直接传给 VcfBrowser,避免重复扫描大文件。 + """ + from seqviz.vcf import scan_vcf + from seqviz.vcf_browser import VcfBrowser + meta, variants, skipped = scan_vcf(path) + if not meta.has_header: + console.print(f"[red]错误: 不是有效的 VCF 文件(缺少 #CHROM 表头): {path}[/red]") + raise typer.Exit(code=1) + if not variants: + console.print(f"[red]错误: VCF 文件中没有变异记录: {path}[/red]") + raise typer.Exit(code=1) + VcfBrowser(path, scanned=(meta, variants, skipped)).run() + + def _launch_browser(paths: list[Path]): """根据路径启动浏览器:目录走文件选择器,文件直接打开。 @@ -45,8 +62,7 @@ def _launch_browser(paths: list[Path]): """ # 单文件且为 .vcf → VCF 变异浏览器 if len(paths) == 1 and _is_vcf(paths[0]): - from seqviz.vcf_browser import VcfBrowser - VcfBrowser(paths[0]).run() + _run_vcf_browser(paths[0]) return source_dir: Path | None = None @@ -81,10 +97,20 @@ def _launch_browser(paths: list[Path]): # 选中结果若为单个 .vcf → 路由到 VcfBrowser if len(open_paths) == 1 and _is_vcf(open_paths[0]): - from seqviz.vcf_browser import VcfBrowser - VcfBrowser(open_paths[0]).run() + _run_vcf_browser(open_paths[0]) break + # VCF 暂不支持与序列文件混合打开:剥离并提示,避免在 FastaBrowser 中产生静默空标签页 + mixed_vcfs = [p for p in open_paths if _is_vcf(p)] + if mixed_vcfs and len(open_paths) > 1: + console.print("[yellow]VCF 文件暂不支持与其他文件混合打开,已跳过: " + + ", ".join(p.name for p in mixed_vcfs) + "[/yellow]") + open_paths = [p for p in open_paths if not _is_vcf(p)] + if not open_paths: + if source_dir is not None: + continue # 回到文件选择器重新选择 + raise typer.Exit() + # 运行序列浏览器;按 B 返回 "back" 则重新进入文件选择器 result = FastaBrowser(open_paths, source_dir=source_dir).run() if result != "back": diff --git a/src/seqviz/file_browser.py b/src/seqviz/file_browser.py index 64442f5..97737c7 100644 --- a/src/seqviz/file_browser.py +++ b/src/seqviz/file_browser.py @@ -31,13 +31,13 @@ def _get_seq_extensions() -> set: def is_sequence_file(path: Path) -> bool: - """判断是否为序列文件(支持 .gz 双后缀)。""" + """判断是否为序列文件(支持 .gz 双后缀;压缩 VCF 暂不支持不列出)。""" if not path.is_file(): return False exts = _get_seq_extensions() if path.suffix.lower() == ".gz": inner_suffix = Path(path.stem).suffix.lower() - return inner_suffix in exts + return inner_suffix in exts and inner_suffix != ".vcf" return path.suffix.lower() in exts diff --git a/src/seqviz/vcf.py b/src/seqviz/vcf.py index d3e504d..d44a28b 100644 --- a/src/seqviz/vcf.py +++ b/src/seqviz/vcf.py @@ -47,11 +47,17 @@ class VcfMeta: format_defs: dict = field(default_factory=dict) filter_defs: dict = field(default_factory=dict) samples: list = field(default_factory=list) + has_header: bool = False # 是否存在 #CHROM 表头行(无效文件判定的依据) def classify_variant(ref: str, alt: str) -> VariantType: - """按 REF/ALT 长度与碱基判定变异类型;多等位取第一个 ALT。""" + """按 REF/ALT 长度与碱基判定变异类型;多等位取第一个 ALT。 + + 符号等位基因(//*)判为 COMPLEX,避免污染 SNP/InDel 计数与 Ts/Tv。 + """ first_alt = alt.split(",")[0] + if first_alt.startswith("<") or first_alt in ("*", "."): + return VariantType.COMPLEX ref_u, alt_u = ref.upper(), first_alt.upper() if len(ref) == 1 and len(first_alt) == 1: return VariantType.TRANSITION if (ref_u, alt_u) in _TRANSITIONS else VariantType.TRANSVERSION @@ -167,6 +173,7 @@ def scan_vcf(path) -> tuple[VcfMeta, list[Variant], int]: header_lines: list[str] = [] variants: list[Variant] = [] sample_names: list[str] = [] + has_header = False skipped = 0 with open(path, encoding="utf-8", errors="replace") as f: while True: @@ -178,6 +185,7 @@ def scan_vcf(path) -> tuple[VcfMeta, list[Variant], int]: header_lines.append(line) continue if line.startswith("#CHROM"): + has_header = True cols = line.rstrip("\n").split("\t") sample_names = cols[9:] if len(cols) > 9 else [] continue @@ -206,6 +214,7 @@ def scan_vcf(path) -> tuple[VcfMeta, list[Variant], int]: )) meta = parse_meta(header_lines) meta.samples = sample_names + meta.has_header = has_header return meta, variants, skipped diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index 84d8c19..3bc6a93 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -3,6 +3,7 @@ 设计文档: docs/superpowers/specs/2026-07-25-vcf-visualization-design.md """ +import re import subprocess from pathlib import Path @@ -44,6 +45,11 @@ _FILTER_CYCLE = ["全部", "PASS", "SNP", "InDel"] +def _chrom_sort_key(chrom: str): + """数字感知的染色体排序键:chr2 < chr10,非数字段按字典序。""" + return tuple(int(p) if p.isdigit() else p for p in re.split(r"(\d+)", chrom)) + + class HelpScreen(ModalScreen): """帮助面板:显示所有快捷键。""" @@ -112,11 +118,22 @@ class VcfBrowser(App): Binding("q", "quit", "退出", show=True), ] - def __init__(self, filepath: Path): + # 列表窗口化虚拟化:OptionList 只物化当前窗口,避免大文件全量重建冻结 UI + WINDOW = 2000 + PAGE = 20 + + def __init__(self, filepath: Path, scanned=None): super().__init__() self.filepath = filepath - self.meta, self.variants, self.skipped = scan_vcf(filepath) + if scanned is not None: + self.meta, self.variants, self.skipped = scanned + else: + self.meta, self.variants, self.skipped = scan_vcf(filepath) self.view: list[int] = list(range(len(self.variants))) # 过滤/排序后的下标映射 + # 默认按位置排序(数字感知),即使文件本身未按坐标排序 + self.view.sort(key=lambda i: ( + _chrom_sort_key(self.variants[i].chrom), self.variants[i].pos)) + self._win_start = 0 # 当前窗口在 view 中的起始下标 self.filter_mode = "全部" self.sort_mode = "位置" self.matrix_mode = False @@ -160,16 +177,43 @@ def _make_option(self, v: Variant) -> Option: return Option(line) def _refresh_list(self): - ol = self.query_one("#variant-list", OptionList) - ol.clear_options() - ol.add_options([self._make_option(self.variants[i]) for i in self.view]) + self._win_start = 0 + self._rebuild_window(0, highlight_abs=0) if self.view: - ol.highlighted = 0 self._show_detail(0) else: self.query_one("#detail", Static).update(Text("(无匹配变异)", style="dim")) self._update_status_bar() + def _rebuild_window(self, win_start: int, highlight_abs: int): + """重建 OptionList 窗口(只物化 view[win_start:win_start+WINDOW])。""" + self._win_start = max(0, min(win_start, len(self.view))) + ol = self.query_one("#variant-list", OptionList) + ol.clear_options() + ol.add_options([ + self._make_option(self.variants[i]) + for i in self.view[self._win_start:self._win_start + self.WINDOW] + ]) + if ol.option_count: + ol.highlighted = max(0, min(highlight_abs - self._win_start, ol.option_count - 1)) + + def _abs_index(self) -> int: + """当前高亮项在 view 中的绝对下标。""" + ol = self.query_one("#variant-list", OptionList) + return self._win_start + (ol.highlighted or 0) + + def _goto_abs(self, target: int): + """跳转到 view 中的绝对下标(必要时平移窗口)。""" + if not self.view: + return + target = max(0, min(target, len(self.view) - 1)) + materialized = min(self.WINDOW, len(self.view) - self._win_start) + if self._win_start <= target < self._win_start + materialized: + self.query_one("#variant-list", OptionList).highlighted = target - self._win_start + else: + new_start = min(max(target - self.WINDOW // 2, 0), max(0, len(self.view) - self.WINDOW)) + self._rebuild_window(new_start, target) + # ── 详情渲染 ── def _build_detail(self, v: Variant) -> Text: t = classify_variant(v.ref, v.alt) @@ -241,8 +285,7 @@ def _detail_text(self) -> str: """当前详情的纯文本(供测试断言)。""" if not self.view: return "" - ol = self.query_one("#variant-list", OptionList) - v = self.variants[self.view[ol.highlighted or 0]] + v = self.variants[self.view[self._abs_index()]] load_variant_detail(self.filepath, v, self.meta.samples) return str(self._build_detail(v)) @@ -272,8 +315,7 @@ def _build_matrix(self, current_list_index: int = 0) -> Text: return txt def _matrix_text(self) -> str: - ol = self.query_one("#variant-list", OptionList) - return str(self._build_matrix(ol.highlighted or 0)) + return str(self._build_matrix(self._abs_index())) def _render_matrix(self, list_index: int): self.query_one("#detail", Static).update(self._build_matrix(list_index)) @@ -309,7 +351,9 @@ def _apply_filter_sort(self): self.variants[i].qual is None, -(self.variants[i].qual or 0.0))) else: - idxs.sort(key=lambda i: (self.variants[i].chrom, self.variants[i].pos)) + # 数字感知染色体排序(chr2 < chr10) + idxs.sort(key=lambda i: ( + _chrom_sort_key(self.variants[i].chrom), self.variants[i].pos)) self.view = idxs self._refresh_list() @@ -377,8 +421,7 @@ def on_input_submitted(self, event: Input.Submitted) -> None: coord = f"{v.chrom}:{v.pos}".lower() coord_comma = f"{v.chrom}:{v.pos:,}".lower() if q == v.id.lower() or q in (coord, coord_comma) or q in v.id.lower(): - ol = self.query_one("#variant-list", OptionList) - ol.highlighted = li + self._goto_abs(li) self.notify(f"找到: {v.chrom}:{v.pos:,} {v.id or ''}".strip(), title="搜索") return self.notify(f"未找到匹配 \"{value}\"", title="搜索", severity="warning") @@ -397,28 +440,27 @@ def on_key(self, event) -> None: # ── 事件 ── def on_option_list_option_highlighted(self, event) -> None: if event.option is not None: - self._show_detail(event.option_index) + # 窗口内下标 → view 绝对下标 + self._show_detail(self._win_start + event.option_index) # ── Actions ── def action_cursor_down(self): - self.query_one("#variant-list", OptionList).action_cursor_down() + self._goto_abs(self._abs_index() + 1) def action_cursor_up(self): - self.query_one("#variant-list", OptionList).action_cursor_up() + self._goto_abs(self._abs_index() - 1) def action_page_down(self): - self.query_one("#variant-list", OptionList).action_page_down() + self._goto_abs(self._abs_index() + self.PAGE) def action_page_up(self): - self.query_one("#variant-list", OptionList).action_page_up() + self._goto_abs(self._abs_index() - self.PAGE) def action_home(self): - self.query_one("#variant-list", OptionList).highlighted = 0 + self._goto_abs(0) def action_end(self): - ol = self.query_one("#variant-list", OptionList) - if ol.option_count: - ol.highlighted = ol.option_count - 1 + self._goto_abs(len(self.view) - 1) async def action_search(self): await self._show_search_bar() @@ -436,8 +478,7 @@ def action_toggle_sort(self): def action_toggle_view(self): self.matrix_mode = not self.matrix_mode - ol = self.query_one("#variant-list", OptionList) - self._show_detail(ol.highlighted or 0) + self._show_detail(self._abs_index()) def action_file_info(self): txt = Text() @@ -460,14 +501,21 @@ def action_file_info(self): txt.append("\nFILTER 定义:\n", style="bold cyan") for fid, desc in self.meta.filter_defs.items(): txt.append(f" {fid:<10}{desc}\n") + if self.meta.info_defs: + txt.append("\nINFO 定义:\n", style="bold cyan") + for iid, desc in self.meta.info_defs.items(): + txt.append(f" {iid:<10}{desc}\n") + if self.meta.format_defs: + txt.append("\nFORMAT 定义:\n", style="bold cyan") + for fid, desc in self.meta.format_defs.items(): + txt.append(f" {fid:<10}{desc}\n") self.query_one("#detail", Static).update(txt) def action_copy_line(self): if not self.view: self.notify("没有可复制的变异", severity="warning") return - ol = self.query_one("#variant-list", OptionList) - v = self.variants[self.view[ol.highlighted or 0]] + v = self.variants[self.view[self._abs_index()]] load_variant_detail(self.filepath, v, self.meta.samples) ok = self._copy_to_clipboard(v.raw) self._last_copied = v.raw diff --git a/test/test_cli.py b/test/test_cli.py index aceb7ae..fe05a47 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -174,6 +174,41 @@ def test_is_vcf_helper(self, tmp_path): assert _is_vcf(fa) is False assert _is_vcf(tmp_path / "missing.vcf") is False + def test_browse_empty_vcf_friendly_error(self, tmp_path): + """空 VCF:友好报错并非零退出(对齐现有空文件策略)。""" + f = tmp_path / "empty.vcf" + f.write_text("") + result = runner.invoke(app, ["browse", str(f)]) + assert result.exit_code != 0 + assert "缺少 #CHROM 表头" in result.output + + def test_browse_header_only_vcf_friendly_error(self, tmp_path): + """有表头但无变异记录:友好报错并非零退出。""" + f = tmp_path / "hdr.vcf" + f.write_text("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n") + result = runner.invoke(app, ["browse", str(f)]) + assert result.exit_code != 0 + assert "没有变异记录" in result.output + + def test_browse_mixed_vcf_and_fasta_skips_vcf(self, tmp_path, monkeypatch): + """VCF 与序列文件混合打开:VCF 被剥离并提示,FastaBrowser 只收到序列文件。""" + import seqviz.cli as cli_mod + fa = tmp_path / "x.fa" + fa.write_text(">s1\nATCG\n") + vcf = tmp_path / "x.vcf" + vcf.write_text("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n" + "chr1\t1\t.\tA\tG\t50\tPASS\t.\n") + captured: dict = {} + + def fake_fasta_run(self): + captured["paths"] = [str(t.filepath) for t in self.file_tabs] + + monkeypatch.setattr(cli_mod.FastaBrowser, "run", fake_fasta_run) + result = runner.invoke(app, ["browse", str(vcf), str(fa)]) + assert result.exit_code == 0 + assert "混合打开" in result.output # 提示信息已输出 + assert captured["paths"] == [str(fa)] # VCF 未进入 FastaBrowser + class TestHelpCommand: def test_main_help(self): diff --git a/test/test_file_browser.py b/test/test_file_browser.py index de9ff59..22fe5f8 100644 --- a/test/test_file_browser.py +++ b/test/test_file_browser.py @@ -41,6 +41,17 @@ def test_gz_double_extension(self, tmp_path): f.write_bytes(b"") assert is_sequence_file(f) + def test_vcf_recognized(self, tmp_path): + f = tmp_path / "var.vcf" + f.write_text("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n") + assert is_sequence_file(f) + + def test_vcf_gz_excluded(self, tmp_path): + """压缩 VCF 暂不支持,不应被列入文件选择器(避免误标为 FASTA/计数0)。""" + f = tmp_path / "var.vcf.gz" + f.write_bytes(b"") + assert not is_sequence_file(f) + def test_non_sequence_file(self, tmp_path): f = tmp_path / "notes.txt" f.write_text("hello") diff --git a/test/test_vcf.py b/test/test_vcf.py index 983b202..20e61c6 100644 --- a/test/test_vcf.py +++ b/test/test_vcf.py @@ -44,6 +44,13 @@ def test_multiallelic_uses_first_alt(self): def test_case_insensitive(self): assert classify_variant("a", "g") == VariantType.TRANSITION + def test_symbolic_alleles_are_complex(self): + """符号等位基因(SV caller / gVCF)不污染 SNP/InDel 计数与 Ts/Tv。""" + assert classify_variant("A", "") == VariantType.COMPLEX + assert classify_variant("A", "") == VariantType.COMPLEX + assert classify_variant("A", "*") == VariantType.COMPLEX + assert classify_variant("A", ",G") == VariantType.COMPLEX # 多等位取首个 + class TestParseGenotype: def test_full_fields(self): @@ -121,6 +128,7 @@ def test_sample_vcf(self): assert len(variants) == 18 assert skipped == 0 assert meta.samples == ["sample1", "sample2", "sample3"] + assert meta.has_header is True first = variants[0] assert (first.chrom, first.pos, first.ref, first.alt) == ("chr1", 10234, "A", "G") assert first.info["AF"] == "0.333" # INFO 在索引阶段已解析 @@ -132,6 +140,7 @@ def test_empty_file(self, tmp_path): f.write_text("") meta, variants, skipped = scan_vcf(f) assert variants == [] and meta.samples == [] + assert meta.has_header is False # 无效文件判定依据 def test_no_header_line(self, tmp_path): f = tmp_path / "nohdr.vcf" diff --git a/test/test_vcf_browser.py b/test/test_vcf_browser.py index 07541dc..0a695e3 100644 --- a/test/test_vcf_browser.py +++ b/test/test_vcf_browser.py @@ -37,7 +37,8 @@ async def _t(): assert "sample1" in text # 逐样本基因型已加载 run(_t()) - def test_empty_vcf(self, tmp_path): + def test_empty_vcf_app_level_graceful(self, tmp_path): + """App 层面对空文件仍可优雅打开(CLI 层拦截报错,见 test_cli)。""" f = tmp_path / "empty.vcf" f.write_text("") async def _t(): @@ -207,6 +208,9 @@ async def _t(): text = str(detail.render()) assert "VCFv4.3" in text assert "chr1" in text + # 规格要求:INFO/FORMAT 字段定义也要展示 + assert "Total Depth" in text # ##INFO DP 的 Description + assert "Genotype" in text # ##FORMAT GT 的 Description run(_t()) def test_copy_line_records_raw(self): @@ -231,3 +235,80 @@ async def _t(): await pilot.pause() assert len(app.screen_stack) == 1 # q 只关闭帮助屏,不退出 run(_t()) + + +# ────────────────────────────────────────────── +# 大文件窗口化虚拟化(回归:全量重建 OptionList 冻结 UI) +# ────────────────────────────────────────────── +class TestWindowedVirtualization: + @staticmethod + def _make_big_vcf(tmp_path, n=5000): + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + for i in range(n): + lines.append(f"chr1\t{1000 + i}\t.\tA\tG\t50.0\tPASS\tDP=10") + f = tmp_path / "big.vcf" + f.write_text("\n".join(lines) + "\n") + return f + + def test_window_caps_materialized_options(self, tmp_path): + """列表只物化 WINDOW 条,而非全量。""" + f = self._make_big_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert len(app.view) == 5000 + assert ol.option_count == app.WINDOW # 只物化窗口 + run(_t()) + + def test_G_jumps_to_last_across_windows(self, tmp_path): + """G 键跨窗口跳到最后一条,绝对下标正确。""" + f = self._make_big_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("G") + await pilot.pause() + assert app._abs_index() == 4999 + assert "5,999" in app._detail_text() # 最后一条 pos=1000+4999(千分位) + await pilot.press("g") + await pilot.pause() + assert app._abs_index() == 0 + run(_t()) + + def test_filter_rebuild_is_windowed(self, tmp_path): + """过滤切换后列表仍只物化窗口大小(不再全量重建)。""" + f = self._make_big_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await pilot.press("f") # PASS only(全部 PASS) + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert len(app.view) == 5000 + assert ol.option_count == app.WINDOW + run(_t()) + + +# ────────────────────────────────────────────── +# 染色体排序(回归:chr10 字典序排在 chr2 前) +# ────────────────────────────────────────────── +class TestChromNaturalSort: + def test_position_sort_natural_order(self, tmp_path): + f = tmp_path / "sort.vcf" + f.write_text( + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n" + "chr10\t100\t.\tA\tG\t50\tPASS\t.\n" + "chr2\t50\t.\tA\tG\t50\tPASS\t.\n" + "chr1\t999\t.\tA\tG\t50\tPASS\t.\n" + ) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + order = [app.variants[i].chrom for i in app.view] + assert order == ["chr1", "chr2", "chr10"] # 非字典序的 chr1,chr10,chr2 + run(_t()) From b41521c2ee8d6e3301db560cbea3f10059a035d4 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 19:38:42 +0800 Subject: [PATCH 08/20] =?UTF-8?q?perf(vcf):=20500MB-scale=20optimization?= =?UTF-8?q?=20=E2=80=94=20cached=20global=20sort=20orders=20(order-preserv?= =?UTF-8?q?ing=20filter,=20no=20re-sort),=20async=20status-bar=20stats;=20?= =?UTF-8?q?PASS=20filter=20toggle=2044.5s=E2=86=923.1s,=20startup=2059s?= =?UTF-8?q?=E2=86=922.8s=20at=205.7M=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/seqviz/vcf_browser.py | 123 ++++++++++++++++++++++++++++---------- 2 files changed, 92 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index d67c0fb..812a652 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ test/__pycache__/ # 未入库的测试样本/临时文件 test/Weixin* +test/big_chr.vcf diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index 3bc6a93..9c993ee 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -5,6 +5,7 @@ import re import subprocess +from functools import partial from pathlib import Path from rich.text import Text @@ -130,9 +131,17 @@ def __init__(self, filepath: Path, scanned=None): else: self.meta, self.variants, self.skipped = scan_vcf(filepath) self.view: list[int] = list(range(len(self.variants))) # 过滤/排序后的下标映射 - # 默认按位置排序(数字感知),即使文件本身未按坐标排序 - self.view.sort(key=lambda i: ( - _chrom_sort_key(self.variants[i].chrom), self.variants[i].pos)) + self._chrom_keys: dict[str, tuple] | None = None # 数字感知染色体键缓存 + self._qual_order: list[int] | None = None # 全局 QUAL 降序(首次 s 键懒计算) + self._types: list[VariantType] | None = None # 变异类型缓存(SNP/InDel 过滤时懒计算) + self._stats_token = 0 # 异步统计的版本号(防竞态) + # 默认按位置排序:VCF 通常已按坐标排序(O(n) 校验快路径),否则全量排序一次并缓存。 + # 后续过滤在有序列表上做保序筛选,不再重复排序(5M 级免冻结的关键)。 + if self._check_scan_sorted(): + self._pos_order = list(self.view) + else: + self.view.sort(key=self._pos_key) + self._pos_order = list(self.view) self._win_start = 0 # 当前窗口在 view 中的起始下标 self.filter_mode = "全部" self.sort_mode = "位置" @@ -151,6 +160,60 @@ def on_mount(self): self._refresh_list() self.query_one("#variant-list", OptionList).focus() + # ── 排序/过滤基础设施 ── + def _check_scan_sorted(self) -> bool: + """O(n) 校验扫描顺序是否已按(数字感知染色体, 坐标)有序(VCF 常态)。""" + key_cache: dict[str, tuple] = {} + prev_key = None + prev_pos = -1 + for v in self.variants: + ck = key_cache.get(v.chrom) + if ck is None: + ck = key_cache[v.chrom] = _chrom_sort_key(v.chrom) + if prev_key is not None and (ck < prev_key or (ck == prev_key and v.pos < prev_pos)): + return False + prev_key, prev_pos = ck, v.pos + self._chrom_keys = key_cache + return True + + def _pos_key(self, i: int): + """位置排序键(染色体键缓存,避免重复正则)。""" + if self._chrom_keys is None: + self._chrom_keys = {c: _chrom_sort_key(c) for c in {v.chrom for v in self.variants}} + v = self.variants[i] + return (self._chrom_keys[v.chrom], v.pos) + + def _match_filter(self, i: int) -> bool: + """当前过滤模式是否命中第 i 条变异(全部模式不调用)。""" + v = self.variants[i] + if self.filter_mode == "PASS": + return v.filter == "PASS" + if self._types is None: + self._types = [classify_variant(x.ref, x.alt) for x in self.variants] + t = self._types[i] + if self.filter_mode == "SNP": + return t in (VariantType.TRANSITION, VariantType.TRANSVERSION) + return t in (VariantType.INSERTION, VariantType.DELETION) + + def _apply_filter_sort(self): + """过滤/排序:在缓存的全局有序列表上做保序筛选,不重复全量排序。""" + if self.sort_mode == "QUAL": + if self._qual_order is None: + # 首次全局 QUAL 降序(缺失排最后),后续过滤直接复用 + order = list(range(len(self.variants))) + order.sort(key=lambda i: ( + self.variants[i].qual is None, + -(self.variants[i].qual or 0.0))) + self._qual_order = order + base = self._qual_order + else: + base = self._pos_order + if self.filter_mode == "全部": + self.view = list(base) + else: + self.view = [i for i in base if self._match_filter(i)] + self._refresh_list() + # ── 列表渲染 ── def _make_option(self, v: Variant) -> Option: t = classify_variant(v.ref, v.alt) @@ -320,43 +383,39 @@ def _matrix_text(self) -> str: def _render_matrix(self, list_index: int): self.query_one("#detail", Static).update(self._build_matrix(list_index)) - # ── 状态栏 ── + # ── 状态栏(异步统计,避免大文件过滤切换时冻结 UI) ── def _update_status_bar(self): - s = compute_stats([self.variants[i] for i in self.view]) + """立即刷新计数/过滤/排序;重型统计后台计算后回填。""" + parts = ( + f" {len(self.view):,} 变异 │ [过滤:{self.filter_mode}] [排序:{self.sort_mode}] │ 统计中…" + ) + if self.skipped: + parts += f" │ 跳过 {self.skipped} 行畸形数据" + self.query_one("#status-bar", Static).update(Text(parts)) + self._stats_token += 1 + token = self._stats_token + snapshot = list(self.view) + self.run_worker(partial(self._stats_worker, token, snapshot), thread=True, exclusive=False) + + def _stats_worker(self, token: int, snapshot: list[int]): + """后台线程:计算统计后回 UI 线程回填(版本号防竞态)。""" + s = compute_stats([self.variants[i] for i in snapshot]) + if token == self._stats_token: + try: + self.call_from_thread(self._apply_stats_bar, s) + except RuntimeError: + pass # 应用已退出 + + def _apply_stats_bar(self, s: dict): parts = ( - f" {s['total']} 变异 │ SNP {s['snp']} · InDel {s['indel']} │ " - f"Ts/Tv {s['ts_tv']:.2f} │ PASS {s['pass_count']} │ " + f" {s['total']:,} 变异 │ SNP {s['snp']:,} · InDel {s['indel']:,} │ " + f"Ts/Tv {s['ts_tv']:.2f} │ PASS {s['pass_count']:,} │ " f"AF均值 {s['mean_af']:.2f} │ [过滤:{self.filter_mode}] [排序:{self.sort_mode}]" ) if self.skipped: parts += f" │ 跳过 {self.skipped} 行畸形数据" self.query_one("#status-bar", Static).update(Text(parts)) - # ── 过滤 / 排序 ── - def _apply_filter_sort(self): - idxs = list(range(len(self.variants))) - if self.filter_mode == "PASS": - idxs = [i for i in idxs if self.variants[i].filter == "PASS"] - elif self.filter_mode == "SNP": - idxs = [i for i in idxs if classify_variant( - self.variants[i].ref, self.variants[i].alt) - in (VariantType.TRANSITION, VariantType.TRANSVERSION)] - elif self.filter_mode == "InDel": - idxs = [i for i in idxs if classify_variant( - self.variants[i].ref, self.variants[i].alt) - in (VariantType.INSERTION, VariantType.DELETION)] - if self.sort_mode == "QUAL": - # QUAL 缺失排最后,其余降序 - idxs.sort(key=lambda i: ( - self.variants[i].qual is None, - -(self.variants[i].qual or 0.0))) - else: - # 数字感知染色体排序(chr2 < chr10) - idxs.sort(key=lambda i: ( - _chrom_sort_key(self.variants[i].chrom), self.variants[i].pos)) - self.view = idxs - self._refresh_list() - # ── 剪贴板(与 browser.py 相同的分层回退策略) ── def _copy_to_clipboard(self, text: str) -> bool: """系统工具优先,失败后回退 OSC 52。""" From d3922deb9aec226da8f0dfe56e0c695dd0c7e7a8 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 4 Aug 2026 21:30:33 +0800 Subject: [PATCH 09/20] perf(vcf): instant-open + smooth scroll on 500MB/5.7M-variant files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Binary quick-scan (first 5000) then background resume: open latency 46s→11ms - GIL-yield tuning (25ms/1000 rows): scroll during scan 2000ms→~200-400ms/press - Hide scrollbar while scanning + lazy restore (5.7M virtual-height recompute stalled UI 3s) - Reuse one binary file handle for detail seek-back reads - Time-throttled batch callbacks + end-of-scan reconciliation (no lost variants) - Async status-bar stats with token guard against exit race Regenerated test/big_chr.vcf: 508MB, 5.7M variants, chr1 coords up to 248,956,641 (>100M). 272 tests pass. --- src/seqviz/cli.py | 11 +-- src/seqviz/vcf.py | 157 ++++++++++++++++++++++++++++--------- src/seqviz/vcf_browser.py | 160 +++++++++++++++++++++++++++++++++++--- test/test_vcf.py | 61 +++++++++++++++ test/test_vcf_browser.py | 43 ++++++++++ 5 files changed, 380 insertions(+), 52 deletions(-) diff --git a/src/seqviz/cli.py b/src/seqviz/cli.py index 26443b9..153e54b 100644 --- a/src/seqviz/cli.py +++ b/src/seqviz/cli.py @@ -38,20 +38,21 @@ def _is_vcf(path: Path) -> bool: def _run_vcf_browser(path: Path): - """扫描并启动 VCF 浏览器;空文件/无 #CHROM 表头时友好报错非零退出。 + """快扫前 5000 条做校验后立即启动 TUI,剩余索引由浏览器后台续扫(启动即显)。 - 扫描结果直接传给 VcfBrowser,避免重复扫描大文件。 + 空文件/无 #CHROM 表头时友好报错非零退出。 """ - from seqviz.vcf import scan_vcf + from seqviz.vcf import scan_vcf_quick from seqviz.vcf_browser import VcfBrowser - meta, variants, skipped = scan_vcf(path) + QUICK_LIMIT = 5000 + meta, variants, skipped, cont = scan_vcf_quick(path, limit=QUICK_LIMIT) if not meta.has_header: console.print(f"[red]错误: 不是有效的 VCF 文件(缺少 #CHROM 表头): {path}[/red]") raise typer.Exit(code=1) if not variants: console.print(f"[red]错误: VCF 文件中没有变异记录: {path}[/red]") raise typer.Exit(code=1) - VcfBrowser(path, scanned=(meta, variants, skipped)).run() + VcfBrowser(path, initial=(meta, variants, skipped, cont)).run() def _launch_browser(paths: list[Path]): diff --git a/src/seqviz/vcf.py b/src/seqviz/vcf.py index d44a28b..a0fb2eb 100644 --- a/src/seqviz/vcf.py +++ b/src/seqviz/vcf.py @@ -1,7 +1,8 @@ """VCF (Variant Call Format) 解析层 — 纯解析,无 UI 依赖。 支持未压缩纯文本 VCF v4.x。采用懒扫描策略: -scan_vcf 只解析前 8 列(含 INFO),样本基因型按需 seek 回读。 +扫描只解析前 8 列(含 INFO),样本基因型按需 seek 回读。 +全链路二进制模式:扫描避免解码样本列(提速 ~3x),offset 为字节偏移。 """ import re @@ -131,6 +132,35 @@ def _parse_info(info_str: str) -> dict: return info +def _index_line(line: bytes, offset: int) -> Variant | None: + """从二进制行构建索引记录(只前 8 列,只解码索引所需部分,快路径)。""" + if len(line) < 2 or line[:1] == b"#": + return None + parts = line.rstrip(b"\r\n").split(b"\t", 8) # 只切前 8 列,避免样本列开销 + if len(parts) < 8: + return None + try: + pos = int(parts[1]) + except ValueError: + return None + qual = None + if parts[5] != b".": + try: + qual = float(parts[5]) + except ValueError: + pass + vid = parts[2].decode("utf-8", "replace") + return Variant( + chrom=parts[0].decode("utf-8", "replace"), pos=pos, + id="" if vid == "." else vid, + ref=parts[3].decode("utf-8", "replace"), + alt=parts[4].decode("utf-8", "replace"), + qual=qual, filter=parts[6].decode("utf-8", "replace"), + info=_parse_info(parts[7].decode("utf-8", "replace")), + offset=offset, + ) + + def parse_variant_line(line: str, offset: int = 0, sample_names: list[str] | None = None) -> Variant | None: """完整解析单条数据行;畸形行(<8 列或 POS 非整数)返回 None。""" @@ -166,66 +196,121 @@ def parse_variant_line(line: str, offset: int = 0, def scan_vcf(path) -> tuple[VcfMeta, list[Variant], int]: - """懒扫描:解析 meta + 每行前 8 列 + 字节 offset。 + """全量懒扫描(二进制快路径)。返回 (meta, variants, skipped)。""" + meta, variants, skipped, _ = scan_vcf_quick(path, limit=0) + return meta, variants, skipped + - 样本列不解析(按需 load_variant_detail)。返回 (meta, variants, skipped)。 +def scan_vcf_quick(path, limit: int = 5000) -> tuple[VcfMeta, list[Variant], int, int]: + """快速扫描:读取 meta + 前 limit 条索引,返回 (meta, variants, skipped, cont_offset)。 + + limit<=0 表示扫完全文(cont_offset=-1)。cont_offset>=0 为续扫起点字节偏移, + 供 scan_vcf_resume 后台继续。用于“启动即显 + 后台续扫”。 """ header_lines: list[str] = [] variants: list[Variant] = [] sample_names: list[str] = [] has_header = False skipped = 0 - with open(path, encoding="utf-8", errors="replace") as f: + cont_offset = -1 + with open(path, "rb", buffering=8 * 1024 * 1024) as f: while True: offset = f.tell() line = f.readline() if not line: break - if line.startswith("##"): - header_lines.append(line) + if line[:2] == b"##": + header_lines.append(line.decode("utf-8", "replace")) continue - if line.startswith("#CHROM"): + if line[:6] == b"#CHROM": has_header = True - cols = line.rstrip("\n").split("\t") + cols = line.decode("utf-8", "replace").rstrip("\r\n").split("\t") sample_names = cols[9:] if len(cols) > 9 else [] continue - parts = line.rstrip("\n").split("\t", 8) # 只切前 8 列,避免样本列开销 - if len(parts) < 8 or parts[0].startswith("#"): - skipped += 1 - continue - try: - pos = int(parts[1]) - except ValueError: + v = _index_line(line, offset) + if v is None: skipped += 1 continue - qual = None - if parts[5] != ".": - try: - qual = float(parts[5]) - except ValueError: - pass - variants.append(Variant( - chrom=parts[0], pos=pos, - id="" if parts[2] == "." else parts[2], - ref=parts[3], alt=parts[4], - qual=qual, filter=parts[6], - info=_parse_info(parts[7]), - offset=offset, - )) + variants.append(v) + if limit and len(variants) >= limit: + cont_offset = f.tell() + break meta = parse_meta(header_lines) meta.samples = sample_names meta.has_header = has_header - return meta, variants, skipped + return meta, variants, skipped, cont_offset + + +def scan_vcf_resume(path, cont_offset: int, on_batch=None, + callback_interval: float = 0.5) -> tuple[list[Variant], int]: + """从 cont_offset 续扫到文件末尾(供后台线程调用)。 + + on_batch(new_variants) 按时间节流回调(距上次 >= callback_interval 秒, + 末尾必回调一次),避免频繁回调压垮 UI 线程。返回 (全部新变异, skipped)。 + 中断:on_batch 抛 StopIteration 时提前结束。 + """ + import time as _time + variants: list[Variant] = [] + skipped = 0 + last_cb_len = 0 + last_cb_time = 0.0 + since_yield = 0 + with open(path, "rb", buffering=8 * 1024 * 1024) as f: + f.seek(cont_offset) + while True: + offset = f.tell() + line = f.readline() + if not line: + break + if line[:1] == b"#": + continue + v = _index_line(line, offset) + if v is None: + skipped += 1 + continue + variants.append(v) + # 固定低占空比让出 GIL:实测扫描循环的 C 函数段长时间独占 GIL, + # 短 sleep(≤20ms)无法为 UI 线程赢得时间片(按键延迟 2s); + # 25ms/1000 行实测 UI 恢复 106-256ms/键,扫描总耗时后台可接受 + since_yield += 1 + if since_yield >= 1000: + since_yield = 0 + _time.sleep(0.025) + if on_batch is not None and len(variants) - last_cb_len >= 50_000: + now = _time.monotonic() + if now - last_cb_time >= callback_interval: + try: + on_batch(variants[last_cb_len:]) + except StopIteration: + break + last_cb_len = len(variants) + last_cb_time = now + if on_batch is not None and len(variants) > last_cb_len: + try: + on_batch(variants[last_cb_len:]) + except StopIteration: + pass + return variants, skipped + +def load_variant_detail(path, variant: Variant, sample_names: list[str], fh=None) -> Variant: + """seek(offset) 回读完整行,填充 format_fields/samples/raw(原地修改并返回)。 -def load_variant_detail(path, variant: Variant, sample_names: list[str]) -> Variant: - """seek(offset) 回读完整行,填充 format_fields/samples/raw(原地修改并返回)。""" + 二进制读取 + 单行解码;传入 fh(已打开二进制句柄)可复用避免重复 open。 + """ if variant.raw: return variant # 已加载 - with open(path, encoding="utf-8", errors="replace") as f: - f.seek(variant.offset) - line = f.readline() - full = parse_variant_line(line, variant.offset, sample_names) + + def _read(handle): + handle.seek(variant.offset) + return handle.readline() + + if fh is not None: + line = _read(fh) + else: + with open(path, "rb") as f: + line = _read(f) + full = parse_variant_line(line.decode("utf-8", "replace"), variant.offset, sample_names) if full is not None: variant.format_fields = full.format_fields variant.samples = full.samples diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index 9c993ee..f91e3a2 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -25,6 +25,7 @@ load_variant_detail, parse_genotype, scan_vcf, + scan_vcf_resume, ) _TYPE_SYMBOL = { @@ -120,16 +121,27 @@ class VcfBrowser(App): ] # 列表窗口化虚拟化:OptionList 只物化当前窗口,避免大文件全量重建冻结 UI - WINDOW = 2000 + WINDOW = 400 PAGE = 20 - def __init__(self, filepath: Path, scanned=None): + def __init__(self, filepath: Path, scanned=None, initial=None): + """scanned=(meta, variants, skipped) 全量结果; + initial=(meta, variants, skipped, cont_offset) 快扫部分结果,cont_offset>=0 时后台续扫。""" super().__init__() self.filepath = filepath - if scanned is not None: + self._cont_offset = -1 + self.scanning = False + if initial is not None: + self.meta, self.variants, self.skipped, self._cont_offset = initial + self.scanning = self._cont_offset >= 0 + elif scanned is not None: self.meta, self.variants, self.skipped = scanned else: self.meta, self.variants, self.skipped = scan_vcf(filepath) + self._fh = None # 详情回读复用句柄(二进制) + self._order_dirty = False # 后台扫描破坏坐标序时置位,下次过滤/排序前重建 + self._view_dirty = False # 扫描中用户改过过滤/排序,完成后重建 view + self._initial_count = 0 # 快扫初始条数(续扫对账基准) self.view: list[int] = list(range(len(self.variants))) # 过滤/排序后的下标映射 self._chrom_keys: dict[str, tuple] | None = None # 数字感知染色体键缓存 self._qual_order: list[int] | None = None # 全局 QUAL 降序(首次 s 键懒计算) @@ -137,11 +149,13 @@ def __init__(self, filepath: Path, scanned=None): self._stats_token = 0 # 异步统计的版本号(防竞态) # 默认按位置排序:VCF 通常已按坐标排序(O(n) 校验快路径),否则全量排序一次并缓存。 # 后续过滤在有序列表上做保序筛选,不再重复排序(5M 级免冻结的关键)。 - if self._check_scan_sorted(): + self._scan_sorted = self._check_scan_sorted() + if self._scan_sorted: self._pos_order = list(self.view) else: self.view.sort(key=self._pos_key) self._pos_order = list(self.view) + self._initial_count = len(self.variants) self._win_start = 0 # 当前窗口在 view 中的起始下标 self.filter_mode = "全部" self.sort_mode = "位置" @@ -157,8 +171,114 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self): + if self.scanning: + # 扫描中总量未定,滚动条无意义且每次追加触发全量重算(卡顿主因) + self.query_one("#variant-list", OptionList).show_scrollbar = False self._refresh_list() self.query_one("#variant-list", OptionList).focus() + if self.scanning: + self.run_worker(self._background_scan_worker, thread=True, exclusive=False) + + def on_unmount(self): + if self._fh is not None and not self._fh.closed: + self._fh.close() + self.scanning = False # 通知后台扫描线程尽早退出 + + # ── 后台续扫(启动即显) ── + def _background_scan_worker(self): + """后台线程:从快扫断点继续索引,按时间节流回 UI 线程增量追加。""" + import time as _time + + def _dispatch(batch): + if not self.scanning: + raise StopIteration # 应用已关闭,中断扫描 + try: + self.call_from_thread(self._append_batch, batch) + except RuntimeError: + raise StopIteration + _time.sleep(0.02) # 让出 GIL,保证 UI 线程滚动流畅 + try: + new_all, skipped_add = scan_vcf_resume( + self.filepath, self._cont_offset, on_batch=_dispatch) + except Exception: # noqa: BLE001 + return + try: + self.call_from_thread(self._finish_scan, new_all, skipped_add) + except RuntimeError: + pass # 应用已退出 + + def _pos_ge(self, a: Variant, b: Variant) -> bool: + """a 是否位于 b 同位或之后(坐标序判定)。""" + ka, kb = self._chrom_key_of(a.chrom), self._chrom_key_of(b.chrom) + return ka > kb or (ka == kb and a.pos >= b.pos) + + def _chrom_key_of(self, chrom: str): + if self._chrom_keys is None: + self._chrom_keys = {} + k = self._chrom_keys.get(chrom) + if k is None: + k = self._chrom_keys[chrom] = _chrom_sort_key(chrom) + return k + + def _extend_index(self, batch: list[Variant]): + """纯数据层:追加索引并维护位置序/pos_order/view(不碰 UI 控件)。""" + ok = self._scan_sorted and not self._order_dirty + if ok and self.variants and not self._pos_ge(batch[0], self.variants[-1]): + ok = False + if ok: + for x, y in zip(batch, batch[1:]): + if not self._pos_ge(y, x): + ok = False + break + start = len(self.variants) + self.variants.extend(batch) + new_idxs = list(range(start, len(self.variants))) + if ok: + self._pos_order.extend(new_idxs) + else: + self._order_dirty = True + default_view = (self.filter_mode == "全部" and self.sort_mode == "位置" and not self._order_dirty) + if default_view: + self.view.extend(new_idxs) + else: + self._view_dirty = True + return default_view + + def _append_batch(self, batch: list[Variant]): + """UI 线程:追加一批索引,默认视图下同步延展列表窗口。""" + if not self.scanning: + return + default_view = self._extend_index(batch) + if default_view: + # 仅重建当前窗口(WINDOW 条,毫秒级),不打断浏览 + self._rebuild_window(self._win_start, self._abs_index()) + self._update_status_bar() + + def _finish_scan(self, new_all: list[Variant], skipped_add: int): + """扫描完成:用全量结果对账补齐(节流回调可能漏尾),刷新视图。""" + self.scanning = False + self.skipped += skipped_add + appended = len(self.variants) - self._initial_count + if appended < len(new_all): + default_view = self._extend_index(new_all[appended:]) + if default_view: + self._rebuild_window(self._win_start, self._abs_index()) + if self._view_dirty or self._order_dirty: + self._apply_filter_sort() + else: + self._update_status_bar() + # 滚动条恢复延迟到空闲时(5.7M 项虚拟高度一次性重算会冻 UI 3s) + self.set_timer(1.5, self._restore_scrollbar) + self.notify(f"扫描完成,共 {len(self.variants):,} 条变异", title="VCF") + + def _restore_scrollbar(self): + self.query_one("#variant-list", OptionList).show_scrollbar = True + + def _detail_fh(self): + """详情回读复用句柄(避免每次导航 open/close)。""" + if self._fh is None or self._fh.closed: + self._fh = open(self.filepath, "rb") + return self._fh # ── 排序/过滤基础设施 ── def _check_scan_sorted(self) -> bool: @@ -197,6 +317,13 @@ def _match_filter(self, i: int) -> bool: def _apply_filter_sort(self): """过滤/排序:在缓存的全局有序列表上做保序筛选,不重复全量排序。""" + if self._order_dirty: + # 后台扫描破坏了坐标序:一次性重建全局位置序(显式操作时才付出该成本) + order = list(range(len(self.variants))) + order.sort(key=self._pos_key) + self._pos_order = order + self._qual_order = None + self._order_dirty = False if self.sort_mode == "QUAL": if self._qual_order is None: # 首次全局 QUAL 降序(缺失排最后),后续过滤直接复用 @@ -341,7 +468,7 @@ def _show_detail(self, list_index: int): return idx = self.view[list_index] v = self.variants[idx] - load_variant_detail(self.filepath, v, self.meta.samples) + load_variant_detail(self.filepath, v, self.meta.samples, fh=self._detail_fh()) self.query_one("#detail", Static).update(self._build_detail(v)) def _detail_text(self) -> str: @@ -349,7 +476,7 @@ def _detail_text(self) -> str: if not self.view: return "" v = self.variants[self.view[self._abs_index()]] - load_variant_detail(self.filepath, v, self.meta.samples) + load_variant_detail(self.filepath, v, self.meta.samples, fh=self._detail_fh()) return str(self._build_detail(v)) # ── 基因型矩阵 ── @@ -365,7 +492,7 @@ def _build_matrix(self, current_list_index: int = 0) -> Text: end = min(start + 21, len(self.view)) for li in range(start, end): v = self.variants[self.view[li]] - load_variant_detail(self.filepath, v, self.meta.samples) + load_variant_detail(self.filepath, v, self.meta.samples, fh=self._detail_fh()) is_cur = li == current_list_index txt.append("▶ " if is_cur else " ", style="bold green" if is_cur else "") txt.append(f"{v.chrom}:{v.pos:<12}", style="bold" if is_cur else "") @@ -385,7 +512,16 @@ def _render_matrix(self, list_index: int): # ── 状态栏(异步统计,避免大文件过滤切换时冻结 UI) ── def _update_status_bar(self): - """立即刷新计数/过滤/排序;重型统计后台计算后回填。""" + """立即刷新计数/过滤/排序;重型统计后台计算后回填。扫描中只显示进度。""" + if self.scanning: + parts = ( + f" {len(self.view):,} 变异(扫描中… 已索引 {len(self.variants):,}) │ " + f"[过滤:{self.filter_mode}] [排序:{self.sort_mode}]" + ) + if self.skipped: + parts += f" │ 跳过 {self.skipped} 行畸形数据" + self.query_one("#status-bar", Static).update(Text(parts)) + return parts = ( f" {len(self.view):,} 变异 │ [过滤:{self.filter_mode}] [排序:{self.sort_mode}] │ 统计中…" ) @@ -402,11 +538,13 @@ def _stats_worker(self, token: int, snapshot: list[int]): s = compute_stats([self.variants[i] for i in snapshot]) if token == self._stats_token: try: - self.call_from_thread(self._apply_stats_bar, s) + self.call_from_thread(self._apply_stats_bar, s, token) except RuntimeError: pass # 应用已退出 - def _apply_stats_bar(self, s: dict): + def _apply_stats_bar(self, s: dict, token: int): + if not self.is_running or token != self._stats_token: + return # 应用退出中或已有更新的统计请求,丢弃过时结果 parts = ( f" {s['total']:,} 变异 │ SNP {s['snp']:,} · InDel {s['indel']:,} │ " f"Ts/Tv {s['ts_tv']:.2f} │ PASS {s['pass_count']:,} │ " @@ -575,7 +713,7 @@ def action_copy_line(self): self.notify("没有可复制的变异", severity="warning") return v = self.variants[self.view[self._abs_index()]] - load_variant_detail(self.filepath, v, self.meta.samples) + load_variant_detail(self.filepath, v, self.meta.samples, fh=self._detail_fh()) ok = self._copy_to_clipboard(v.raw) self._last_copied = v.raw if ok: diff --git a/test/test_vcf.py b/test/test_vcf.py index 20e61c6..8e8b987 100644 --- a/test/test_vcf.py +++ b/test/test_vcf.py @@ -13,6 +13,8 @@ parse_meta, parse_variant_line, scan_vcf, + scan_vcf_quick, + scan_vcf_resume, ) SAMPLE_VCF = Path(__file__).parent / "sample.vcf" @@ -159,6 +161,55 @@ def test_malformed_lines_skipped_and_counted(self, tmp_path): assert len(variants) == 2 and skipped == 1 +class TestQuickScanAndResume: + def test_quick_scan_limit_and_continuation(self): + """快扫 + 续扫拼接结果 == 全量扫描(断点不丢不重)。""" + _, full, _ = scan_vcf(SAMPLE_VCF) + meta, head, skipped1, cont = scan_vcf_quick(SAMPLE_VCF, limit=5) + assert len(head) == 5 + assert cont > 0 + assert meta.samples == ["sample1", "sample2", "sample3"] + tail, skipped2 = scan_vcf_resume(SAMPLE_VCF, cont) + merged = head + tail + assert len(merged) == len(full) + assert [(v.chrom, v.pos, v.ref, v.alt) for v in merged] == \ + [(v.chrom, v.pos, v.ref, v.alt) for v in full] + assert skipped1 + skipped2 == 0 + + def test_quick_scan_small_file_no_continuation(self): + """文件小于 limit:一次扫完,cont_offset=-1。""" + meta, variants, skipped, cont = scan_vcf_quick(SAMPLE_VCF, limit=5000) + assert len(variants) == 18 + assert cont == -1 + + def test_quick_scan_full_equivalent(self): + """limit<=0 等同全量扫描。""" + meta, variants, skipped, cont = scan_vcf_quick(SAMPLE_VCF, limit=0) + assert len(variants) == 18 and cont == -1 + + def test_resume_batch_callback(self): + """on_batch 回调末尾必触发,累计覆盖全部数据。""" + _, full, _ = scan_vcf(SAMPLE_VCF) + _, _, _, cont = scan_vcf_quick(SAMPLE_VCF, limit=3) + batches = [] + tail, _ = scan_vcf_resume(SAMPLE_VCF, cont, on_batch=batches.append, + callback_interval=0.0) + assert sum(len(b) for b in batches) == len(tail) == len(full) - 3 + + def test_resume_stop_iteration_aborts(self, tmp_path): + """on_batch 抛 StopIteration 提前中断扫描。""" + f = tmp_path / "many.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 10001)] + f.write_text("\n".join(lines) + "\n") + + def _stop(batch): + raise StopIteration + _, _, _, cont = scan_vcf_quick(f, limit=10) + tail, _ = scan_vcf_resume(f, cont, on_batch=_stop) + assert len(tail) < 10000 # 提前中断 + + class TestLoadVariantDetail: def test_roundtrip(self): _, variants, _ = scan_vcf(SAMPLE_VCF) @@ -169,6 +220,16 @@ def test_roundtrip(self): assert first.format_fields == ["GT", "DP", "GQ", "AD"] assert first.raw.startswith("chr1\t10234") + def test_roundtrip_with_reused_handle(self): + """句柄复用路径(fh 参数)结果与默认路径一致。""" + _, variants, _ = scan_vcf(SAMPLE_VCF) + samples = ["sample1", "sample2", "sample3"] + with open(SAMPLE_VCF, "rb") as fh: + for v in variants[:3]: + load_variant_detail(SAMPLE_VCF, v, samples, fh=fh) + assert variants[0].samples["sample1"] == "0/1:15:99:10,5" + assert variants[2].raw.startswith("chr1\t15892") + def test_idempotent(self): _, variants, _ = scan_vcf(SAMPLE_VCF) first = variants[0] diff --git a/test/test_vcf_browser.py b/test/test_vcf_browser.py index 0a695e3..e0e7321 100644 --- a/test/test_vcf_browser.py +++ b/test/test_vcf_browser.py @@ -4,6 +4,7 @@ from textual.widgets import OptionList +from seqviz.vcf import scan_vcf_quick from seqviz.vcf_browser import VcfBrowser TEST_DIR = Path(__file__).parent @@ -237,6 +238,48 @@ async def _t(): run(_t()) +# ────────────────────────────────────────────── +# 后台续扫(启动即显) +# ────────────────────────────────────────────── +class TestBackgroundScan: + def test_immediate_display_then_background_completion(self): + """快扫 2 条立即启动,后台续扫完成后总量补齐、无丢失。""" + meta, head, skipped, cont = scan_vcf_quick(SAMPLE_VCF, limit=2) + assert cont >= 0 + async def _t(): + app = VcfBrowser(SAMPLE_VCF, initial=(meta, head, skipped, cont)) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + assert len(app.variants) >= 2 # 首屏至少有快扫的 2 条 + # 等待后台扫描完成(对账补齐;小文件可能瞬间完成) + for _ in range(100): + if not app.scanning: + break + await pilot.pause() + assert app.scanning is False + assert len(app.variants) == 18 # sample.vcf 全量,不丢不重 + assert len(app.view) == 18 + ol = app.query_one("#variant-list", OptionList) + assert ol.option_count == 18 + run(_t()) + + def test_scan_aborts_on_exit(self, tmp_path): + """退出应用后扫描线程停止(不挂起)。""" + # 生成 2 万条使续扫需要一定时间 + f = tmp_path / "m.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 20_001)] + f.write_text("\n".join(lines) + "\n") + meta, head, skipped, cont = scan_vcf_quick(f, limit=10) + async def _t(): + app = VcfBrowser(f, initial=(meta, head, skipped, cont)) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + # 退出后 scanning 标志应被置 False(on_unmount) + assert app.scanning is False + run(_t()) + + # ────────────────────────────────────────────── # 大文件窗口化虚拟化(回归:全量重建 OptionList 冻结 UI) # ────────────────────────────────────────────── From 1c58ccc5ac4014c4fb9a3f441b565348ba9f4299 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Wed, 5 Aug 2026 04:09:50 +0800 Subject: [PATCH 10/20] fix(vcf): list jumping to top / fake scrollbar bottom / lost scroll position on large files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes: - Every 0.5s batch callback rebuilt the window via clear_options(), resetting OptionList scroll state → periodic jump-to-top while browsing - Scrollbar was re-enabled after scan over a 400-option windowed buffer, falsely implying 'drag to bottom = end of data' (bottom showed chr1:17,065, the ~400th variant, not the 5.7M-th at 248,956,641) Fixes: - Batch appends never clear/rebuild: incremental add_options only when the window sits at the data tail and is unfilled; otherwise data-only extend - Scrollbar only shown when the list is fully materialized (<= WINDOW items); large lists rely on position indicator (sub_title 'X / N') + G/g jumps - Mouse wheel on large lists moves the browsing position (window shifts) instead of scrolling inside the 400-option buffer Verified on test/big_chr.vcf (508MB, 5.7M variants): - 8s mid-scan with multiple batches: 0 position jumps - G reaches true last variant chr1:248,956,641, indicator 5,700,000 / 5,700,000 - filter/sort/navigation stable after scan completion 280 tests pass (8 new regressions). --- src/seqviz/vcf_browser.py | 82 ++++++++++++++---- test/test_vcf_browser.py | 175 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 16 deletions(-) diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index f91e3a2..0b0464c 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -52,6 +52,29 @@ def _chrom_sort_key(chrom: str): return tuple(int(p) if p.isdigit() else p for p in re.split(r"(\d+)", chrom)) +class VariantList(OptionList): + """大列表窗口化下的滚轮语义修正。 + + 默认滚轮只在物化的 WINDOW 条缓冲内滚动视口(不移动选中项), + 与“选中项 = 浏览位置”的模型矛盾;改为滚轮直接移动位置,窗口随之平移。 + 小列表(全量物化,有滚动条)保持原生滚动行为。 + """ + + def scroll_down(self, animate: bool = True) -> None: + app = self.app + if isinstance(app, VcfBrowser) and len(app.view) > app.WINDOW: + app._goto_abs(app._abs_index() + 3) + else: + super().scroll_down(animate) + + def scroll_up(self, animate: bool = True) -> None: + app = self.app + if isinstance(app, VcfBrowser) and len(app.view) > app.WINDOW: + app._goto_abs(app._abs_index() - 3) + else: + super().scroll_up(animate) + + class HelpScreen(ModalScreen): """帮助面板:显示所有快捷键。""" @@ -165,15 +188,12 @@ def __init__(self, filepath: Path, scanned=None, initial=None): def compose(self) -> ComposeResult: yield Header() with Horizontal(): - yield OptionList(id="variant-list") + yield VariantList(id="variant-list") yield Static("", id="detail") yield Static("", id="status-bar") yield Footer() def on_mount(self): - if self.scanning: - # 扫描中总量未定,滚动条无意义且每次追加触发全量重算(卡顿主因) - self.query_one("#variant-list", OptionList).show_scrollbar = False self._refresh_list() self.query_one("#variant-list", OptionList).focus() if self.scanning: @@ -244,15 +264,31 @@ def _extend_index(self, batch: list[Variant]): self._view_dirty = True return default_view + def _extend_and_sync(self, batch: list[Variant]) -> bool: + """追加索引数据,按需增量延展列表窗口。 + + 永不 clear 重建:clear_options 会重置滚动状态,导致用户视角周期性跳顶; + 仅当窗口贴着数据尾部且未填满时才 add_options 增量补充。 + """ + ol = self.query_one("#variant-list", OptionList) + window_at_tail = self._win_start + ol.option_count >= len(self.view) + default_view = self._extend_index(batch) + if default_view and window_at_tail and ol.option_count < self.WINDOW: + start_new = len(self.view) - len(batch) + room = self.WINDOW - ol.option_count + ol.add_options([ + self._make_option(self.variants[i]) + for i in range(start_new, min(start_new + room, len(self.view))) + ]) + return default_view + def _append_batch(self, batch: list[Variant]): - """UI 线程:追加一批索引,默认视图下同步延展列表窗口。""" + """UI 线程:追加一批索引(增量,不打断浏览)。""" if not self.scanning: return - default_view = self._extend_index(batch) - if default_view: - # 仅重建当前窗口(WINDOW 条,毫秒级),不打断浏览 - self._rebuild_window(self._win_start, self._abs_index()) + self._extend_and_sync(batch) self._update_status_bar() + self._sync_position_indicator() def _finish_scan(self, new_all: list[Variant], skipped_add: int): """扫描完成:用全量结果对账补齐(节流回调可能漏尾),刷新视图。""" @@ -260,19 +296,30 @@ def _finish_scan(self, new_all: list[Variant], skipped_add: int): self.skipped += skipped_add appended = len(self.variants) - self._initial_count if appended < len(new_all): - default_view = self._extend_index(new_all[appended:]) - if default_view: - self._rebuild_window(self._win_start, self._abs_index()) + self._extend_and_sync(new_all[appended:]) if self._view_dirty or self._order_dirty: self._apply_filter_sort() else: self._update_status_bar() - # 滚动条恢复延迟到空闲时(5.7M 项虚拟高度一次性重算会冻 UI 3s) - self.set_timer(1.5, self._restore_scrollbar) + self._sync_scrollbar() + self._sync_position_indicator() self.notify(f"扫描完成,共 {len(self.variants):,} 条变异", title="VCF") - def _restore_scrollbar(self): - self.query_one("#variant-list", OptionList).show_scrollbar = True + def _sync_scrollbar(self): + """滚动条仅在列表全量物化(≤WINDOW 条)时显示。 + + 大列表只物化窗口,滚动条会虚假地暗示“拖到底 = 全部数据末尾” + (实际只能到 400 条缓冲底部),因此隐藏;改用位置指示器 + G/g 跳转。 + """ + ol = self.query_one("#variant-list", OptionList) + ol.show_scrollbar = len(self.view) <= self.WINDOW + + def _sync_position_indicator(self): + """副标题位置指示器:当前行 / 总行数(含扫描中增长的总量)。""" + if self.view: + self.sub_title = f"{self._abs_index() + 1:,} / {len(self.view):,}" + else: + self.sub_title = "0 / 0" def _detail_fh(self): """详情回读复用句柄(避免每次导航 open/close)。""" @@ -373,6 +420,8 @@ def _refresh_list(self): self._show_detail(0) else: self.query_one("#detail", Static).update(Text("(无匹配变异)", style="dim")) + self._sync_scrollbar() + self._sync_position_indicator() self._update_status_bar() def _rebuild_window(self, win_start: int, highlight_abs: int): @@ -639,6 +688,7 @@ def on_option_list_option_highlighted(self, event) -> None: if event.option is not None: # 窗口内下标 → view 绝对下标 self._show_detail(self._win_start + event.option_index) + self._sync_position_indicator() # ── Actions ── def action_cursor_down(self): diff --git a/test/test_vcf_browser.py b/test/test_vcf_browser.py index e0e7321..65da271 100644 --- a/test/test_vcf_browser.py +++ b/test/test_vcf_browser.py @@ -280,6 +280,181 @@ async def _t(): run(_t()) +# ────────────────────────────────────────────── +# 批次追加不重置浏览位置(回归:列表周期性跳顶) +# ────────────────────────────────────────────── +class TestBatchAppendPositionStability: + @staticmethod + def _make_vcf(tmp_path, n): + f = tmp_path / "pos.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{1000 + i * 10}\t.\tA\tG\t50\tPASS\t." for i in range(n)] + f.write_text("\n".join(lines) + "\n") + return f + + def test_batch_append_never_resets_position(self, tmp_path): + """窗口不在尾部时,批次追加不触碰列表:高亮/窗口/物化数均不变。""" + f = self._make_vcf(tmp_path, 1000) + meta, head, skipped, cont = scan_vcf_quick(f, limit=500) + from seqviz.vcf import scan_vcf_resume + tail, _ = scan_vcf_resume(f, cont) + + async def _t(): + app = VcfBrowser(f, initial=(meta, head, skipped, -1)) + app.scanning = True # 手工驱动批次,不启动后台线程 + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert ol.option_count == app.WINDOW # 500 条只物化 400 + for _ in range(50): + await pilot.press("j") + await pilot.pause() + before = (app._win_start, ol.highlighted, app._abs_index()) + app._append_batch(tail[:250]) + await pilot.pause() + after = (app._win_start, ol.highlighted, app._abs_index()) + assert before == after # 位置零变化(无 clear 重建) + assert ol.option_count == app.WINDOW # 窗口不在尾部 → 不追加选项 + assert len(app.view) == 750 + app.scanning = False + run(_t()) + + def test_batch_append_incremental_at_tail(self, tmp_path): + """窗口贴尾部且未填满:增量 add_options(非 clear 重建),高亮不变。""" + meta, head, skipped, cont = scan_vcf_quick(SAMPLE_VCF, limit=2) + from seqviz.vcf import scan_vcf_resume + tail, _ = scan_vcf_resume(SAMPLE_VCF, cont) + + async def _t(): + app = VcfBrowser(SAMPLE_VCF, initial=(meta, head, skipped, -1)) + app.scanning = True + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + await pilot.press("j") + await pilot.pause() + hl_before = ol.highlighted + app._append_batch(tail) # 16 条,窗口未满 → 增量补充 + await pilot.pause() + assert ol.option_count == 18 # 全部补齐 + assert ol.highlighted == hl_before # 高亮未被重置 + app.scanning = False + run(_t()) + + +# ────────────────────────────────────────────── +# 滚动条诚实性(回归:拖到底只见 chr1:17,065) +# ────────────────────────────────────────────── +class TestScrollbarHonesty: + def test_large_list_hides_scrollbar(self, tmp_path): + """大列表(>WINDOW)永不显示滚动条,避免虚假的“拖到底=全部末尾”暗示。""" + f = tmp_path / "big.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 1001)] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert len(app.view) == 1000 > app.WINDOW + assert ol.show_scrollbar is False + run(_t()) + + def test_small_list_shows_scrollbar(self): + """小列表全量物化,滚动条正常显示。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert len(app.view) == 18 <= app.WINDOW + assert ol.show_scrollbar is True + run(_t()) + + def test_filter_to_small_restores_scrollbar(self, tmp_path): + """过滤后列表变小(≤WINDOW)时滚动条恢复。""" + f = tmp_path / "mixed.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 501)] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tLowQual\t." for i in range(1001, 1401)] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + ol = app.query_one("#variant-list", OptionList) + assert ol.show_scrollbar is False # 900 条 > WINDOW + await pilot.press("f") # PASS → 500 条 > WINDOW + await pilot.pause() + assert ol.show_scrollbar is False + await pilot.press("f") # SNP → 900 条 + await pilot.press("f") # InDel → 0 条 ≤ WINDOW + await pilot.pause() + assert ol.show_scrollbar is True + run(_t()) + + +# ────────────────────────────────────────────── +# 扫描完成后 G 到真实末尾(回归:底部数据不完整) +# ────────────────────────────────────────────── +class TestEndAfterScan: + def test_G_reaches_true_last_variant(self): + """后台扫描完成后,G 应定位到最后一条变异(非 400 条缓冲尾部)。""" + meta, head, skipped, cont = scan_vcf_quick(SAMPLE_VCF, limit=2) + async def _t(): + app = VcfBrowser(SAMPLE_VCF, initial=(meta, head, skipped, cont)) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + for _ in range(200): + if not app.scanning: + break + await pilot.pause() + assert not app.scanning + assert len(app.variants) == 18 + await pilot.press("G") + await pilot.pause() + v = app.variants[app.view[app._abs_index()]] + assert (v.chrom, v.pos) == ("chr3", 33333) # 真实末尾 + assert "33,333" in app._detail_text() + run(_t()) + + def test_position_indicator_updates(self): + """位置指示器(副标题)随导航更新。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + assert app.sub_title == "1 / 18" + await pilot.press("j") + await pilot.pause() + assert app.sub_title == "2 / 18" + await pilot.press("G") + await pilot.pause() + assert app.sub_title == "18 / 18" + run(_t()) + + def test_wheel_moves_position_on_large_list(self, tmp_path): + """大列表滚轮 = 移动浏览位置(窗口平移),非缓冲内视口滚动。""" + f = tmp_path / "w.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 1001)] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + assert app._abs_index() == 0 + ol = app.query_one("#variant-list", OptionList) + ol.scroll_down() # 滚轮下滚 + await pilot.pause() + assert app._abs_index() == 3 + ol.scroll_up() + await pilot.pause() + assert app._abs_index() == 0 + run(_t()) + + # ────────────────────────────────────────────── # 大文件窗口化虚拟化(回归:全量重建 OptionList 冻结 UI) # ────────────────────────────────────────────── From 91e891eb4fd42ad276e00a1df0ea3b3c4591b4ae Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Wed, 5 Aug 2026 05:20:58 +0800 Subject: [PATCH 11/20] feat(vcf): coordinate/range jump + true proportional scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search bar (/) now supports chr1:10000 (nearest variant), chr1:10000-20000 and chr1:10000..20000 (first variant in range), with thousands separators and auto-swapped reversed ranges - Binary search on position-ordered view: 0.2ms jump on 5.7M variants (linear fallback for QUAL sort mode); mid-scan searches report indexed count - New AbsoluteScrollbar widget: thumb maps to the FULL view (position and size), click/drag to jump anywhere — replaces the misleading 400-option-buffer scrollbar on large lists - parse_coord_query added to pure parser layer (vcf.py), fully unit-tested - Help panel, search placeholder and README updated 298 tests pass (18 new). --- README.md | 10 +- src/seqviz/theme.py | 5 + src/seqviz/vcf.py | 23 +++++ src/seqviz/vcf_browser.py | 169 ++++++++++++++++++++++++++++++-- test/test_vcf.py | 30 ++++++ test/test_vcf_browser.py | 201 +++++++++++++++++++++++++++++++++++++- 6 files changed, 428 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2dbd250..f9cd24b 100644 --- a/README.md +++ b/README.md @@ -129,11 +129,19 @@ seqviz browse variants.vcf | 按键 | 功能 | 按键 | 功能 | |------|------|------|------| | `j` / `k` | 上下移动 | `Space` / `b` | 翻页 | -| `/` | 搜索 ID 或坐标 | `f` | 过滤循环(全部/PASS/SNP/InDel) | +| `/` | 搜索:ID / 坐标 / 范围 | `f` | 过滤循环(全部/PASS/SNP/InDel) | | `s` | 排序(位置 ↔ QUAL) | `t` | 详情 ↔ 基因型矩阵 | | `i` | 文件信息 | `y` | 复制当前 VCF 行 | | `?` | 帮助 | `q` | 退出 | +**搜索 `/` 支持的格式:** + +- `rs12345` — 按变异 ID 搜索 +- `chr1:10234` — 跳到该坐标最近的变异(精确或最近邻) +- `chr1:10000-20000` / `chr1:10000..20000` — 跳到区间内第一个变异(两种分隔符均可,支持千分位逗号) + +> 列表右侧为**真实比例滚动条**:滑块位置/长度映射全量变异(非窗口缓冲),点击/拖拽可直达任意位置。大文件采用快扫启动 + 后台续扫,扫描中搜索仅在已索引数据内查找并提示已索引条数。 + > 底部状态栏常驻统计:变异总数 · SNP/InDel · Ts/Tv · PASS 数 · AF 均值,随过滤实时联动。目前支持未压缩 `.vcf`(bgzip/tabix 规划中)。 ## 配置 diff --git a/src/seqviz/theme.py b/src/seqviz/theme.py index fb1b19c..2284a7a 100644 --- a/src/seqviz/theme.py +++ b/src/seqviz/theme.py @@ -405,6 +405,11 @@ def build_vcf_browser_css(theme: dict) -> str: #variant-list > OptionList > .option-list--option-hover {{ background: {highlight}; }} + #abs-scrollbar {{ + width: 1; + background: {bg}; + color: {accent}; + }} #detail {{ width: 1fr; padding: 0 1; diff --git a/src/seqviz/vcf.py b/src/seqviz/vcf.py index a0fb2eb..5748ab4 100644 --- a/src/seqviz/vcf.py +++ b/src/seqviz/vcf.py @@ -15,6 +15,29 @@ _DESC_RE = re.compile(r'Description="([^"]*)"') _LEN_RE = re.compile(r"length=(\d+)") +# 坐标查询:chr1:10000 / chr1:10000-20000 / chr1:10000..20000(支持千分位逗号与空格) +_COORD_RE = re.compile( + r"^\s*([^\s:]+)\s*:\s*(\d[\d,]*)(?:\s*(?:-|\.\.)\s*(\d[\d,]*))?\s*$" +) + + +def parse_coord_query(text: str) -> tuple[str, int, int | None] | None: + """解析坐标/范围查询字符串。 + + 支持:chr1:10000、chr1:10000-20000、chr1:10000..20000、chr1:10,000(千分位)。 + 返回 (chrom, start, end);end 为 None 表示单坐标;无法解析返回 None。 + 起止顺序颠倒时自动交换。 + """ + m = _COORD_RE.match(text or "") + if not m: + return None + chrom = m.group(1) + start = int(m.group(2).replace(",", "")) + end = int(m.group(3).replace(",", "")) if m.group(3) else None + if end is not None and end < start: + start, end = end, start + return chrom, start, end + class VariantType(Enum): TRANSITION = "transition" # 转换(嘌呤↔嘌呤 / 嘧啶↔嘧啶) diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index 0b0464c..b4ce9cc 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -13,6 +13,7 @@ from textual.binding import Binding from textual.containers import Horizontal from textual.screen import ModalScreen +from textual.widget import Widget from textual.widgets import Footer, Header, Input, OptionList, Static from textual.widgets.option_list import Option @@ -23,6 +24,7 @@ classify_variant, compute_stats, load_variant_detail, + parse_coord_query, parse_genotype, scan_vcf, scan_vcf_resume, @@ -75,6 +77,62 @@ def scroll_up(self, animate: bool = True) -> None: super().scroll_up(animate) +class AbsoluteScrollbar(Widget): + """真实比例滚动条:映射全量 view 的绝对位置(窗口化大列表专用)。 + + OptionList 原生滚动条只代表物化的 WINDOW 条缓冲(虚假的“拖到底=全部末尾”); + 本控件滑块位置/长度按 当前位置/窗口占比 映射全量 view,点击/拖拽直达。 + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._dragging = False + + def render(self): + app = self.app + h = self.size.height + txt = Text() + if not isinstance(app, VcfBrowser) or h <= 0 or len(app.view) <= app.WINDOW: + return txt + n = len(app.view) + thumb_h = max(1, round(h * min(app.WINDOW, n) / n)) + track = h - thumb_h + frac = app._abs_index() / max(n - 1, 1) + thumb_start = round(frac * track) + for row in range(h): + if thumb_start <= row < thumb_start + thumb_h: + txt.append("█") + else: + txt.append("░", style="dim") + if row < h - 1: + txt.append("\n") + return txt + + def _jump_to(self, y: int): + app = self.app + if not isinstance(app, VcfBrowser) or not app.view: + return + h = max(self.size.height, 1) + frac = min(max(y / h, 0.0), 1.0) + app._goto_abs(int(frac * (len(app.view) - 1))) + + def on_mouse_down(self, event) -> None: + self._dragging = True + self.capture_mouse() + self._jump_to(event.y) + event.stop() + + def on_mouse_move(self, event) -> None: + if self._dragging: + self._jump_to(event.y) + event.stop() + + def on_mouse_up(self, event) -> None: + if self._dragging: + self._dragging = False + event.stop() + + class HelpScreen(ModalScreen): """帮助面板:显示所有快捷键。""" @@ -106,7 +164,7 @@ def on_mount(self): rows = [ ("j / k", "上下移动"), ("n / p", "下/上一条变异"), ("Space / b", "翻页"), ("g / G", "顶部 / 底部"), - ("/", "搜索 ID 或坐标"), ("f", "过滤循环 (全部/PASS/SNP/InDel)"), + ("/", "搜索: ID / chr:pos / chr:a-b"), ("f", "过滤循环 (全部/PASS/SNP/InDel)"), ("s", "排序切换 (位置/QUAL)"), ("t", "详情 ↔ 基因型矩阵"), ("i", "文件信息"), ("y", "复制当前 VCF 行"), ("?", "帮助"), ("q", "退出"), @@ -189,6 +247,7 @@ def compose(self) -> ComposeResult: yield Header() with Horizontal(): yield VariantList(id="variant-list") + yield AbsoluteScrollbar(id="abs-scrollbar") yield Static("", id="detail") yield Static("", id="status-bar") yield Footer() @@ -237,9 +296,76 @@ def _chrom_key_of(self, chrom: str): self._chrom_keys = {} k = self._chrom_keys.get(chrom) if k is None: - k = self._chrom_keys[chrom] = _chrom_sort_key(chrom) + k = self._chrom_keys[chrom] = _chrom_sort_key(chrom.lower()) # 大小写不敏感 return k + # ── 坐标/范围查找(遗留建议:二分直达坐标) ── + def _bisect_view(self, target_key: tuple, pos: int) -> int: + """在位置有序的 view 上二分:返回第一个 (染色体键, 坐标) >= (target_key, pos) 的下标。 + + 仅当 sort_mode == “位置”时 view 保证有序(过滤为保序筛选)。 + """ + lo, hi = 0, len(self.view) + while lo < hi: + mid = (lo + hi) // 2 + v = self.variants[self.view[mid]] + if (self._chrom_key_of(v.chrom), v.pos) < (target_key, pos): + lo = mid + 1 + else: + hi = mid + return lo + + def _find_coord_in_view(self, chrom: str, start: int, end: int | None) -> int | None: + """在 view 中找第一个 chrom 匹配且 pos ∈ [start, end] 的绝对下标。 + + 位置排序模式二分 O(log n);其他排序模式(如 QUAL)线性扫描。 + """ + target_key = self._chrom_key_of(chrom) + if self.sort_mode == "位置": + lo = self._bisect_view(target_key, start) + if lo < len(self.view): + v = self.variants[self.view[lo]] + if (self._chrom_key_of(v.chrom) == target_key and v.pos >= start + and (end is None or v.pos <= end)): + return lo + return None + for li, vi in enumerate(self.view): + v = self.variants[vi] + if (self._chrom_key_of(v.chrom) == target_key and v.pos >= start + and (end is None or v.pos <= end)): + return li + return None + + def _find_coord_nearest(self, chrom: str, pos: int) -> int | None: + """单坐标最近邻:精确命中优先,否则取前后候选中距离更近者。 + + 位置排序模式二分定位 + 邻位比较;其他模式线性扫描取最小距离。 + """ + target_key = self._chrom_key_of(chrom) + if self.sort_mode != "位置": + best, best_d = None, None + for li, vi in enumerate(self.view): + v = self.variants[vi] + if self._chrom_key_of(v.chrom) != target_key: + continue + d = abs(v.pos - pos) + if best_d is None or d < best_d: + best, best_d = li, d + return best + n = len(self.view) + lo = self._bisect_view(target_key, pos) + best, best_d = None, None + for li in (lo, lo - 1): + if not (0 <= li < n): + continue + v = self.variants[self.view[li]] + if self._chrom_key_of(v.chrom) != target_key: + continue + d = abs(v.pos - pos) + if best_d is None or d < best_d: + best, best_d = li, d + return best + def _extend_index(self, batch: list[Variant]): """纯数据层:追加索引并维护位置序/pos_order/view(不碰 UI 控件)。""" ok = self._scan_sorted and not self._order_dirty @@ -306,13 +432,15 @@ def _finish_scan(self, new_all: list[Variant], skipped_add: int): self.notify(f"扫描完成,共 {len(self.variants):,} 条变异", title="VCF") def _sync_scrollbar(self): - """滚动条仅在列表全量物化(≤WINDOW 条)时显示。 - - 大列表只物化窗口,滚动条会虚假地暗示“拖到底 = 全部数据末尾” - (实际只能到 400 条缓冲底部),因此隐藏;改用位置指示器 + G/g 跳转。 + """滚动策略:小列表用 OptionList 原生滚动条; + 大列表(>WINDOW)隐藏原生滚动条(它只代表 400 条缓冲,是虚假表示), + 改用 AbsoluteScrollbar 真实比例映射全量 view。 """ ol = self.query_one("#variant-list", OptionList) ol.show_scrollbar = len(self.view) <= self.WINDOW + sb = self.query_one("#abs-scrollbar", AbsoluteScrollbar) + sb.styles.display = "block" if len(self.view) > self.WINDOW else "none" + sb.refresh() def _sync_position_indicator(self): """副标题位置指示器:当前行 / 总行数(含扫描中增长的总量)。""" @@ -320,6 +448,8 @@ def _sync_position_indicator(self): self.sub_title = f"{self._abs_index() + 1:,} / {len(self.view):,}" else: self.sub_title = "0 / 0" + # 位置变化 → 同步真实比例滚动条滑块 + self.query_one("#abs-scrollbar", AbsoluteScrollbar).refresh() def _detail_fh(self): """详情回读复用句柄(避免每次导航 open/close)。""" @@ -638,8 +768,9 @@ def _copy_to_clipboard(self, text: str) -> bool: # ── 搜索命令栏 ── async def _show_search_bar(self) -> None: self._remove_search_bar() - bar = Input(placeholder="搜索 ID 或坐标 (如 rs12345 / chr1:10234)... (Enter 确认, Esc 取消)", - id="search-input") + bar = Input( + placeholder="搜索 ID / 坐标 / 范围(如 rs12345、chr1:10234、chr1:10000-20000 或 chr1:10000..20000)… (Enter 确认, Esc 取消)", + id="search-input") await self.mount(bar) bar.focus() @@ -661,6 +792,28 @@ def on_input_submitted(self, event: Input.Submitted) -> None: value = event.value.strip() if not value: return + # 1) 坐标/范围查询:chr1:10000 / chr1:10000-20000 / chr1:10000..20000 + coord_q = parse_coord_query(value) + if coord_q is not None: + chrom, start, end = coord_q + if not self.view: + self.notify("当前没有可搜索的变异", title="搜索", severity="warning") + return + if end is not None: + li = self._find_coord_in_view(chrom, start, end) + else: + li = self._find_coord_nearest(chrom, start) + suffix = f"(当前已索引 {len(self.variants):,} 条)" if self.scanning else "" + if li is None: + rng = f"{chrom}:{start:,}-{end:,}" if end is not None else f"{chrom}:{start:,}" + msg = f"{rng} 该范围内无变异" if end is not None else f"{chrom} 上未找到 {start:,} 附近的变异" + self.notify(msg + suffix, title="搜索", severity="warning") + return + self._goto_abs(li) + v = self.variants[self.view[li]] + self.notify(f"跳转到 {v.chrom}:{v.pos:,} {v.id or ''}".strip() + suffix, title="搜索") + return + # 2) 回退:ID / 精确坐标字符串匹配 q = value.lower() for li, vi in enumerate(self.view): v = self.variants[vi] diff --git a/test/test_vcf.py b/test/test_vcf.py index 8e8b987..b3d0333 100644 --- a/test/test_vcf.py +++ b/test/test_vcf.py @@ -15,6 +15,7 @@ scan_vcf, scan_vcf_quick, scan_vcf_resume, + parse_coord_query, ) SAMPLE_VCF = Path(__file__).parent / "sample.vcf" @@ -161,6 +162,35 @@ def test_malformed_lines_skipped_and_counted(self, tmp_path): assert len(variants) == 2 and skipped == 1 +class TestParseCoordQuery: + def test_single_coord(self): + assert parse_coord_query("chr1:10000") == ("chr1", 10000, None) + + def test_range_dash(self): + assert parse_coord_query("chr1:10000-20000") == ("chr1", 10000, 20000) + + def test_range_dotdot(self): + assert parse_coord_query("chr1:10000..20000") == ("chr1", 10000, 20000) + + def test_range_reversed_swapped(self): + assert parse_coord_query("chr1:20000-10000") == ("chr1", 10000, 20000) + + def test_thousands_separator(self): + assert parse_coord_query("chr1:10,000-20,000") == ("chr1", 10000, 20000) + assert parse_coord_query("chrX:1,234,567") == ("chrX", 1234567, None) + + def test_whitespace_tolerated(self): + assert parse_coord_query(" chr1 : 100 - 200 ") == ("chr1", 100, 200) + + def test_invalid_inputs(self): + assert parse_coord_query("rs12345") is None + assert parse_coord_query("chr1:abc") is None + assert parse_coord_query("chr1:") is None + assert parse_coord_query("") is None + assert parse_coord_query(None) is None + assert parse_coord_query("chr1:100-200-300") is None + + class TestQuickScanAndResume: def test_quick_scan_limit_and_continuation(self): """快扫 + 续扫拼接结果 == 全量扫描(断点不丢不重)。""" diff --git a/test/test_vcf_browser.py b/test/test_vcf_browser.py index 65da271..860fb9d 100644 --- a/test/test_vcf_browser.py +++ b/test/test_vcf_browser.py @@ -5,7 +5,7 @@ from textual.widgets import OptionList from seqviz.vcf import scan_vcf_quick -from seqviz.vcf_browser import VcfBrowser +from seqviz.vcf_browser import VcfBrowser, AbsoluteScrollbar TEST_DIR = Path(__file__).parent SAMPLE_VCF = TEST_DIR / "sample.vcf" @@ -455,6 +455,205 @@ async def _t(): run(_t()) +# ────────────────────────────────────────────── +# 坐标/范围跳转(/ 命令栏) +# ────────────────────────────────────────────── +class TestCoordJump: + @staticmethod + def _capture_notify(app) -> list: + """包装 app.notify 捕获消息(Notifications 对象不提供读取接口)。""" + msgs: list = [] + orig = app.notify + + def capture(message, *args, **kwargs): + msgs.append(str(message)) + return orig(message, *args, **kwargs) + + app.notify = capture + return msgs + + async def _search(self, pilot, query: str): + await pilot.press("slash") + await pilot.pause() + await pilot.press(*query) + await pilot.press("enter") + await pilot.pause() + + def test_exact_coord_jump(self): + """精确坐标:chr1:15892 命中 rs67890。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + msgs = self._capture_notify(app) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chr1:15892") + v = app.variants[app.view[app._abs_index()]] + assert (v.chrom, v.pos, v.id) == ("chr1", 15892, "rs67890") + assert "跳转到 chr1:15,892" in msgs[-1] + run(_t()) + + def test_nearest_coord_jump(self): + """最近邻:chr1:10300 → 10234(跞66)而非 10567(跞267)。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chr1:10300") + v = app.variants[app.view[app._abs_index()]] + assert v.pos == 10234 + # 反方向最近邻:10500 → 10567 + await self._search(pilot, "chr1:10500") + v = app.variants[app.view[app._abs_index()]] + assert v.pos == 10567 + run(_t()) + + def test_range_jump_first_in_range(self, tmp_path): + """范围跳转:命中区间内第一个变异(两种分隔符)。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chr1:11000-40000") + v = app.variants[app.view[app._abs_index()]] + assert v.pos == 15892 # [11000,40000] 内第一条 + await self._search(pilot, "chr2:20000..50000") + v = app.variants[app.view[app._abs_index()]] + assert (v.chrom, v.pos) == ("chr2", 23456) + run(_t()) + + def test_range_no_match_warns(self): + """范围内无变异:提示该范围内无变异。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + msgs = self._capture_notify(app) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chr1:50000000-60000000") + assert "该范围内无变异" in msgs[-1] + run(_t()) + + def test_chrom_without_variants_warns(self): + """不存在的染色体单坐标:提示未找到。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + msgs = self._capture_notify(app) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chrX:100") + assert "未找到" in msgs[-1] + run(_t()) + + def test_invalid_format_falls_back_to_id_search(self): + """格式错误(chr1:abc)回退 ID 搜索 → 未找到匹配。""" + async def _t(): + app = VcfBrowser(SAMPLE_VCF) + msgs = self._capture_notify(app) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chr1:abc") + assert "未找到匹配" in msgs[-1] + run(_t()) + + def test_coord_jump_shifts_window_on_large_list(self, tmp_path): + """大列表(窗口化)坐标跳转:跨窗口平移且详情同步。""" + f = tmp_path / "c.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 1001)] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chr1:800") + assert app._abs_index() == 799 # pos=800 是第 800 条 + assert app._win_start > 0 # 窗口已平移 + assert "800" in app._detail_text() + run(_t()) + + def test_scanning_suffix_in_message(self, tmp_path): + """扫描中搜索:提示含(当前已索引 N 条)。""" + f = tmp_path / "s.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO", + "chr1\t100\t.\tA\tG\t50\tPASS\t."] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + msgs = self._capture_notify(app) + app.scanning = True # 模拟扫描中 + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await self._search(pilot, "chr9:1-2") + assert "该范围内无变异" in msgs[-1] + assert "当前已索引" in msgs[-1] + app.scanning = False + run(_t()) + + +# ────────────────────────────────────────────── +# 真实比例滚动条(遗留建议:映射全量 view) +# ────────────────────────────────────────────── +class TestAbsoluteScrollbar: + def test_visibility_follows_list_size(self, tmp_path): + """大列表显示真实比例滚动条,小列表隐藏。""" + f = tmp_path / "b.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 1001)] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + sb = app.query_one("#abs-scrollbar", AbsoluteScrollbar) + assert str(sb.styles.display) == "block" # 1000 > WINDOW + rendered = str(sb.render()) + assert "█" in rendered and "░" in rendered # 滑块 + 轨道 + # 小列表:隐藏 + app2 = VcfBrowser(SAMPLE_VCF) + async with app2.run_test(size=(120, 30)) as pilot: + await pilot.pause() + sb2 = app2.query_one("#abs-scrollbar", AbsoluteScrollbar) + assert str(sb2.styles.display) == "none" # 18 ≤ WINDOW + run(_t()) + + def test_jump_to_maps_full_view(self, tmp_path): + """点击映射全量 view:底部 → 最后一条,中部 → 中位附近。""" + f = tmp_path / "j.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i * 100}\t.\tA\tG\t50\tPASS\t." for i in range(1, 5001)] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + sb = app.query_one("#abs-scrollbar", AbsoluteScrollbar) + sb._jump_to(sb.size.height) # 底部 + await pilot.pause() + assert app._abs_index() == len(app.view) - 1 == 4999 + sb._jump_to(0) # 顶部 + await pilot.pause() + assert app._abs_index() == 0 + run(_t()) + + def test_thumb_position_tracks_navigation(self, tmp_path): + """导航后滑块位置同步(渲染内容随位置变化)。""" + f = tmp_path / "n.vcf" + lines = ["#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"] + lines += [f"chr1\t{i}\t.\tA\tG\t50\tPASS\t." for i in range(1, 2001)] + f.write_text("\n".join(lines) + "\n") + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + sb = app.query_one("#abs-scrollbar", AbsoluteScrollbar) + top_render = str(sb.render()) + await pilot.press("G") + await pilot.pause() + sb.refresh() + bottom_render = str(sb.render()) + assert top_render != bottom_render # 滑块已移动 + run(_t()) + + # ────────────────────────────────────────────── # 大文件窗口化虚拟化(回归:全量重建 OptionList 冻结 UI) # ────────────────────────────────────────────── From 73dc02b51d4a27a4e146169aa6434d13edc68c20 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Fri, 7 Aug 2026 18:02:56 +0800 Subject: [PATCH 12/20] chore: bump version to 0.7.0-vcf.1 (VCF experimental release) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3b8281e..0d9e65d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ packages = ["src/seqviz"] [project] name = "seqviz" -version = "0.6.3" +version = "0.7.0-vcf.1" description = "A terminal visualizer for biological sequence data — FASTA / FASTQ / VCF" readme = "README.md" requires-python = ">=3.12" From 34ddfd04209329b9f26a6347bc1afa94fd284f8d Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Fri, 7 Aug 2026 18:03:23 +0800 Subject: [PATCH 13/20] fix: use PEP 440 compliant version format (0.7.0rc1) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0d9e65d..2b8bcb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ packages = ["src/seqviz"] [project] name = "seqviz" -version = "0.7.0-vcf.1" +version = "0.7.0rc1" description = "A terminal visualizer for biological sequence data — FASTA / FASTQ / VCF" readme = "README.md" requires-python = ">=3.12" From f1fa55323ca78d453f8717809ff5004a40161aed Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Tue, 11 Aug 2026 17:41:45 +0800 Subject: [PATCH 14/20] =?UTF-8?q?fix(browser):=20=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=90=8E=E7=84=A6=E7=82=B9=E6=9C=AA=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E8=AE=BE=E7=BD=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 on_mount 方法中的注释说明焦点设置功能 - 在应用启动后显式设置焦点到侧栏组件 - 确保 Footer 组件能够显示完整的快捷键信息 - 优化了初始加载时的用户体验和界面响应 --- README.md | 190 +++++++++++++++++++++++------------------------------- uv.lock | 2 +- 2 files changed, 81 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index f9cd24b..1e5fa1b 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,46 @@ seqviz logo -# Seqviz — ⚡ Terminal Sequence Viewer +# Seqviz — 终端序列查看器 -**在终端中彩色、交互地查看 FASTA / FASTQ 序列文件** +**在终端里彩色、交互地看 FASTA / FASTQ / VCF 文件** [安装](#安装) · [快速开始](#快速开始) · [浏览器](#交互式浏览器) · [配置](#配置) -当前版本 v0.6.3 · 需要 Python >= 3.12 +v0.6.3 · Python >= 3.12 --- -## ✨ 功能特性 +做生信的人大概都有过这种体验:想看一眼 FASTA 文件的内容,`cat` 出来满屏 ATCG 糊在一起;想统计序列条数和 N50,得现写一段 Python;想翻 VCF 文件找某个变异,`grep` 半天对不齐列。 -- 🧬 **DNA / 蛋白质自动检测** — 四色碱基着色 (A/T/C/G),氨基酸按化学性质分组着色 -- 📊 **FASTQ 质量可视化** — Phred 梯度着色、质量分布条、Q30 统计 -- 🧪 **VCF 变异浏览** — 变异类型着色、逐样本基因型 + reads 比例条、基因型矩阵、过滤/排序/Ts-Tv 统计 -- 🖥️ **交互式 TUI 浏览器** — 搜索、跳转、复制、导出、多标签页,支持十万级序列 -- 📁 **目录文件选择器** — 扫描目录中的序列文件,预览、多选批量打开 -- 📈 **统计摘要** — 序列条数、总长度、N50、GC 含量,表格化输出 -- ⚡ **极致性能** — 未压缩文件 O(1) seek 定位 + 虚拟化列表 + 按需渲染,GB 级文件流畅浏览 -- 🎨 **8 套内置主题** — light / dark / nord / gruvbox / catppuccin / solarized / rose-pine / tokyo-night,一键切换 -- 📦 **gzip 支持** — 直接读取 `.gz` 压缩文件,无需预解压(顺序浏览;随机跳转需解压中间数据,随文件大小线性变慢) +Seqviz 就是为了解决这些小事。一个终端工具,装上就能用。 + +## 能做什么 + +- **彩色查看序列** — DNA 四色碱基着色 (A/T/C/G),蛋白质按化学性质分组,一眼就能看出序列特征 +- **FASTQ 质量可视化** — Phred 梯度着色、Q30 统计,质控不用开 FastQC +- **VCF 变异浏览** — 变异类型着色、逐样本基因型 + reads 比例条、Ts/Tv 统计 +- **交互式浏览器** — 搜索、跳转、复制、导出、多标签页,十万级序列流畅滚动 +- **目录文件选择器** — 扫描目录里的序列文件,预览、多选批量打开 +- **统计摘要** — 条数、总长度、N50、GC 含量,一条命令搞定 +- **大文件友好** — 未压缩文件 O(1) seek 定位 + 虚拟化列表,GB 级文件也能流畅浏览 +- **8 套主题** — nord / gruvbox / catppuccin / tokyo-night 等,`config.json` 改一行就换 +- **gzip 直接读** — `.gz` 文件不用解压就能看(随机跳转需解压,会慢一些) ## 安装 ```bash -# 从源码安装(推荐,PyPI 发布筹备中) +# 推荐方式 git clone https://github.com/Georicl/seqviz.git && cd seqviz uv tool install . -# 或从 GitHub Release 下载 wheel 后安装 +# 或者下载 wheel 安装 uv tool install seqviz--py3-none-any.whl ``` -安装后启用 shell 补全(可选): +Shell 补全(可选): ```bash seqviz --install-completion zsh # 或 bash @@ -53,34 +57,32 @@ uv run seqviz --help ## 快速开始 ```bash -# 打开当前目录的文件选择器 +# 不传文件?打开目录选择器 seqviz -# 查看单个文件 +# 看文件 seqviz view genome.fasta seqviz fqview reads.fastq -# 统计摘要 +# 统计 seqviz stats genome.fasta -# 交互式浏览器 +# 交互式浏览 seqviz browse genome.fasta - -# VCF 变异浏览器 seqviz browse variants.vcf ``` -## 命令一览 +## 命令 -| 命令 | 说明 | -|------|------| -| `seqviz` | 打开当前目录文件选择器 | -| `seqviz view ` | 彩色查看 FASTA 文件 | -| `seqviz fqview ` | 彩色查看 FASTQ 文件(含质量值) | -| `seqviz head ` | 查看前 N 条序列 | -| `seqviz stats ` | 输出统计摘要表格 | -| `seqviz browse ` | 交互式 TUI 浏览器(FASTA/FASTQ/VCF 自动路由) | -| `seqviz config [--init]` | 查看/生成配置文件 | +| 命令 | 干什么的 | +|------|---------| +| `seqviz` | 打开目录文件选择器 | +| `seqviz view ` | 彩色看 FASTA | +| `seqviz fqview ` | 彩色看 FASTQ(带质量值) | +| `seqviz head ` | 看前 N 条序列 | +| `seqviz stats ` | 统计摘要 | +| `seqviz browse ` | 交互式浏览器(自动识别 FASTA/FASTQ/VCF) | +| `seqviz config [--init]` | 看/生成配置 | ## 交互式浏览器 @@ -88,31 +90,31 @@ seqviz browse variants.vcf seqviz browse genome.fasta ``` -左右双栏布局:**左侧序列列表**(虚拟化)+ **右侧序列详情**(按需渲染) +左边序列列表(虚拟化渲染),右边序列详情(按需加载)。 ### 快捷键 | 按键 | 功能 | 按键 | 功能 | |------|------|------|------| -| `j` / `k` | 上下滚动 | `n` / `p` | 下/上一条序列 | -| `Space` / `b` | 下/上翻页 | `g` / `G` | 跳到顶部/底部 | -| `/` | 搜索序列名 | `:` | 跳转到第 N 条 | -| `y` | 复制到剪贴板 | `c` | 范围复制 (如 `100-200`) | -| `e` | 导出序列到文件 | `B` | 返回文件选择器 | -| `Tab` | 切换文件标签页 | `?` | 帮助面板 | +| `j` / `k` | 上下滚 | `n` / `p` | 下/上一条 | +| `Space` / `b` | 翻页 | `g` / `G` | 顶/底部 | +| `/` | 搜索序列名 | `:` | 跳到第 N 条 | +| `y` | 复制 | `c` | 范围复制 | +| `e` | 导出 | `B` | 回文件选择器 | +| `Tab` | 切标签页 | `?` | 帮助 | | `q` | 退出 | | | ### 文件选择器 -当 `browse` 传入目录(或直接运行 `seqviz`)时启动。自动扫描 `.fa .fasta .fna .faa .aa .seq .fq .fastq .vcf` 及 `.gz`。 +传目录给 `browse`(或直接运行 `seqviz`)就会启动。自动扫描 `.fa .fasta .fna .fq .fastq .vcf` 及 `.gz`。 | 按键 | 功能 | |------|------| -| `j` / `k` | 上下移动 | -| `Space` | 切换多选 | +| `j` / `k` | 上下移 | +| `Space` | 多选 | | `a` | 全选/取消 | -| `Enter` | 打开(多选则批量标签页) | -| `q` | 取消并退出 | +| `Enter` | 打开(多选会开多个标签页) | +| `q` | 退出 | ## VCF 变异浏览器 @@ -120,38 +122,36 @@ seqviz browse genome.fasta seqviz browse variants.vcf ``` -左右双栏:**左侧变异列表**(懒扫描索引 + 虚拟化)+ **右侧变异详情 / 基因型矩阵**。 +左边变异列表,右边详情面板或基因型矩阵。 - 变异类型着色:转换 ● 绿 · 颠换 ● 蓝 · 插入 ◆ 黄 · 缺失 ◆ 红 -- 行颜色编码可信度:PASS 绿 · 低质量黄 · 低深度红 +- 行颜色表示可信度:PASS 绿 · 低质量黄 · 低深度红 - 详情面板逐样本展示基因型 + AD reads 比例条 | 按键 | 功能 | 按键 | 功能 | |------|------|------|------| -| `j` / `k` | 上下移动 | `Space` / `b` | 翻页 | -| `/` | 搜索:ID / 坐标 / 范围 | `f` | 过滤循环(全部/PASS/SNP/InDel) | -| `s` | 排序(位置 ↔ QUAL) | `t` | 详情 ↔ 基因型矩阵 | -| `i` | 文件信息 | `y` | 复制当前 VCF 行 | +| `j` / `k` | 上下移 | `Space` / `b` | 翻页 | +| `/` | 搜索 | `f` | 筛选循环 | +| `s` | 排序 | `t` | 详情 ↔ 矩阵 | +| `i` | 文件信息 | `y` | 复制行 | | `?` | 帮助 | `q` | 退出 | -**搜索 `/` 支持的格式:** +**搜索 `/` 支持:** -- `rs12345` — 按变异 ID 搜索 -- `chr1:10234` — 跳到该坐标最近的变异(精确或最近邻) -- `chr1:10000-20000` / `chr1:10000..20000` — 跳到区间内第一个变异(两种分隔符均可,支持千分位逗号) +- `rs12345` — 按 ID 搜 +- `chr1:10234` — 跳到坐标最近的变异 +- `chr1:10000-20000` — 跳到区间内第一个变异 -> 列表右侧为**真实比例滚动条**:滑块位置/长度映射全量变异(非窗口缓冲),点击/拖拽可直达任意位置。大文件采用快扫启动 + 后台续扫,扫描中搜索仅在已索引数据内查找并提示已索引条数。 - -> 底部状态栏常驻统计:变异总数 · SNP/InDel · Ts/Tv · PASS 数 · AF 均值,随过滤实时联动。目前支持未压缩 `.vcf`(bgzip/tabix 规划中)。 +> 列表右侧是真实比例滚动条,点击/拖拽直达任意位置。大文件用快扫启动 + 后台续扫。底部状态栏实时显示变异总数、SNP/InDel、Ts/Tv、PASS 数、AF 均值。 ## 配置 ```bash -seqviz config --init # 生成 ~/.config/seqviz/config.json 和 theme.json -seqviz config # 查看当前生效配置与可用主题 +seqviz config --init # 生成 ~/.config/seqviz/config.json +seqviz config # 查看当前配置 ``` -只需写入想覆盖的字段,其余自动保持默认(深度合并)。 +只写想改的字段,其余保持默认。
config.json 示例 @@ -162,74 +162,44 @@ seqviz config # 查看当前生效配置与可用主题 "browser": { "wrap_width": 60, "auto_wrap": true, - "scroll_step": 5, - "sidebar_width": 32, - "show_line_numbers": true, - "show_quality": true + "scroll_step": 5 }, "colors": { - "dna": { "A": "green", "T": "red", "C": "blue", "G": "yellow", "N": "dim" }, - "quality_thresholds": { "high": 30, "medium": 20, "low": 10 } - }, - "file_browser": { - "extensions": [".fa", ".fasta", ".fna", ".faa", ".aa", ".seq", ".fq", ".fastq"] + "dna": { "A": "green", "T": "red", "C": "blue", "G": "yellow" } } } ``` -> `auto_wrap: true`(默认)时,序列按窗口宽度自动换行;设为 `false` 则使用固定的 `wrap_width`。 -> -> `colors.dna` 自定义碱基着色(Rich 颜色名或十六进制);`colors.quality_thresholds` 控制质量值三档着色的 Phred 阈值;`file_browser.extensions` 控制文件选择器扫描的后缀。运行 `seqviz config` 可查看完整默认值。 -
-## 内置主题 +## 主题 -8 套精选配色,在 `config.json` 中设置 `"theme": "名称"` 即可切换: +8 套配色,`config.json` 里 `"theme": "名称"` 切换: -| 主题 | 风格 | 色调 | -|------|------|------| -| `light` | 白底黑字 · 清晰明亮 | ☀️ 亮色 | -| `dark` | 经典深色 · 护眼低对比(默认) | 🌙 暗色 | -| `nord` | 北极冷色 · 柔和优雅 | 🧊 冷色 | -| `gruvbox` | 暖色复古 · 棕黄基调 | 🔥 暖色 | -| `catppuccin` | 柔和粉彩 · 温暖暗色 | 🌸 粉彩 | -| `solarized` | 经典 Solarized Dark | 🌊 青蓝 | -| `rose-pine` | 玫瑰松木 · 低饱和暖紫 | 🌹 暖紫 | -| `tokyo-night` | 东京夜景 · 蓝紫冷调 | 🌃 蓝紫 | - -用户可通过 `~/.config/seqviz/theme.json` 进一步覆盖任意颜色字段。 - -## 配色方案 - -**DNA 碱基:** -`A` 绿色 · `T` 红色 · `C` 蓝色 · `G` 黄色 · `N` 灰色 - -**蛋白质氨基酸(按化学性质):** -疏水性 (AVILMFYW) 绿色系 · 亲水性 (STNQ) 蓝色系 · 碱性 (RKH) 红色系 · 酸性 (DE) 紫色系 · 特殊 (GPC) 灰色系 +| 主题 | 风格 | +|------|------| +| `light` | 白底,明亮 | +| `dark` | 经典深色(默认) | +| `nord` | 北极冷色 | +| `gruvbox` | 暖色复古 | +| `catppuccin` | 柔和粉彩 | +| `solarized` | 经典 Solarized Dark | +| `rose-pine` | 低饱和暖紫 | +| `tokyo-night` | 蓝紫冷调 | -**质量值 (Phred):** -≥30 绿色 · ≥20 黄色 · ≥10 亮红 · <10 红色 +也可以用 `~/.config/seqviz/theme.json` 自定义任意颜色。 ## 性能 -基于大文件实测(Apple Silicon · SMB 网络卷): +Apple Silicon + SMB 网络卷实测: -| 操作 | 数据规模 | 耗时 | -|------|---------|------| +| 操作 | 规模 | 耗时 | +|------|------|------| | 扫描索引 | 10K 序列 (25MB) | ~7 ms | | 扫描索引 | 50K reads (30MB) | ~46 ms | | 扫描索引 | 1.3G pangenome (350 seqs) | ~1.3 s | -| 加载序列 | 79Mbp chr1 (分块模式) | ~3 ms | -| 滚动 | 超长序列浏览 | ~3.8 ms/次 | - -> **v0.6.3 健壮性与可靠性修复**:导出不再静默覆盖已有文件(自动追加序号)并净化全部跨平台非法文件名字符;同一文件多标签页后台扫描不再错配污染数据;非 UTF-8 编码 header 宽容处理不再崩溃;theme.json 非法类型字段自动过滤不再导致应用无法启动;文件选择器序列计数真正可取消(GB 级文件预览后退出不再挂起);窗口加宽后不再空白屏;剪贴板工具异常退出不再崩溃;畸形 FASTQ 空行宽容与友好报错;空文件 CLI 改为非零退出;测试环境隔离用户配置;测试套件扩充至 210 项。 -> -> **v0.6.2 健壮性与体验修复**:修复帮助面板打开时按 q 直接退出应用(现为关闭面板);Tab 键真实切换文件标签页且不再穿透模态帮助屏;空文件按 e/y 不再崩溃;配置类型校验宽容 0/1 与整数值 float(避免行为静默反转);文件选择器序列计数移入后台线程并去重、退出可取消;格式检测统一单一入口、gzip 后缀大小写全链路一致;`config --init` 不再生成覆盖内置主题的 theme.json;修复状态栏被 Footer 遮挡;测试套件扩充至 183 项。 -> -> **v0.6.1 正确性与性能修复**:修复后台扫描重复追加导致的侧栏点选错位;修复可变行宽/含空行 FASTA 分块读取错位(checkpoint 索引回退);后台扫描移入线程不再冻结 UI(p95 延迟 1445ms→71ms);大序列指标缓存 + 流式导出;测试套件扩充至 160 项。 -> -> **v0.5.0 性能优化**:懒长度扫描使索引提速 25-68x,分块加载使大序列(>1Mbp)加载提速 634x、内存降低 7917x。 +| 加载序列 | 79Mbp chr1 | ~3 ms | +| 滚动 | 超长序列 | ~3.8 ms/次 | ## 开发 diff --git a/uv.lock b/uv.lock index 6044040..28a674a 100644 --- a/uv.lock +++ b/uv.lock @@ -229,7 +229,7 @@ wheels = [ [[package]] name = "seqviz" -version = "0.6.3" +version = "0.7.0rc1" source = { editable = "." } dependencies = [ { name = "rich" }, From 18239154b33886b0855e9a9335ffa99bd8a0ba8f Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Wed, 19 Aug 2026 06:23:06 +0800 Subject: [PATCH 15/20] =?UTF-8?q?feat:=20VCF=20=E5=8F=AF=E8=A7=86=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E8=AF=A6=E6=83=85=E9=9D=A2=E6=9D=BF=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=EF=BC=8C=E4=BF=AE=E5=A4=8D=20issue=20#4=20?= =?UTF-8?q?=E4=B8=A4=E9=A1=B9=E9=81=97=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VCF 浏览器右侧详情/基因型矩阵面板支持键盘滚动浏览 (Tab 聚焦,j/k/↑↓/PgUp/PgDn/Space/b/g/G,Esc 返回列表; n/p 保持变异级导航语义不受焦点影响) - issue #4: SequenceView.on_unmount 组件自身关闭持久句柄 (Textual 先卸载子组件,App 级钩子 query 返回空集从未生效) - issue #4: SequenceList 构造/追加改用批量 add_options (逐个 add_option 导致索引耗时退化 25 倍,复核者 A/B 验证 4.4x 提速) - 修复软屏蔽 DNA(RepeatMasker 小写 x)误判为蛋白质 - CLI _check_file 只拒绝目录,恢复 /dev/stdin、命名管道等 Unix 惯用输入 - 新增 clipboard.py 共享分层回退剪贴板(browser/vcf_browser 统一) - 327 项测试全部通过,ruff check src/ 零告警 --- README.md | 4 +- src/seqviz/__init__.py | 13 +++ src/seqviz/browser.py | 64 ++++-------- src/seqviz/cli.py | 167 ++++++++++++++++++------------- src/seqviz/clipboard.py | 44 ++++++++ src/seqviz/fastq.py | 25 +++-- src/seqviz/parsers.py | 2 +- src/seqviz/renderer.py | 5 +- src/seqviz/seq_type.py | 11 +- src/seqviz/stats.py | 28 +++--- src/seqviz/theme.py | 11 +- src/seqviz/vcf.py | 10 +- src/seqviz/vcf_browser.py | 156 ++++++++++++++++++++--------- test/test_browser.py | 82 ++++++++++++++- test/test_cli.py | 69 ++++++++++++- test/test_core.py | 29 ++++-- test/test_parsers.py | 32 ++++++ test/test_vcf.py | 23 +++++ test/test_vcf_browser.py | 204 +++++++++++++++++++++++++++++++++++++- 19 files changed, 767 insertions(+), 212 deletions(-) create mode 100644 src/seqviz/clipboard.py diff --git a/README.md b/README.md index 1e5fa1b..534615f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [安装](#安装) · [快速开始](#快速开始) · [浏览器](#交互式浏览器) · [配置](#配置) -v0.6.3 · Python >= 3.12 +v0.7.0rc1 · Python >= 3.12 @@ -206,7 +206,7 @@ Apple Silicon + SMB 网络卷实测: ```bash git clone https://github.com/Georicl/seqviz.git && cd seqviz uv sync -uv run pytest test/ -v # 254 个测试 +uv run pytest test/ -v # 327 个测试 ``` ## License diff --git a/src/seqviz/__init__.py b/src/seqviz/__init__.py index e69de29..a4bbe05 100644 --- a/src/seqviz/__init__.py +++ b/src/seqviz/__init__.py @@ -0,0 +1,13 @@ +"""seqviz — 生物序列数据终端可视化工具。 + +FASTA / FASTQ / VCF 的彩色查看、统计与交互式终端浏览。 +""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("seqviz") +except PackageNotFoundError: # 源码树直接运行时(未安装)回退 + __version__ = "0.7.0rc1" + +__all__ = ["__version__"] diff --git a/src/seqviz/browser.py b/src/seqviz/browser.py index 70a0f45..8ca3dc9 100644 --- a/src/seqviz/browser.py +++ b/src/seqviz/browser.py @@ -1,6 +1,5 @@ import gzip import re -import subprocess from collections.abc import Generator from enum import Enum from pathlib import Path @@ -22,7 +21,7 @@ ) from textual.widgets.option_list import Option -from seqviz import config +from seqviz import clipboard, config from seqviz.renderer import ( colorize_quality, colorize_sequence, @@ -127,6 +126,8 @@ def _iter_sequences(filepath: Path, fmt: FileFormat, start_idx: int = 0) -> Gene offset += len(plus_line) quality_line = f.readline() # quality 行 offset += len(quality_line) + if not (seq_line and plus_line and quality_line): + return # 文件在记录中间截断:丢弃不完整记录,避免幻影条目 if idx >= start_idx: header = header_line.strip()[1:].decode(errors="replace") yield SequenceInfo(idx, header, record_offset, len(seq_line.strip()), has_quality=True) @@ -154,8 +155,7 @@ class SequenceList(OptionList): def __init__(self, sequences: list[SequenceInfo], **kwargs): super().__init__(**kwargs) - for seq in sequences: - self.add_option(Option(self._make_label(seq), id=f"seq-{seq.index}")) + self.add_options([Option(self._make_label(seq), id=f"seq-{seq.index}") for seq in sequences]) @staticmethod def _make_label(seq: SequenceInfo) -> str: @@ -174,9 +174,10 @@ def append_sequences(self, new_seqs: list[SequenceInfo]): """为新扫描到的序列追加 Option(后台扫描用)。 只加 Option,不追加数据 list——数据由 FileTab.sequences 统一持有。 + 批量 add_options:逐个 add_option 每次都标脏挂载中的 OptionList 触发重排重绘, + 400K reads 下索引耗时从 ~70s 退化;一次批量添加可提速数倍(issue #4 复核验证)。 """ - for seq in new_seqs: - self.add_option(Option(self._make_label(seq), id=f"seq-{seq.index}")) + self.add_options([Option(self._make_label(seq), id=f"seq-{seq.index}") for seq in new_seqs]) class SequenceView(Static): @@ -229,6 +230,11 @@ def close(self): self._fh.close() self._fh = None + def on_unmount(self): + """组件自身卸载时关闭持久句柄(issue #4:Textual 先卸载子组件再触发 App 的 + on_unmount,App 级 query(SequenceView) 此时已返回空集,钩子必须挂在组件自身)。""" + self.close() + def load_sequence(self, seq_info: SequenceInfo): """用 offset 直接 seek 到目标位置。大序列分块加载。 @@ -810,17 +816,12 @@ def on_mount(self): self.run_worker(self._background_scan, thread=True, exclusive=False) def on_unmount(self): - """应用退出时置取消标志并关闭所有持久文件句柄,避免文件描述符泄露。 + """应用退出时置取消标志,通知后台扫描线程尽早退出。 - 后台扫描线程在每批之间检查取消标志后尽早退出, - 避免退出后继续占用 CPU/IO 或向已关闭的事件循环投递更新。 + 文件句柄关闭由 SequenceView.on_unmount 在组件自身卸载时完成 + (App 级钩子触发时子组件已卸载,query 拿不到 view)。 """ self._scan_cancelled = True - try: - for view in self.query(SequenceView): - view.close() - except Exception: # noqa: BLE001, S110 - pass # 应用启动期异常时 screen 栈可能不存在,避免掩盖真实错误 def _background_scan(self): """后台扫描剩余序列(在独立线程中运行,避免阻塞 Textual 事件循环)。 @@ -1062,40 +1063,11 @@ def on_key(self, event) -> None: # ── 导出 & 复制 ── def _copy_to_clipboard(self, text: str) -> bool: - """复制文本到剪贴板,成功返回 True。 + """复制文本到剪贴板,成功返回 True(系统工具优先,失败回退 OSC 52)。 - 策略:系统工具优先(反馈可靠),失败后回退 OSC 52(适用于无图形界面/SSH 场景)。 + 实现见 clipboard.py,与 VcfBrowser 共用同一套回退策略。 """ - import platform - system = platform.system() - data = text.encode() - try: - if system == "Darwin": # macOS - subprocess.run(["pbcopy"], input=data, check=True) - return True - elif system == "Linux": - # 依次尝试 xclip / xsel / wl-copy,任一成功即可 - for cmd in ( - ["xclip", "-selection", "clipboard"], - ["xsel", "--clipboard", "--input"], - ["wl-copy"], - ): - try: - subprocess.run(cmd, input=data, check=True) - return True - except (OSError, subprocess.CalledProcessError): - continue - else: # Windows - subprocess.run(["clip"], input=data, check=True) - return True - except (OSError, subprocess.CalledProcessError): - pass # 工具缺失或异常退出(非 OSError),回退 OSC 52 - # 回退: OSC 52 —— 通过终端转义序列写入本地剪贴板(需终端支持,SSH 下同样有效) - try: - self.copy_to_clipboard(text) - return True - except Exception: # noqa: BLE001 - return False # 剪贴板不可用 + return clipboard.copy_to_clipboard(text, self.copy_to_clipboard) def _handle_range_copy(self, value: str): """解析位置范围并复制对应序列片段。""" diff --git a/src/seqviz/cli.py b/src/seqviz/cli.py index 153e54b..44c0cf6 100644 --- a/src/seqviz/cli.py +++ b/src/seqviz/cli.py @@ -8,7 +8,7 @@ from seqviz import theme as theme_mod from seqviz.browser import FastaBrowser from seqviz.fastq import parse_fastq -from seqviz.file_browser import run_file_browser, scan_directory +from seqviz.file_browser import is_vcf_file, run_file_browser, scan_directory from seqviz.parsers import parse_fasta from seqviz.renderer import ( colorize_quality, @@ -18,23 +18,73 @@ quality_stats, ) from seqviz.seq_type import SeqType, detect_seq_type -from seqviz.stats import calc_sequence_stats +from seqviz.stats import calc_n50, calc_sequence_stats app = typer.Typer() console = Console() +# 明确不支持的文件类型(避免按 FASTA 静默解析产生空界面) +_UNSUPPORTED_EXTS = {".bam", ".bcf", ".cram", ".sam"} + def _check_file(file: Path) -> Path: - """检查文件存在,不存在则友好报错退出。""" + """检查文件存在且非目录,否则友好报错退出。 + + 不拒绝非常规文件:/dev/stdin、命名管道等 Unix 惯用输入仍可正常使用, + 真实读取错误由 open 层的友好报错兜底。 + """ if not file.exists(): console.print(f"[red]错误: 文件不存在: {file}[/red]") raise typer.Exit(code=1) + if file.is_dir(): + console.print(f"[red]错误: 不是文件(是目录): {file}[/red]") + raise typer.Exit(code=1) return file -def _is_vcf(path: Path) -> bool: - """判断是否为 VCF 文件(仅支持未压缩 .vcf)。""" - return path.is_file() and path.suffix.lower() == ".vcf" +def _read_error_exit(e: OSError) -> None: + """读取文件失败(权限/IO 等)时友好报错,而非裸 traceback。""" + console.print(f"[red]错误: 无法读取文件: {e}[/red]") + raise typer.Exit(code=1) from None + + +# 兼容旧名称:统一复用 file_browser.is_vcf_file 的单一实现,避免两套判定分歧 +_is_vcf = is_vcf_file + + +def _reject_unsupported(path: Path) -> None: + """对明确不支持的文件类型友好报错,避免静默按 FASTA 解析产生空界面。""" + if not path.is_file(): + return + suffix = path.suffix.lower() + if suffix == ".gz": + inner = Path(path.stem).suffix.lower() + if inner == ".vcf": + console.print(f"[red]错误: 暂不支持压缩 VCF 文件: {path}[/red](请先解压为 .vcf)") + raise typer.Exit(code=1) + suffix = inner + if suffix in _UNSUPPORTED_EXTS: + console.print(f"[red]错误: 暂不支持的文件类型: {path}[/red]") + raise typer.Exit(code=1) + + +def _print_fasta_record(header: str, seq: str, wrap: int) -> None: + """打印一条 FASTA 记录:header + 类型标签 + 位置标尺 + 分块着色序列。""" + seqtype = detect_seq_type(seq) + type_label = "DNA" if seqtype == SeqType.DNA else "Protein" if seqtype == SeqType.PROTEIN else "Unknown" + + console.print( + f"[bold cyan]> {header}[/bold cyan] " + f"[dim]\\[{type_label}] {len(seq)}bp[/dim]" + ) + # 按 wrap 宽度切分,逐行着色并输出(避免整条 Rich Text 的 span explosion) + for chunk_start in range(0, len(seq), wrap): + chunk_end = min(chunk_start + wrap, len(seq)) + ruler = position_ruler(chunk_start + 1, chunk_end - chunk_start) + console.print(" ", end="") + console.print(ruler) + console.print(" ", end="") + console.print(colorize_sequence(seq[chunk_start:chunk_end], seq_type=seqtype)) def _run_vcf_browser(path: Path): @@ -118,8 +168,21 @@ def _launch_browser(paths: list[Path]): break +def _version_callback(value: bool) -> None: + if value: + from seqviz import __version__ + console.print(f"seqviz {__version__}") + raise typer.Exit() + + @app.callback(invoke_without_command=True) -def main(ctx: typer.Context): +def main( + ctx: typer.Context, + version: bool = typer.Option( + False, "--version", "-V", help="显示版本号", + callback=_version_callback, is_eager=True, + ), +): """seqviz — 生物序列数据终端可视化工具。 不带任何命令时,默认打开当前目录的文件浏览器。 @@ -135,26 +198,12 @@ def view( ): """美化查看 FASTA 文件""" _check_file(file) - for header, seq in parse_fasta(str(file)): - seqtype = detect_seq_type(seq) - type_label = "DNA" if seqtype == SeqType.DNA else "Protein" if seqtype == SeqType.PROTEIN else "Unknown" - - # header + 类型标签 + 长度 - console.print( - f"[bold cyan]> {header}[/bold cyan] " - f"[dim]\\[{type_label}] {len(seq)}bp[/dim]" - ) - - # 按 wrap 宽度切分,逐行着色并输出(避免整条 Rich Text 的 span explosion) - for chunk_start in range(0, len(seq), wrap): - chunk_end = min(chunk_start + wrap, len(seq)) - ruler = position_ruler(chunk_start + 1, chunk_end - chunk_start) - console.print(" ", end="") - console.print(ruler) - console.print(" ", end="") - console.print(colorize_sequence(seq[chunk_start:chunk_end], seq_type=seqtype)) - - console.print() # 序列间空行 + try: + for header, seq in parse_fasta(str(file)): + _print_fasta_record(header, seq, wrap) + console.print() # 序列间空行 + except OSError as e: + _read_error_exit(e) @app.command() def stats( @@ -167,19 +216,22 @@ def stats( total_len = 0 count = 0 - for header, seq in parse_fasta(str(file)): - length, gc = calc_sequence_stats(seq) - lengths.append(length) - total_gc += gc - total_len += length - count += 1 + try: + for header, seq in parse_fasta(str(file)): + length, gc = calc_sequence_stats(seq) + lengths.append(length) + total_gc += gc + total_len += length + count += 1 + except OSError as e: + _read_error_exit(e) if count == 0: console.print("[red]文件中没有序列[/red]") raise typer.Exit(code=1) # 错误路径应非零退出 lengths.sort(reverse=True) - n50 = _calc_n50(lengths, total_len) + n50 = calc_n50(lengths, total_len) # 用 Rich Table 输出 table = Table(title=f"{file} 统计摘要") table.add_column("指标", style="bold cyan") @@ -204,26 +256,15 @@ def head( """查看 FASTA 文件的前 N 条序列。""" _check_file(file) count = 0 - for header, seq in parse_fasta(str(file)): - if count >= n: - break - - seqtype = detect_seq_type(seq) - type_label = "DNA" if seqtype == SeqType.DNA else "Protein" if seqtype == SeqType.PROTEIN else "Unknown" - console.print( - f"[bold cyan]> {header}[/bold cyan] " - f"[dim]\\[{type_label}] {len(seq)}bp[/dim]" - ) - # 按 chunk 着色,避免整条 span explosion - for chunk_start in range(0, len(seq), wrap): - chunk_end = min(chunk_start + wrap, len(seq)) - ruler = position_ruler(chunk_start + 1, chunk_end - chunk_start) - console.print(" ", end="") - console.print(ruler) - console.print(" ", end="") - console.print(colorize_sequence(seq[chunk_start:chunk_end], seq_type=seqtype)) - console.print() - count += 1 + try: + for header, seq in parse_fasta(str(file)): + if count >= n: + break + _print_fasta_record(header, seq, wrap) + console.print() + count += 1 + except OSError as e: + _read_error_exit(e) if count == 0: console.print("[red]文件中没有序列[/red]") @@ -285,9 +326,11 @@ def fqview( console.print() # read 间空行 except ValueError as e: - # 畸形 FASTQ(非 '@' 开头记录等):友好报错而非裸 traceback + # 畸形 FASTQ(非 '@' 开头记录/截断等):友好报错而非裸 traceback console.print(f"[red]错误: {e}[/red]") raise typer.Exit(code=1) from None + except OSError as e: + _read_error_exit(e) if count == 0: console.print("[red]文件中没有序列[/red]") @@ -305,6 +348,7 @@ def browse( if not p.exists(): console.print(f"[red]错误: 路径不存在: {p}[/red]") raise typer.Exit(code=1) + _reject_unsupported(p) _launch_browser(list(files)) @@ -370,15 +414,4 @@ def config( for t in themes ) console.print(f"[dim]可用主题:[/dim] {theme_list}") - console.print(f"[dim]在 config.json 中设置 \"theme\": \"{themes[0]}\" 切换主题[/dim]") - - -def _calc_n50(sorted_lengths: list[int], total_len: int) -> int: - """计算 N50:累计长度达到总长 50% 时对应的序列长度。""" - half = total_len / 2 - cumsum = 0 - for length in sorted_lengths: - cumsum += length - if cumsum >= half: - return length - return 0 \ No newline at end of file + console.print(f"[dim]在 config.json 中设置 \"theme\": \"{themes[0]}\" 切换主题[/dim]") \ No newline at end of file diff --git a/src/seqviz/clipboard.py b/src/seqviz/clipboard.py new file mode 100644 index 0000000..a33c992 --- /dev/null +++ b/src/seqviz/clipboard.py @@ -0,0 +1,44 @@ +"""跨平台剪贴板复制:系统工具优先,失败后回退 OSC 52。 + +OSC 52 通过终端转义序列写入剪贴板,适用于无图形界面 / SSH 场景。 +browser.py 与 vcf_browser.py 共用此实现,避免两套逻辑漂移。 +""" + +import subprocess +from collections.abc import Callable + + +def copy_to_clipboard(text: str, osc52_fallback: Callable[[str], None]) -> bool: + """复制文本到剪贴板,成功返回 True。 + + 策略:系统工具优先(反馈可靠),失败后回退 OSC 52(需终端支持,SSH 下同样有效)。 + """ + import platform + system = platform.system() + data = text.encode() + try: + if system == "Darwin": # macOS + subprocess.run(["pbcopy"], input=data, check=True) + return True + elif system == "Linux": + # 依次尝试 xclip / xsel / wl-copy,任一成功即可 + for cmd in ( + ["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"], + ["wl-copy"], + ): + try: + subprocess.run(cmd, input=data, check=True) + return True + except (OSError, subprocess.CalledProcessError): + continue + else: # Windows + subprocess.run(["clip"], input=data, check=True) + return True + except (OSError, subprocess.CalledProcessError): + pass # 工具缺失或异常退出(非 OSError),回退 OSC 52 + try: + osc52_fallback(text) + return True + except Exception: # noqa: BLE001 + return False # 剪贴板不可用 diff --git a/src/seqviz/fastq.py b/src/seqviz/fastq.py index e0d0f16..4ecbe67 100644 --- a/src/seqviz/fastq.py +++ b/src/seqviz/fastq.py @@ -7,10 +7,13 @@ def parse_fastq(filepath: str | Path) -> Generator[tuple[str, str, str], None, N """ 流式解析 FASTQ 文件。 逐条 yield (header, sequence, quality)。 + + 文件在记录中间截断(缺序列行/分隔符/质量行)时抛 ValueError, + 避免静默产出空序列/空质量的幻影记录。 """ filepath = Path(filepath) opener = gzip.open if filepath.suffix.lower() == ".gz" else open - + with opener(filepath, "rt", encoding="utf-8", errors="replace") as f: while True: header_line = f.readline() @@ -21,11 +24,19 @@ def parse_fastq(filepath: str | Path) -> Generator[tuple[str, str, str], None, N if not header_line.strip(): continue # 跳过空行(尾部空行/空行分隔),避免报格式错误 - seq = f.readline().rstrip("\n") - f.readline() # "+" 分隔符,跳过 - quality = f.readline().rstrip("\n") - if not header_line.startswith("@"): raise ValueError(f"FASTQ 格式错误: 期望 '@' 开头, 得到: {header_line!r}") - - yield header_line[1:], seq, quality # 去掉 @ \ No newline at end of file + + seq = f.readline() + if not seq: + raise ValueError(f"FASTQ 格式错误: 记录 {header_line[1:]!r} 在序列行处截断") + plus = f.readline() + if not plus: + raise ValueError(f"FASTQ 格式错误: 记录 {header_line[1:]!r} 缺少 '+' 分隔符(文件截断)") + if not plus.startswith("+"): + raise ValueError(f"FASTQ 格式错误: 期望 '+' 分隔符, 得到: {plus.rstrip(chr(10))!r}(序列可能跨行)") + quality = f.readline() + if not quality: + raise ValueError(f"FASTQ 格式错误: 记录 {header_line[1:]!r} 缺少质量行(文件截断)") + + yield header_line[1:], seq.rstrip("\n"), quality.rstrip("\n") diff --git a/src/seqviz/parsers.py b/src/seqviz/parsers.py index 9e7b576..e89bfa7 100644 --- a/src/seqviz/parsers.py +++ b/src/seqviz/parsers.py @@ -23,7 +23,7 @@ def parse_fasta(filepath: str | Path) -> Generator[tuple[str, str], None, None]: if header is not None: yield header, "".join(seq_parts) - header = line[1:] # 去掉 > 符号 + header = line[1:].strip() # 去掉 > 符号并去除首尾空白(与 browser 索引一致) seq_parts = [] else: diff --git a/src/seqviz/renderer.py b/src/seqviz/renderer.py index c82fc35..10655d1 100644 --- a/src/seqviz/renderer.py +++ b/src/seqviz/renderer.py @@ -131,7 +131,10 @@ def quality_bar(quality: str, width: int = 40) -> Text: medium = _get_quality_thresholds().get("medium", 20) low = _get_quality_thresholds().get("low", 10) - bin_size = max(1, len(scores) // width) + # 向上取整分桶:保证输出条数不超过 width(len 落在 (width, 2*width) 时 + # 旧的 len // width 会退化为 bin_size=1,导致条数超过指定宽度) + width = max(1, width) + bin_size = max(1, (len(scores) + width - 1) // width) text = Text() for i in range(0, len(scores), bin_size): chunk = scores[i:i + bin_size] diff --git a/src/seqviz/seq_type.py b/src/seqviz/seq_type.py index 68c2bf5..0b2a0dd 100644 --- a/src/seqviz/seq_type.py +++ b/src/seqviz/seq_type.py @@ -7,6 +7,10 @@ class SeqType(Enum): UNKNOWN = "unknown" DNA_CHARS = set("ATCGUNRYWSMKBDHV-.") +# 蛋白质独有氨基酸:除经典 E/F/I/L/P/Q 外,X(未知氨基酸)、J(亮/异亮)、 +# O(吡咯赖氨酸)、Z(谷氨酰胺/谷氨酸)在实际蛋白质文件中很常见, +# 不纳入会导致含这些字符的蛋白质被误判为 UNKNOWN 并使用 DNA 调色板着色。 +PROTEIN_ONLY_CHARS = set("EFILPQXJZO") def detect_seq_type(seq: str, sample_size: int = 1000) -> SeqType: """ @@ -19,9 +23,10 @@ def detect_seq_type(seq: str, sample_size: int = 1000) -> SeqType: # 如果没有, 返回DNA类型 return SeqType.DNA - protein_field = set("EFILPQ") - if non_dna <= protein_field: - # 如果全是蛋白质独有简并码, 返回蛋白质类型 + if non_dna <= PROTEIN_ONLY_CHARS and (non_dna & set("EFILPQ")): + # 如果全是蛋白质独有简并码且至少含一个经典蛋白残基(E/F/I/L/P/Q),返回蛋白质类型。 + # 要求经典残基:真核基因组软屏蔽(RepeatMasker 小写 x 重复区)主体为 DNA + x, + # 若无此条件会被误判为 PROTEIN(真实蛋白质在前 1000 字符采样中几乎必含经典残基)。 return SeqType.PROTEIN # 什么都不是返回未知 diff --git a/src/seqviz/stats.py b/src/seqviz/stats.py index 7275be4..9cb944f 100644 --- a/src/seqviz/stats.py +++ b/src/seqviz/stats.py @@ -1,21 +1,17 @@ -from dataclasses import dataclass - - -@dataclass -class SequenceStats: - header: str - length: int - gc_count: int - - @property - def gc_content(self) -> float: - if self.length == 0: - return 0.0 - return self.gc_count / self.length - def calc_sequence_stats(seq: str) -> tuple[int, int]: """返回 (length, gc_count)。使用 count 代替逐字符遍历。""" upper_seq = seq.upper() length = len(upper_seq) gc_count = upper_seq.count("G") + upper_seq.count("C") - return length, gc_count \ No newline at end of file + return length, gc_count + + +def calc_n50(sorted_lengths: list[int], total_len: int) -> int: + """计算 N50:累计长度达到总长 50% 时对应的序列长度。""" + half = total_len / 2 + cumsum = 0 + for length in sorted_lengths: + cumsum += length + if cumsum >= half: + return length + return 0 diff --git a/src/seqviz/theme.py b/src/seqviz/theme.py index 2284a7a..fe44b64 100644 --- a/src/seqviz/theme.py +++ b/src/seqviz/theme.py @@ -412,9 +412,18 @@ def build_vcf_browser_css(theme: dict) -> str: }} #detail {{ width: 1fr; - padding: 0 1; + height: 1fr; background: {bg}; color: {fg}; + /* 透明边框占位:聚焦切色时不引起内容重排 */ + border-left: thick {bg}; + }} + #detail:focus {{ + border-left: thick {accent}; + }} + #detail-content {{ + height: auto; + padding: 0 1; }} #status-bar {{ dock: bottom; diff --git a/src/seqviz/vcf.py b/src/seqviz/vcf.py index 5748ab4..944030e 100644 --- a/src/seqviz/vcf.py +++ b/src/seqviz/vcf.py @@ -127,7 +127,7 @@ def parse_meta(header_lines: list[str]) -> VcfMeta: lm = _LEN_RE.search(line) if m: meta.contigs[m.group(1)] = int(lm.group(1)) if lm else 0 - elif line.startswith("##INFO=") or line.startswith("##FORMAT="): + elif line.startswith(("##INFO=", "##FORMAT=")): m = _ID_RE.search(line) dm = _DESC_RE.search(line) target = meta.info_defs if line.startswith("##INFO=") else meta.format_defs @@ -186,8 +186,12 @@ def _index_line(line: bytes, offset: int) -> Variant | None: def parse_variant_line(line: str, offset: int = 0, sample_names: list[str] | None = None) -> Variant | None: - """完整解析单条数据行;畸形行(<8 列或 POS 非整数)返回 None。""" - raw = line.rstrip("\n") + """完整解析单条数据行;畸形行(<8 列或 POS 非整数)返回 None。 + + 注意同时去除 \\r\\n:CRLF 行尾文件的 \\r 若残留会污染 raw(y 复制) + 与最后一个样本列(AD 等数值字段解析失败)。 + """ + raw = line.rstrip("\r\n") parts = raw.split("\t") if len(parts) < 8 or parts[0].startswith("#"): return None diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index b4ce9cc..22ab6ca 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -4,19 +4,21 @@ """ import re -import subprocess +from collections.abc import Sequence from functools import partial +from itertools import pairwise from pathlib import Path from rich.text import Text from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Horizontal +from textual.containers import Horizontal, VerticalScroll from textual.screen import ModalScreen from textual.widget import Widget from textual.widgets import Footer, Header, Input, OptionList, Static from textual.widgets.option_list import Option +from seqviz import clipboard from seqviz import theme as theme_mod from seqviz.vcf import ( Variant, @@ -148,7 +150,7 @@ class HelpScreen(ModalScreen): } """ - BINDINGS = [ + BINDINGS: Sequence[Binding] = [ Binding("question_mark", "dismiss", "关闭"), Binding("escape", "dismiss", "关闭"), Binding("q", "dismiss", "关闭"), @@ -162,10 +164,11 @@ def on_mount(self): panel.border_title = "Seqviz VCF — 快捷键" text = Text() rows = [ - ("j / k", "上下移动"), ("n / p", "下/上一条变异"), - ("Space / b", "翻页"), ("g / G", "顶部 / 底部"), + ("j / k", "上下移动(右侧聚焦时滚动详情)"), ("n / p", "下/上一条变异"), + ("Space / b", "翻页(右侧聚焦时翻详情)"), ("g / G", "顶部 / 底部"), ("/", "搜索: ID / chr:pos / chr:a-b"), ("f", "过滤循环 (全部/PASS/SNP/InDel)"), ("s", "排序切换 (位置/QUAL)"), ("t", "详情 ↔ 基因型矩阵"), + ("Tab / Esc", "左侧列表 ↔ 右侧详情面板"), ("i", "文件信息"), ("y", "复制当前 VCF 行"), ("?", "帮助"), ("q", "退出"), ] @@ -175,6 +178,62 @@ def on_mount(self): panel.update(text) +class DetailPanel(VerticalScroll, can_focus=True): + """右侧详情/基因型矩阵面板:内容超出可视区域时可滚动浏览。 + + 内部 #detail-content 承载渲染文本(height:auto 撑开虚拟高度,容器滚动)。 + Tab 聚焦后 ↑↓ / PageUp/PageDown / Space/b / g/G 滚动内容(j/k 由 App + 优先绑定委托;n/p 始终为变异级导航);Esc 返回左侧变异列表。未聚焦时不拦截任何按键, + 左侧列表的导航/搜索/过滤/排序/复制行为完全不变。 + """ + + BINDINGS: Sequence[Binding] = [ + Binding("down", "detail_down", "详情下滚", show=False), + Binding("up", "detail_up", "详情上滚", show=False), + Binding("space", "detail_page_down", "下翻页", show=False), + Binding("b", "detail_page_up", "上翻页", show=False), + Binding("pageup", "detail_page_up", "上翻页", show=False), + Binding("pagedown", "detail_page_down", "下翻页", show=False), + Binding("g", "detail_home", "详情顶部", show=False), + Binding("G", "detail_end", "详情底部", show=False), + Binding("escape", "back_to_list", "返回列表", show=False), + ] + + STEP = 3 # 每次 j/k/↑/↓ 滚动行数(与滚轮默认步幅一致) + + def compose(self) -> ComposeResult: + yield Static("", id="detail-content") + + def content(self) -> Static: + return self.query_one("#detail-content", Static) + + def set_content(self, renderable) -> None: + """更新内容并回到顶部(切换变异/视图时调用)。""" + self.content().update(renderable) + self.scroll_home(animate=False) + + def action_detail_down(self): + self.scroll_relative(0, self.STEP, animate=False) + + def action_detail_up(self): + self.scroll_relative(0, -self.STEP, animate=False) + + def action_detail_page_down(self): + self.scroll_relative(0, self.size.height, animate=False) + + def action_detail_page_up(self): + self.scroll_relative(0, -self.size.height, animate=False) + + def action_detail_home(self): + self.scroll_home(animate=False) + + def action_detail_end(self): + self.scroll_end(animate=False) + + def action_back_to_list(self): + self.app.query_one("#variant-list", OptionList).focus() + + class VcfBrowser(App): """VCF 变异浏览器。""" @@ -182,11 +241,11 @@ class VcfBrowser(App): DARK = theme_mod.is_dark_theme(theme_mod.get_theme_name()) CSS = theme_mod.build_vcf_browser_css(theme_mod.get_theme()) - BINDINGS = [ + BINDINGS: Sequence[Binding] = [ Binding("j", "cursor_down", "下移", show=True, priority=True), Binding("k", "cursor_up", "上移", show=True, priority=True), - Binding("n", "cursor_down", "下一条", show=False), - Binding("p", "cursor_up", "上一条", show=False), + Binding("n", "next_variant", "下一条", show=False), + Binding("p", "prev_variant", "上一条", show=False), Binding("space", "page_down", "下翻页", show=True), Binding("b", "page_up", "上翻页", show=True), Binding("g", "home", "顶部", show=True), @@ -248,7 +307,7 @@ def compose(self) -> ComposeResult: with Horizontal(): yield VariantList(id="variant-list") yield AbsoluteScrollbar(id="abs-scrollbar") - yield Static("", id="detail") + yield DetailPanel(id="detail") yield Static("", id="status-bar") yield Footer() @@ -372,7 +431,7 @@ def _extend_index(self, batch: list[Variant]): if ok and self.variants and not self._pos_ge(batch[0], self.variants[-1]): ok = False if ok: - for x, y in zip(batch, batch[1:]): + for x, y in pairwise(batch): if not self._pos_ge(y, x): ok = False break @@ -451,10 +510,17 @@ def _sync_position_indicator(self): # 位置变化 → 同步真实比例滚动条滑块 self.query_one("#abs-scrollbar", AbsoluteScrollbar).refresh() + def _detail(self) -> DetailPanel: + return self.query_one("#detail", DetailPanel) + + def _detail_focused(self) -> bool: + """右侧详情面板是否持有焦点(j/k 等优先绑定据此委托滚动)。""" + return isinstance(self.focused, DetailPanel) + def _detail_fh(self): """详情回读复用句柄(避免每次导航 open/close)。""" if self._fh is None or self._fh.closed: - self._fh = open(self.filepath, "rb") + self._fh = open(self.filepath, "rb") # noqa: SIM115 — 句柄跨调用复用,on_unmount 统一关闭 return self._fh # ── 排序/过滤基础设施 ── @@ -549,7 +615,7 @@ def _refresh_list(self): if self.view: self._show_detail(0) else: - self.query_one("#detail", Static).update(Text("(无匹配变异)", style="dim")) + self._detail().set_content(Text("(无匹配变异)", style="dim")) self._sync_scrollbar() self._sync_position_indicator() self._update_status_bar() @@ -648,7 +714,7 @@ def _show_detail(self, list_index: int): idx = self.view[list_index] v = self.variants[idx] load_variant_detail(self.filepath, v, self.meta.samples, fh=self._detail_fh()) - self.query_one("#detail", Static).update(self._build_detail(v)) + self._detail().set_content(self._build_detail(v)) def _detail_text(self) -> str: """当前详情的纯文本(供测试断言)。""" @@ -687,7 +753,7 @@ def _matrix_text(self) -> str: return str(self._build_matrix(self._abs_index())) def _render_matrix(self, list_index: int): - self.query_one("#detail", Static).update(self._build_matrix(list_index)) + self._detail().set_content(self._build_matrix(list_index)) # ── 状态栏(异步统计,避免大文件过滤切换时冻结 UI) ── def _update_status_bar(self): @@ -733,37 +799,10 @@ def _apply_stats_bar(self, s: dict, token: int): parts += f" │ 跳过 {self.skipped} 行畸形数据" self.query_one("#status-bar", Static).update(Text(parts)) - # ── 剪贴板(与 browser.py 相同的分层回退策略) ── + # ── 剪贴板(与 browser.py 共用 clipboard.py 的同一套分层回退策略) ── def _copy_to_clipboard(self, text: str) -> bool: """系统工具优先,失败后回退 OSC 52。""" - import platform - system = platform.system() - data = text.encode() - try: - if system == "Darwin": - subprocess.run(["pbcopy"], input=data, check=True) - return True - elif system == "Linux": - for cmd in ( - ["xclip", "-selection", "clipboard"], - ["xsel", "--clipboard", "--input"], - ["wl-copy"], - ): - try: - subprocess.run(cmd, input=data, check=True) - return True - except (OSError, subprocess.CalledProcessError): - continue - else: # Windows - subprocess.run(["clip"], input=data, check=True) - return True - except (OSError, subprocess.CalledProcessError): - pass - try: - self.copy_to_clipboard(text) - return True - except Exception: # noqa: BLE001 - return False + return clipboard.copy_to_clipboard(text, self.copy_to_clipboard) # ── 搜索命令栏 ── async def _show_search_bar(self) -> None: @@ -777,7 +816,7 @@ async def _show_search_bar(self) -> None: def _remove_search_bar(self) -> None: try: self.query_one("#search-input", Input).remove() - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001, S110 — 搜索栏不存在时静默即可 pass def _get_search_bar(self) -> Input | None: @@ -844,24 +883,51 @@ def on_option_list_option_highlighted(self, event) -> None: self._sync_position_indicator() # ── Actions ── + # 右侧详情聚焦时,导航键委托给 DetailPanel 滚动;否则保持列表导航语义。 def action_cursor_down(self): + if self._detail_focused(): + self._detail().action_detail_down() + return self._goto_abs(self._abs_index() + 1) def action_cursor_up(self): + if self._detail_focused(): + self._detail().action_detail_up() + return self._goto_abs(self._abs_index() - 1) def action_page_down(self): + if self._detail_focused(): + self._detail().action_detail_page_down() + return self._goto_abs(self._abs_index() + self.PAGE) def action_page_up(self): + if self._detail_focused(): + self._detail().action_detail_page_up() + return self._goto_abs(self._abs_index() - self.PAGE) def action_home(self): + if self._detail_focused(): + self._detail().action_detail_home() + return self._goto_abs(0) def action_end(self): + if self._detail_focused(): + self._detail().action_detail_end() + return self._goto_abs(len(self.view) - 1) + def action_next_variant(self): + """n:始终导航变异(不受右侧详情焦点影响,保持变异级导航语义)。""" + self._goto_abs(self._abs_index() + 1) + + def action_prev_variant(self): + """p:始终导航变异(不受右侧详情焦点影响,保持变异级导航语义)。""" + self._goto_abs(self._abs_index() - 1) + async def action_search(self): await self._show_search_bar() @@ -909,7 +975,7 @@ def action_file_info(self): txt.append("\nFORMAT 定义:\n", style="bold cyan") for fid, desc in self.meta.format_defs.items(): txt.append(f" {fid:<10}{desc}\n") - self.query_one("#detail", Static).update(txt) + self._detail().set_content(txt) def action_copy_line(self): if not self.view: diff --git a/test/test_browser.py b/test/test_browser.py index 7ef71b7..f007199 100644 --- a/test/test_browser.py +++ b/test/test_browser.py @@ -5,7 +5,7 @@ import pytest -from seqviz.browser import FastaBrowser +from seqviz.browser import FastaBrowser, FileFormat, SequenceInfo, SequenceList TEST_DIR = Path(__file__).parent TEST_FA = TEST_DIR / "test.fa" @@ -175,12 +175,12 @@ class TestCopyExport: def test_copy_seq_to_clipboard(self, monkeypatch): async def _t(): copied = {} - import seqviz.browser as b + import seqviz.clipboard as cb def fake_run(cmd, input=None, check=False): copied["data"] = input.decode() if input else "" class R: pass return R() - monkeypatch.setattr(b.subprocess, "run", fake_run) + monkeypatch.setattr(cb.subprocess, "run", fake_run) app = FastaBrowser([TEST_FA]) async with app.run_test(size=(100, 30)) as pilot: await pilot.pause() @@ -549,3 +549,79 @@ async def _t(): assert len(seqs) == 1 assert "caf" in seqs[0].header # 非法字节被替换,其余保留 run(_t()) + + +class TestTruncatedFastq: + def test_no_phantom_records(self, tmp_path): + """截断的 FASTQ 不应产生空序列/空质量的幻影条目。""" + p = tmp_path / "trunc.fastq" + p.write_text("@r1\nACGT\n+\n") # 缺质量行 + seqs = FastaBrowser._scan_file(p, FileFormat.FASTQ) + assert seqs == [] + + def test_wellformed_still_scans(self, tmp_path): + """完整记录的 FASTQ 扫描不受截断守卫影响。""" + p = tmp_path / "ok.fastq" + p.write_text("@r1\nACGT\n+\nIIII\n@r2\nGGCC\n+\nHHHH\n") + seqs = FastaBrowser._scan_file(p, FileFormat.FASTQ) + assert len(seqs) == 2 + + +# ────────────────────────────────────────────── +# issue #4 回归:句柄泄露 + 批量追加 +# ────────────────────────────────────────────── +class TestIssue4HandleCleanup: + def test_sequence_view_closes_handle_on_unmount(self): + """SequenceView 自身卸载时关闭持久句柄(App 级钩子触发时子组件已卸载)。""" + captured = {} + async def _t(): + app = FastaBrowser([TEST_FA]) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + mv = app.query_one("#main-0") + mv._get_fh() # 确保持久句柄已打开 + assert mv._fh is not None and not mv._fh.closed + captured["view"] = mv + run(_t()) + # 退出后句柄应被关闭(close() 置 None) + assert captured["view"]._fh is None + + def test_all_views_closed_on_exit(self): + """多文件标签页的所有 SequenceView 退出后句柄均关闭。""" + captured = {} + async def _t(): + app = FastaBrowser([TEST_FA, TEST_FASTQ]) + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + views = list(app.query("SequenceView")) + for mv in views: + mv._get_fh() + assert all(mv._fh is not None for mv in views) + captured["views"] = views + run(_t()) + assert all(mv._fh is None for mv in captured["views"]) + + +class TestIssue4BatchAppend: + def test_append_sequences_batch_adds_all(self): + """批量 add_options:append_sequences 一次性追加全部 Option。""" + seqs = [SequenceInfo(i, f"seq{i}", i * 100, 50) for i in range(5)] + sl = SequenceList(seqs[:2]) + assert sl.option_count == 2 + sl.append_sequences(seqs[2:]) + assert sl.option_count == 5 + + def test_init_batch_builds_all_options(self): + """构造时批量添加,Option 数与序列数一致且 id 正确。""" + seqs = [SequenceInfo(i, f"h{i}", i * 10, 20) for i in range(10)] + sl = SequenceList(seqs) + assert sl.option_count == 10 + ids = [sl.get_option_at_index(i).id for i in range(10)] + assert ids == [f"seq-{i}" for i in range(10)] + + def test_append_empty_batch_noop(self): + """空批次追加不改变 Option 数。""" + seqs = [SequenceInfo(i, f"s{i}", i, 10) for i in range(3)] + sl = SequenceList(seqs) + sl.append_sequences([]) + assert sl.option_count == 3 diff --git a/test/test_cli.py b/test/test_cli.py index fe05a47..14d882f 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -190,6 +190,27 @@ def test_browse_header_only_vcf_friendly_error(self, tmp_path): assert result.exit_code != 0 assert "没有变异记录" in result.output + def test_browse_vcf_gz_rejected(self, tmp_path): + """压缩 VCF(.vcf.gz)应友好报错,而非静默按 FASTA 解析打开空界面。""" + import gzip + f = tmp_path / "x.vcf.gz" + with gzip.open(f, "wt") as fh: + fh.write("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n" + "chr1\t1\t.\tA\tG\t50\tPASS\t.\n") + result = runner.invoke(app, ["browse", str(f)]) + assert result.exit_code == 1 + assert "压缩 VCF" in result.output + assert "Traceback" not in result.output + + def test_browse_unsupported_bam_rejected(self, tmp_path): + """明确不支持的文件类型(如 .bam)应友好报错。""" + f = tmp_path / "x.bam" + f.write_bytes(b"\x1f\x8b") + result = runner.invoke(app, ["browse", str(f)]) + assert result.exit_code == 1 + assert "暂不支持" in result.output + assert "Traceback" not in result.output + def test_browse_mixed_vcf_and_fasta_skips_vcf(self, tmp_path, monkeypatch): """VCF 与序列文件混合打开:VCF 被剥离并提示,FastaBrowser 只收到序列文件。""" import seqviz.cli as cli_mod @@ -227,12 +248,12 @@ def test_view_help(self): class TestN50Value: def test_n50_numeric_correctness(self): """N50 数值正确性(回归:此前仅字符串存在断言)。""" - from seqviz.cli import _calc_n50 + from seqviz.stats import calc_n50 # 长度 [20, 12],总长 32,半值 16;降序累计 20 >= 16 → N50 = 20 - assert _calc_n50([20, 12], 32) == 20 + assert calc_n50([20, 12], 32) == 20 # [100, 50, 30, 20] 总长 200,半值 100;累计 100 >= 100 → N50 = 100 - assert _calc_n50([100, 50, 30, 20], 200) == 100 - assert _calc_n50([], 0) == 0 + assert calc_n50([100, 50, 30, 20], 200) == 100 + assert calc_n50([], 0) == 0 def test_stats_n50_value_in_output(self): """stats 输出的 N50 应为具体数值(test.fa: 20bp + 12bp → N50=20)。""" @@ -266,6 +287,46 @@ def test_fqview_empty_file(self, tmp_path): assert "没有序列" in result.output +class TestCommandOnDirectory: + """对目录执行文件命令应友好报错,而非裸 traceback。""" + + def test_view_on_directory(self, tmp_path): + result = runner.invoke(app, ["view", str(tmp_path)]) + assert result.exit_code == 1 + assert "不是文件" in result.output + assert "Traceback" not in result.output + + def test_stats_on_directory(self, tmp_path): + result = runner.invoke(app, ["stats", str(tmp_path)]) + assert result.exit_code == 1 + assert "不是文件" in result.output + assert "Traceback" not in result.output + + def test_head_on_directory(self, tmp_path): + result = runner.invoke(app, ["head", str(tmp_path)]) + assert result.exit_code == 1 + assert "不是文件" in result.output + assert "Traceback" not in result.output + + def test_fqview_on_directory(self, tmp_path): + result = runner.invoke(app, ["fqview", str(tmp_path)]) + assert result.exit_code == 1 + assert "不是文件" in result.output + assert "Traceback" not in result.output + + +class TestVersionOption: + def test_version_flag(self): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert result.output.strip().startswith("seqviz ") + + def test_version_short_flag(self): + result = runner.invoke(app, ["-V"]) + assert result.exit_code == 0 + assert "seqviz" in result.output + + class TestMalformedFastq: def test_trailing_blank_line_tolerated(self, tmp_path): """尾部空行应被跳过而非报格式错误。""" diff --git a/test/test_core.py b/test/test_core.py index 97158c6..84fcb91 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9,7 +9,7 @@ from seqviz.parsers import parse_fasta from seqviz.fastq import parse_fastq from seqviz.seq_type import SeqType, detect_seq_type -from seqviz.stats import calc_sequence_stats, SequenceStats +from seqviz.stats import calc_sequence_stats from seqviz.renderer import ( colorize_sequence, colorize_quality, @@ -134,9 +134,19 @@ def test_protein(self): def test_protein_realistic(self): assert detect_seq_type("MALWMRLLPLLALLALWGPDPAAA") == SeqType.PROTEIN + def test_protein_with_ambiguous_residues(self): + """含 X(未知氨基酸)/J/O/Z 的蛋白质应判为 PROTEIN,而非 UNKNOWN。""" + assert detect_seq_type("MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTX") == SeqType.PROTEIN + assert detect_seq_type("MKWVTFISLLFLFSSAYSRGVFRRDTHKSEIAHRFKDLGEQHFKGLVLIAFSQYLQQCPFDEHVKLVNELTEFAKTCVADESHAGCEKSLHTLFGDELCKVASLRETYGDMADCCEKQEPERNECFLSHKDDSPDLPKLKPDPNTLCDEFKADEKKFWGKYLYEIARRHPYFYAPELLYYANKYNGVFQECCQAEDKGACLLPKIETMREKVLASSARQRLRCASIQKFGERALKAWSVARLSQKFPKAEFVEVTKLVTDLTKVHKECCHGDLLECADDRADLAKYICDNQDTISSKLKECCDKPLLEKSHCIAEVEKDAIPENLPPLTADFAEDKDVCKNYQEAKDAFLGSFLYEYSRRHPEYAVSVLLRLAKEYEATLEECCAKDDPHACYSTVFDKLKHLVDEPQNLIKQNCDQFEKLGEYGFQNALIVRYTRKVPQVSTPTLVEVSRSLGKVGTRCCTKPESERMPCTEDYLSLILNRLCVLHEKTPVSEKVTKCCTESLVNRRPCFSALTPDETYVPKAFDEKLFTFHADICTLPDTEKQIKKQTALVELLKHKPKATEEQLKTVMENFVAFVDKCCAADDKEACFAVEGPKLVVSTQTALA") == SeqType.PROTEIN + def test_unknown(self): # 含非 DNA 也非蛋白质典型字符 - assert detect_seq_type("ATCGZBXOU") == SeqType.UNKNOWN + assert detect_seq_type("ATCG12!") == SeqType.UNKNOWN + + def test_softmasked_dna_not_protein(self): + """真核基因组软屏蔽(RepeatMasker 小写 x 重复区)不得误判为蛋白质。""" + assert detect_seq_type("ATCGatcgxxxATCGnnnATCGxx") == SeqType.UNKNOWN + assert detect_seq_type("XXXX") == SeqType.UNKNOWN # 仅含 X 无经典残基同样回落 UNKNOWN def test_empty(self): assert detect_seq_type("") == SeqType.DNA # 空集合是 DNA 子集 @@ -173,14 +183,6 @@ def test_gc_case_insensitive(self): def test_empty_sequence(self): assert calc_sequence_stats("") == (0, 0) - def test_sequence_stats_gc_content(self): - s = SequenceStats(header="x", length=4, gc_count=2) - assert s.gc_content == 0.5 - - def test_sequence_stats_zero_length(self): - s = SequenceStats(header="x", length=0, gc_count=0) - assert s.gc_content == 0.0 - # ────────────────────────────────────────────── # renderer @@ -237,6 +239,13 @@ def test_quality_bar_returns_text(self): assert isinstance(result, Text) assert len(result.plain) > 0 + def test_quality_bar_respects_width(self): + """条数不应超过指定宽度(len 落在 (width, 2*width) 区间时的回归)。""" + for n, width in ((100, 60), (40, 60), (200, 40), (1000, 40)): + result = quality_bar("I" * n, width=width) + assert len(result.plain) <= width + assert len(result.plain) > 0 + def test_quality_bar_empty(self): assert quality_bar("").plain == "" diff --git a/test/test_parsers.py b/test/test_parsers.py index 75779bc..7f8736f 100644 --- a/test/test_parsers.py +++ b/test/test_parsers.py @@ -120,6 +120,14 @@ def test_header_with_special_chars(self, tmp_path: Path): records = list(parse_fasta(f)) assert records[0][0] == "sp|P12345|PROTEIN_OS Human gene=XXX" + def test_header_trailing_whitespace_stripped(self, tmp_path: Path): + """header 首尾空白应去除,与 browser 索引解析保持一致。""" + f = tmp_path / "ws.fa" + f.write_text(">seq1 description \nACGT\n> seq2\t\nGGCC\n") + records = list(parse_fasta(f)) + assert records[0][0] == "seq1 description" + assert records[1][0] == "seq2" + def test_no_trailing_newline(self, tmp_path: Path): """文件末尾无换行符""" f = tmp_path / "nonl.fa" @@ -200,3 +208,27 @@ def test_fastq_non_utf8_header(self, tmp_path: Path): f.write_bytes(b"@caf\xe9\nACGT\n+\nIIII\n") records = list(parse_fastq(f)) assert len(records) == 1 + + def test_fastq_truncated_record_raises(self, tmp_path: Path): + """记录在质量行处截断:应报错而非静默产出空质量的幻影记录。""" + from seqviz.fastq import parse_fastq + f = tmp_path / "trunc1.fastq" + f.write_text("@r1\nACGT\n+\n") # 缺质量行 + with pytest.raises(ValueError, match="质量行"): + list(parse_fastq(f)) + f2 = tmp_path / "trunc2.fastq" + f2.write_text("@r2\nACGT") # 缺 + 与质量行 + with pytest.raises(ValueError): + list(parse_fastq(f2)) + f3 = tmp_path / "trunc3.fastq" + f3.write_text("@r3\n") # 只有 header + with pytest.raises(ValueError, match="序列行"): + list(parse_fastq(f3)) + + def test_fastq_multiline_sequence_clear_error(self, tmp_path: Path): + """序列跨行(多行序列)应提示 '+' 分隔符错误而非误导性的 '@' 错误。""" + from seqviz.fastq import parse_fastq + f = tmp_path / "multi.fastq" + f.write_text("@r1\nACGT\nACGT\n+\nIIIIIIII\n") + with pytest.raises(ValueError, match=r"\+"): + list(parse_fastq(f)) diff --git a/test/test_vcf.py b/test/test_vcf.py index b3d0333..f95b0f9 100644 --- a/test/test_vcf.py +++ b/test/test_vcf.py @@ -107,6 +107,14 @@ def test_full_line(self): assert v.samples == {"s1": "0/1:15", "s2": "0/0:18"} assert v.offset == 100 + def test_crlf_line_parsing(self): + """CRLF 行尾:\\r 不应残留进 raw 或最后一个样本列。""" + line = "chr1\t10234\trs123\tA\tG\t99.5\tPASS\tDP=45\tGT:DP:AD\t0/1:15:10,5\t0/0:18:18,0\r\n" + v = parse_variant_line(line, offset=0, sample_names=["S1", "S2"]) + assert v is not None + assert v.raw == line.rstrip("\r\n") # 修复前 raw 带尾随 \r + assert v.samples["S2"] == "0/0:18:18,0" # 修复前带 '18,0\\r' + def test_dot_id_and_qual(self): v = parse_variant_line("chr1\t5\t.\tA\tG\t.\tPASS\t.") assert v.id == "" and v.qual is None @@ -268,6 +276,21 @@ def test_idempotent(self): load_variant_detail(SAMPLE_VCF, first, ["sample1", "sample2", "sample3"]) assert first.raw == raw1 + def test_crlf_detail_roundtrip(self, tmp_path): + """CRLF VCF 的详情回读:最后一个样本的 AD 应能解析,raw 不应带 \\r。""" + p = tmp_path / "crlf.vcf" + p.write_bytes( + b"#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tS1\tS2\r\n" + b"chr1\t10234\trs123\tA\tG\t99.5\tPASS\tDP=45\tGT:DP:AD\t0/1:15:10,5\t0/0:18:18,0\r\n" + ) + meta, variants, skipped = scan_vcf(p) + assert skipped == 0 + v = variants[0] + load_variant_detail(p, v, ["S1", "S2"]) + gt = parse_genotype(v.samples["S2"], v.format_fields) + assert gt["AD"] == [18, 0] # 修复前 int('0\\r') 失败 → [] + assert not v.raw.endswith("\r") + class TestComputeStats: def test_sample_vcf_stats(self): diff --git a/test/test_vcf_browser.py b/test/test_vcf_browser.py index 860fb9d..aad5c89 100644 --- a/test/test_vcf_browser.py +++ b/test/test_vcf_browser.py @@ -5,7 +5,7 @@ from textual.widgets import OptionList from seqviz.vcf import scan_vcf_quick -from seqviz.vcf_browser import VcfBrowser, AbsoluteScrollbar +from seqviz.vcf_browser import AbsoluteScrollbar, DetailPanel, VcfBrowser TEST_DIR = Path(__file__).parent SAMPLE_VCF = TEST_DIR / "sample.vcf" @@ -204,9 +204,9 @@ async def _t(): await pilot.pause() await pilot.press("i") await pilot.pause() - detail = app.query_one("#detail") + content = app.query_one("#detail-content") # 信息面板渲染为 Rich Text,检查其纯文本内容 - text = str(detail.render()) + text = str(content.render()) assert "VCFv4.3" in text assert "chr1" in text # 规格要求:INFO/FORMAT 字段定义也要展示 @@ -729,3 +729,201 @@ async def _t(): order = [app.variants[i].chrom for i in app.view] assert order == ["chr1", "chr2", "chr10"] # 非字典序的 chr1,chr10,chr2 run(_t()) + + +# ────────────────────────────────────────────── +# 右侧详情/基因型矩阵滚动浏览 +# ────────────────────────────────────────────── +class TestDetailPanelScroll: + @staticmethod + def _make_wide_vcf(tmp_path, samples=40): + """多样本 + AD 字段的 VCF:单条详情约 3 行/样本,远超可视区域。""" + names = [f"sample{i:02d}" for i in range(samples)] + lines = [ + "##fileformat=VCFv4.3", + '##FORMAT=', + '##FORMAT=', + "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t" + "\t".join(names), + "chr1\t1000\t.\tA\tG\t50\tPASS\t.\tGT:AD\t" + "\t".join(["0/1:10,5"] * samples), + "chr1\t2000\t.\tC\tT\t50\tPASS\t.\tGT:AD\t" + "\t".join(["1/1:0,20"] * samples), + ] + f = tmp_path / "wide.vcf" + f.write_text("\n".join(lines) + "\n") + return f + + @staticmethod + def _max_scroll(detail) -> float: + return max(detail.virtual_size.height - detail.size.height, 0) + + def test_tab_focuses_detail_and_jk_scrolls(self, tmp_path): + """Tab 聚焦右侧后,j/k 滚动详情而非移动列表。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + ol = app.query_one("#variant-list", OptionList) + assert self._max_scroll(detail) > 0 # 内容确实超出可视区域 + assert not detail.has_focus + await pilot.press("tab") + await pilot.pause() + assert detail.has_focus + assert detail.scroll_offset.y == 0 + await pilot.press("j", "j") + await pilot.pause() + assert detail.scroll_offset.y > 0 + after_j = detail.scroll_offset.y + await pilot.press("k") + await pilot.pause() + assert detail.scroll_offset.y < after_j + assert ol.highlighted == 0 # 列表未受影响 + run(_t()) + + def test_arrow_and_page_keys_scroll_detail(self, tmp_path): + """方向键 / PageUp / PageDown / Space / b 均可滚动聚焦的详情。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + await pilot.press("tab") + await pilot.pause() + await pilot.press("down") + await pilot.pause() + assert detail.scroll_offset.y > 0 + await pilot.press("pagedown") + await pilot.pause() + after_pagedown = detail.scroll_offset.y + assert after_pagedown > 1 + await pilot.press("pageup") + await pilot.pause() + assert detail.scroll_offset.y < after_pagedown + await pilot.press("space") + await pilot.pause() + assert detail.scroll_offset.y >= after_pagedown - 1 + await pilot.press("b") + await pilot.pause() + assert detail.scroll_offset.y < after_pagedown + await pilot.press("g") + await pilot.pause() + assert detail.scroll_offset.y == 0 + run(_t()) + + def test_gG_scroll_focused_detail_top_bottom(self, tmp_path): + """详情聚焦时 g/G 滚动到顶/到底(而非跳转列表)。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + ol = app.query_one("#variant-list", OptionList) + await pilot.press("tab") + await pilot.pause() + await pilot.press("G") + await pilot.pause() + assert detail.scroll_offset.y >= self._max_scroll(detail) - 1 + assert ol.highlighted == 0 # 未跳转列表 + await pilot.press("g") + await pilot.pause() + assert detail.scroll_offset.y == 0 + run(_t()) + + def test_escape_returns_focus_to_list(self, tmp_path): + """Esc 返回变异列表,此后 j/k 恢复列表导航语义。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + ol = app.query_one("#variant-list", OptionList) + await pilot.press("tab") + await pilot.pause() + assert detail.has_focus + await pilot.press("escape") + await pilot.pause() + assert ol.has_focus + await pilot.press("j") + await pilot.pause() + assert ol.highlighted == 1 # 恢复列表导航 + assert detail.scroll_offset.y == 0 + run(_t()) + + def test_navigating_variants_resets_detail_scroll(self, tmp_path): + """详情滚动后切换变异,详情自动回到顶部。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + await pilot.press("tab", "G") # 详情滚到底 + await pilot.pause() + assert detail.scroll_offset.y > 0 + await pilot.press("escape", "j") # 回列表并下一条 + await pilot.pause() + assert detail.scroll_offset.y == 0 + run(_t()) + + def test_matrix_view_scrollable(self, tmp_path): + """基因型矩阵视图(多样本超宽)同样可滚动。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 15)) as pilot: + await pilot.pause() + await pilot.press("t") # 切换基因型矩阵 + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + assert self._max_scroll(detail) > 0 + await pilot.press("tab") + await pilot.pause() + await pilot.press("G") + await pilot.pause() + assert detail.scroll_offset.y >= self._max_scroll(detail) - 1 + await pilot.press("g") + await pilot.pause() + assert detail.scroll_offset.y == 0 + run(_t()) + + def test_list_navigation_unchanged_without_focus(self, tmp_path): + """列表聚焦时 j/k/space/g/G 仍为列表导航,详情不滚动。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + ol = app.query_one("#variant-list", OptionList) + await pilot.press("j", "k", "space", "g") + await pilot.pause() + assert ol.highlighted == 0 # space 下翻 PAGE 条后 g 回顶部 + assert detail.scroll_offset.y == 0 + await pilot.press("G") + await pilot.pause() + assert ol.highlighted == ol.option_count - 1 # G 跳列表末尾 + run(_t()) + + def test_np_always_navigates_variants_even_when_detail_focused(self, tmp_path): + """n/p 始终为变异级导航:详情聚焦时仍移动列表而非滚动详情。""" + f = self._make_wide_vcf(tmp_path) + async def _t(): + app = VcfBrowser(f) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + detail = app.query_one("#detail", DetailPanel) + ol = app.query_one("#variant-list", OptionList) + await pilot.press("tab") + await pilot.pause() + assert detail.has_focus + await pilot.press("n") + await pilot.pause() + assert ol.highlighted == 1 # n 仍导航列表 + assert detail.scroll_offset.y == 0 # 切换变异后详情回顶 + await pilot.press("p") + await pilot.pause() + assert ol.highlighted == 0 + run(_t()) From d7442f297eb83dbea0cfa8f9bb290215d8161b7d Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Wed, 19 Aug 2026 06:33:46 +0800 Subject: [PATCH 16/20] =?UTF-8?q?refactor(vcf):=20=E7=A7=BB=E9=99=A4=20Spa?= =?UTF-8?q?ce/b=20=E7=BF=BB=E9=A1=B5=E9=94=AE=EF=BC=88j/k=20+=20=E9=BC=A0?= =?UTF-8?q?=E6=A0=87=E6=BB=9A=E8=BD=AE=20+=20=E5=9D=90=E6=A0=87=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=B7=B2=E5=AE=8C=E5=85=A8=E8=A6=86=E7=9B=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VCF 浏览器 App 级 Space/b 绑定与 action_page_down/up、PAGE 常量移除 - DetailPanel 移除 Space/b 绑定(PageUp/PageDown 物理键保留为详情翻页) - 帮助屏与 README 快捷键表同步更新 - 测试同步:Space/b 按下无任何副作用,方向键/PageUp/PageDown/g/G 滚动不受影响 --- README.md | 7 ++++--- src/seqviz/vcf_browser.py | 21 ++------------------- test/test_vcf_browser.py | 14 ++++---------- 3 files changed, 10 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 534615f..b456c23 100644 --- a/README.md +++ b/README.md @@ -130,11 +130,12 @@ seqviz browse variants.vcf | 按键 | 功能 | 按键 | 功能 | |------|------|------|------| -| `j` / `k` | 上下移 | `Space` / `b` | 翻页 | +| `j` / `k` | 上下移(右侧聚焦时滚动详情) | `g` / `G` | 顶/底部 | | `/` | 搜索 | `f` | 筛选循环 | | `s` | 排序 | `t` | 详情 ↔ 矩阵 | -| `i` | 文件信息 | `y` | 复制行 | -| `?` | 帮助 | `q` | 退出 | +| `Tab` / `Esc` | 列表 ↔ 详情面板 | `i` | 文件信息 | +| `y` | 复制行 | `?` | 帮助 | +| `q` | 退出 | | | **搜索 `/` 支持:** diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index 22ab6ca..f2619b8 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -165,7 +165,7 @@ def on_mount(self): text = Text() rows = [ ("j / k", "上下移动(右侧聚焦时滚动详情)"), ("n / p", "下/上一条变异"), - ("Space / b", "翻页(右侧聚焦时翻详情)"), ("g / G", "顶部 / 底部"), + ("g / G", "顶部 / 底部"), ("/", "搜索: ID / chr:pos / chr:a-b"), ("f", "过滤循环 (全部/PASS/SNP/InDel)"), ("s", "排序切换 (位置/QUAL)"), ("t", "详情 ↔ 基因型矩阵"), ("Tab / Esc", "左侧列表 ↔ 右侧详情面板"), @@ -182,7 +182,7 @@ class DetailPanel(VerticalScroll, can_focus=True): """右侧详情/基因型矩阵面板:内容超出可视区域时可滚动浏览。 内部 #detail-content 承载渲染文本(height:auto 撑开虚拟高度,容器滚动)。 - Tab 聚焦后 ↑↓ / PageUp/PageDown / Space/b / g/G 滚动内容(j/k 由 App + Tab 聚焦后 ↑↓ / PageUp/PageDown / g/G 滚动内容(j/k 由 App 优先绑定委托;n/p 始终为变异级导航);Esc 返回左侧变异列表。未聚焦时不拦截任何按键, 左侧列表的导航/搜索/过滤/排序/复制行为完全不变。 """ @@ -190,8 +190,6 @@ class DetailPanel(VerticalScroll, can_focus=True): BINDINGS: Sequence[Binding] = [ Binding("down", "detail_down", "详情下滚", show=False), Binding("up", "detail_up", "详情上滚", show=False), - Binding("space", "detail_page_down", "下翻页", show=False), - Binding("b", "detail_page_up", "上翻页", show=False), Binding("pageup", "detail_page_up", "上翻页", show=False), Binding("pagedown", "detail_page_down", "下翻页", show=False), Binding("g", "detail_home", "详情顶部", show=False), @@ -246,8 +244,6 @@ class VcfBrowser(App): Binding("k", "cursor_up", "上移", show=True, priority=True), Binding("n", "next_variant", "下一条", show=False), Binding("p", "prev_variant", "上一条", show=False), - Binding("space", "page_down", "下翻页", show=True), - Binding("b", "page_up", "上翻页", show=True), Binding("g", "home", "顶部", show=True), Binding("G", "end", "底部", show=True), Binding("slash", "search", "搜索", show=True), @@ -262,7 +258,6 @@ class VcfBrowser(App): # 列表窗口化虚拟化:OptionList 只物化当前窗口,避免大文件全量重建冻结 UI WINDOW = 400 - PAGE = 20 def __init__(self, filepath: Path, scanned=None, initial=None): """scanned=(meta, variants, skipped) 全量结果; @@ -896,18 +891,6 @@ def action_cursor_up(self): return self._goto_abs(self._abs_index() - 1) - def action_page_down(self): - if self._detail_focused(): - self._detail().action_detail_page_down() - return - self._goto_abs(self._abs_index() + self.PAGE) - - def action_page_up(self): - if self._detail_focused(): - self._detail().action_detail_page_up() - return - self._goto_abs(self._abs_index() - self.PAGE) - def action_home(self): if self._detail_focused(): self._detail().action_detail_home() diff --git a/test/test_vcf_browser.py b/test/test_vcf_browser.py index aad5c89..8140836 100644 --- a/test/test_vcf_browser.py +++ b/test/test_vcf_browser.py @@ -781,7 +781,7 @@ async def _t(): run(_t()) def test_arrow_and_page_keys_scroll_detail(self, tmp_path): - """方向键 / PageUp / PageDown / Space / b 均可滚动聚焦的详情。""" + """方向键 / PageUp / PageDown 均可滚动聚焦的详情(Space/b 翻页已移除)。""" f = self._make_wide_vcf(tmp_path) async def _t(): app = VcfBrowser(f) @@ -800,12 +800,6 @@ async def _t(): await pilot.press("pageup") await pilot.pause() assert detail.scroll_offset.y < after_pagedown - await pilot.press("space") - await pilot.pause() - assert detail.scroll_offset.y >= after_pagedown - 1 - await pilot.press("b") - await pilot.pause() - assert detail.scroll_offset.y < after_pagedown await pilot.press("g") await pilot.pause() assert detail.scroll_offset.y == 0 @@ -890,7 +884,7 @@ async def _t(): run(_t()) def test_list_navigation_unchanged_without_focus(self, tmp_path): - """列表聚焦时 j/k/space/g/G 仍为列表导航,详情不滚动。""" + """列表聚焦时 j/k/g/G 仍为列表导航,详情不滚动;Space/b 已无绑定不产生任何行为。""" f = self._make_wide_vcf(tmp_path) async def _t(): app = VcfBrowser(f) @@ -898,9 +892,9 @@ async def _t(): await pilot.pause() detail = app.query_one("#detail", DetailPanel) ol = app.query_one("#variant-list", OptionList) - await pilot.press("j", "k", "space", "g") + await pilot.press("j", "k", "space", "b", "g") await pilot.pause() - assert ol.highlighted == 0 # space 下翻 PAGE 条后 g 回顶部 + assert ol.highlighted == 0 # space/b 已移除,无任何副作用 assert detail.scroll_offset.y == 0 await pilot.press("G") await pilot.pause() From 0ab0d55eff5d2e1395ab935d76f840c81781a223 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Wed, 19 Aug 2026 06:43:54 +0800 Subject: [PATCH 17/20] =?UTF-8?q?feat(cli):=20=E6=B5=8F=E8=A7=88=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E4=B8=BA=E4=B8=BB=E5=8A=9F=E8=83=BD=20=E2=80=94=20seq?= =?UTF-8?q?viz=20<=E8=B7=AF=E5=BE=84>=20=E7=9B=B4=E6=8E=A5=E6=89=93?= =?UTF-8?q?=E5=BC=80=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 _DefaultBrowseGroup(TyperGroup):首个参数非已知子命令时自动路由到 browse (seqviz test.fa ≡ seqviz browse test.fa;seqviz 无参仍打开当前目录选择器) - browse 改为 hidden 兼容别名,--help 命令列表只保留辅助子命令 (view/stats/head/fqview/config) - 主帮助文案更新:直接跟文件/目录路径即可打开浏览器 - README 同步:快速开始/命令表/各浏览器章节全部改用 seqviz <路径> 用法 - 新增 6 项默认路由测试(文件直达/VCF 路由/不存在路径/无参目录/子命令不被吞/选项不误路由) - 333 项测试全部通过,ruff check src/ 零告警 --- README.md | 18 ++++++------ src/seqviz/cli.py | 30 +++++++++++++++++--- test/test_cli.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b456c23..ce95ea2 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ uv run seqviz --help ## 快速开始 ```bash +# 直接打开文件(主功能,自动识别 FASTA/FASTQ/VCF) +seqviz genome.fasta +seqviz variants.vcf + # 不传文件?打开目录选择器 seqviz @@ -66,28 +70,24 @@ seqviz fqview reads.fastq # 统计 seqviz stats genome.fasta - -# 交互式浏览 -seqviz browse genome.fasta -seqviz browse variants.vcf ``` ## 命令 | 命令 | 干什么的 | |------|---------| +| `seqviz ` | 交互式浏览器(主功能,自动识别 FASTA/FASTQ/VCF) | | `seqviz` | 打开目录文件选择器 | | `seqviz view ` | 彩色看 FASTA | | `seqviz fqview ` | 彩色看 FASTQ(带质量值) | | `seqviz head ` | 看前 N 条序列 | | `seqviz stats ` | 统计摘要 | -| `seqviz browse ` | 交互式浏览器(自动识别 FASTA/FASTQ/VCF) | | `seqviz config [--init]` | 看/生成配置 | ## 交互式浏览器 ```bash -seqviz browse genome.fasta +seqviz genome.fasta ``` 左边序列列表(虚拟化渲染),右边序列详情(按需加载)。 @@ -106,7 +106,7 @@ seqviz browse genome.fasta ### 文件选择器 -传目录给 `browse`(或直接运行 `seqviz`)就会启动。自动扫描 `.fa .fasta .fna .fq .fastq .vcf` 及 `.gz`。 +传目录(如 `seqviz data/`,或直接运行 `seqviz`)就会启动。自动扫描 `.fa .fasta .fna .fq .fastq .vcf` 及 `.gz`。 | 按键 | 功能 | |------|------| @@ -119,7 +119,7 @@ seqviz browse genome.fasta ## VCF 变异浏览器 ```bash -seqviz browse variants.vcf +seqviz variants.vcf ``` 左边变异列表,右边详情面板或基因型矩阵。 @@ -207,7 +207,7 @@ Apple Silicon + SMB 网络卷实测: ```bash git clone https://github.com/Georicl/seqviz.git && cd seqviz uv sync -uv run pytest test/ -v # 327 个测试 +uv run pytest test/ -v # 333 个测试 ``` ## License diff --git a/src/seqviz/cli.py b/src/seqviz/cli.py index 44c0cf6..7b371d0 100644 --- a/src/seqviz/cli.py +++ b/src/seqviz/cli.py @@ -3,6 +3,7 @@ import typer from rich.console import Console from rich.table import Table +from typer.core import TyperGroup from seqviz import config as config_mod from seqviz import theme as theme_mod @@ -20,7 +21,21 @@ from seqviz.seq_type import SeqType, detect_seq_type from seqviz.stats import calc_n50, calc_sequence_stats -app = typer.Typer() + +class _DefaultBrowseGroup(TyperGroup): + """浏览是主功能:首个参数不是已知子命令时,默认路由到 browse。 + + seqviz test.fa ≡ seqviz browse test.fa + seqviz view test.fa → 子命令 view 不变 + """ + + def parse_args(self, ctx, args): + if args and not args[0].startswith("-") and args[0] not in self.commands: + args = ["browse", *args] + return super().parse_args(ctx, args) + + +app = typer.Typer(cls=_DefaultBrowseGroup) console = Console() # 明确不支持的文件类型(避免按 FASTA 静默解析产生空界面) @@ -185,7 +200,14 @@ def main( ): """seqviz — 生物序列数据终端可视化工具。 - 不带任何命令时,默认打开当前目录的文件浏览器。 + 直接跟文件/目录路径即可打开交互式浏览器(主功能): + + seqviz reads.fastq 打开单个文件 + + seqviz data/ 目录文件选择器 + + 不带任何参数时,默认打开当前目录的文件浏览器。 + 其余子命令(view/stats/head/fqview/config)为辅助功能。 """ if ctx.invoked_subcommand is None: _launch_browser([Path(".")]) @@ -339,11 +361,11 @@ def fqview( console.print(f"[dim]共显示 {count} 条 reads[/dim]") -@app.command() +@app.command(hidden=True) def browse( files: list[Path] = typer.Argument(help="FASTA/FASTQ 文件或目录路径(目录会启动文件选择器)"), ): - """交互式浏览 FASTA/FASTQ/VCF 文件(支持多文件标签页、目录浏览)。""" + """交互式浏览 FASTA/FASTQ/VCF 文件(主功能;等价于 seqviz <路径>,保留作兼容别名)。""" for p in files: # 校验路径存在,与其他子命令的友好报错保持一致 if not p.exists(): console.print(f"[red]错误: 路径不存在: {p}[/red]") diff --git a/test/test_cli.py b/test/test_cli.py index 14d882f..bcc2a37 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -231,14 +231,82 @@ def fake_fasta_run(self): assert captured["paths"] == [str(fa)] # VCF 未进入 FastaBrowser +# ────────────────────────────────────────────── +# 浏览为主功能:seqviz <路径> 直接打开浏览器 +# ────────────────────────────────────────────── +class TestDefaultBrowseRouting: + def test_path_without_subcommand_opens_browser(self, monkeypatch): + """seqviz test.fa ≡ seqviz browse test.fa(主功能直达)。""" + import seqviz.cli as cli_mod + captured: dict = {} + + def fake_run(self): + captured["n_tabs"] = len(self.file_tabs) + + monkeypatch.setattr(cli_mod.FastaBrowser, "run", fake_run) + result = runner.invoke(app, [TEST_FA]) + assert result.exit_code == 0 + assert captured["n_tabs"] == 1 + + def test_missing_path_without_subcommand(self): + """seqviz nonexistent.fa 友好报错而非裸 traceback / 未知命令。""" + result = runner.invoke(app, ["nonexistent_file.fa"]) + assert result.exit_code != 0 + assert "路径不存在" in result.output + + def test_vcf_without_subcommand_routes_to_vcf_browser(self, monkeypatch): + """seqviz x.vcf 直接路由到 VcfBrowser。""" + import seqviz.cli as cli_mod + from seqviz import vcf_browser as vcf_browser_mod + called: dict = {} + + def fake_vcf_run(self): + called["vcf"] = str(self.filepath) + + monkeypatch.setattr(vcf_browser_mod.VcfBrowser, "run", fake_vcf_run) + monkeypatch.setattr(cli_mod.FastaBrowser, "run", lambda self: called.__setitem__("fasta", True)) + vcf_path = str(Path(__file__).parent / "sample.vcf") + result = runner.invoke(app, [vcf_path]) + assert result.exit_code == 0 + assert called.get("vcf") == vcf_path + assert "fasta" not in called + + def test_no_args_opens_current_directory(self, monkeypatch): + """seqviz 不带参数:默认打开当前目录文件浏览器。""" + import seqviz.cli as cli_mod + captured: dict = {} + + def fake_launch(paths): + captured["paths"] = [str(p) for p in paths] + + monkeypatch.setattr(cli_mod, "_launch_browser", fake_launch) + result = runner.invoke(app, []) + assert result.exit_code == 0 + assert captured["paths"] == ["."] + + def test_subcommands_not_shadowed(self): + """已知子命令不被默认路由吞掉:view 仍正常执行。""" + result = runner.invoke(app, ["view", TEST_FA]) + assert result.exit_code == 0 + assert ">" in result.output + + def test_options_not_routed_to_browse(self): + """以 - 开头的参数不被当作路径路由(--help 正常显示)。""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Usage" in result.output + + class TestHelpCommand: def test_main_help(self): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "view" in result.output assert "stats" in result.output - assert "browse" in result.output assert "config" in result.output + # browse 为主功能默认路由,不再作为独立子命令展示 + assert "browse" not in result.output + assert "seqviz reads.fastq" in result.output or "直接跟文件" in result.output def test_view_help(self): result = runner.invoke(app, ["view", "--help"]) From 62f3ce1a92ee05a2e465ac4e63c878086dcdf5bf Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Wed, 19 Aug 2026 07:28:21 +0800 Subject: [PATCH 18/20] chore: add CI workflow, AGENTS.md and LICENSE to version control --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++ AGENTS.md | 55 ++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 AGENTS.md create mode 100644 LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..be572e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main, vcf] + pull_request: + branches: [main, vcf] + +jobs: + lint: + name: Ruff Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.14 + + - name: Run ruff check + run: uvx ruff check src/ test/ + + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.14"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests with coverage + run: uv run pytest test/ -x -q --tb=short --cov=seqviz --cov-report=term-missing --cov-fail-under=85 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f8f382f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md — seqviz 项目代理指引 + +## 项目概述 + +seqviz 是一个生物序列数据的终端可视化工具,支持 **FASTA / FASTQ / VCF** 三种格式的彩色查看、统计与交互式浏览。核心卖点:DNA 碱基着色、FASTQ 质量值梯度、VCF 双栏浏览(变异列表 + 基因型矩阵)、GB 级大文件流畅滚动。 + +- 技术栈:**Typer**(CLI)+ **Rich**(终端渲染)+ **Textual**(TUI) +- Python:**>= 3.14**(`pyproject.toml` 中 `requires-python`) +- 构建/包管理:**uv**(`uv build`、`uv run`、`uv add`) +- 入口命令:`seqviz = "seqviz.cli:app"`,浏览是主功能(`seqviz <路径>` 直接打开浏览器) + +## 常用命令 + +```bash +uv run pytest test/ -x -q --tb=short # 运行测试 +uv run ruff check src/ test/ # lint +uv build # 构建 wheel/sdist 到 dist/ +``` + +## 核心模块职责 + +| 模块 | 职责 | +|------|------| +| `cli.py` | Typer CLI 入口;`_DefaultBrowseGroup` 实现"浏览为主"路由(首参非子命令时默认走 browse);子命令 view/stats/head/fqview/config/browse | +| `browser.py` | FASTA/FASTQ 交互式 TUI 浏览器(Textual App);分块加载 + O(1) seek 定位 + 多标签 | +| `vcf_browser.py` | VCF 双栏 TUI 浏览器:左变异列表 + 右详情/基因型矩阵;设计文档见 `docs/superpowers/specs/2026-07-25-vcf-visualization-design.md` | +| `file_browser.py` | 目录文件选择器:扫描序列文件、预览、多选批量打开 | +| `parsers.py` / `fastq.py` / `vcf.py` | 解析层:FASTA 流式解析 / FASTQ 解析 / VCF 分类·基因型·lazy-scan·统计 | +| `renderer.py` | Rich 渲染:碱基着色、质量值着色、位置标尺 | +| `seq_type.py` | 序列类型检测(DNA/RNA/蛋白质) | +| `stats.py` | 统计:条数、总长度、N50、GC 含量 | +| `clipboard.py` | 跨平台剪贴板复制:系统工具优先(pbcopy/xclip/wl-copy),失败回退 OSC 52;browser 与 vcf_browser 共用此实现 | +| `config.py` / `theme.py` | JSON 配置系统(`config/config.json`);8 套内置主题 + `theme.json` 自定义 | + +## 开发约定 + +1. **包布局**:`src/` layout,wheel 打包 `packages = ["src/seqviz"]`;所有源码在 `src/seqviz/`,测试在 `test/`(文件名与模块对应)。 +2. **ruff per-file-ignores**(勿误改): + - `src/seqviz/cli.py` 豁免 `B008` — Typer 惯用法:`typer.Argument/Option` 在函数默认参数中调用; + - `test/*` 豁免 `RUF059`/`RUF015` — 测试中解包未使用变量、切片取首元素是常见模式。 +3. **坐标系统**:VCF 全链路保持 **1-based**(解析、存储、搜索、显示均不转换);FASTA 范围复制时做 1-based → 0-based 转换。 +4. **大文件策略**:浏览器侧用分块加载 + seek 定位 + 虚拟化列表;`parse_fasta` 全量流式解析仅用于 CLI view/stats/head 小路径,勿在大文件浏览路径中引入全量加载。 +5. **gzip**:`.gz` 后缀(大小写不敏感)统一用 `gzip.open` 透明读取。 +6. **Textual 注意**:Widget 基类无 `update` 方法,需类型断言为 `Static` 再调用。 +7. **发布**:版本号遵循 PEP 440(如 `0.7.0rc1`,不用 `0.7.0-vcf.1` 这类格式)。 + +## 已知架构债务 + +修改以下区域时保持克制,勿扩大重复: + +- **`browser.py` 是 God Object**(~1200 行,含多个类);规划拆分为 `sequence_view.py`(分块引擎)/ `components.py`(共享 UI 组件)/ `app.py`(控制器)。 +- **VCF 检测三处平行**:`cli.py:_is_vcf`、`file_browser.is_vcf_file`、`browser.detect_format`;`FileFormat` 枚举尚未纳入 VCF。 +- **硬编码常量**:`vcf_browser.py` 中 `WINDOW = 400` 未走配置系统。 +- **`_load_chunk` 无 LRU 缓存**:一次滚动约 40 次独立 seek+read。 +- **CLI 双轨制**:view/stats/head 用 `parse_fasta` 全量加载,与浏览器的分块路径并存。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e11ce4a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Georicl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From c00ed426f4a1d74ada598a619d74e942731d6235 Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Wed, 19 Aug 2026 18:08:16 +0800 Subject: [PATCH 19/20] =?UTF-8?q?chore:=20=E4=BF=AE=E5=A4=8D=20CI=20lint?= =?UTF-8?q?=20=E5=A4=B1=E8=B4=A5=E5=B9=B6=E5=8F=91=E5=B8=83=20v0.7.0rc2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 21 个 ruff 错误 (import 排序/清理),test/** 豁免 RUF059/RUF015 - 类型注解现代化 (Generator[X]、去字符串化),requires-python >= 3.14 - CI lint 锁定 ruff@0.16.3,移除冗余 Python 安装步骤 - 版本升至 0.7.0rc2,同步 __init__.py / README / TODO.md --- .github/workflows/ci.yml | 5 +- README.md | 2 +- TODO.md | 347 ++++++++++++++++++++------------------ pyproject.toml | 6 +- src/seqviz/__init__.py | 2 +- src/seqviz/browser.py | 6 +- src/seqviz/fastq.py | 2 +- src/seqviz/parsers.py | 2 +- test/run_perf_test.py | 6 +- test/test_browser.py | 2 - test/test_config_theme.py | 2 - test/test_core.py | 15 +- test/test_file_browser.py | 9 +- test/test_parsers.py | 1 - test/test_vcf.py | 2 +- uv.lock | 34 +--- 16 files changed, 212 insertions(+), 231 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be572e0..fa1ab78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,8 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 - - name: Set up Python - run: uv python install 3.14 - - name: Run ruff check - run: uvx ruff check src/ test/ + run: uvx ruff@0.16.3 check src/ test/ test: name: Test diff --git a/README.md b/README.md index ce95ea2..d0b8456 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [安装](#安装) · [快速开始](#快速开始) · [浏览器](#交互式浏览器) · [配置](#配置) -v0.7.0rc1 · Python >= 3.12 +v0.7.0rc2 · Python >= 3.14 diff --git a/TODO.md b/TODO.md index 1886635..af6a5c6 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,9 @@ > 生物序列数据终端可视化工具 —— 做序列界的 `bat` > -> 原名 `fasta-fmt`,已更名为 `seqviz`(涵盖 FASTA/FASTQ/VCF 等序列数据可视化)。以下为初始规划文档,包名/命令请以 `seqviz` 为准。 +> 原名 `fasta-fmt`,已更名为 `seqviz`(涵盖 FASTA/FASTQ/VCF 等序列数据可视化)。 + +**当前版本**: v0.7.0rc2 --- @@ -12,34 +14,36 @@ ``` fasta-fmt/ ├── src/ -│ └── fasta_fmt/ -│ ├── __init__.py +│ └── seqviz/ +│ ├── __init__.py # 包入口 │ ├── cli.py # CLI 入口 (Typer) -│ ├── parsers/ # 格式解析器 -│ │ ├── __init__.py -│ │ ├── base.py # 解析器基类 -│ │ ├── fasta.py # FASTA 解析 -│ │ ├── fastq.py # FASTQ 解析 -│ │ ├── gff.py # GFF/GFF3 解析 -│ │ └── bed.py # BED 解析 -│ ├── renderers/ # 渲染引擎 -│ │ ├── __init__.py -│ │ ├── sequence.py # 序列着色渲染 -│ │ ├── quality.py # 质量值可视化 -│ │ └── table.py # 表格/统计渲染 -│ ├── stats/ # 统计计算 -│ │ ├── __init__.py -│ │ └── calculator.py # N50, GC%, 长度分布等 -│ ├── filters/ # 序列筛选 -│ │ ├── __init__.py -│ │ └── sequence.py # 按长度/GC/名称筛选 -│ └── themes/ # 配色主题 -│ ├── __init__.py -│ └── default.py # 默认碱基配色 -├── tests/ +│ ├── parsers.py # FASTA 解析器 +│ ├── fastq.py # FASTQ 解析器 +│ ├── vcf.py # VCF 解析器 +│ ├── renderer.py # 序列着色渲染 +│ ├── seq_type.py # 序列类型检测 (DNA/Protein) +│ ├── stats.py # 统计计算 (N50, GC%, 长度分布) +│ ├── browser.py # TUI 交互式浏览器 (Textual) +│ ├── vcf_browser.py # VCF TUI 浏览器 (双栏布局) +│ ├── file_browser.py # 目录文件选择器 +│ ├── clipboard.py # 剪贴板操作 (分层回退) +│ ├── config.py # JSON 配置系统 +│ └── theme.py # 主题系统 (8 套内置主题) +├── test/ +│ ├── conftest.py # 动态 fixture + 配置隔离 +│ ├── test_browser.py +│ ├── test_cli.py +│ ├── test_config_theme.py +│ ├── test_core.py +│ ├── test_file_browser.py │ ├── test_parsers.py -│ ├── test_renderers.py -│ └── test_stats.py +│ ├── test_performance.py +│ ├── test_regressions.py +│ ├── test_vcf.py +│ └── test_vcf_browser.py +├── config/ +│ ├── config.json # 默认配置模板 +│ └── theme.json # 主题配置模板 ├── pyproject.toml ├── README.md └── TODO.md @@ -47,168 +51,179 @@ fasta-fmt/ --- -## 开发阶段 - -### Phase 1: MVP (第1周) - -- [ ] **1.1 项目基础配置** - - [ ] 配置 pyproject.toml (依赖: rich, typer) - - [ ] 配置 uv 开发环境 - - [ ] 设置 CLI 入口点 `fasta-fmt` - -- [ ] **1.2 FASTA 解析器** - - [ ] 实现流式 FASTA 解析 (支持大文件, 不全部加载) - - [ ] 支持 gzip 压缩文件 (.fa.gz) - - [ ] 解析 header (ID, description) - - [ ] 解析 sequence - -- [ ] **1.3 序列着色渲染** - - [ ] DNA 碱基着色: A(绿) T(红) C(蓝) G(黄) - - [ ] 蛋白质氨基酸着色 (按化学性质分组) - - [ ] 使用 Rich 库实现终端彩色输出 - - [ ] 支持管道输出 (检测是否为 TTY) - -- [ ] **1.4 基础 CLI 命令** - - [ ] `fasta-fmt view ` - 美化查看 - - [ ] `fasta-fmt stats ` - 统计摘要 - - [ ] `fasta-fmt head -n 10` - 查看前N条序列 - -### Phase 2: 核心功能 (第2周) - -- [ ] **2.1 FASTQ 支持** - - [ ] FASTQ 格式解析 - - [ ] 质量值着色 (Phred score 梯度色) - - [ ] 质量值分布图 (终端内 ASCII 图) - -- [ ] **2.2 统计功能增强** - - [ ] N50 / N90 / L50 计算 - - [ ] GC 含量统计 - - [ ] 序列长度分布 (min/max/mean/median) - - [ ] 多文件批量统计 - - [ ] 表格化输出 (Rich Table) - -- [ ] **2.3 序列筛选** - - [ ] `--min-len` / `--max-len` 按长度筛选 - - [ ] `--min-gc` / `--max-gc` 按 GC 含量筛选 - - [ ] `--grep` 按名称/描述模糊搜索 - - [ ] `--regex` 按序列 motif 正则筛选 - -### Phase 3: 进阶功能 (第3-4周) - -- [ ] **3.1 多格式支持** - - [ ] GFF/GFF3 解析与美化 - - [ ] BED 格式支持 - - [ ] 格式互转: `fasta-fmt convert input.gff --to bed` - -- [ ] **3.2 交互式浏览 (可选)** - - [ ] 大文件索引 (记录每条序列的偏移量) - - [ ] 交互式翻页浏览 - - [ ] 序列搜索跳转 - -- [ ] **3.3 输出增强** - - [ ] `--format markdown` 输出 Markdown 表格 - - [ ] `--format html` 生成 HTML 报告 - - [ ] `--no-color` 纯文本输出 (管道友好) - - [ ] `--wrap N` 序列换行宽度 - -### Phase 4: 发布 (第4周) - -- [ ] **4.1 文档与测试** - - [ ] 完善 README (含 GIF 演示) - - [ ] 单元测试覆盖率 > 80% - - [ ] 添加示例数据 - -- [ ] **4.2 发布配置** - - [ ] PyPI 发布配置 - - [ ] GitHub Actions CI - - [ ] LICENSE (MIT) +## 已完成功能 + +### Phase 1: MVP ✅ + +- [x] **1.1 项目基础配置** + - [x] 配置 pyproject.toml (依赖: rich, textual, typer) + - [x] 配置 uv 开发环境 + - [x] 设置 CLI 入口点 `seqviz` + +- [x] **1.2 FASTA 解析器** + - [x] 实现流式 FASTA 解析 (支持大文件, 分块加载) + - [x] 解析 header (ID, description) + - [x] 解析 sequence + - [x] 支持 gzip 压缩文件 (.fa.gz) + - [x] 大文件 seek 定位 + 虚拟列表 + +- [x] **1.3 序列着色渲染** + - [x] DNA 碱基着色: A(绿) T(红) C(蓝) G(黄) + - [x] 蛋白质氨基酸着色 (按化学性质分组) + - [x] 使用 Rich 库实现终端彩色输出 + - [x] 支持管道输出 (检测是否为 TTY) + - [x] 序列类型自动检测 (DNA/Protein) + +- [x] **1.4 基础 CLI 命令** + - [x] `seqviz view ` - 美化查看 + - [x] `seqviz stats ` - 统计摘要 + - [x] `seqviz head -n 10` - 查看前N条序列 + +### Phase 2: 核心功能 ✅ + +- [x] **2.1 FASTQ 支持** + - [x] FASTQ 格式解析 + - [x] 质量值着色 (Phred score 梯度色) + - [x] `seqviz fqview` 命令 (序列 + 质量值对齐着色) + +- [x] **2.2 统计功能增强** + - [x] N50 / N90 / L50 计算 + - [x] GC 含量统计 + - [x] 序列长度分布 (min/max/mean/median) + - [x] 表格化输出 (Rich Table) + +- [ ] **2.3 序列筛选** (未实现,已移入下方待办) + +### Phase 3: 进阶功能 ✅ (大幅超越原规划) + +- [x] **3.1 VCF 格式支持** (原规划为 GFF/BED,实际实现 VCF) + - [x] VCF 懒扫描解析 (30 万变异 ~1s) + - [x] 变异分类与着色 (SNP/Insertion/Deletion) + - [x] 双栏 TUI 布局 (变异列表 + 基因型矩阵) + - [x] 过滤/排序/搜索 + - [x] 后台续扫 + GIL 让出优化 + +- [x] **3.2 交互式 TUI 浏览器** (Textual 框架) + - [x] 大文件分块加载 (二进制快路径 + checkpoint 索引) + - [x] 交互式翻页浏览 (j/k/Space/PageUp/PageDown) + - [x] 序列搜索跳转 (/ 搜索, n 下一个) + - [x] 位置跳转 (g 跳转输入) + - [x] 序列范围复制 (y 键, 支持 100-200 格式) + - [x] 帮助面板 (? 键) + - [x] 多文件标签切换 (Tab) + - [x] 侧栏序列信息面板 + +- [x] **3.3 目录文件浏览器** + - [x] 目录浏览与文件选择 + - [x] 文件格式标记 ([F] FASTA, [Q] FASTQ, [V] VCF) + - [x] 序列数/变异数预览 + +- [x] **3.4 JSON 配置系统** + - [x] 用户级配置 (~/.config/seqviz/config.json) + - [x] 8 套内置主题 + - [x] 可配置交互参数 (wrap_width, scroll_step 等) + - [x] `seqviz config` 命令 (查看/初始化配置) + +### Phase 4: 发布 (部分完成) + +- [x] **4.1 文档与测试** + - [x] README 文档 + - [x] 单元测试 333 项全部通过 + - [x] 性能测试与回归测试 + - [x] 单元测试覆盖率 > 80% (CI 门禁 85%) + - [ ] GIF 演示 + +- [x] **4.2 发布配置** + - [x] PyPI 发布配置 (hatchling) + - [x] GitHub Actions CI + - [x] LICENSE --- -## 技术栈 +## 待办 / 未来规划 -| 组件 | 选择 | 用途 | -|------|------|------| -| CLI 框架 | Typer | 命令行参数解析 | -| 终端渲染 | Rich | 彩色输出、表格、面板 | -| 包管理 | uv | 依赖管理 | -| 测试 | pytest | 单元测试 | -| Python | >= 3.14 | 运行时 | +### 高优先级 ---- +- [x] 添加 GitHub Actions CI (自动测试 + ruff lint) +- [x] 添加 LICENSE 文件 (MIT) +- [ ] browser.py 拆分重构 (当前 1220 行 God Object) + - [ ] 提取 sequence_view.py (分块引擎 + SequenceView) + - [ ] 提取 components.py (共享 UI 组件) + - [ ] 提取 app.py (控制器) +- [ ] 统一 VCF 检测逻辑 (消除 cli.py / file_browser.py / browser.py 三处平行判断) -## CLI 命令设计 +### 中优先级 -```bash -# 查看序列 (碱基着色) -fasta-fmt view genome.fasta -fasta-fmt view reads.fastq --quality # 显示质量值着色 +- [ ] _load_chunk 添加 LRU 缓存 (减少 HDD 上的 seek+read 次数) +- [ ] compute_stats 改为接受 Iterable (消除中间列表分配) +- [ ] vcf_browser.py 硬编码常量可配置化 (WINDOW=400) +- [x] 剪贴板逻辑去重 (已提取为 clipboard.py 共享模块) -# 统计信息 -fasta-fmt stats genome.fasta -fasta-fmt stats *.fasta --format table # 多文件表格对比 +### 低优先级 / 可选 -# 查看前N条 -fasta-fmt head genome.fasta -n 5 +- [ ] GFF/GFF3 格式支持 +- [ ] BED 格式支持 +- [ ] 格式互转命令 +- [ ] `--format markdown` / `--format html` 输出 +- [ ] `--no-color` 纯文本输出 +- [ ] `--min-len` / `--max-len` 按长度筛选 +- [ ] `--grep` 按名称/描述模糊搜索 +- [ ] `--min-gc` / `--max-gc` 按 GC 含量筛选 +- [ ] `--regex` 按序列 motif 正则筛选 -# 筛选 -fasta-fmt view genome.fasta --min-len 1000 --max-len 50000 -fasta-fmt view genome.fasta --grep "mitochondria" -fasta-fmt view genome.fasta --min-gc 0.4 +--- -# 格式转换 -fasta-fmt convert annotation.gff3 --to bed -fasta-fmt convert genes.fasta --to fastq --dummy-quality 30 +## 技术栈 -# 序列提取 -fasta-fmt extract genome.fasta --id "chr1" --start 1000 --end 2000 -``` +| 组件 | 选择 | 用途 | +|------|------|------| +| CLI 框架 | Typer | 命令行参数解析 | +| TUI 框架 | Textual | 交互式终端浏览器 | +| 终端渲染 | Rich | 彩色输出、表格、面板 | +| 包管理 | uv | 依赖管理 | +| 构建 | hatchling | wheel/sdist 构建 | +| 测试 | pytest + pytest-cov | 单元测试 + 覆盖率 | +| Lint | ruff | 代码检查与格式化 | +| Python | >= 3.14 | 运行时 | --- -## 碱基配色方案 +## CLI 命令 -``` -DNA: - A (Adenine) → 绿色 (green) - T (Thymine) → 红色 (red) - C (Cytosine) → 蓝色 (blue) - G (Guanine) → 黄色 (yellow) - N (Unknown) → 灰色 (dim) - -Protein (按化学性质): - 疏水性 (AVILMFYW) → 绿色系 - 亲水性 (STNQ) → 蓝色系 - 碱性 (RKH) → 红色系 - 酸性 (DE) → 紫色系 - 特殊 (GPC) → 灰色系 - -Quality (Phred): - Q >= 30 → 绿色 (高质量) - Q >= 20 → 黄色 (中等) - Q >= 10 → 橙色 (低) - Q < 10 → 红色 (极低) +```bash +# 交互式浏览器 (主功能,直接跟路径) +seqviz genome.fasta # 打开单文件浏览器 +seqviz data/ # 目录文件选择器 +seqviz # 当前目录浏览器 + +# 辅助命令 +seqviz view genome.fasta # 美化查看 (Rich 输出) +seqviz stats genome.fasta # 统计摘要 +seqviz head genome.fasta -n 5 # 查看前 N 条 +seqviz fqview reads.fastq # FASTQ 质量值着色 +seqviz config # 查看配置 +seqviz config --init # 生成配置模板 ``` --- -## 性能目标 +## 性能指标 (已达成) -- 1GB FASTA 文件: `view` 首屏输出 < 100ms (流式) -- 100MB FASTQ 文件: `stats` 完成 < 5s -- 内存占用: 不超过文件大小 (流式处理) +- 50k 序列文件扫描: 36ms +- 2M bp 超长序列加载: 10ms +- 滚动渲染: 5.6ms/次 +- 30 万 VCF 变异扫描: ~1s +- VCF 过滤切换: ~600ms (窗口化虚拟化) --- ## 里程碑 -| 版本 | 内容 | 预计时间 | -|------|------|----------| -| v0.1.0 | MVP: view + stats + 碱基着色 | 第1周 | -| v0.2.0 | FASTQ + 筛选 + 多文件统计 | 第2周 | -| v0.3.0 | 多格式 + 转换 + HTML报告 | 第3-4周 | -| v1.0.0 | 稳定版 + 完整文档 + PyPI | 第4周 | -``` - ---- - +| 版本 | 内容 | 状态 | +|------|------|------| +| v0.1.0 | MVP: view + stats + 碱基着色 | ✅ 已发布 | +| v0.2.0 | FASTQ + 筛选 | ✅ 已发布 | +| v0.3.0 | JSON 配置 + 性能优化 | ✅ 已发布 | +| v0.5.0 | TUI 浏览器 + 文件选择器 | ✅ 已发布 | +| v0.7.0 | VCF 可视化 + 双栏布局 | 🔄 rc2 | +| v1.0.0 | 稳定版 + CI + 完整文档 | 📋 规划中 | diff --git a/pyproject.toml b/pyproject.toml index 2b8bcb8..4934845 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,11 @@ packages = ["src/seqviz"] [project] name = "seqviz" -version = "0.7.0rc1" +version = "0.7.0rc2" description = "A terminal visualizer for biological sequence data — FASTA / FASTQ / VCF" readme = "README.md" -requires-python = ">=3.12" +license = "MIT" +requires-python = ">=3.14" dependencies = [ "rich>=15.0.0", "textual>=8.2.8", @@ -28,3 +29,4 @@ dev = [ [tool.ruff.lint.per-file-ignores] "src/seqviz/cli.py" = ["B008"] # Typer 惯用法:typer.Argument/Option 在默认参数中调用 +"test/**" = ["RUF059", "RUF015"] # 测试中解包未使用变量和切片取首元素是常见模式 diff --git a/src/seqviz/__init__.py b/src/seqviz/__init__.py index a4bbe05..3f67e32 100644 --- a/src/seqviz/__init__.py +++ b/src/seqviz/__init__.py @@ -8,6 +8,6 @@ try: __version__ = version("seqviz") except PackageNotFoundError: # 源码树直接运行时(未安装)回退 - __version__ = "0.7.0rc1" + __version__ = "0.7.0rc2" __all__ = ["__version__"] diff --git a/src/seqviz/browser.py b/src/seqviz/browser.py index 8ca3dc9..8e2f3cf 100644 --- a/src/seqviz/browser.py +++ b/src/seqviz/browser.py @@ -100,7 +100,7 @@ def __init__(self, index: int, header: str, offset: int, length: int = -1, has_q self.checkpoints = None # [(碱基位置, 文件偏移)],仅非等宽大序列使用 -def _iter_sequences(filepath: Path, fmt: FileFormat, start_idx: int = 0) -> Generator[SequenceInfo, None, None]: +def _iter_sequences(filepath: Path, fmt: FileFormat, start_idx: int = 0) -> Generator[SequenceInfo]: """通用序列迭代器:二进制模式流式解析 FASTA/FASTQ,yield SequenceInfo。 Args: @@ -971,7 +971,7 @@ def on_option_list_option_selected(self, message: OptionList.OptionSelected) -> self._update_status() # ── 命令栏(动态挂载/卸载)── - def _get_command_bar(self) -> "CommandBar | None": + def _get_command_bar(self) -> CommandBar | None: bars = self.query("#command-bar") return bars.first() if bars else None @@ -1100,7 +1100,7 @@ def _handle_range_copy(self, value: str): else: self.notify("剪贴板不可用", title="范围复制", severity="warning") - def _iter_seq_text(self) -> Generator[str, None, None]: + def _iter_seq_text(self) -> Generator[str]: """流式生成当前序列的纯文本(FASTA/FASTQ 格式),逐块 yield,内存恒定。 大序列不会一次性拼接整条字符串,导出时边生成边写入。 diff --git a/src/seqviz/fastq.py b/src/seqviz/fastq.py index 4ecbe67..e4288aa 100644 --- a/src/seqviz/fastq.py +++ b/src/seqviz/fastq.py @@ -3,7 +3,7 @@ from pathlib import Path -def parse_fastq(filepath: str | Path) -> Generator[tuple[str, str, str], None, None]: +def parse_fastq(filepath: str | Path) -> Generator[tuple[str, str, str]]: """ 流式解析 FASTQ 文件。 逐条 yield (header, sequence, quality)。 diff --git a/src/seqviz/parsers.py b/src/seqviz/parsers.py index e89bfa7..9d25666 100644 --- a/src/seqviz/parsers.py +++ b/src/seqviz/parsers.py @@ -3,7 +3,7 @@ from pathlib import Path -def parse_fasta(filepath: str | Path) -> Generator[tuple[str, str], None, None]: +def parse_fasta(filepath: str | Path) -> Generator[tuple[str, str]]: """流式打开fasta 文件""" filepath = Path(filepath) diff --git a/test/run_perf_test.py b/test/run_perf_test.py index 9d400a1..c20cb23 100644 --- a/test/run_perf_test.py +++ b/test/run_perf_test.py @@ -1,14 +1,14 @@ """Comprehensive performance test for seqviz with large files.""" +import gzip import os import sys -import time -import gzip import tempfile +import time from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from seqviz.browser import FastaBrowser, FileFormat, SequenceView, _open_seq_file +from seqviz.browser import FastaBrowser, FileFormat, SequenceView DATA_DIR = "/tmp/seqviz_perf_test" GENOME = Path(DATA_DIR) / "genome_5g.fa" diff --git a/test/test_browser.py b/test/test_browser.py index f007199..c1f1ea6 100644 --- a/test/test_browser.py +++ b/test/test_browser.py @@ -3,8 +3,6 @@ import asyncio from pathlib import Path -import pytest - from seqviz.browser import FastaBrowser, FileFormat, SequenceInfo, SequenceList TEST_DIR = Path(__file__).parent diff --git a/test/test_config_theme.py b/test/test_config_theme.py index ecabe51..00a52ba 100644 --- a/test/test_config_theme.py +++ b/test/test_config_theme.py @@ -2,8 +2,6 @@ import json -import pytest - from seqviz import config as config_mod from seqviz import theme as theme_mod diff --git a/test/test_core.py b/test/test_core.py index 84fcb91..5872913 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -1,23 +1,22 @@ """核心模块测试:parsers / seq_type / stats / renderer""" import gzip -from pathlib import Path import pytest from rich.text import Text -from seqviz.parsers import parse_fasta from seqviz.fastq import parse_fastq -from seqviz.seq_type import SeqType, detect_seq_type -from seqviz.stats import calc_sequence_stats +from seqviz.parsers import parse_fasta from seqviz.renderer import ( - colorize_sequence, + DNA_COLORS, colorize_quality, - quality_stats, - quality_bar, + colorize_sequence, position_ruler, - DNA_COLORS, + quality_bar, + quality_stats, ) +from seqviz.seq_type import SeqType, detect_seq_type +from seqviz.stats import calc_sequence_stats # ────────────────────────────────────────────── diff --git a/test/test_file_browser.py b/test/test_file_browser.py index 22fe5f8..aee4c92 100644 --- a/test/test_file_browser.py +++ b/test/test_file_browser.py @@ -3,15 +3,15 @@ import asyncio from pathlib import Path +from seqviz.browser import FileFormat from seqviz.file_browser import ( FileBrowser, FileInfo, + detect_file_format, + format_size, is_sequence_file, scan_directory, - format_size, - detect_file_format, ) -from seqviz.browser import FileFormat TEST_DIR = Path(__file__).parent @@ -213,6 +213,7 @@ async def _t(): class TestCountSequences: def test_count_fasta(self, tmp_path): import threading + from seqviz.file_browser import count_sequences p = tmp_path / "x.fa" p.write_text("".join(f">s{i}\nACGT\n" for i in range(7))) @@ -228,6 +229,7 @@ def test_count_fastq(self, tmp_path): def test_count_gzip(self, tmp_path): import gzip + from seqviz.file_browser import count_sequences p = tmp_path / "x.fa.gz" with gzip.open(p, "wt") as f: @@ -247,6 +249,7 @@ def test_count_missing_file_returns_zero(self, tmp_path): def test_cancel_event_interrupts_large_count(self, tmp_path): """取消事件置位后应尽早中断(返回值小于完整计数)。""" import threading + from seqviz.file_browser import count_sequences p = tmp_path / "big.fa" p.write_text("".join(f">s{i}\nA\n" for i in range(20000))) diff --git a/test/test_parsers.py b/test/test_parsers.py index 7f8736f..6e63c0a 100644 --- a/test/test_parsers.py +++ b/test/test_parsers.py @@ -1,7 +1,6 @@ """seqviz.parsers 模块的测试套件""" import gzip -import tempfile from pathlib import Path import pytest diff --git a/test/test_vcf.py b/test/test_vcf.py index f95b0f9..ad5acb0 100644 --- a/test/test_vcf.py +++ b/test/test_vcf.py @@ -9,13 +9,13 @@ classify_variant, compute_stats, load_variant_detail, + parse_coord_query, parse_genotype, parse_meta, parse_variant_line, scan_vcf, scan_vcf_quick, scan_vcf_resume, - parse_coord_query, ) SAMPLE_VCF = Path(__file__).parent / "sample.vcf" diff --git a/uv.lock b/uv.lock index 28a674a..534f1d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.14" [[package]] name = "annotated-doc" @@ -26,36 +26,6 @@ version = "7.15.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, @@ -229,7 +199,7 @@ wheels = [ [[package]] name = "seqviz" -version = "0.7.0rc1" +version = "0.7.0rc2" source = { editable = "." } dependencies = [ { name = "rich" }, From b873675d670e1c2234a83eb6830bb8c03b0293da Mon Sep 17 00:00:00 2001 From: Xiangyang Date: Sat, 29 Aug 2026 03:11:39 +0800 Subject: [PATCH 20/20] =?UTF-8?q?fix(vcf):=20=E4=BF=AE=E5=A4=8D=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=89=AB=E6=8F=8F=E7=BC=93=E5=AD=98=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 _types/_qual_order 缓存在后台扫描追加变异后未失效导致的 IndexError - 后台扫描失败时通知用户而非静默吞掉异常 - CLI 打开 VCF 文件时捕获 OSError 并友好报错 333 tests passed, ruff lint clean. --- src/seqviz/cli.py | 6 +++++- src/seqviz/vcf_browser.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/seqviz/cli.py b/src/seqviz/cli.py index 7b371d0..0147ea4 100644 --- a/src/seqviz/cli.py +++ b/src/seqviz/cli.py @@ -110,7 +110,11 @@ def _run_vcf_browser(path: Path): from seqviz.vcf import scan_vcf_quick from seqviz.vcf_browser import VcfBrowser QUICK_LIMIT = 5000 - meta, variants, skipped, cont = scan_vcf_quick(path, limit=QUICK_LIMIT) + try: + meta, variants, skipped, cont = scan_vcf_quick(path, limit=QUICK_LIMIT) + except OSError as exc: + console.print(f"[red]错误: 无法读取文件 {path}: {exc}[/red]") + raise typer.Exit(code=1) if not meta.has_header: console.print(f"[red]错误: 不是有效的 VCF 文件(缺少 #CHROM 表头): {path}[/red]") raise typer.Exit(code=1) diff --git a/src/seqviz/vcf_browser.py b/src/seqviz/vcf_browser.py index f2619b8..8e994ac 100644 --- a/src/seqviz/vcf_browser.py +++ b/src/seqviz/vcf_browser.py @@ -333,7 +333,15 @@ def _dispatch(batch): try: new_all, skipped_add = scan_vcf_resume( self.filepath, self._cont_offset, on_batch=_dispatch) - except Exception: # noqa: BLE001 + except StopIteration: + return # 应用已关闭,正常退出 + except Exception as exc: # noqa: BLE001 + # 通知用户扫描失败,而非静默吞掉异常 + try: + self.call_from_thread( + self.notify, f"后台扫描失败: {exc}", title="VCF", severity="error") + except RuntimeError: + pass # 应用已退出 return try: self.call_from_thread(self._finish_scan, new_all, skipped_add) @@ -467,6 +475,8 @@ def _append_batch(self, batch: list[Variant]): if not self.scanning: return self._extend_and_sync(batch) + self._types = None # 缓存失效:新变异追加后类型列表长度不匹配 + self._qual_order = None # 缓存失效:QUAL 排序索引不包含新变异 self._update_status_bar() self._sync_position_indicator() @@ -477,6 +487,8 @@ def _finish_scan(self, new_all: list[Variant], skipped_add: int): appended = len(self.variants) - self._initial_count if appended < len(new_all): self._extend_and_sync(new_all[appended:]) + self._types = None # 缓存失效:扫描完成后类型列表可能不完整 + self._qual_order = None # 缓存失效:QUAL 排序索引可能不完整 if self._view_dirty or self._order_dirty: self._apply_filter_sort() else: