From 18d0827c8ccc4713755b572c97637716bed2963b Mon Sep 17 00:00:00 2001 From: takecchi Date: Tue, 21 Jul 2026 17:37:45 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=9A=A0=E3=82=8C=E3=81=8C1=E4=BB=B6?= =?UTF-8?q?=E3=81=AE=E3=81=A8=E3=81=8D=E3=81=AF"=E2=86=93=E4=BB=961?= =?UTF-8?q?=E4=BB=B6"=E3=81=A7=E3=81=AF=E3=81=AA=E3=81=8F=E5=AE=9F?= =?UTF-8?q?=E9=A0=85=E7=9B=AE=E3=82=92=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セッション一覧で画面が埋まった状態で下から2番目のセッションを選ぶと、 末尾1件が "↓ 他 1 件" インジケータに化けていた。インジケータも実項目も 1 行を占めるため、1 件しか隠れていない端はインジケータを出さずその項目 自体を表示するほうが有益(描画行数は不変)。上端も対称に扱う。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/layout.spec.ts | 32 ++++++++++++++++++++++++++++++++ src/core/layout.ts | 26 ++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/core/layout.spec.ts b/src/core/layout.spec.ts index 4ef30a9..3540bf6 100644 --- a/src/core/layout.spec.ts +++ b/src/core/layout.spec.ts @@ -117,6 +117,38 @@ describe('listView', () => { } }); + it('shows the last item instead of a "1 more" indicator (second-from-bottom selected)', () => { + // 画面が埋まった状態で下から2番目を選ぶと、従来は末尾1件が「↓ 他 1 件」に + // 化けていた。インジケータではなくその実項目を表示する。 + const v = listView(10, 8, 5); + expect(v.end).toBe(10); // 末尾の項目まで表示 + expect(v.showBelow).toBe(false); // 「↓ 他 1 件」は出さない + expect(v.hiddenBelow).toBe(0); + expect(8).toBeGreaterThanOrEqual(v.start); + expect(8).toBeLessThan(v.end); + expect(renderedRows(v)).toBe(5); // 描画行数は cap のまま + }); + + it('never shows an indicator that hides only one item (shows the item instead)', () => { + for (const total of [6, 7, 10, 15, 30]) { + for (let cap = 2; cap <= 9; cap++) { + for (let sel = 0; sel < total; sel++) { + const v = listView(total, sel, cap); + const at = `total=${total} cap=${cap} sel=${sel}`; + if (v.showBelow) { + expect(v.hiddenBelow, `${at} below`).toBeGreaterThan(1); + } + if (v.showAbove) { + expect(v.hiddenAbove, `${at} above`).toBeGreaterThan(1); + } + expect(sel, `${at} start`).toBeGreaterThanOrEqual(v.start); + expect(sel, `${at} end`).toBeLessThan(v.end); + expect(renderedRows(v), `${at} rows`).toBeLessThanOrEqual(cap); + } + } + } + }); + it('never overflows a tiny cap (drops indicators, keeps one content row)', () => { const v1 = listView(10, 5, 1); expect(v1.end - v1.start).toBe(1); diff --git a/src/core/layout.ts b/src/core/layout.ts index b3751c0..4993305 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -89,6 +89,11 @@ export interface ListView { * 予約するため、描画行数(項目 + インジケータ)は常に `cap` 以下になる。 * 選択はウィンドウ下端寄りにアンカーする(下へ動かすとスクロールする挙動。 * コンポーザの {@link visibleLineRange} と同じ)。 + * + * ただし端に隠れているのが 1 件だけの場合は、インジケータ(「他 1 件」)を出さず + * その項目自体を表示する。インジケータも実項目も 1 行なので描画行数は変わらず、 + * 「1 件を隠して代わりに 1 行のインジケータを出す」より実項目を見せたほうがよい + * (下から 2 番目を選ぶと最後の 1 件が「↓ 他 1 件」に化ける、を防ぐ)。 */ export function listView(total: number, selected: number, cap: number): ListView { const c = Math.max(1, Math.floor(cap)); @@ -132,11 +137,24 @@ export function listView(total: number, selected: number, cap: number): ListView showAbove = false; } } - const hiddenAbove = win.start; - const hiddenBelow = total - win.end; + // 隠れているのが 1 件だけの端は、インジケータ用に予約した 1 行へその項目を + // 直接出す(描画行数は不変)。両端が同時に 1 件になることは overflow 時には + // 起きない(それは total === cap を意味し、その場合は上で早期 return 済み)。 + let start = win.start; + let end = win.end; + if (showBelow && total - end === 1) { + end += 1; + showBelow = false; + } + if (showAbove && start === 1) { + start -= 1; + showAbove = false; + } + const hiddenAbove = start; + const hiddenBelow = total - end; return { - start: win.start, - end: win.end, + start, + end, hiddenAbove, hiddenBelow, showAbove: showAbove && hiddenAbove > 0,